diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..5c2c5f1b17f7580b6b7d76f3a0f6b8ee0ae9b719 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +FROM python:3.13-alpine AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Asia/Shanghai \ + # 把 uv 包安装到系统 Python 环境 + UV_PROJECT_ENVIRONMENT=/opt/venv + +# 确保 uv 的 bin 目录 +ENV PATH="$UV_PROJECT_ENVIRONMENT/bin:$PATH" + +RUN apk add --no-cache \ + tzdata \ + ca-certificates \ + build-base \ + linux-headers \ + libffi-dev \ + openssl-dev \ + curl-dev \ + cargo \ + rust + +WORKDIR /app + +# 安装 uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +COPY pyproject.toml uv.lock ./ + +RUN uv sync --frozen --no-dev --no-install-project \ + && find /opt/venv -type d -name "__pycache__" -prune -exec rm -rf {} + \ + && find /opt/venv -type f -name "*.pyc" -delete \ + && find /opt/venv -type d -name "tests" -prune -exec rm -rf {} + \ + && find /opt/venv -type d -name "test" -prune -exec rm -rf {} + \ + && find /opt/venv -type d -name "testing" -prune -exec rm -rf {} + \ + && find /opt/venv -type f -name "*.so" -exec strip --strip-unneeded {} + || true \ + && rm -rf /root/.cache /tmp/uv-cache + +FROM python:3.13-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Asia/Shanghai \ + VIRTUAL_ENV=/opt/venv + +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +RUN apk add --no-cache \ + tzdata \ + ca-certificates \ + libffi \ + openssl \ + libgcc \ + libstdc++ \ + libcurl + +WORKDIR /app + +COPY --from=builder /opt/venv /opt/venv + +COPY config.defaults.toml ./ +COPY app ./app +COPY main.py ./ +COPY scripts ./scripts + +RUN mkdir -p /app/data /app/logs \ + && chmod +x /app/scripts/entrypoint.sh + +RUN chmod +x /app/scripts/entrypoint.sh +RUN chmod +x /app/scripts/init_storage.sh + +EXPOSE 7860 + +ENTRYPOINT ["/app/scripts/entrypoint.sh"] + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2a43b86b965c6735a338394c4d17e6a7a5a71078 Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/api/pages/__init__.py b/app/api/pages/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6aada6e570d8bdd2190f5b2b151be615476b974a --- /dev/null +++ b/app/api/pages/__init__.py @@ -0,0 +1,13 @@ +"""UI pages router.""" + +from fastapi import APIRouter + +from app.api.pages.admin import router as admin_router +from app.api.pages.public import router as public_router + +router = APIRouter() + +router.include_router(public_router) +router.include_router(admin_router) + +__all__ = ["router"] diff --git a/app/api/pages/admin.py b/app/api/pages/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..bb581e8922ce6673765fa57bc12936dc21ff9c9c --- /dev/null +++ b/app/api/pages/admin.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import FileResponse, RedirectResponse + +router = APIRouter() +STATIC_DIR = Path(__file__).resolve().parents[2] / "static" + + +@router.get("/admin", include_in_schema=False) +async def admin_root(): + return RedirectResponse(url="/admin/login") + + +@router.get("/admin/login", include_in_schema=False) +async def admin_login(): + return FileResponse(STATIC_DIR / "admin/pages/login.html") + + +@router.get("/admin/config", include_in_schema=False) +async def admin_config(): + return FileResponse(STATIC_DIR / "admin/pages/config.html") + + +@router.get("/admin/cache", include_in_schema=False) +async def admin_cache(): + return FileResponse(STATIC_DIR / "admin/pages/cache.html") + + +@router.get("/admin/token", include_in_schema=False) +async def admin_token(): + return FileResponse(STATIC_DIR / "admin/pages/token.html") diff --git a/app/api/pages/public.py b/app/api/pages/public.py new file mode 100644 index 0000000000000000000000000000000000000000..08913c8fc57021ffd6ff1086cbad2f23c25f6cd8 --- /dev/null +++ b/app/api/pages/public.py @@ -0,0 +1,51 @@ +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") + + +@router.get("/chat", include_in_schema=False) +async def public_chat(): + if not is_public_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(STATIC_DIR / "public/pages/chat.html") diff --git a/app/api/v1/admin_api/__init__.py b/app/api/v1/admin_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..14763373d915424f33bcca1a72b4c47c36d38ede --- /dev/null +++ b/app/api/v1/admin_api/__init__.py @@ -0,0 +1,15 @@ +"""Admin API router (app_key protected).""" + +from fastapi import APIRouter + +from app.api.v1.admin_api.cache import router as cache_router +from app.api.v1.admin_api.config import router as config_router +from app.api.v1.admin_api.token import router as tokens_router + +router = APIRouter() + +router.include_router(config_router) +router.include_router(tokens_router) +router.include_router(cache_router) + +__all__ = ["router"] diff --git a/app/api/v1/admin_api/cache.py b/app/api/v1/admin_api/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..0dc902a7395cff5256df9219bc5b158340f7e3ab --- /dev/null +++ b/app/api/v1/admin_api/cache.py @@ -0,0 +1,445 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from app.core.auth import verify_app_key +from app.core.batch import create_task, expire_task +from app.services.grok.batch_services.assets import ListService, DeleteService +from app.services.token.manager import get_token_manager +router = APIRouter() + + +@router.get("/cache", dependencies=[Depends(verify_app_key)]) +async def cache_stats(request: Request): + """获取缓存统计""" + from app.services.grok.utils.cache import CacheService + + try: + cache_service = CacheService() + image_stats = cache_service.get_stats("image") + video_stats = cache_service.get_stats("video") + + mgr = await get_token_manager() + pools = mgr.pools + accounts = [] + for pool_name, pool in pools.items(): + for info in pool.list(): + raw_token = ( + info.token[4:] if info.token.startswith("sso=") else info.token + ) + masked = ( + f"{raw_token[:8]}...{raw_token[-16:]}" + if len(raw_token) > 24 + else raw_token + ) + accounts.append( + { + "token": raw_token, + "token_masked": masked, + "pool": pool_name, + "status": info.status, + "last_asset_clear_at": info.last_asset_clear_at, + } + ) + + scope = request.query_params.get("scope") + selected_token = request.query_params.get("token") + tokens_param = request.query_params.get("tokens") + selected_tokens = [] + if tokens_param: + selected_tokens = [t.strip() for t in tokens_param.split(",") if t.strip()] + + online_stats = { + "count": 0, + "status": "unknown", + "token": None, + "last_asset_clear_at": None, + } + online_details = [] + account_map = {a["token"]: a for a in accounts} + if selected_tokens: + total = 0 + raw_results = await ListService.fetch_assets_details( + selected_tokens, + account_map, + ) + for token, res in raw_results.items(): + if res.get("ok"): + data = res.get("data", {}) + detail = data.get("detail") + total += data.get("count", 0) + else: + account = account_map.get(token) + detail = { + "token": token, + "token_masked": account["token_masked"] if account else token, + "count": 0, + "status": f"error: {res.get('error')}", + "last_asset_clear_at": account["last_asset_clear_at"] + if account + else None, + } + if detail: + online_details.append(detail) + online_stats = { + "count": total, + "status": "ok" if selected_tokens else "no_token", + "token": None, + "last_asset_clear_at": None, + } + scope = "selected" + elif scope == "all": + total = 0 + tokens = list(dict.fromkeys([account["token"] for account in accounts])) + raw_results = await ListService.fetch_assets_details( + tokens, + account_map, + ) + for token, res in raw_results.items(): + if res.get("ok"): + data = res.get("data", {}) + detail = data.get("detail") + total += data.get("count", 0) + else: + account = account_map.get(token) + detail = { + "token": token, + "token_masked": account["token_masked"] if account else token, + "count": 0, + "status": f"error: {res.get('error')}", + "last_asset_clear_at": account["last_asset_clear_at"] + if account + else None, + } + if detail: + online_details.append(detail) + online_stats = { + "count": total, + "status": "ok" if accounts else "no_token", + "token": None, + "last_asset_clear_at": None, + } + else: + token = selected_token + if token: + raw_results = await ListService.fetch_assets_details( + [token], + account_map, + ) + res = raw_results.get(token, {}) + data = res.get("data", {}) + detail = data.get("detail") if res.get("ok") else None + if detail: + online_stats = { + "count": data.get("count", 0), + "status": detail.get("status", "ok"), + "token": detail.get("token"), + "token_masked": detail.get("token_masked"), + "last_asset_clear_at": detail.get("last_asset_clear_at"), + } + else: + match = next((a for a in accounts if a["token"] == token), None) + online_stats = { + "count": 0, + "status": f"error: {res.get('error')}", + "token": token, + "token_masked": match["token_masked"] if match else token, + "last_asset_clear_at": match["last_asset_clear_at"] + if match + else None, + } + else: + online_stats = { + "count": 0, + "status": "not_loaded", + "token": None, + "last_asset_clear_at": None, + } + + response = { + "local_image": image_stats, + "local_video": video_stats, + "online": online_stats, + "online_accounts": accounts, + "online_scope": scope or "none", + "online_details": online_details, + } + return response + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/cache/list", dependencies=[Depends(verify_app_key)]) +async def list_local( + cache_type: str = "image", + type_: str = Query(default=None, alias="type"), + page: int = 1, + page_size: int = 1000, +): + """列出本地缓存文件""" + from app.services.grok.utils.cache import CacheService + + try: + if type_: + cache_type = type_ + cache_service = CacheService() + result = cache_service.list_files(cache_type, page, page_size) + return {"status": "success", **result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/cache/clear", dependencies=[Depends(verify_app_key)]) +async def clear_local(data: dict): + """清理本地缓存""" + from app.services.grok.utils.cache import CacheService + + cache_type = data.get("type", "image") + + try: + cache_service = CacheService() + result = cache_service.clear(cache_type) + return {"status": "success", "result": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/cache/item/delete", dependencies=[Depends(verify_app_key)]) +async def delete_local_item(data: dict): + """删除单个本地缓存文件""" + from app.services.grok.utils.cache import CacheService + + cache_type = data.get("type", "image") + name = data.get("name") + if not name: + raise HTTPException(status_code=400, detail="Missing file name") + try: + cache_service = CacheService() + result = cache_service.delete_file(cache_type, name) + return {"status": "success", "result": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/cache/online/clear", dependencies=[Depends(verify_app_key)]) +async def clear_online(data: dict): + """清理在线缓存""" + try: + mgr = await get_token_manager() + tokens = data.get("tokens") + + if isinstance(tokens, list): + token_list = [t.strip() for t in tokens if isinstance(t, str) and t.strip()] + if not token_list: + raise HTTPException(status_code=400, detail="No tokens provided") + + token_list = list(dict.fromkeys(token_list)) + + results = {} + raw_results = await DeleteService.clear_assets( + token_list, + mgr, + ) + for token, res in raw_results.items(): + if res.get("ok"): + results[token] = res.get("data", {}) + else: + results[token] = {"status": "error", "error": res.get("error")} + + return {"status": "success", "results": results} + + token = data.get("token") or mgr.get_token() + if not token: + raise HTTPException( + status_code=400, detail="No available token to perform cleanup" + ) + + raw_results = await DeleteService.clear_assets( + [token], + mgr, + ) + res = raw_results.get(token, {}) + data = res.get("data", {}) + if res.get("ok") and data.get("status") == "success": + return {"status": "success", "result": data.get("result")} + return {"status": "error", "error": data.get("error") or res.get("error")} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/cache/online/clear/async", dependencies=[Depends(verify_app_key)]) +async def clear_online_async(data: dict): + """清理在线缓存(异步批量 + SSE 进度)""" + mgr = await get_token_manager() + tokens = data.get("tokens") + if not isinstance(tokens, list): + raise HTTPException(status_code=400, detail="No tokens provided") + + token_list = [t.strip() for t in tokens if isinstance(t, str) and t.strip()] + if not token_list: + raise HTTPException(status_code=400, detail="No tokens provided") + + task = create_task(len(token_list)) + + async def _run(): + try: + async def _on_item(item: str, res: dict): + ok = bool(res.get("data", {}).get("ok")) + task.record(ok) + + raw_results = await DeleteService.clear_assets( + token_list, + mgr, + include_ok=True, + on_item=_on_item, + should_cancel=lambda: task.cancelled, + ) + + if task.cancelled: + task.finish_cancelled() + return + + results = {} + ok_count = 0 + fail_count = 0 + for token, res in raw_results.items(): + data = res.get("data", {}) + if data.get("ok"): + ok_count += 1 + results[token] = {"status": "success", "result": data.get("result")} + else: + fail_count += 1 + results[token] = {"status": "error", "error": data.get("error")} + + result = { + "status": "success", + "summary": { + "total": len(token_list), + "ok": ok_count, + "fail": fail_count, + }, + "results": results, + } + task.finish(result) + except Exception as e: + task.fail_task(str(e)) + finally: + import asyncio + asyncio.create_task(expire_task(task.id, 300)) + + import asyncio + asyncio.create_task(_run()) + + return { + "status": "success", + "task_id": task.id, + "total": len(token_list), + } + + +@router.post("/cache/online/load/async", dependencies=[Depends(verify_app_key)]) +async def load_cache_async(data: dict): + """在线资产统计(异步批量 + SSE 进度)""" + from app.services.grok.utils.cache import CacheService + + mgr = await get_token_manager() + + accounts = [] + for pool_name, pool in mgr.pools.items(): + for info in pool.list(): + raw_token = info.token[4:] if info.token.startswith("sso=") else info.token + masked = ( + f"{raw_token[:8]}...{raw_token[-16:]}" + if len(raw_token) > 24 + else raw_token + ) + accounts.append( + { + "token": raw_token, + "token_masked": masked, + "pool": pool_name, + "status": info.status, + "last_asset_clear_at": info.last_asset_clear_at, + } + ) + + account_map = {a["token"]: a for a in accounts} + + tokens = data.get("tokens") + scope = data.get("scope") + selected_tokens: List[str] = [] + if isinstance(tokens, list): + selected_tokens = [str(t).strip() for t in tokens if str(t).strip()] + + if not selected_tokens and scope == "all": + selected_tokens = [account["token"] for account in accounts] + scope = "all" + elif selected_tokens: + scope = "selected" + else: + raise HTTPException(status_code=400, detail="No tokens provided") + + task = create_task(len(selected_tokens)) + + async def _run(): + try: + cache_service = CacheService() + image_stats = cache_service.get_stats("image") + video_stats = cache_service.get_stats("video") + + async def _on_item(item: str, res: dict): + ok = bool(res.get("data", {}).get("ok")) + task.record(ok) + + raw_results = await ListService.fetch_assets_details( + selected_tokens, + account_map, + include_ok=True, + on_item=_on_item, + should_cancel=lambda: task.cancelled, + ) + + if task.cancelled: + task.finish_cancelled() + return + + online_details = [] + total = 0 + for token, res in raw_results.items(): + data = res.get("data", {}) + detail = data.get("detail") + if detail: + online_details.append(detail) + total += data.get("count", 0) + + online_stats = { + "count": total, + "status": "ok" if selected_tokens else "no_token", + "token": None, + "last_asset_clear_at": None, + } + + result = { + "local_image": image_stats, + "local_video": video_stats, + "online": online_stats, + "online_accounts": accounts, + "online_scope": scope or "none", + "online_details": online_details, + } + task.finish(result) + except Exception as e: + task.fail_task(str(e)) + finally: + import asyncio + asyncio.create_task(expire_task(task.id, 300)) + + import asyncio + asyncio.create_task(_run()) + + return { + "status": "success", + "task_id": task.id, + "total": len(selected_tokens), + } + diff --git a/app/api/v1/admin_api/config.py b/app/api/v1/admin_api/config.py new file mode 100644 index 0000000000000000000000000000000000000000..f0a9c2d0b2f317d2b5ed51cde6532a6f4397a8e5 --- /dev/null +++ b/app/api/v1/admin_api/config.py @@ -0,0 +1,53 @@ +import os + +from fastapi import APIRouter, Depends, HTTPException + +from app.core.auth import verify_app_key +from app.core.config import config +from app.core.storage import get_storage as resolve_storage, LocalStorage, RedisStorage, SQLStorage + +router = APIRouter() + + +@router.get("/verify", dependencies=[Depends(verify_app_key)]) +async def admin_verify(): + """验证后台访问密钥(app_key)""" + return {"status": "success"} + + +@router.get("/config", dependencies=[Depends(verify_app_key)]) +async def get_config(): + """获取当前配置""" + # 暴露原始配置字典 + return config._config + + +@router.post("/config", dependencies=[Depends(verify_app_key)]) +async def update_config(data: dict): + """更新配置""" + try: + await config.update(data) + return {"status": "success", "message": "配置已更新"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/storage", dependencies=[Depends(verify_app_key)]) +async def get_storage_mode(): + """获取当前存储模式""" + storage_type = os.getenv("SERVER_STORAGE_TYPE", "").lower() + if not storage_type: + storage = resolve_storage() + if isinstance(storage, LocalStorage): + storage_type = "local" + elif isinstance(storage, RedisStorage): + storage_type = "redis" + elif isinstance(storage, SQLStorage): + storage_type = { + "mysql": "mysql", + "mariadb": "mysql", + "postgres": "pgsql", + "postgresql": "pgsql", + "pgsql": "pgsql", + }.get(storage.dialect, storage.dialect) + return {"type": storage_type or "local"} diff --git a/app/api/v1/admin_api/token.py b/app/api/v1/admin_api/token.py new file mode 100644 index 0000000000000000000000000000000000000000..6eec136d6b07687a6874b5481365c319e0d174d9 --- /dev/null +++ b/app/api/v1/admin_api/token.py @@ -0,0 +1,395 @@ +import asyncio + +import orjson +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse + +from app.core.auth import get_app_key, verify_app_key +from app.core.batch import create_task, expire_task, get_task +from app.core.logger import logger +from app.core.storage import get_storage +from app.services.grok.batch_services.usage import UsageService +from app.services.grok.batch_services.nsfw import NSFWService +from app.services.token.manager import get_token_manager + +router = APIRouter() + + +@router.get("/tokens", dependencies=[Depends(verify_app_key)]) +async def get_tokens(): + """获取所有 Token""" + storage = get_storage() + tokens = await storage.load_tokens() + return tokens or {} + + +@router.post("/tokens", dependencies=[Depends(verify_app_key)]) +async def update_tokens(data: dict): + """更新 Token 信息""" + storage = get_storage() + try: + from app.services.token.models import TokenInfo + + async with storage.acquire_lock("tokens_save", timeout=10): + existing = await storage.load_tokens() or {} + normalized = {} + allowed_fields = set(TokenInfo.model_fields.keys()) + existing_map = {} + for pool_name, tokens in existing.items(): + if not isinstance(tokens, list): + continue + pool_map = {} + for item in tokens: + if isinstance(item, str): + token_data = {"token": item} + elif isinstance(item, dict): + token_data = dict(item) + else: + continue + raw_token = token_data.get("token") + if isinstance(raw_token, str) and raw_token.startswith("sso="): + token_data["token"] = raw_token[4:] + token_key = token_data.get("token") + if isinstance(token_key, str): + pool_map[token_key] = token_data + existing_map[pool_name] = pool_map + for pool_name, tokens in (data or {}).items(): + if not isinstance(tokens, list): + continue + pool_list = [] + for item in tokens: + if isinstance(item, str): + token_data = {"token": item} + elif isinstance(item, dict): + token_data = dict(item) + else: + continue + + raw_token = token_data.get("token") + if isinstance(raw_token, str) and raw_token.startswith("sso="): + token_data["token"] = raw_token[4:] + + base = existing_map.get(pool_name, {}).get( + token_data.get("token"), {} + ) + merged = dict(base) + merged.update(token_data) + if merged.get("tags") is None: + merged["tags"] = [] + + filtered = {k: v for k, v in merged.items() if k in allowed_fields} + try: + info = TokenInfo(**filtered) + pool_list.append(info.model_dump()) + except Exception as e: + logger.warning(f"Skip invalid token in pool '{pool_name}': {e}") + continue + normalized[pool_name] = pool_list + + await storage.save_tokens(normalized) + mgr = await get_token_manager() + await mgr.reload() + return {"status": "success", "message": "Token 已更新"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/tokens/refresh", dependencies=[Depends(verify_app_key)]) +async def refresh_tokens(data: dict): + """刷新 Token 状态""" + try: + mgr = await get_token_manager() + tokens = [] + if isinstance(data.get("token"), str) and data["token"].strip(): + tokens.append(data["token"].strip()) + if isinstance(data.get("tokens"), list): + tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()]) + + if not tokens: + raise HTTPException(status_code=400, detail="No tokens provided") + + unique_tokens = list(dict.fromkeys(tokens)) + + raw_results = await UsageService.batch( + unique_tokens, + mgr, + ) + + results = {} + for token, res in raw_results.items(): + if res.get("ok"): + results[token] = res.get("data", False) + else: + results[token] = False + + response = {"status": "success", "results": results} + return response + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/tokens/refresh/async", dependencies=[Depends(verify_app_key)]) +async def refresh_tokens_async(data: dict): + """刷新 Token 状态(异步批量 + SSE 进度)""" + mgr = await get_token_manager() + tokens = [] + if isinstance(data.get("token"), str) and data["token"].strip(): + tokens.append(data["token"].strip()) + if isinstance(data.get("tokens"), list): + tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()]) + + if not tokens: + raise HTTPException(status_code=400, detail="No tokens provided") + + unique_tokens = list(dict.fromkeys(tokens)) + + task = create_task(len(unique_tokens)) + + async def _run(): + try: + + async def _on_item(item: str, res: dict): + task.record(bool(res.get("ok"))) + + raw_results = await UsageService.batch( + unique_tokens, + mgr, + on_item=_on_item, + should_cancel=lambda: task.cancelled, + ) + + if task.cancelled: + task.finish_cancelled() + return + + results: dict[str, bool] = {} + ok_count = 0 + fail_count = 0 + for token, res in raw_results.items(): + if res.get("ok") and res.get("data") is True: + ok_count += 1 + results[token] = True + else: + fail_count += 1 + results[token] = False + + await mgr._save(force=True) + + result = { + "status": "success", + "summary": { + "total": len(unique_tokens), + "ok": ok_count, + "fail": fail_count, + }, + "results": results, + } + task.finish(result) + except Exception as e: + task.fail_task(str(e)) + finally: + import asyncio + asyncio.create_task(expire_task(task.id, 300)) + + import asyncio + asyncio.create_task(_run()) + + return { + "status": "success", + "task_id": task.id, + "total": len(unique_tokens), + } + + +@router.get("/batch/{task_id}/stream") +async def batch_stream(task_id: str, request: Request): + app_key = get_app_key() + if app_key: + key = request.query_params.get("app_key") + if key != app_key: + raise HTTPException(status_code=401, detail="Invalid authentication token") + task = get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + async def event_stream(): + queue = task.attach() + try: + yield f"data: {orjson.dumps({'type': 'snapshot', **task.snapshot()}).decode()}\n\n" + + final = task.final_event() + if final: + yield f"data: {orjson.dumps(final).decode()}\n\n" + return + + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=15) + except asyncio.TimeoutError: + yield ": ping\n\n" + final = task.final_event() + if final: + yield f"data: {orjson.dumps(final).decode()}\n\n" + return + continue + + yield f"data: {orjson.dumps(event).decode()}\n\n" + if event.get("type") in ("done", "error", "cancelled"): + return + finally: + task.detach(queue) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@router.post("/batch/{task_id}/cancel", dependencies=[Depends(verify_app_key)]) +async def batch_cancel(task_id: str): + task = get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + task.cancel() + return {"status": "success"} + + +@router.post("/tokens/nsfw/enable", dependencies=[Depends(verify_app_key)]) +async def enable_nsfw(data: dict): + """批量开启 NSFW (Unhinged) 模式""" + try: + mgr = await get_token_manager() + + tokens = [] + if isinstance(data.get("token"), str) and data["token"].strip(): + tokens.append(data["token"].strip()) + if isinstance(data.get("tokens"), list): + tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()]) + + if not tokens: + for pool_name, pool in mgr.pools.items(): + for info in pool.list(): + raw = ( + info.token[4:] if info.token.startswith("sso=") else info.token + ) + tokens.append(raw) + + if not tokens: + raise HTTPException(status_code=400, detail="No tokens available") + + unique_tokens = list(dict.fromkeys(tokens)) + + raw_results = await NSFWService.batch( + unique_tokens, + mgr, + ) + + results = {} + ok_count = 0 + fail_count = 0 + + for token, res in raw_results.items(): + masked = f"{token[:8]}...{token[-8:]}" if len(token) > 20 else token + if res.get("ok") and res.get("data", {}).get("success"): + ok_count += 1 + results[masked] = res.get("data", {}) + else: + fail_count += 1 + results[masked] = res.get("data") or {"error": res.get("error")} + + response = { + "status": "success", + "summary": { + "total": len(unique_tokens), + "ok": ok_count, + "fail": fail_count, + }, + "results": results, + } + + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Enable NSFW failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/tokens/nsfw/enable/async", dependencies=[Depends(verify_app_key)]) +async def enable_nsfw_async(data: dict): + """批量开启 NSFW (Unhinged) 模式(异步批量 + SSE 进度)""" + mgr = await get_token_manager() + + tokens = [] + if isinstance(data.get("token"), str) and data["token"].strip(): + tokens.append(data["token"].strip()) + if isinstance(data.get("tokens"), list): + tokens.extend([str(t).strip() for t in data["tokens"] if str(t).strip()]) + + if not tokens: + for pool_name, pool in mgr.pools.items(): + for info in pool.list(): + raw = info.token[4:] if info.token.startswith("sso=") else info.token + tokens.append(raw) + + if not tokens: + raise HTTPException(status_code=400, detail="No tokens available") + + unique_tokens = list(dict.fromkeys(tokens)) + + task = create_task(len(unique_tokens)) + + async def _run(): + try: + + async def _on_item(item: str, res: dict): + ok = bool(res.get("ok") and res.get("data", {}).get("success")) + task.record(ok) + + raw_results = await NSFWService.batch( + unique_tokens, + mgr, + on_item=_on_item, + should_cancel=lambda: task.cancelled, + ) + + if task.cancelled: + task.finish_cancelled() + return + + results = {} + ok_count = 0 + fail_count = 0 + for token, res in raw_results.items(): + masked = f"{token[:8]}...{token[-8:]}" if len(token) > 20 else token + if res.get("ok") and res.get("data", {}).get("success"): + ok_count += 1 + results[masked] = res.get("data", {}) + else: + fail_count += 1 + results[masked] = res.get("data") or {"error": res.get("error")} + + await mgr._save(force=True) + + result = { + "status": "success", + "summary": { + "total": len(unique_tokens), + "ok": ok_count, + "fail": fail_count, + }, + "results": results, + } + task.finish(result) + except Exception as e: + task.fail_task(str(e)) + finally: + import asyncio + asyncio.create_task(expire_task(task.id, 300)) + + import asyncio + asyncio.create_task(_run()) + + return { + "status": "success", + "task_id": task.id, + "total": len(unique_tokens), + } diff --git a/app/api/v1/chat.py b/app/api/v1/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..784403fc8ac7b3e65de2be6c6d2766ae4b99b237 --- /dev/null +++ b/app/api/v1/chat.py @@ -0,0 +1,862 @@ +""" +Chat Completions API 路由 +""" + +from typing import Any, AsyncGenerator, AsyncIterable, Dict, List, Optional, Union +import base64 +import binascii +import time +import uuid + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse, JSONResponse +from pydantic import BaseModel, Field +import orjson + +from app.services.grok.services.chat import ChatService +from app.services.grok.services.image import ImageGenerationService +from app.services.grok.services.image_edit import ImageEditService +from app.services.grok.services.model import ModelService +from app.services.grok.services.video import VideoService +from app.services.grok.utils.response import make_chat_response +from app.services.token import get_token_manager +from app.core.config import get_config +from app.core.exceptions import ValidationException, AppException, ErrorType + + +class MessageItem(BaseModel): + """消息项""" + + role: str + content: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] + tool_calls: Optional[List[Dict[str, Any]]] = None + tool_call_id: Optional[str] = None + name: Optional[str] = None + + +class VideoConfig(BaseModel): + """视频生成配置""" + + aspect_ratio: Optional[str] = Field("3:2", description="视频比例: 1280x720(16:9), 720x1280(9:16), 1792x1024(3:2), 1024x1792(2:3), 1024x1024(1:1)") + video_length: Optional[int] = Field(6, description="视频时长(秒): 6 / 10 / 15") + resolution_name: Optional[str] = Field("480p", description="视频分辨率: 480p, 720p") + preset: Optional[str] = Field("custom", description="风格预设: fun, normal, spicy") + + +class ImageConfig(BaseModel): + """图片生成配置""" + + n: Optional[int] = Field(1, ge=1, le=10, description="生成数量 (1-10)") + size: Optional[str] = Field("1024x1024", description="图片尺寸") + response_format: Optional[str] = Field(None, description="响应格式") + + +class ChatCompletionRequest(BaseModel): + """Chat Completions 请求""" + + model: str = Field(..., description="模型名称") + messages: List[MessageItem] = Field(..., description="消息数组") + stream: Optional[bool] = Field(None, description="是否流式输出") + reasoning_effort: Optional[str] = Field(None, description="推理强度: none/minimal/low/medium/high/xhigh") + temperature: Optional[float] = Field(0.8, description="采样温度: 0-2") + top_p: Optional[float] = Field(0.95, description="nucleus 采样: 0-1") + # 视频生成配置 + video_config: Optional[VideoConfig] = Field(None, description="视频生成参数") + # 图片生成配置 + image_config: Optional[ImageConfig] = Field(None, description="图片生成参数") + # Tool calling + tools: Optional[List[Dict[str, Any]]] = Field(None, description="Tool definitions") + tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Tool choice: auto/required/none/specific") + parallel_tool_calls: Optional[bool] = Field(True, description="Allow parallel tool calls") + + +VALID_ROLES = {"developer", "system", "user", "assistant", "tool"} +USER_CONTENT_TYPES = {"text", "image_url", "input_audio", "file"} +ALLOWED_IMAGE_SIZES = { + "1280x720", + "720x1280", + "1792x1024", + "1024x1792", + "1024x1024", +} +IMAGINE_FAST_MODEL_ID = "grok-imagine-1.0-fast" + + +def _validate_media_input(value: str, field_name: str, param: str): + """Verify media input is a valid URL or data URI""" + if not isinstance(value, str) or not value.strip(): + raise ValidationException( + message=f"{field_name} cannot be empty", + param=param, + code="empty_media", + ) + value = value.strip() + if value.startswith("data:"): + return + if value.startswith("http://") or value.startswith("https://"): + return + candidate = "".join(value.split()) + if len(candidate) >= 32 and len(candidate) % 4 == 0: + try: + base64.b64decode(candidate, validate=True) + raise ValidationException( + message=f"{field_name} base64 must be provided as a data URI (data:;base64,...)", + param=param, + code="invalid_media", + ) + except binascii.Error: + pass + raise ValidationException( + message=f"{field_name} must be a URL or data URI", + param=param, + code="invalid_media", + ) + + +def _extract_prompt_images(messages: List[MessageItem]) -> tuple[str, List[str]]: + """Extract prompt text and image URLs from messages""" + last_text = "" + image_urls: List[str] = [] + + for msg in messages: + role = msg.role or "user" + content = msg.content + if isinstance(content, str): + text = content.strip() + if text: + last_text = text + continue + if isinstance(content, dict): + content = [content] + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "text": + text = block.get("text", "") + if isinstance(text, str) and text.strip(): + last_text = text.strip() + elif block_type == "image_url" and role == "user": + image = block.get("image_url") or {} + url = image.get("url", "") + if isinstance(url, str) and url.strip(): + image_urls.append(url.strip()) + + return last_text, image_urls + + +def _resolve_image_format(value: Optional[str]) -> str: + fmt = value or get_config("app.image_format") or "url" + if isinstance(fmt, str): + fmt = fmt.lower() + if fmt == "base64": + return "b64_json" + if fmt in ("b64_json", "url"): + return fmt + raise ValidationException( + message="image_format must be one of url, base64, b64_json", + param="image_format", + code="invalid_image_format", + ) + + +def _image_field(response_format: str) -> str: + if response_format == "url": + return "url" + return "b64_json" + + +def _imagine_fast_server_image_config() -> ImageConfig: + """Load server-side image generation parameters for grok-imagine-1.0-fast.""" + n = int(get_config("imagine_fast.n", 1) or 1) + size = str(get_config("imagine_fast.size", "1024x1024") or "1024x1024") + response_format = str( + get_config("imagine_fast.response_format", get_config("app.image_format") or "url") + or "url" + ) + return ImageConfig(n=n, size=size, response_format=response_format) + + +async def _safe_sse_stream(stream: AsyncIterable[str]) -> AsyncGenerator[str, None]: + """Ensure streaming endpoints return SSE error payloads instead of transport-level 5xx breaks.""" + try: + async for chunk in stream: + yield chunk + except AppException as e: + payload = { + "error": { + "message": e.message, + "type": e.error_type, + "code": e.code, + } + } + yield f"event: error\ndata: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + payload = { + "error": { + "message": str(e) or "stream_error", + "type": "server_error", + "code": "stream_error", + } + } + yield f"event: error\ndata: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + + +def _streaming_error_response(exc: Exception) -> StreamingResponse: + if isinstance(exc, AppException): + payload = { + "error": { + "message": exc.message, + "type": exc.error_type, + "code": exc.code, + } + } + else: + payload = { + "error": { + "message": str(exc) or "stream_error", + "type": "server_error", + "code": "stream_error", + } + } + + async def _one_shot_error() -> AsyncGenerator[str, None]: + yield f"event: error\ndata: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + _one_shot_error(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + +def _validate_image_config(image_conf: ImageConfig, *, stream: bool): + n = image_conf.n or 1 + if n < 1 or n > 10: + raise ValidationException( + message="n must be between 1 and 10", + param="image_config.n", + code="invalid_n", + ) + if stream and n not in (1, 2): + raise ValidationException( + message="Streaming is only supported when n=1 or n=2", + param="image_config.n", + code="invalid_stream_n", + ) + if image_conf.response_format: + allowed_formats = {"b64_json", "base64", "url"} + if image_conf.response_format not in allowed_formats: + raise ValidationException( + message="response_format must be one of b64_json, base64, url", + param="image_config.response_format", + code="invalid_response_format", + ) + if image_conf.size and image_conf.size not in ALLOWED_IMAGE_SIZES: + raise ValidationException( + message=f"size must be one of {sorted(ALLOWED_IMAGE_SIZES)}", + param="image_config.size", + code="invalid_size", + ) +def validate_request(request: ChatCompletionRequest): + """验证请求参数""" + # 验证模型 + if not ModelService.valid(request.model): + raise ValidationException( + message=f"The model `{request.model}` does not exist or you do not have access to it.", + param="model", + code="model_not_found", + ) + + # 验证消息 + for idx, msg in enumerate(request.messages): + if not isinstance(msg.role, str) or msg.role not in VALID_ROLES: + raise ValidationException( + message=f"role must be one of {sorted(VALID_ROLES)}", + param=f"messages.{idx}.role", + code="invalid_role", + ) + + # tool role: requires tool_call_id, content can be None/empty + if msg.role == "tool": + if not msg.tool_call_id: + raise ValidationException( + message="tool messages must have a 'tool_call_id' field", + param=f"messages.{idx}.tool_call_id", + code="missing_tool_call_id", + ) + continue + + # assistant with tool_calls: content can be None + if msg.role == "assistant" and msg.tool_calls: + continue + + content = msg.content + + # 兼容部分客户端会发送 assistant/tool 空内容(例如工具调用中间态) + if content is None: + if msg.role in {"assistant", "tool"}: + continue + raise ValidationException( + message="Message content cannot be null", + param=f"messages.{idx}.content", + code="empty_content", + ) + + # 字符串内容 + if isinstance(content, str): + if not content.strip(): + raise ValidationException( + message="Message content cannot be empty", + param=f"messages.{idx}.content", + code="empty_content", + ) + + # 列表内容 + elif isinstance(content, dict): + content = [content] + for c_idx, item in enumerate(content): + if not isinstance(item, dict): + raise ValidationException( + message="Message content items must be objects", + param=f"messages.{idx}.content.{c_idx}", + code="invalid_content_item", + ) + item_type = item.get("type") + if item_type != "text": + raise ValidationException( + message="When content is an object, type must be 'text'", + param=f"messages.{idx}.content.{c_idx}.type", + code="invalid_content_type", + ) + text = item.get("text", "") + if not isinstance(text, str) or not text.strip(): + raise ValidationException( + message="messages.%d.content.%d.text must be a non-empty string" + % (idx, c_idx), + param=f"messages.{idx}.content.{c_idx}.text", + code="empty_content", + ) + + elif isinstance(content, list): + if not content: + raise ValidationException( + message="Message content cannot be an empty array", + param=f"messages.{idx}.content", + code="empty_content", + ) + + for block_idx, block in enumerate(content): + # 检查空对象 + if not isinstance(block, dict): + raise ValidationException( + message="Content block must be an object", + param=f"messages.{idx}.content.{block_idx}", + code="invalid_block", + ) + if not block: + raise ValidationException( + message="Content block cannot be empty", + param=f"messages.{idx}.content.{block_idx}", + code="empty_block", + ) + + # 检查 type 字段 + if "type" not in block: + raise ValidationException( + message="Content block must have a 'type' field", + param=f"messages.{idx}.content.{block_idx}", + code="missing_type", + ) + + block_type = block.get("type") + + # 检查 type 空值 + if ( + not block_type + or not isinstance(block_type, str) + or not block_type.strip() + ): + raise ValidationException( + message="Content block 'type' cannot be empty", + param=f"messages.{idx}.content.{block_idx}.type", + code="empty_type", + ) + + # 验证 type 有效性 + if msg.role == "user": + if block_type not in USER_CONTENT_TYPES: + raise ValidationException( + message=f"Invalid content block type: '{block_type}'", + param=f"messages.{idx}.content.{block_idx}.type", + code="invalid_type", + ) + else: + if block_type != "text": + raise ValidationException( + message=f"The `{msg.role}` role only supports 'text' type, got '{block_type}'", + param=f"messages.{idx}.content.{block_idx}.type", + code="invalid_type", + ) + + # 验证字段是否存在 & 非空 + if block_type == "text": + text = block.get("text", "") + if not isinstance(text, str) or not text.strip(): + raise ValidationException( + message="Text content cannot be empty", + param=f"messages.{idx}.content.{block_idx}.text", + code="empty_text", + ) + elif block_type == "image_url": + image_url = block.get("image_url") + if not image_url or not isinstance(image_url, dict): + raise ValidationException( + message="image_url must have a 'url' field", + param=f"messages.{idx}.content.{block_idx}.image_url", + code="missing_url", + ) + _validate_media_input( + image_url.get("url", ""), + "image_url.url", + f"messages.{idx}.content.{block_idx}.image_url.url", + ) + elif block_type == "input_audio": + audio = block.get("input_audio") + if not audio or not isinstance(audio, dict): + raise ValidationException( + message="input_audio must have a 'data' field", + param=f"messages.{idx}.content.{block_idx}.input_audio", + code="missing_audio", + ) + _validate_media_input( + audio.get("data", ""), + "input_audio.data", + f"messages.{idx}.content.{block_idx}.input_audio.data", + ) + elif block_type == "file": + file_data = block.get("file") + if not file_data or not isinstance(file_data, dict): + raise ValidationException( + message="file must have a 'file_data' field", + param=f"messages.{idx}.content.{block_idx}.file", + code="missing_file", + ) + _validate_media_input( + file_data.get("file_data", ""), + "file.file_data", + f"messages.{idx}.content.{block_idx}.file.file_data", + ) + elif content is None: + raise ValidationException( + message="Message content cannot be empty", + param=f"messages.{idx}.content", + code="empty_content", + ) + else: + raise ValidationException( + message="Message content must be a string or array", + param=f"messages.{idx}.content", + code="invalid_content", + ) + + # 默认验证 + if request.stream is not None: + if isinstance(request.stream, bool): + pass + elif isinstance(request.stream, str): + if request.stream.lower() in ("true", "1", "yes"): + request.stream = True + elif request.stream.lower() in ("false", "0", "no"): + request.stream = False + else: + raise ValidationException( + message="stream must be a boolean", + param="stream", + code="invalid_stream", + ) + else: + raise ValidationException( + message="stream must be a boolean", + param="stream", + code="invalid_stream", + ) + + allowed_efforts = {"none", "minimal", "low", "medium", "high", "xhigh"} + if request.reasoning_effort is not None: + if not isinstance(request.reasoning_effort, str) or ( + request.reasoning_effort not in allowed_efforts + ): + raise ValidationException( + message=f"reasoning_effort must be one of {sorted(allowed_efforts)}", + param="reasoning_effort", + code="invalid_reasoning_effort", + ) + + if request.temperature is None: + request.temperature = 0.8 + else: + try: + request.temperature = float(request.temperature) + except Exception: + raise ValidationException( + message="temperature must be a float", + param="temperature", + code="invalid_temperature", + ) + if not (0 <= request.temperature <= 2): + raise ValidationException( + message="temperature must be between 0 and 2", + param="temperature", + code="invalid_temperature", + ) + + if request.top_p is None: + request.top_p = 0.95 + else: + try: + request.top_p = float(request.top_p) + except Exception: + raise ValidationException( + message="top_p must be a float", + param="top_p", + code="invalid_top_p", + ) + if not (0 <= request.top_p <= 1): + raise ValidationException( + message="top_p must be between 0 and 1", + param="top_p", + code="invalid_top_p", + ) + + # 验证 tools + if request.tools is not None: + if not isinstance(request.tools, list): + raise ValidationException( + message="tools must be an array", + param="tools", + code="invalid_tools", + ) + for t_idx, tool in enumerate(request.tools): + if not isinstance(tool, dict) or tool.get("type") != "function": + raise ValidationException( + message="Each tool must have type='function'", + param=f"tools.{t_idx}.type", + code="invalid_tool_type", + ) + func = tool.get("function") + if not isinstance(func, dict) or not func.get("name"): + raise ValidationException( + message="Each tool function must have a 'name'", + param=f"tools.{t_idx}.function.name", + code="missing_function_name", + ) + + # 验证 tool_choice + if request.tool_choice is not None: + if isinstance(request.tool_choice, str): + if request.tool_choice not in ("auto", "required", "none"): + raise ValidationException( + message="tool_choice must be 'auto', 'required', 'none', or a specific function object", + param="tool_choice", + code="invalid_tool_choice", + ) + elif isinstance(request.tool_choice, dict): + if request.tool_choice.get("type") != "function" or not request.tool_choice.get("function", {}).get("name"): + raise ValidationException( + message="tool_choice object must have type='function' and function.name", + param="tool_choice", + code="invalid_tool_choice", + ) + + model_info = ModelService.get(request.model) + # image 验证 + if model_info and (model_info.is_image or model_info.is_image_edit): + prompt, image_urls = _extract_prompt_images(request.messages) + if not prompt: + raise ValidationException( + message="Prompt cannot be empty", + param="messages", + code="empty_prompt", + ) + image_conf = _imagine_fast_server_image_config() if request.model == IMAGINE_FAST_MODEL_ID else (request.image_config or ImageConfig()) + n = image_conf.n or 1 + if not (1 <= n <= 10): + raise ValidationException( + message="n must be between 1 and 10", + param="image_config.n", + code="invalid_n", + ) + if request.stream and n not in (1, 2): + raise ValidationException( + message="Streaming is only supported when n=1 or n=2", + param="stream", + code="invalid_stream_n", + ) + + response_format = _resolve_image_format(image_conf.response_format) + image_conf.n = n + image_conf.response_format = response_format + if not image_conf.size: + image_conf.size = "1024x1024" + allowed_sizes = { + "1280x720", + "720x1280", + "1792x1024", + "1024x1792", + "1024x1024", + } + if image_conf.size not in allowed_sizes: + raise ValidationException( + message=f"size must be one of {sorted(allowed_sizes)}", + param="image_config.size", + code="invalid_size", + ) + request.image_config = image_conf + + # image edit 验证 + if model_info and model_info.is_image_edit: + _, image_urls = _extract_prompt_images(request.messages) + if not image_urls: + raise ValidationException( + message="image_url is required for image edits", + param="messages", + code="missing_image", + ) + + # video 验证 + if model_info and model_info.is_video: + config = request.video_config or VideoConfig() + 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", + } + if config.aspect_ratio is None: + config.aspect_ratio = "3:2" + if config.aspect_ratio not in ratio_map: + raise ValidationException( + message=f"aspect_ratio must be one of {list(ratio_map.keys())}", + param="video_config.aspect_ratio", + code="invalid_aspect_ratio", + ) + config.aspect_ratio = ratio_map[config.aspect_ratio] + + if config.video_length not in (6, 10, 15): + raise ValidationException( + message="video_length must be 6, 10, or 15 seconds", + param="video_config.video_length", + code="invalid_video_length", + ) + if config.resolution_name not in ("480p", "720p"): + raise ValidationException( + message="resolution_name must be one of ['480p', '720p']", + param="video_config.resolution_name", + code="invalid_resolution", + ) + if config.preset not in ("fun", "normal", "spicy", "custom"): + raise ValidationException( + message="preset must be one of ['fun', 'normal', 'spicy', 'custom']", + param="video_config.preset", + code="invalid_preset", + ) + request.video_config = config + + +router = APIRouter(tags=["Chat"]) + + +@router.post("/chat/completions") +async def chat_completions(request: ChatCompletionRequest): + """Chat Completions API - 兼容 OpenAI""" + from app.core.logger import logger + + # 参数验证 + validate_request(request) + + logger.debug(f"Chat request: model={request.model}, stream={request.stream}") + + # 检测模型类型 + model_info = ModelService.get(request.model) + if model_info and model_info.is_image_edit: + prompt, image_urls = _extract_prompt_images(request.messages) + if not image_urls: + raise ValidationException( + message="Image is required", + param="image", + code="missing_image", + ) + + is_stream = ( + request.stream if request.stream is not None else get_config("app.stream") + ) + image_conf = request.image_config or ImageConfig() + _validate_image_config(image_conf, stream=bool(is_stream)) + response_format = _resolve_image_format(image_conf.response_format) + response_field = _image_field(response_format) + n = image_conf.n or 1 + + token_mgr = await get_token_manager() + await token_mgr.reload_if_stale() + + token = None + for pool_name in ModelService.pool_candidates_for_model(request.model): + token = token_mgr.get_token(pool_name) + if token: + break + + if not token: + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + result = await ImageEditService().edit( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=prompt, + images=image_urls, + n=n, + response_format=response_format, + stream=bool(is_stream), + chat_format=True, + ) + + if result.stream: + return StreamingResponse( + _safe_sse_stream(result.data), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + content = result.data[0] if result.data else "" + return JSONResponse( + content=make_chat_response(request.model, content) + ) + + if model_info and model_info.is_image: + prompt, _ = _extract_prompt_images(request.messages) + + is_stream = ( + request.stream if request.stream is not None else get_config("app.stream") + ) + image_conf = _imagine_fast_server_image_config() if request.model == IMAGINE_FAST_MODEL_ID else (request.image_config or ImageConfig()) + _validate_image_config(image_conf, stream=bool(is_stream)) + response_format = _resolve_image_format(image_conf.response_format) + response_field = _image_field(response_format) + n = image_conf.n or 1 + size = image_conf.size or "1024x1024" + aspect_ratio_map = { + "1280x720": "16:9", + "720x1280": "9:16", + "1792x1024": "3:2", + "1024x1792": "2:3", + "1024x1024": "1:1", + } + aspect_ratio = aspect_ratio_map.get(size, "2:3") + + token_mgr = await get_token_manager() + await token_mgr.reload_if_stale() + + token = None + for pool_name in ModelService.pool_candidates_for_model(request.model): + token = token_mgr.get_token(pool_name) + if token: + break + + if not token: + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + result = await ImageGenerationService().generate( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=prompt, + n=n, + response_format=response_format, + size=size, + aspect_ratio=aspect_ratio, + stream=bool(is_stream), + chat_format=True, + ) + + if result.stream: + return StreamingResponse( + _safe_sse_stream(result.data), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + content = result.data[0] if result.data else "" + usage = result.usage_override + return JSONResponse( + content=make_chat_response(request.model, content, usage=usage) + ) + + if model_info and model_info.is_video: + # 提取视频配置 (默认值在 Pydantic 模型中处理) + v_conf = request.video_config or VideoConfig() + + try: + result = await VideoService.completions( + model=request.model, + messages=[msg.model_dump() for msg in request.messages], + stream=request.stream, + reasoning_effort=request.reasoning_effort, + aspect_ratio=v_conf.aspect_ratio, + video_length=v_conf.video_length, + resolution=v_conf.resolution_name, + preset=v_conf.preset, + ) + except Exception as e: + if request.stream is not False: + return _streaming_error_response(e) + raise + else: + try: + result = await ChatService.completions( + model=request.model, + messages=[msg.model_dump() for msg in request.messages], + stream=request.stream, + reasoning_effort=request.reasoning_effort, + temperature=request.temperature, + top_p=request.top_p, + tools=request.tools, + tool_choice=request.tool_choice, + parallel_tool_calls=request.parallel_tool_calls, + ) + except Exception as e: + if request.stream is not False: + return _streaming_error_response(e) + raise + + if isinstance(result, dict): + return JSONResponse(content=result) + else: + return StreamingResponse( + _safe_sse_stream(result), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +__all__ = ["router"] diff --git a/app/api/v1/files.py b/app/api/v1/files.py new file mode 100644 index 0000000000000000000000000000000000000000..0c630bbc2a199220af27ba4229ebb7a546b98f63 --- /dev/null +++ b/app/api/v1/files.py @@ -0,0 +1,69 @@ +""" +文件服务 API 路由 +""" + +import aiofiles.os +from pathlib import Path +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.core.logger import logger +from app.core.storage import DATA_DIR + +router = APIRouter(tags=["Files"]) + +# 缓存根目录 +BASE_DIR = DATA_DIR / "tmp" +IMAGE_DIR = BASE_DIR / "image" +VIDEO_DIR = BASE_DIR / "video" + + +@router.get("/image/{filename:path}") +async def get_image(filename: str): + """ + 获取图片文件 + """ + if "/" in filename: + filename = filename.replace("/", "-") + + file_path = IMAGE_DIR / filename + + if await aiofiles.os.path.exists(file_path): + if await aiofiles.os.path.isfile(file_path): + content_type = "image/jpeg" + if file_path.suffix.lower() == ".png": + content_type = "image/png" + elif file_path.suffix.lower() == ".webp": + content_type = "image/webp" + + # 增加缓存头,支持高并发场景下的浏览器/CDN缓存 + return FileResponse( + file_path, + media_type=content_type, + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + + logger.warning(f"Image not found: {filename}") + raise HTTPException(status_code=404, detail="Image not found") + + +@router.get("/video/{filename:path}") +async def get_video(filename: str): + """ + 获取视频文件 + """ + if "/" in filename: + filename = filename.replace("/", "-") + + file_path = VIDEO_DIR / filename + + if await aiofiles.os.path.exists(file_path): + if await aiofiles.os.path.isfile(file_path): + return FileResponse( + file_path, + media_type="video/mp4", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + + logger.warning(f"Video not found: {filename}") + raise HTTPException(status_code=404, detail="Video not found") diff --git a/app/api/v1/image.py b/app/api/v1/image.py new file mode 100644 index 0000000000000000000000000000000000000000..88d643f02c4d53666433f112c40e03d874165952 --- /dev/null +++ b/app/api/v1/image.py @@ -0,0 +1,452 @@ +""" +Image Generation API 路由 +""" + +import base64 +import time +from pathlib import Path +from typing import List, Optional, Union + +from fastapi import APIRouter, File, Form, UploadFile +from fastapi.responses import StreamingResponse, JSONResponse +from pydantic import BaseModel, Field, ValidationError + +from app.services.grok.services.image import ImageGenerationService +from app.services.grok.services.image_edit import ImageEditService +from app.services.grok.services.model import ModelService +from app.services.token import get_token_manager +from app.core.exceptions import ValidationException, AppException, ErrorType +from app.core.config import get_config + + +router = APIRouter(tags=["Images"]) + +ALLOWED_IMAGE_SIZES = { + "1280x720", + "720x1280", + "1792x1024", + "1024x1792", + "1024x1024", +} + +SIZE_TO_ASPECT = { + "1280x720": "16:9", + "720x1280": "9:16", + "1792x1024": "3:2", + "1024x1792": "2:3", + "1024x1024": "1:1", +} +ALLOWED_ASPECT_RATIOS = {"1:1", "2:3", "3:2", "9:16", "16:9"} + + +class ImageGenerationRequest(BaseModel): + """图片生成请求 - OpenAI 兼容""" + + prompt: str = Field(..., description="图片描述") + model: Optional[str] = Field("grok-imagine-1.0", description="模型名称") + n: Optional[int] = Field(1, ge=1, le=10, description="生成数量 (1-10)") + size: Optional[str] = Field( + "1024x1024", + description="图片尺寸: 1280x720, 720x1280, 1792x1024, 1024x1792, 1024x1024", + ) + quality: Optional[str] = Field("standard", description="图片质量 (暂不支持)") + response_format: Optional[str] = Field(None, description="响应格式") + style: Optional[str] = Field(None, description="风格 (暂不支持)") + stream: Optional[bool] = Field(False, description="是否流式输出") + + +class ImageEditRequest(BaseModel): + """图片编辑请求 - OpenAI 兼容""" + + prompt: str = Field(..., description="编辑描述") + model: Optional[str] = Field("grok-imagine-1.0-edit", description="模型名称") + image: Optional[Union[str, List[str]]] = Field(None, description="待编辑图片文件") + n: Optional[int] = Field(1, ge=1, le=10, description="生成数量 (1-10)") + size: Optional[str] = Field( + "1024x1024", + description="图片尺寸: 1280x720, 720x1280, 1792x1024, 1024x1792, 1024x1024", + ) + quality: Optional[str] = Field("standard", description="图片质量 (暂不支持)") + response_format: Optional[str] = Field(None, description="响应格式") + style: Optional[str] = Field(None, description="风格 (暂不支持)") + stream: Optional[bool] = Field(False, description="是否流式输出") + + +def _validate_common_request( + request: Union[ImageGenerationRequest, ImageEditRequest], + *, + allow_ws_stream: bool = False, +): + """通用参数校验""" + # 验证 prompt + if not request.prompt or not request.prompt.strip(): + raise ValidationException( + message="Prompt cannot be empty", param="prompt", code="empty_prompt" + ) + + # 验证 n 参数范围 + if request.n < 1 or request.n > 10: + raise ValidationException( + message="n must be between 1 and 10", param="n", code="invalid_n" + ) + + # 流式只支持 n=1 或 n=2 + if request.stream and request.n not in [1, 2]: + raise ValidationException( + message="Streaming is only supported when n=1 or n=2", + param="stream", + code="invalid_stream_n", + ) + + if allow_ws_stream: + if request.stream and request.response_format: + allowed_stream_formats = {"b64_json", "base64", "url"} + if request.response_format not in allowed_stream_formats: + raise ValidationException( + message="Streaming only supports response_format=b64_json/base64/url", + param="response_format", + code="invalid_response_format", + ) + + if request.response_format: + allowed_formats = {"b64_json", "base64", "url"} + if request.response_format not in allowed_formats: + raise ValidationException( + message=f"response_format must be one of {sorted(allowed_formats)}", + param="response_format", + code="invalid_response_format", + ) + + if request.size and request.size not in ALLOWED_IMAGE_SIZES: + raise ValidationException( + message=f"size must be one of {sorted(ALLOWED_IMAGE_SIZES)}", + param="size", + code="invalid_size", + ) + + +def validate_generation_request(request: ImageGenerationRequest): + """验证图片生成请求参数""" + if request.model != "grok-imagine-1.0": + raise ValidationException( + message="The model `grok-imagine-1.0` is required for image generation.", + param="model", + code="model_not_supported", + ) + # 验证模型 - 通过 is_image 检查 + model_info = ModelService.get(request.model) + if not model_info or not model_info.is_image: + # 获取支持的图片模型列表 + image_models = [m.model_id for m in ModelService.MODELS if m.is_image] + raise ValidationException( + message=( + f"The model `{request.model}` is not supported for image generation. " + f"Supported: {image_models}" + ), + param="model", + code="model_not_supported", + ) + _validate_common_request(request, allow_ws_stream=True) + + +def resolve_response_format(response_format: Optional[str]) -> str: + """解析响应格式""" + fmt = response_format or get_config("app.image_format") + if isinstance(fmt, str): + fmt = fmt.lower() + if fmt in ("b64_json", "base64", "url"): + return fmt + raise ValidationException( + message="response_format must be one of b64_json, base64, url", + param="response_format", + code="invalid_response_format", + ) + + +def response_field_name(response_format: str) -> str: + """获取响应字段名""" + return {"url": "url", "base64": "base64"}.get(response_format, "b64_json") + + +def resolve_aspect_ratio(size: str) -> str: + """Map OpenAI size to Grok Imagine aspect ratio.""" + value = (size or "").strip() + if not value: + return "2:3" + if value in SIZE_TO_ASPECT: + return SIZE_TO_ASPECT[value] + if ":" in value: + try: + left, right = value.split(":", 1) + left_i = int(left.strip()) + right_i = int(right.strip()) + if left_i > 0 and right_i > 0: + ratio = f"{left_i}:{right_i}" + if ratio in ALLOWED_ASPECT_RATIOS: + return ratio + except (TypeError, ValueError): + pass + return "2:3" + + +def validate_edit_request(request: ImageEditRequest, images: List[UploadFile]): + """验证图片编辑请求参数""" + if request.model != "grok-imagine-1.0-edit": + raise ValidationException( + message=("The model `grok-imagine-1.0-edit` is required for image edits."), + param="model", + code="model_not_supported", + ) + model_info = ModelService.get(request.model) + if not model_info or not model_info.is_image_edit: + edit_models = [m.model_id for m in ModelService.MODELS if m.is_image_edit] + raise ValidationException( + message=( + f"The model `{request.model}` is not supported for image edits. " + f"Supported: {edit_models}" + ), + param="model", + code="model_not_supported", + ) + _validate_common_request(request, allow_ws_stream=False) + if not images: + raise ValidationException( + message="Image is required", + param="image", + code="missing_image", + ) + if len(images) > 16: + raise ValidationException( + message="Too many images. Maximum is 16.", + param="image", + code="invalid_image_count", + ) + + +async def _get_token(model: str): + """获取可用 token""" + token_mgr = await get_token_manager() + await token_mgr.reload_if_stale() + + token = None + for pool_name in ModelService.pool_candidates_for_model(model): + token = token_mgr.get_token(pool_name) + if token: + break + + if not token: + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + return token_mgr, token + + +@router.post("/images/generations") +async def create_image(request: ImageGenerationRequest): + """ + Image Generation API + + 流式响应格式: + - event: image_generation.partial_image + - event: image_generation.completed + + 非流式响应格式: + - {"created": ..., "data": [{"b64_json": "..."}], "usage": {...}} + """ + # stream 默认为 false + if request.stream is None: + request.stream = False + + if request.response_format is None: + request.response_format = resolve_response_format(None) + + # 参数验证 + validate_generation_request(request) + + # 兼容 base64/b64_json + if request.response_format == "base64": + request.response_format = "b64_json" + + response_format = resolve_response_format(request.response_format) + response_field = response_field_name(response_format) + + # 获取 token 和模型信息 + token_mgr, token = await _get_token(request.model) + model_info = ModelService.get(request.model) + aspect_ratio = resolve_aspect_ratio(request.size) + + result = await ImageGenerationService().generate( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=request.prompt, + n=request.n, + response_format=response_format, + size=request.size, + aspect_ratio=aspect_ratio, + stream=bool(request.stream), + ) + + if result.stream: + return StreamingResponse( + result.data, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + data = [{response_field: img} for img in result.data] + usage = result.usage_override or { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": {"text_tokens": 0, "image_tokens": 0}, + } + + return JSONResponse( + content={ + "created": int(time.time()), + "data": data, + "usage": usage, + } + ) + + +@router.post("/images/edits") +async def edit_image( + prompt: str = Form(...), + image: List[UploadFile] = File(...), + model: Optional[str] = Form("grok-imagine-1.0-edit"), + n: int = Form(1), + size: str = Form("1024x1024"), + quality: str = Form("standard"), + response_format: Optional[str] = Form(None), + style: Optional[str] = Form(None), + stream: Optional[bool] = Form(False), +): + """ + Image Edits API + + 同官方 API 格式,仅支持 multipart/form-data 文件上传 + """ + if response_format is None: + response_format = resolve_response_format(None) + + try: + edit_request = ImageEditRequest( + prompt=prompt, + model=model, + n=n, + size=size, + quality=quality, + response_format=response_format, + style=style, + stream=stream, + ) + except ValidationError as exc: + errors = exc.errors() + if errors: + first = errors[0] + loc = first.get("loc", []) + msg = first.get("msg", "Invalid request") + code = first.get("type", "invalid_value") + param_parts = [ + str(x) for x in loc if not (isinstance(x, int) or str(x).isdigit()) + ] + param = ".".join(param_parts) if param_parts else None + raise ValidationException(message=msg, param=param, code=code) + raise ValidationException(message="Invalid request", code="invalid_value") + + if edit_request.stream is None: + edit_request.stream = False + + response_format = resolve_response_format(edit_request.response_format) + if response_format == "base64": + response_format = "b64_json" + edit_request.response_format = response_format + response_field = response_field_name(response_format) + + # 参数验证 + validate_edit_request(edit_request, image) + + max_image_bytes = 50 * 1024 * 1024 + allowed_types = {"image/png", "image/jpeg", "image/webp", "image/jpg"} + + images: List[str] = [] + for item in image: + content = await item.read() + await item.close() + if not content: + raise ValidationException( + message="File content is empty", + param="image", + code="empty_file", + ) + if len(content) > max_image_bytes: + raise ValidationException( + message="Image file too large. Maximum is 50MB.", + param="image", + code="file_too_large", + ) + mime = (item.content_type or "").lower() + if mime == "image/jpg": + mime = "image/jpeg" + ext = Path(item.filename or "").suffix.lower() + if mime not in allowed_types: + if ext in (".jpg", ".jpeg"): + mime = "image/jpeg" + elif ext == ".png": + mime = "image/png" + elif ext == ".webp": + mime = "image/webp" + else: + raise ValidationException( + message="Unsupported image type. Supported: png, jpg, webp.", + param="image", + code="invalid_image_type", + ) + b64 = base64.b64encode(content).decode() + images.append(f"data:{mime};base64,{b64}") + + # 获取 token 和模型信息 + token_mgr, token = await _get_token(edit_request.model) + model_info = ModelService.get(edit_request.model) + + result = await ImageEditService().edit( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=edit_request.prompt, + images=images, + n=edit_request.n, + response_format=response_format, + stream=bool(edit_request.stream), + ) + + if result.stream: + return StreamingResponse( + result.data, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + data = [{response_field: img} for img in result.data] + + return JSONResponse( + content={ + "created": int(time.time()), + "data": data, + "usage": { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": {"text_tokens": 0, "image_tokens": 0}, + }, + } + ) + + +__all__ = ["router"] diff --git a/app/api/v1/models.py b/app/api/v1/models.py new file mode 100644 index 0000000000000000000000000000000000000000..fe5bdc0e131955bc21298705d600c473ec986888 --- /dev/null +++ b/app/api/v1/models.py @@ -0,0 +1,28 @@ +""" +Models API 路由 +""" + +from fastapi import APIRouter + +from app.services.grok.services.model import ModelService + + +router = APIRouter(tags=["Models"]) + + +@router.get("/models") +async def list_models(): + """OpenAI 兼容 models 列表接口""" + data = [ + { + "id": m.model_id, + "object": "model", + "created": 0, + "owned_by": "grok2api@chenyme", + } + for m in ModelService.list() + ] + return {"object": "list", "data": data} + + +__all__ = ["router"] diff --git a/app/api/v1/public_api/__init__.py b/app/api/v1/public_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..56f7159905e5b529f1449d2690ac96a0f6529fb5 --- /dev/null +++ b/app/api/v1/public_api/__init__.py @@ -0,0 +1,18 @@ +"""Public API router (public_key protected).""" + +from fastapi import APIRouter, Depends + +from app.api.v1.chat import router as chat_router +from app.api.v1.public_api.imagine import router as imagine_router +from app.api.v1.public_api.video import router as video_router +from app.api.v1.public_api.voice import router as voice_router +from app.core.auth import verify_public_key + +router = APIRouter() + +router.include_router(chat_router, dependencies=[Depends(verify_public_key)]) +router.include_router(imagine_router) +router.include_router(video_router) +router.include_router(voice_router) + +__all__ = ["router"] diff --git a/app/api/v1/public_api/imagine.py b/app/api/v1/public_api/imagine.py new file mode 100644 index 0000000000000000000000000000000000000000..83f59c341b4bbbb64920df924fa6b98a8972aa04 --- /dev/null +++ b/app/api/v1/public_api/imagine.py @@ -0,0 +1,505 @@ +import asyncio +import time +import uuid +from typing import Optional, List, Dict, Any + +import orjson +from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect +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 +from app.services.grok.services.model import ModelService +from app.services.token.manager import get_token_manager + +router = APIRouter() + +IMAGINE_SESSION_TTL = 600 +_IMAGINE_SESSIONS: dict[str, dict] = {} +_IMAGINE_SESSIONS_LOCK = asyncio.Lock() + + +async def _clean_sessions(now: float) -> None: + expired = [ + key + for key, info in _IMAGINE_SESSIONS.items() + if now - float(info.get("created_at") or 0) > IMAGINE_SESSION_TTL + ] + for key in expired: + _IMAGINE_SESSIONS.pop(key, None) + + +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: + await _clean_sessions(now) + _IMAGINE_SESSIONS[task_id] = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "nsfw": nsfw, + "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 _IMAGINE_SESSIONS_LOCK: + await _clean_sessions(now) + info = _IMAGINE_SESSIONS.get(task_id) + if not info: + return None + created_at = float(info.get("created_at") or 0) + if now - created_at > IMAGINE_SESSION_TTL: + _IMAGINE_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 _IMAGINE_SESSIONS_LOCK: + _IMAGINE_SESSIONS.pop(task_id, None) + + +async def _drop_sessions(task_ids: List[str]) -> int: + if not task_ids: + return 0 + removed = 0 + async with _IMAGINE_SESSIONS_LOCK: + for task_id in task_ids: + if task_id and task_id in _IMAGINE_SESSIONS: + _IMAGINE_SESSIONS.pop(task_id, None) + removed += 1 + return removed + + +@router.websocket("/imagine/ws") +async def public_imagine_ws(websocket: WebSocket): + session_id = None + task_id = websocket.query_params.get("task_id") + if task_id: + info = await _get_session(task_id) + if info: + session_id = task_id + + ok = True + if session_id is None: + public_key = get_public_api_key() + public_enabled = is_public_enabled() + if not public_key: + ok = public_enabled + else: + key = websocket.query_params.get("public_key") + ok = key == public_key + + if not ok: + await websocket.close(code=1008) + return + + await websocket.accept() + stop_event = asyncio.Event() + run_task: Optional[asyncio.Task] = None + + async def _send(payload: dict) -> bool: + try: + await websocket.send_text(orjson.dumps(payload).decode()) + return True + except Exception: + return False + + async def _stop_run(): + nonlocal run_task + stop_event.set() + if run_task and not run_task.done(): + run_task.cancel() + try: + await run_task + except Exception: + pass + run_task = None + stop_event.clear() + + async def _run(prompt: str, aspect_ratio: str, 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: + await _send( + { + "type": "error", + "message": "Image model is not available.", + "code": "model_not_supported", + } + ) + return + + token_mgr = await get_token_manager() + run_id = uuid.uuid4().hex + + await _send( + { + "type": "status", + "status": "running", + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "run_id": run_id, + } + ) + + while not stop_event.is_set(): + try: + await token_mgr.reload_if_stale() + token = None + for pool_name in ModelService.pool_candidates_for_model( + model_info.model_id + ): + token = token_mgr.get_token(pool_name) + if token: + break + + if not token: + await _send( + { + "type": "error", + "message": "No available tokens. Please try again later.", + "code": "rate_limit_exceeded", + } + ) + await asyncio.sleep(2) + continue + + result = await ImageGenerationService().generate( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=prompt, + n=6, + response_format="b64_json", + size="1024x1024", + aspect_ratio=aspect_ratio, + stream=True, + enable_nsfw=nsfw, + ) + 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": "error", + "message": "Image generation returned empty data.", + "code": "empty_image", + } + ) + + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Imagine stream error: {e}") + await _send( + { + "type": "error", + "message": str(e), + "code": "internal_error", + } + ) + await asyncio.sleep(1.5) + + await _send({"type": "status", "status": "stopped", "run_id": run_id}) + + try: + while True: + try: + raw = await websocket.receive_text() + except (RuntimeError, WebSocketDisconnect): + break + + try: + payload = orjson.loads(raw) + except Exception: + await _send( + { + "type": "error", + "message": "Invalid message format.", + "code": "invalid_payload", + } + ) + continue + + action = payload.get("type") + if action == "start": + prompt = str(payload.get("prompt") or "").strip() + if not prompt: + await _send( + { + "type": "error", + "message": "Prompt cannot be empty.", + "code": "invalid_prompt", + } + ) + continue + 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, nsfw)) + elif action == "stop": + await _stop_run() + else: + await _send( + { + "type": "error", + "message": "Unknown action.", + "code": "invalid_action", + } + ) + + except WebSocketDisconnect: + logger.debug("WebSocket disconnected by client") + except Exception as e: + logger.warning(f"WebSocket error: {e}") + finally: + await _stop_run() + + try: + from starlette.websockets import WebSocketState + if websocket.client_state == WebSocketState.CONNECTED: + await websocket.close(code=1000, reason="Server closing connection") + except Exception as e: + logger.debug(f"WebSocket close ignored: {e}") + if session_id: + await _drop_session(session_id) + + +@router.get("/imagine/sse") +async def public_imagine_sse( + request: Request, + task_id: str = Query(""), + prompt: str = Query(""), + aspect_ratio: str = Query("2:3"), +): + """Imagine 图片瀑布流(SSE 兜底)""" + session = None + if task_id: + session = await _get_session(task_id) + if not session: + raise HTTPException(status_code=404, detail="Task not found") + else: + public_key = get_public_api_key() + public_enabled = is_public_enabled() + if not public_key: + if not public_enabled: + raise HTTPException(status_code=401, detail="Public access is disabled") + else: + key = request.query_params.get("public_key") + if key != public_key: + raise HTTPException(status_code=401, detail="Invalid authentication token") + + 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: + model_id = "grok-imagine-1.0" + model_info = ModelService.get(model_id) + if not model_info or not model_info.is_image: + yield ( + f"data: {orjson.dumps({'type': 'error', 'message': 'Image model is not available.', 'code': 'model_not_supported'}).decode()}\n\n" + ) + return + + token_mgr = await get_token_manager() + sequence = 0 + run_id = uuid.uuid4().hex + + yield ( + f"data: {orjson.dumps({'type': 'status', 'status': 'running', 'prompt': prompt, 'aspect_ratio': ratio, 'run_id': run_id}).decode()}\n\n" + ) + + while True: + if await request.is_disconnected(): + break + if task_id: + session_alive = await _get_session(task_id) + if not session_alive: + break + + try: + await token_mgr.reload_if_stale() + token = None + for pool_name in ModelService.pool_candidates_for_model( + model_info.model_id + ): + token = token_mgr.get_token(pool_name) + if token: + break + + if not token: + yield ( + f"data: {orjson.dumps({'type': 'error', 'message': 'No available tokens. Please try again later.', 'code': 'rate_limit_exceeded'}).decode()}\n\n" + ) + await asyncio.sleep(2) + continue + + result = await ImageGenerationService().generate( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=prompt, + n=6, + response_format="b64_json", + size="1024x1024", + aspect_ratio=ratio, + stream=True, + enable_nsfw=nsfw, + ) + 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: + 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: + logger.warning(f"Imagine SSE error: {e}") + yield ( + f"data: {orjson.dumps({'type': 'error', 'message': str(e), 'code': 'internal_error'}).decode()}\n\n" + ) + await asyncio.sleep(1.5) + + yield ( + f"data: {orjson.dumps({'type': 'status', 'status': 'stopped', 'run_id': run_id}).decode()}\n\n" + ) + finally: + if task_id: + await _drop_session(task_id) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +@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)]) +async def public_imagine_start(data: ImagineStartRequest): + prompt = (data.prompt or "").strip() + 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, data.nsfw) + return {"task_id": task_id, "aspect_ratio": ratio} + + +class ImagineStopRequest(BaseModel): + task_ids: List[str] + + +@router.post("/imagine/stop", dependencies=[Depends(verify_public_key)]) +async def public_imagine_stop(data: ImagineStopRequest): + removed = await _drop_sessions(data.task_ids or []) + return {"status": "success", "removed": removed} diff --git a/app/api/v1/public_api/video.py b/app/api/v1/public_api/video.py new file mode 100644 index 0000000000000000000000000000000000000000..c88182c862111de4d4256b8ee1865b2846a3a658 --- /dev/null +++ b/app/api/v1/public_api/video.py @@ -0,0 +1,274 @@ +import asyncio +import time +import uuid +from typing import Optional, List, Dict, Any + +import orjson +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.core.auth import verify_public_key +from app.core.logger import logger +from app.services.grok.services.video import VideoService +from app.services.grok.services.model import ModelService + +router = APIRouter() + +VIDEO_SESSION_TTL = 600 +_VIDEO_SESSIONS: dict[str, dict] = {} +_VIDEO_SESSIONS_LOCK = asyncio.Lock() + +_VIDEO_RATIO_MAP = { + "1280x720": "16:9", + "720x1280": "9:16", + "1792x1024": "3:2", + "1024x1792": "2:3", + "1024x1024": "1:1", + "16:9": "16:9", + "9:16": "9:16", + "3:2": "3:2", + "2:3": "2:3", + "1:1": "1:1", +} + + +async def _clean_sessions(now: float) -> None: + expired = [ + key + for key, info in _VIDEO_SESSIONS.items() + if now - float(info.get("created_at") or 0) > VIDEO_SESSION_TTL + ] + for key in expired: + _VIDEO_SESSIONS.pop(key, None) + + +async def _new_session( + prompt: str, + aspect_ratio: str, + video_length: int, + resolution_name: str, + preset: str, + image_url: Optional[str], + reasoning_effort: Optional[str], +) -> str: + task_id = uuid.uuid4().hex + now = time.time() + async with _VIDEO_SESSIONS_LOCK: + await _clean_sessions(now) + _VIDEO_SESSIONS[task_id] = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "video_length": video_length, + "resolution_name": resolution_name, + "preset": preset, + "image_url": image_url, + "reasoning_effort": reasoning_effort, + "created_at": now, + } + return task_id + + +async def _get_session(task_id: str) -> Optional[dict]: + if not task_id: + return None + now = time.time() + async with _VIDEO_SESSIONS_LOCK: + await _clean_sessions(now) + info = _VIDEO_SESSIONS.get(task_id) + if not info: + return None + created_at = float(info.get("created_at") or 0) + if now - created_at > VIDEO_SESSION_TTL: + _VIDEO_SESSIONS.pop(task_id, None) + return None + return dict(info) + + +async def _drop_session(task_id: str) -> None: + if not task_id: + return + async with _VIDEO_SESSIONS_LOCK: + _VIDEO_SESSIONS.pop(task_id, None) + + +async def _drop_sessions(task_ids: List[str]) -> int: + if not task_ids: + return 0 + removed = 0 + async with _VIDEO_SESSIONS_LOCK: + for task_id in task_ids: + if task_id and task_id in _VIDEO_SESSIONS: + _VIDEO_SESSIONS.pop(task_id, None) + removed += 1 + return removed + + +def _normalize_ratio(value: Optional[str]) -> str: + raw = (value or "").strip() + return _VIDEO_RATIO_MAP.get(raw, "") + + +def _validate_image_url(image_url: str) -> None: + value = (image_url or "").strip() + if not value: + return + if value.startswith("data:"): + return + if value.startswith("http://") or value.startswith("https://"): + return + raise HTTPException( + status_code=400, + detail="image_url must be a URL or data URI (data:;base64,...)", + ) + + +class VideoStartRequest(BaseModel): + prompt: str + aspect_ratio: Optional[str] = "3:2" + video_length: Optional[int] = 6 + resolution_name: Optional[str] = "480p" + preset: Optional[str] = "normal" + image_url: Optional[str] = None + reasoning_effort: Optional[str] = None + + +@router.post("/video/start", dependencies=[Depends(verify_public_key)]) +async def public_video_start(data: VideoStartRequest): + prompt = (data.prompt or "").strip() + if not prompt: + raise HTTPException(status_code=400, detail="Prompt cannot be empty") + + aspect_ratio = _normalize_ratio(data.aspect_ratio) + if not aspect_ratio: + raise HTTPException( + status_code=400, + detail="aspect_ratio must be one of ['16:9','9:16','3:2','2:3','1:1']", + ) + + video_length = int(data.video_length or 6) + if video_length not in (6, 10, 15): + raise HTTPException( + status_code=400, detail="video_length must be 6, 10, or 15 seconds" + ) + + resolution_name = str(data.resolution_name or "480p") + if resolution_name not in ("480p", "720p"): + raise HTTPException( + status_code=400, + detail="resolution_name must be one of ['480p','720p']", + ) + + preset = str(data.preset or "normal") + if preset not in ("fun", "normal", "spicy", "custom"): + raise HTTPException( + status_code=400, + detail="preset must be one of ['fun','normal','spicy','custom']", + ) + + image_url = (data.image_url or "").strip() or None + if image_url: + _validate_image_url(image_url) + + reasoning_effort = (data.reasoning_effort or "").strip() or None + if reasoning_effort: + allowed = {"none", "minimal", "low", "medium", "high", "xhigh"} + if reasoning_effort not in allowed: + raise HTTPException( + status_code=400, + detail=f"reasoning_effort must be one of {sorted(allowed)}", + ) + + task_id = await _new_session( + prompt, + aspect_ratio, + video_length, + resolution_name, + preset, + image_url, + reasoning_effort, + ) + return {"task_id": task_id, "aspect_ratio": aspect_ratio} + + +@router.get("/video/sse") +async def public_video_sse(request: Request, task_id: str = Query("")): + session = await _get_session(task_id) + if not session: + raise HTTPException(status_code=404, detail="Task not found") + + prompt = str(session.get("prompt") or "").strip() + aspect_ratio = str(session.get("aspect_ratio") or "3:2") + video_length = int(session.get("video_length") or 6) + resolution_name = str(session.get("resolution_name") or "480p") + preset = str(session.get("preset") or "normal") + image_url = session.get("image_url") + reasoning_effort = session.get("reasoning_effort") + + async def event_stream(): + try: + model_id = "grok-imagine-1.0-video" + model_info = ModelService.get(model_id) + if not model_info or not model_info.is_video: + payload = { + "error": "Video model is not available.", + "code": "model_not_supported", + } + yield f"data: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + return + + if image_url: + messages: List[Dict[str, Any]] = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ] + else: + messages = [{"role": "user", "content": prompt}] + + stream = await VideoService.completions( + model_id, + messages, + stream=True, + reasoning_effort=reasoning_effort, + aspect_ratio=aspect_ratio, + video_length=video_length, + resolution=resolution_name, + preset=preset, + ) + + async for chunk in stream: + if await request.is_disconnected(): + break + yield chunk + except Exception as e: + logger.warning(f"Public video SSE error: {e}") + payload = {"error": str(e), "code": "internal_error"} + yield f"data: {orjson.dumps(payload).decode()}\n\n" + yield "data: [DONE]\n\n" + finally: + await _drop_session(task_id) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +class VideoStopRequest(BaseModel): + task_ids: List[str] + + +@router.post("/video/stop", dependencies=[Depends(verify_public_key)]) +async def public_video_stop(data: VideoStopRequest): + removed = await _drop_sessions(data.task_ids or []) + return {"status": "success", "removed": removed} + + +__all__ = ["router"] diff --git a/app/api/v1/public_api/voice.py b/app/api/v1/public_api/voice.py new file mode 100644 index 0000000000000000000000000000000000000000..12612f0989e06157760ec23f94afbe1e061d6a02 --- /dev/null +++ b/app/api/v1/public_api/voice.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.core.auth import verify_public_key +from app.core.exceptions import AppException +from app.services.grok.services.voice import VoiceService +from app.services.token.manager import get_token_manager + +router = APIRouter() + + +class VoiceTokenResponse(BaseModel): + token: str + url: str + participant_name: str = "" + room_name: str = "" + + +@router.get( + "/voice/token", + dependencies=[Depends(verify_public_key)], + response_model=VoiceTokenResponse, +) +async def public_voice_token( + voice: str = "ara", + personality: str = "assistant", + speed: float = 1.0, +): + """获取 Grok Voice Mode (LiveKit) Token""" + token_mgr = await get_token_manager() + sso_token = None + for pool_name in ("ssoBasic", "ssoSuper"): + sso_token = token_mgr.get_token(pool_name) + if sso_token: + break + + if not sso_token: + raise AppException( + "No available tokens for voice mode", + code="no_token", + status_code=503, + ) + + service = VoiceService() + try: + data = await service.get_token( + token=sso_token, + voice=voice, + personality=personality, + speed=speed, + ) + token = data.get("token") + if not token: + raise AppException( + "Upstream returned no voice token", + code="upstream_error", + status_code=502, + ) + + return VoiceTokenResponse( + token=token, + url="wss://livekit.grok.com", + participant_name="", + room_name="", + ) + + except Exception as e: + if isinstance(e, AppException): + raise + raise AppException( + f"Voice token error: {str(e)}", + code="voice_error", + status_code=500, + ) + + +@router.get("/verify", dependencies=[Depends(verify_public_key)]) +async def public_verify_api(): + """验证 Public Key""" + return {"status": "success"} diff --git a/app/api/v1/response.py b/app/api/v1/response.py new file mode 100644 index 0000000000000000000000000000000000000000..b06aed84bbb3e23700a04799c00184fa11431728 --- /dev/null +++ b/app/api/v1/response.py @@ -0,0 +1,81 @@ +""" +Responses API 路由 (OpenAI compatible). +""" + +from typing import Any, Dict, List, Optional, Union + +from fastapi import APIRouter +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, Field + +from app.core.exceptions import ValidationException +from app.services.grok.services.responses import ResponsesService + + +router = APIRouter(tags=["Responses"]) + + +class ResponseCreateRequest(BaseModel): + model: str = Field(..., description="Model name") + input: Optional[Any] = Field(None, description="Input content") + instructions: Optional[str] = Field(None, description="System instructions") + stream: Optional[bool] = Field(False, description="Stream response") + max_output_tokens: Optional[int] = Field(None, description="Max output tokens") + temperature: Optional[float] = Field(None, description="Sampling temperature") + top_p: Optional[float] = Field(None, description="Nucleus sampling") + tools: Optional[List[Dict[str, Any]]] = Field(None, description="Tool definitions") + tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Tool choice") + parallel_tool_calls: Optional[bool] = Field(True, description="Allow parallel tool calls") + reasoning: Optional[Dict[str, Any]] = Field(None, description="Reasoning options") + metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata") + user: Optional[str] = Field(None, description="User identifier") + store: Optional[bool] = Field(None, description="Store response") + previous_response_id: Optional[str] = Field(None, description="Previous response id") + truncation: Optional[str] = Field(None, description="Truncation behavior") + + class Config: + extra = "allow" + + +@router.post("/responses") +async def create_response(request: ResponseCreateRequest): + if not request.model: + raise ValidationException(message="model is required", param="model", code="invalid_request_error") + + if request.input is None: + raise ValidationException(message="input is required", param="input", code="invalid_request_error") + + reasoning_effort = None + if isinstance(request.reasoning, dict): + reasoning_effort = request.reasoning.get("effort") or request.reasoning.get("reasoning_effort") + + result = await ResponsesService.create( + model=request.model, + input_value=request.input, + instructions=request.instructions, + stream=bool(request.stream), + temperature=request.temperature, + top_p=request.top_p, + tools=request.tools, + tool_choice=request.tool_choice, + parallel_tool_calls=request.parallel_tool_calls, + reasoning_effort=reasoning_effort, + max_output_tokens=request.max_output_tokens, + metadata=request.metadata, + user=request.user, + store=request.store, + previous_response_id=request.previous_response_id, + truncation=request.truncation, + ) + + if request.stream: + return StreamingResponse( + result, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + return JSONResponse(content=result) + + +__all__ = ["router"] diff --git a/app/api/v1/video.py b/app/api/v1/video.py new file mode 100644 index 0000000000000000000000000000000000000000..c16e7a0385c316bd8c0f1535e9658885a23bdf06 --- /dev/null +++ b/app/api/v1/video.py @@ -0,0 +1,3 @@ +""" +TODO:Video Generation API 路由 +""" diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..e6258f0d8f92c168699484b820c1a05ebbd2d088 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,198 @@ +""" +API 认证模块 +""" + +import hashlib +from typing import Optional, Iterable +from fastapi import HTTPException, status, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from app.core.config import get_config + +DEFAULT_API_KEY = "" +DEFAULT_APP_KEY = "grok2api" +DEFAULT_PUBLIC_KEY = "" +DEFAULT_PUBLIC_ENABLED = False + +# 定义 Bearer Scheme +security = HTTPBearer( + auto_error=False, + scheme_name="API Key", + description="Enter your API Key in the format: Bearer ", +) + + +def get_admin_api_key() -> str: + """ + 获取后台 API Key。 + + 为空时表示不启用后台接口认证。 + """ + api_key = get_config("app.api_key", DEFAULT_API_KEY) + return api_key or "" + + +def _normalize_api_keys(value: Optional[object]) -> list[str]: + if not value: + return [] + if isinstance(value, str): + raw = value.strip() + if not raw: + return [] + return [part.strip() for part in raw.split(",") if part.strip()] + if isinstance(value, Iterable): + keys: list[str] = [] + for item in value: + if not item: + continue + if isinstance(item, str): + stripped = item.strip() + if stripped: + keys.append(stripped) + return keys + return [] + +def get_app_key() -> str: + """ + 获取 App Key(后台管理密码)。 + """ + app_key = get_config("app.app_key", DEFAULT_APP_KEY) + return app_key or "" + +def get_public_api_key() -> str: + """ + 获取 Public API Key。 + + 为空时表示不启用 public 接口认证。 + """ + public_key = get_config("app.public_key", DEFAULT_PUBLIC_KEY) + return public_key or "" + +def is_public_enabled() -> bool: + """ + 是否开启 public 功能入口。 + """ + return bool(get_config("app.public_enabled", DEFAULT_PUBLIC_ENABLED)) + + +def _hash_public_key(key: str) -> str: + """计算 public_key 的 SHA-256 哈希,与前端 hashPublicKey 保持一致。""" + return hashlib.sha256(f"grok2api-public:{key}".encode()).hexdigest() + + +def _match_public_key(credentials: str, public_key: str) -> bool: + """检查凭证是否匹配 public_key(支持原始值和 public- 哈希格式)。""" + if not public_key: + return False + normalized = public_key.strip() + if not normalized: + return False + if credentials == normalized: + return True + if credentials.startswith("public-"): + expected_hash = _hash_public_key(normalized) + if credentials == f"public-{expected_hash}": + return True + return False + + +async def verify_api_key( + auth: Optional[HTTPAuthorizationCredentials] = Security(security), +) -> Optional[str]: + """ + 验证 Bearer Token + + 如果 config.toml 中未配置 api_key,则不启用认证。 + """ + api_key = get_admin_api_key() + api_keys = _normalize_api_keys(api_key) + if not api_keys: + return None + + if not auth: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 标准 api_key 验证 + if auth.credentials in api_keys: + return auth.credentials + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def verify_app_key( + auth: Optional[HTTPAuthorizationCredentials] = Security(security), +) -> Optional[str]: + """ + 验证后台登录密钥(app_key)。 + + app_key 必须配置,否则拒绝登录。 + """ + app_key = get_app_key() + + if not app_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="App key is not configured", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not auth: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if auth.credentials != app_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return auth.credentials + + +async def verify_public_key( + auth: Optional[HTTPAuthorizationCredentials] = Security(security), +) -> Optional[str]: + """ + 验证 Public Key(public 接口使用)。 + + 默认不公开,需配置 public_key 才能访问;若开启 public_enabled 且未配置 public_key,则放开访问。 + """ + public_key = get_public_api_key() + public_enabled = is_public_enabled() + + if not public_key: + if public_enabled: + return None + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Public access is disabled", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not auth: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if _match_public_key(auth.credentials, public_key): + return auth.credentials + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/app/core/batch.py b/app/core/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..7c62c015dd0fe085ab66b0f882ff8019f6c50394 --- /dev/null +++ b/app/core/batch.py @@ -0,0 +1,233 @@ +""" +Batch utilities. + +- run_batch: generic batch concurrency runner +- BatchTask: SSE task manager for admin batch operations +""" + +import asyncio +import time +import uuid +from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar + +from app.core.logger import logger + +T = TypeVar("T") + + +async def run_batch( + items: List[str], + worker: Callable[[str], Awaitable[T]], + *, + batch_size: int = 50, + task: Optional["BatchTask"] = None, + on_item: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None, + should_cancel: Optional[Callable[[], bool]] = None, +) -> Dict[str, Dict[str, Any]]: + """ + 分批并发执行,单项失败不影响整体 + + Args: + items: 待处理项列表 + worker: 异步处理函数 + batch_size: 每批大小 + + Returns: + {item: {"ok": bool, "data": ..., "error": ...}} + """ + try: + batch_size = int(batch_size) + except Exception: + batch_size = 50 + + batch_size = max(1, batch_size) + + async def _one(item: str) -> tuple[str, dict]: + if (should_cancel and should_cancel()) or (task and task.cancelled): + return item, {"ok": False, "error": "cancelled", "cancelled": True} + try: + data = await worker(item) + result = {"ok": True, "data": data} + if task: + task.record(True) + if on_item: + try: + await on_item(item, result) + except Exception: + pass + return item, result + except Exception as e: + logger.warning(f"Batch item failed: {item[:16]}... - {e}") + result = {"ok": False, "error": str(e)} + if task: + task.record(False, error=str(e)) + if on_item: + try: + await on_item(item, result) + except Exception: + pass + return item, result + + results: Dict[str, dict] = {} + + # 分批执行,避免一次性创建所有 task + for i in range(0, len(items), batch_size): + if (should_cancel and should_cancel()) or (task and task.cancelled): + break + chunk = items[i : i + batch_size] + pairs = await asyncio.gather(*(_one(x) for x in chunk)) + results.update(dict(pairs)) + + return results + + +class BatchTask: + def __init__(self, total: int): + self.id = uuid.uuid4().hex + self.total = int(total) + self.processed = 0 + self.ok = 0 + self.fail = 0 + self.status = "running" + self.warning: Optional[str] = None + self.result: Optional[Dict[str, Any]] = None + self.error: Optional[str] = None + self.created_at = time.time() + self._queues: List[asyncio.Queue] = [] + self._final_event: Optional[Dict[str, Any]] = None + self.cancelled = False + + def snapshot(self) -> Dict[str, Any]: + return { + "task_id": self.id, + "status": self.status, + "total": self.total, + "processed": self.processed, + "ok": self.ok, + "fail": self.fail, + "warning": self.warning, + } + + def attach(self) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=200) + self._queues.append(q) + return q + + def detach(self, q: asyncio.Queue) -> None: + if q in self._queues: + self._queues.remove(q) + + def _publish(self, event: Dict[str, Any]) -> None: + for q in list(self._queues): + try: + q.put_nowait(event) + except Exception: + # Drop if queue is full or closed + pass + + def record( + self, ok: bool, *, item: Any = None, detail: Any = None, error: str = "" + ) -> None: + self.processed += 1 + if ok: + self.ok += 1 + else: + self.fail += 1 + event: Dict[str, Any] = { + "type": "progress", + "task_id": self.id, + "total": self.total, + "processed": self.processed, + "ok": self.ok, + "fail": self.fail, + } + if item is not None: + event["item"] = item + if detail is not None: + event["detail"] = detail + if error: + event["error"] = error + self._publish(event) + + def finish(self, result: Dict[str, Any], *, warning: Optional[str] = None) -> None: + self.status = "done" + self.result = result + self.warning = warning + event = { + "type": "done", + "task_id": self.id, + "total": self.total, + "processed": self.processed, + "ok": self.ok, + "fail": self.fail, + "warning": self.warning, + "result": result, + } + self._final_event = event + self._publish(event) + + def fail_task(self, error: str) -> None: + self.status = "error" + self.error = error + event = { + "type": "error", + "task_id": self.id, + "total": self.total, + "processed": self.processed, + "ok": self.ok, + "fail": self.fail, + "error": error, + } + self._final_event = event + self._publish(event) + + def cancel(self) -> None: + self.cancelled = True + + def finish_cancelled(self) -> None: + self.status = "cancelled" + event = { + "type": "cancelled", + "task_id": self.id, + "total": self.total, + "processed": self.processed, + "ok": self.ok, + "fail": self.fail, + } + self._final_event = event + self._publish(event) + + def final_event(self) -> Optional[Dict[str, Any]]: + return self._final_event + + +_TASKS: Dict[str, BatchTask] = {} + + +def create_task(total: int) -> BatchTask: + task = BatchTask(total) + _TASKS[task.id] = task + return task + + +def get_task(task_id: str) -> Optional[BatchTask]: + return _TASKS.get(task_id) + + +def delete_task(task_id: str) -> None: + _TASKS.pop(task_id, None) + + +async def expire_task(task_id: str, delay: int = 300) -> None: + await asyncio.sleep(delay) + delete_task(task_id) + + +__all__ = [ + "run_batch", + "BatchTask", + "create_task", + "get_task", + "delete_task", + "expire_task", +] diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..185b30f194456927f28d5acb1403fdce66e1bae1 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,326 @@ +""" +配置管理 + +- config.toml: 运行时配置 +- config.defaults.toml: 默认配置基线 +""" + +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict +import tomllib + +from app.core.logger import logger + +DEFAULT_CONFIG_FILE = Path(__file__).parent.parent.parent / "config.defaults.toml" + + +def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + """深度合并字典: override 覆盖 base.""" + if not isinstance(base, dict): + return deepcopy(override) if isinstance(override, dict) else deepcopy(base) + + result = deepcopy(base) + if not isinstance(override, dict): + return result + + for key, val in override.items(): + if isinstance(val, dict) and isinstance(result.get(key), dict): + result[key] = _deep_merge(result[key], val) + else: + result[key] = val + return result + + +def _migrate_deprecated_config( + config: Dict[str, Any], valid_sections: set +) -> tuple[Dict[str, Any], set]: + """ + 迁移废弃的配置节到新配置结构 + + Returns: + (迁移后的配置, 废弃的配置节集合) + """ + # 配置映射规则:旧配置 -> 新配置 + MIGRATION_MAP = { + # grok.* -> 对应的新配置节 + "grok.temporary": "app.temporary", + "grok.disable_memory": "app.disable_memory", + "grok.stream": "app.stream", + "grok.thinking": "app.thinking", + "grok.dynamic_statsig": "app.dynamic_statsig", + "grok.filter_tags": "app.filter_tags", + "grok.timeout": "voice.timeout", + "grok.base_proxy_url": "proxy.base_proxy_url", + "grok.asset_proxy_url": "proxy.asset_proxy_url", + "network.base_proxy_url": "proxy.base_proxy_url", + "network.asset_proxy_url": "proxy.asset_proxy_url", + "grok.cf_clearance": "proxy.cf_clearance", + "grok.browser": "proxy.browser", + "grok.user_agent": "proxy.user_agent", + "security.cf_clearance": "proxy.cf_clearance", + "security.browser": "proxy.browser", + "security.user_agent": "proxy.user_agent", + "grok.max_retry": "retry.max_retry", + "grok.retry_status_codes": "retry.retry_status_codes", + "grok.retry_backoff_base": "retry.retry_backoff_base", + "grok.retry_backoff_factor": "retry.retry_backoff_factor", + "grok.retry_backoff_max": "retry.retry_backoff_max", + "grok.retry_budget": "retry.retry_budget", + "grok.video_idle_timeout": "video.stream_timeout", + "grok.image_ws_nsfw": "image.nsfw", + "grok.image_ws_blocked_seconds": "image.final_timeout", + "grok.image_ws_final_min_bytes": "image.final_min_bytes", + "grok.image_ws_medium_min_bytes": "image.medium_min_bytes", + # legacy sections + "network.base_proxy_url": "proxy.base_proxy_url", + "network.asset_proxy_url": "proxy.asset_proxy_url", + "network.timeout": [ + "chat.timeout", + "image.timeout", + "video.timeout", + "voice.timeout", + ], + "security.cf_clearance": "proxy.cf_clearance", + "security.browser": "proxy.browser", + "security.user_agent": "proxy.user_agent", + "timeout.stream_idle_timeout": [ + "chat.stream_timeout", + "image.stream_timeout", + "video.stream_timeout", + ], + "timeout.video_idle_timeout": "video.stream_timeout", + "image.image_ws_nsfw": "image.nsfw", + "image.image_ws_blocked_seconds": "image.final_timeout", + "image.image_ws_final_min_bytes": "image.final_min_bytes", + "image.image_ws_medium_min_bytes": "image.medium_min_bytes", + "performance.assets_max_concurrent": [ + "asset.upload_concurrent", + "asset.download_concurrent", + "asset.list_concurrent", + "asset.delete_concurrent", + ], + "performance.assets_delete_batch_size": "asset.delete_batch_size", + "performance.assets_batch_size": "asset.list_batch_size", + "performance.media_max_concurrent": ["chat.concurrent", "video.concurrent"], + "performance.usage_max_concurrent": "usage.concurrent", + "performance.usage_batch_size": "usage.batch_size", + "performance.nsfw_max_concurrent": "nsfw.concurrent", + "performance.nsfw_batch_size": "nsfw.batch_size", + } + + deprecated_sections = set(config.keys()) - valid_sections + if not deprecated_sections: + return config, set() + + result = {k: deepcopy(v) for k, v in config.items() if k in valid_sections} + migrated_count = 0 + + # 处理废弃配置节或旧配置键 + for old_section, old_values in config.items(): + if not isinstance(old_values, dict): + continue + for old_key, old_value in old_values.items(): + old_path = f"{old_section}.{old_key}" + new_paths = MIGRATION_MAP.get(old_path) + if not new_paths: + continue + if isinstance(new_paths, str): + new_paths = [new_paths] + for new_path in new_paths: + try: + new_section, new_key = new_path.split(".", 1) + if new_section not in result: + result[new_section] = {} + if new_key not in result[new_section]: + result[new_section][new_key] = old_value + migrated_count += 1 + logger.debug( + f"Migrated config: {old_path} -> {new_path} = {old_value}" + ) + except Exception as e: + logger.warning( + f"Skip config migration for {old_path}: {e}" + ) + continue + if isinstance(result.get(old_section), dict): + result[old_section].pop(old_key, None) + + # 兼容旧 chat.* 配置键迁移到 app.* + legacy_chat_map = { + "temporary": "temporary", + "disable_memory": "disable_memory", + "stream": "stream", + "thinking": "thinking", + "dynamic_statsig": "dynamic_statsig", + "filter_tags": "filter_tags", + } + chat_section = config.get("chat") + if isinstance(chat_section, dict): + app_section = result.setdefault("app", {}) + for old_key, new_key in legacy_chat_map.items(): + if old_key in chat_section and new_key not in app_section: + app_section[new_key] = chat_section[old_key] + if isinstance(result.get("chat"), dict): + result["chat"].pop(old_key, None) + migrated_count += 1 + logger.debug( + f"Migrated config: chat.{old_key} -> app.{new_key} = {chat_section[old_key]}" + ) + + if migrated_count > 0: + logger.info( + f"Migrated {migrated_count} config items from deprecated/legacy sections" + ) + + return result, deprecated_sections + + +def _load_defaults() -> Dict[str, Any]: + """加载默认配置文件""" + if not DEFAULT_CONFIG_FILE.exists(): + return {} + try: + with DEFAULT_CONFIG_FILE.open("rb") as f: + return tomllib.load(f) + except Exception as e: + logger.warning(f"Failed to load defaults from {DEFAULT_CONFIG_FILE}: {e}") + return {} + + +class Config: + """配置管理器""" + + _instance = None + _config = {} + + def __init__(self): + self._config = {} + self._defaults = {} + self._code_defaults = {} + self._defaults_loaded = False + + def register_defaults(self, defaults: Dict[str, Any]): + """注册代码中定义的默认值""" + self._code_defaults = _deep_merge(self._code_defaults, defaults) + + def _ensure_defaults(self): + if self._defaults_loaded: + return + file_defaults = _load_defaults() + # 合并文件默认值和代码默认值(代码默认值优先级更低) + self._defaults = _deep_merge(self._code_defaults, file_defaults) + self._defaults_loaded = True + + async def load(self): + """显式加载配置""" + try: + from app.core.storage import get_storage, LocalStorage + + self._ensure_defaults() + + storage = get_storage() + config_data = await storage.load_config() + from_remote = True + + # 从本地 data/config.toml 初始化后端 + if config_data is None: + local_storage = LocalStorage() + from_remote = False + try: + # 尝试读取本地配置 + config_data = await local_storage.load_config() + except Exception as e: + logger.info(f"Failed to auto-init config from local: {e}") + config_data = {} + + config_data = config_data or {} + + # 检查是否有废弃的配置节 + valid_sections = set(self._defaults.keys()) + config_data, deprecated_sections = _migrate_deprecated_config( + config_data, valid_sections + ) + if deprecated_sections: + logger.info( + f"Cleaned deprecated config sections: {deprecated_sections}" + ) + + merged = _deep_merge(self._defaults, config_data) + + # 自动回填缺失配置到存储 + # 或迁移了配置后需要更新 + # 保护:当远程存储返回 None 且本地也没有可迁移配置时,不覆盖远程配置,避免误重置。 + has_local_seed = bool(config_data) + allow_bootstrap_empty_remote = ( + (not from_remote) and has_local_seed + ) + should_persist = ( + allow_bootstrap_empty_remote + or (merged != config_data and bool(config_data)) + or deprecated_sections + ) + if should_persist: + async with storage.acquire_lock("config_save", timeout=10): + await storage.save_config(merged) + if not from_remote and has_local_seed: + logger.info( + f"Initialized remote storage ({storage.__class__.__name__}) with config baseline." + ) + if deprecated_sections: + logger.info("Configuration automatically migrated and cleaned.") + elif not from_remote and not has_local_seed: + logger.warning( + "Skip persisting defaults: empty config source detected, keep runtime merged config only." + ) + + self._config = merged + except Exception as e: + logger.error(f"Error loading config: {e}") + self._config = {} + + def get(self, key: str, default: Any = None) -> Any: + """ + 获取配置值 + + Args: + key: 配置键,格式 "section.key" + default: 默认值 + """ + if "." in key: + try: + section, attr = key.split(".", 1) + return self._config.get(section, {}).get(attr, default) + except (ValueError, AttributeError): + return default + + return self._config.get(key, default) + + async def update(self, new_config: dict): + """更新配置""" + from app.core.storage import get_storage + + storage = get_storage() + async with storage.acquire_lock("config_save", timeout=10): + self._ensure_defaults() + base = _deep_merge(self._defaults, self._config or {}) + merged = _deep_merge(base, new_config or {}) + await storage.save_config(merged) + self._config = merged + + +# 全局配置实例 +config = Config() + + +def get_config(key: str, default: Any = None) -> Any: + """获取配置""" + return config.get(key, default) + + +def register_defaults(defaults: Dict[str, Any]): + """注册默认配置""" + config.register_defaults(defaults) + + +__all__ = ["Config", "config", "get_config", "register_defaults"] diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..24aa281bc1a11d20348884ec734ddaa60f3833ff --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,232 @@ +""" +全局异常处理 - OpenAI 兼容错误格式 +""" + +from typing import Any +from enum import Enum +from fastapi import Request, HTTPException +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError + +from app.core.logger import logger + + +# ============= 错误类型 ============= + + +class ErrorType(str, Enum): + """OpenAI 错误类型""" + + INVALID_REQUEST = "invalid_request_error" + AUTHENTICATION = "authentication_error" + PERMISSION = "permission_error" + NOT_FOUND = "not_found_error" + RATE_LIMIT = "rate_limit_error" + SERVER = "server_error" + SERVICE_UNAVAILABLE = "service_unavailable_error" + + +# ============= 辅助函数 ============= + + +def error_response( + message: str, + error_type: str = ErrorType.INVALID_REQUEST.value, + param: str = None, + code: str = None, +) -> dict: + """构建 OpenAI 错误响应""" + return { + "error": {"message": message, "type": error_type, "param": param, "code": code} + } + + +# ============= 异常类 ============= + + +class AppException(Exception): + """应用基础异常""" + + def __init__( + self, + message: str, + error_type: str = ErrorType.SERVER.value, + code: str = None, + param: str = None, + status_code: int = 500, + ): + self.message = message + self.error_type = error_type + self.code = code + self.param = param + self.status_code = status_code + super().__init__(message) + + +class ValidationException(AppException): + """验证错误""" + + def __init__(self, message: str, param: str = None, code: str = None): + super().__init__( + message=message, + error_type=ErrorType.INVALID_REQUEST.value, + code=code or "invalid_value", + param=param, + status_code=400, + ) + + +class AuthenticationException(AppException): + """认证错误""" + + def __init__(self, message: str = "Invalid API key"): + super().__init__( + message=message, + error_type=ErrorType.AUTHENTICATION.value, + code="invalid_api_key", + status_code=401, + ) + + +class UpstreamException(AppException): + """上游服务错误""" + + def __init__(self, message: str, details: Any = None): + super().__init__( + message=message, + error_type=ErrorType.SERVER.value, + code="upstream_error", + status_code=502, + ) + self.details = details + + +class StreamIdleTimeoutError(Exception): + """流空闲超时错误""" + + def __init__(self, idle_seconds: float): + self.idle_seconds = idle_seconds + super().__init__(f"Stream idle timeout after {idle_seconds}s") + + +# ============= 异常处理器 ============= + + +async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: + """处理应用异常""" + logger.warning(f"AppException: {exc.error_type} - {exc.message}") + + return JSONResponse( + status_code=exc.status_code, + content=error_response( + message=exc.message, + error_type=exc.error_type, + param=exc.param, + code=exc.code, + ), + ) + + +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + """处理 HTTP 异常""" + type_map = { + 400: ErrorType.INVALID_REQUEST.value, + 401: ErrorType.AUTHENTICATION.value, + 403: ErrorType.PERMISSION.value, + 404: ErrorType.NOT_FOUND.value, + 429: ErrorType.RATE_LIMIT.value, + } + error_type = type_map.get(exc.status_code, ErrorType.SERVER.value) + + # 默认 code 映射 + code_map = { + 401: "invalid_api_key", + 403: "insufficient_quota", + 404: "model_not_found", + 429: "rate_limit_exceeded", + } + code = code_map.get(exc.status_code, None) + + logger.warning(f"HTTPException: {exc.status_code} - {exc.detail}") + + return JSONResponse( + status_code=exc.status_code, + content=error_response( + message=str(exc.detail), error_type=error_type, code=code + ), + ) + + +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + """处理验证错误""" + errors = exc.errors() + + if errors: + first = errors[0] + loc = first.get("loc", []) + msg = first.get("msg", "Invalid request") + code = first.get("type", "invalid_value") + + # JSON 解析错误 + if code == "json_invalid" or "JSON" in msg: + message = "Invalid JSON in request body. Please check for trailing commas or syntax errors." + param = "body" + else: + param_parts = [ + str(x) for x in loc if not (isinstance(x, int) or str(x).isdigit()) + ] + param = ".".join(param_parts) if param_parts else None + message = msg + else: + param, message, code = None, "Invalid request", "invalid_value" + + logger.warning(f"ValidationError: {param} - {message}") + + return JSONResponse( + status_code=400, + content=error_response( + message=message, + error_type=ErrorType.INVALID_REQUEST.value, + param=param, + code=code, + ), + ) + + +async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """处理未捕获异常""" + logger.exception(f"Unhandled: {type(exc).__name__}: {str(exc)}") + + return JSONResponse( + status_code=500, + content=error_response( + message="Internal server error", + error_type=ErrorType.SERVER.value, + code="internal_error", + ), + ) + + +# ============= 注册 ============= + + +def register_exception_handlers(app): + """注册异常处理器""" + app.add_exception_handler(AppException, app_exception_handler) + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(Exception, generic_exception_handler) + + +__all__ = [ + "ErrorType", + "AppException", + "ValidationException", + "AuthenticationException", + "UpstreamException", + "StreamIdleTimeoutError", + "error_response", + "register_exception_handlers", +] diff --git a/app/core/logger.py b/app/core/logger.py new file mode 100644 index 0000000000000000000000000000000000000000..0b0290f776e3c8021206d172c4fb64fa1461d6d6 --- /dev/null +++ b/app/core/logger.py @@ -0,0 +1,151 @@ +""" +结构化 JSON 日志 - 极简格式 +""" + +import sys +import os +import json +import traceback +from pathlib import Path +from loguru import logger + +# Provide logging.Logger compatibility for legacy calls +if not hasattr(logger, "isEnabledFor"): + logger.isEnabledFor = lambda _level: True + +# 日志目录 +DEFAULT_LOG_DIR = Path(__file__).parent.parent.parent / "logs" +LOG_DIR = Path(os.getenv("LOG_DIR", str(DEFAULT_LOG_DIR))) +_LOG_DIR_READY = False + + +def _prepare_log_dir() -> bool: + """确保日志目录可用""" + global LOG_DIR, _LOG_DIR_READY + if _LOG_DIR_READY: + return True + try: + LOG_DIR.mkdir(parents=True, exist_ok=True) + _LOG_DIR_READY = True + return True + except Exception: + _LOG_DIR_READY = False + return False + + +def _format_json(record) -> str: + """格式化日志""" + # ISO8601 时间 + time_str = record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + tz = record["time"].strftime("%z") + if tz: + time_str += tz[:3] + ":" + tz[3:] + + log_entry = { + "time": time_str, + "level": record["level"].name.lower(), + "msg": record["message"], + "caller": f"{record['file'].name}:{record['line']}", + } + + # trace 上下文 + extra = record["extra"] + if extra.get("traceID"): + log_entry["traceID"] = extra["traceID"] + if extra.get("spanID"): + log_entry["spanID"] = extra["spanID"] + + # 其他 extra 字段 + for key, value in extra.items(): + if key not in ("traceID", "spanID") and not key.startswith("_"): + log_entry[key] = value + + # 错误及以上级别添加堆栈跟踪 + if record["level"].no >= 40 and record["exception"]: + log_entry["stacktrace"] = "".join( + traceback.format_exception( + record["exception"].type, + record["exception"].value, + record["exception"].traceback, + ) + ) + + return json.dumps(log_entry, ensure_ascii=False) + +def _env_flag(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on", "y") + + +def _make_json_sink(output): + """创建 JSON sink""" + + def sink(message): + json_str = _format_json(message.record) + print(json_str, file=output, flush=True) + + return sink + + +def _file_json_sink(message): + """写入日志文件""" + record = message.record + json_str = _format_json(record) + log_file = LOG_DIR / f"app_{record['time'].strftime('%Y-%m-%d')}.log" + with open(log_file, "a", encoding="utf-8") as f: + f.write(json_str + "\n") + + +def setup_logging( + level: str = "DEBUG", + json_console: bool = True, + file_logging: bool = True, +): + """设置日志配置""" + logger.remove() + file_logging = _env_flag("LOG_FILE_ENABLED", file_logging) + + # 控制台输出 + if json_console: + logger.add( + _make_json_sink(sys.stdout), + level=level, + format="{message}", + colorize=False, + ) + else: + logger.add( + sys.stdout, + level=level, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {file.name}:{line} - {message}", + colorize=True, + ) + + # 文件输出 + if file_logging: + if _prepare_log_dir(): + logger.add( + _file_json_sink, + level=level, + format="{message}", + enqueue=True, + ) + else: + logger.warning("File logging disabled: no writable log directory.") + + return logger + + +def get_logger(trace_id: str = "", span_id: str = ""): + """获取绑定了 trace 上下文的 logger""" + bound = {} + if trace_id: + bound["traceID"] = trace_id + if span_id: + bound["spanID"] = span_id + return logger.bind(**bound) if bound else logger + + +__all__ = ["logger", "setup_logging", "get_logger", "LOG_DIR"] diff --git a/app/core/response_middleware.py b/app/core/response_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..4c0a07ecec7d4b41da0d9b7fe51378b248fc0de4 --- /dev/null +++ b/app/core/response_middleware.py @@ -0,0 +1,85 @@ +""" +响应中间件 +Response Middleware + +用于记录请求日志、生成 TraceID 和计算请求耗时 +""" + +import time +import uuid +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from app.core.logger import logger + + +class ResponseLoggerMiddleware(BaseHTTPMiddleware): + """ + 请求日志/响应追踪中间件 + Request Logging and Response Tracking Middleware + """ + + async def dispatch(self, request: Request, call_next): + # 生成请求 ID + trace_id = str(uuid.uuid4()) + 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( + f"Request: {request.method} {request.url.path}", + extra={ + "traceID": trace_id, + "method": request.method, + "path": request.url.path, + }, + ) + + try: + response = await call_next(request) + + # 计算耗时 + duration = (time.time() - start_time) * 1000 + + # 记录响应信息 + logger.info( + f"Response: {request.method} {request.url.path} - {response.status_code} ({duration:.2f}ms)", + extra={ + "traceID": trace_id, + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "duration_ms": round(duration, 2), + }, + ) + + return response + + except Exception as e: + duration = (time.time() - start_time) * 1000 + logger.error( + f"Response Error: {request.method} {request.url.path} - {str(e)} ({duration:.2f}ms)", + extra={ + "traceID": trace_id, + "method": request.method, + "path": request.url.path, + "duration_ms": round(duration, 2), + "error": str(e), + }, + ) + raise e diff --git a/app/core/storage.py b/app/core/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..9f99e5f0414c29e8f1d87b6d220c6f6ab807c307 --- /dev/null +++ b/app/core/storage.py @@ -0,0 +1,1478 @@ +""" +统一存储服务 (Professional Storage Service) +支持 Local (TOML), Redis, MySQL, PostgreSQL + +特性: +- 全异步 I/O (Async I/O) +- 连接池管理 (Connection Pooling) +- 分布式/本地锁 (Distributed/Local Locking) +- 内存优化 (序列化性能优化) +""" + +import abc +import os +import asyncio +import hashlib +import time +import tomllib +from typing import Any, ClassVar, Dict, Optional +from pathlib import Path +from enum import Enum + +try: + import fcntl +except ImportError: # pragma: no cover - non-posix platforms + fcntl = None +from contextlib import asynccontextmanager + +import orjson +import aiofiles +from app.core.logger import logger + +# 数据目录(支持通过环境变量覆盖) +DEFAULT_DATA_DIR = Path(__file__).parent.parent.parent / "data" +DATA_DIR = Path(os.getenv("DATA_DIR", str(DEFAULT_DATA_DIR))).expanduser() + +# 配置文件路径 +CONFIG_FILE = DATA_DIR / "config.toml" +TOKEN_FILE = DATA_DIR / "token.json" +LOCK_DIR = DATA_DIR / ".locks" + + +# JSON 序列化优化助手函数 +def json_dumps(obj: Any) -> str: + return orjson.dumps(obj).decode("utf-8") + + +def json_loads(obj: str | bytes) -> Any: + return orjson.loads(obj) + + +def json_dumps_sorted(obj: Any) -> str: + return orjson.dumps(obj, option=orjson.OPT_SORT_KEYS).decode("utf-8") + + +class StorageError(Exception): + """存储服务基础异常""" + + pass + + +class BaseStorage(abc.ABC): + """存储基类""" + + @abc.abstractmethod + async def load_config(self) -> Dict[str, Any]: + """加载配置""" + pass + + @abc.abstractmethod + async def save_config(self, data: Dict[str, Any]): + """保存配置""" + pass + + @abc.abstractmethod + async def load_tokens(self) -> Dict[str, Any]: + """加载所有 Token""" + pass + + @abc.abstractmethod + async def save_tokens(self, data: Dict[str, Any]): + """保存所有 Token""" + pass + + async def save_tokens_delta( + self, updated: list[Dict[str, Any]], deleted: Optional[list[str]] = None + ): + """增量保存 Token(默认回退到全量保存)""" + existing = await self.load_tokens() or {} + + deleted_set = set(deleted or []) + if deleted_set: + for pool_name, tokens in list(existing.items()): + if not isinstance(tokens, list): + continue + filtered = [] + for item in tokens: + if isinstance(item, str): + token_str = item + elif isinstance(item, dict): + token_str = item.get("token") + else: + token_str = None + if token_str and token_str in deleted_set: + continue + filtered.append(item) + existing[pool_name] = filtered + + for item in updated or []: + if not isinstance(item, dict): + continue + pool_name = item.get("pool_name") + token_str = item.get("token") + if not pool_name or not token_str: + continue + pool_list = existing.setdefault(pool_name, []) + normalized = { + k: v + for k, v in item.items() + if k not in ("pool_name", "_update_kind") + } + replaced = False + for idx, current in enumerate(pool_list): + if isinstance(current, str): + if current == token_str: + pool_list[idx] = normalized + replaced = True + break + elif isinstance(current, dict) and current.get("token") == token_str: + pool_list[idx] = normalized + replaced = True + break + if not replaced: + pool_list.append(normalized) + + await self.save_tokens(existing) + + @abc.abstractmethod + async def close(self): + """关闭资源""" + pass + + @asynccontextmanager + async def acquire_lock(self, name: str, timeout: int = 10): + """ + 获取锁 (互斥访问) + 用于读写操作的临界区保护 + + Args: + name: 锁名称 + timeout: 超时时间 (秒) + """ + # 默认空实现,用于 fallback + yield + + async def verify_connection(self) -> bool: + """健康检查""" + return True + + +class LocalStorage(BaseStorage): + """ + 本地文件存储 + - 使用 aiofiles 进行异步 I/O + - 使用 asyncio.Lock 进行进程内并发控制 + - 如果需要多进程安全,需要系统级文件锁 (fcntl) + """ + + def __init__(self): + self._lock = asyncio.Lock() + + @asynccontextmanager + async def acquire_lock(self, name: str, timeout: int = 10): + if fcntl is None: + try: + async with asyncio.timeout(timeout): + async with self._lock: + yield + except asyncio.TimeoutError: + logger.warning(f"LocalStorage: 获取锁 '{name}' 超时 ({timeout}s)") + raise StorageError(f"无法获取锁 '{name}'") + return + + lock_path = LOCK_DIR / f"{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + fd = None + locked = False + start = time.monotonic() + + async with self._lock: + try: + fd = open(lock_path, "a+") + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + break + except BlockingIOError: + if time.monotonic() - start >= timeout: + raise StorageError(f"无法获取锁 '{name}'") + await asyncio.sleep(0.05) + yield + except StorageError: + logger.warning(f"LocalStorage: 获取锁 '{name}' 超时 ({timeout}s)") + raise + finally: + if fd: + if locked: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except Exception: + pass + try: + fd.close() + except Exception: + pass + + async def load_config(self) -> Dict[str, Any]: + if not CONFIG_FILE.exists(): + return {} + try: + async with aiofiles.open(CONFIG_FILE, "rb") as f: + content = await f.read() + return tomllib.loads(content.decode("utf-8")) + except Exception as e: + logger.error(f"LocalStorage: 加载配置失败: {e}") + return {} + + async def save_config(self, data: Dict[str, Any]): + try: + lines = [] + for section, items in data.items(): + if not isinstance(items, dict): + continue + lines.append(f"[{section}]") + for key, val in items.items(): + if isinstance(val, bool): + val_str = "true" if val else "false" + elif isinstance(val, str): + escaped = val.replace('"', '\\"') + val_str = f'"{escaped}"' + elif isinstance(val, (int, float)): + val_str = str(val) + elif isinstance(val, (list, dict)): + val_str = json_dumps(val) + else: + val_str = f'"{str(val)}"' + lines.append(f"{key} = {val_str}") + lines.append("") + + content = "\n".join(lines) + + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(CONFIG_FILE, "w", encoding="utf-8") as f: + await f.write(content) + except Exception as e: + logger.error(f"LocalStorage: 保存配置失败: {e}") + raise StorageError(f"保存配置失败: {e}") + + async def load_tokens(self) -> Dict[str, Any]: + if not TOKEN_FILE.exists(): + return {} + try: + async with aiofiles.open(TOKEN_FILE, "rb") as f: + content = await f.read() + return json_loads(content) + except Exception as e: + logger.error(f"LocalStorage: 加载 Token 失败: {e}") + return {} + + async def save_tokens(self, data: Dict[str, Any]): + try: + TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) + temp_path = TOKEN_FILE.with_suffix(".tmp") + + # 原子写操作: 写入临时文件 -> 重命名 + async with aiofiles.open(temp_path, "wb") as f: + await f.write(orjson.dumps(data, option=orjson.OPT_INDENT_2)) + + # 使用 os.replace 保证原子性 + os.replace(temp_path, TOKEN_FILE) + + except Exception as e: + logger.error(f"LocalStorage: 保存 Token 失败: {e}") + raise StorageError(f"保存 Token 失败: {e}") + + async def close(self): + pass + + +class RedisStorage(BaseStorage): + """ + Redis 存储 + - 使用 redis-py 异步客户端 (自带连接池) + - 支持分布式锁 (redis.lock) + - 扁平化数据结构优化性能 + """ + + def __init__(self, url: str): + try: + from redis import asyncio as aioredis + except ImportError: + raise ImportError("需要安装 redis 包: pip install redis") + + # 显式配置连接池 + # 使用 decode_responses=True 简化字符串处理,但在处理复杂对象时使用 orjson + self.redis = aioredis.from_url( + url, decode_responses=True, health_check_interval=30 + ) + self.config_key = "grok2api:config" # Hash: section.key -> value_json + self.key_pools = "grok2api:pools" # Set: pool_names + self.prefix_pool_set = "grok2api:pool:" # Set: pool -> token_ids + self.prefix_token_hash = "grok2api:token:" # Hash: token_id -> token_data + self.lock_prefix = "grok2api:lock:" + + @asynccontextmanager + async def acquire_lock(self, name: str, timeout: int = 10): + # 使用 Redis 分布式锁 + lock_key = f"{self.lock_prefix}{name}" + lock = self.redis.lock(lock_key, timeout=timeout, blocking_timeout=5) + acquired = False + try: + acquired = await lock.acquire() + if not acquired: + raise StorageError(f"RedisStorage: 无法获取锁 '{name}'") + yield + finally: + if acquired: + try: + await lock.release() + except Exception: + # 锁可能已过期或被意外释放,忽略异常 + pass + + async def verify_connection(self) -> bool: + try: + return await self.redis.ping() + except Exception: + return False + + async def load_config(self) -> Dict[str, Any]: + """从 Redis Hash 加载配置""" + try: + raw_data = await self.redis.hgetall(self.config_key) + if not raw_data: + return None + + config = {} + for composite_key, val_str in raw_data.items(): + if "." not in composite_key: + continue + section, key = composite_key.split(".", 1) + + if section not in config: + config[section] = {} + + try: + val = json_loads(val_str) + except Exception: + val = val_str + config[section][key] = val + return config + except Exception as e: + logger.error(f"RedisStorage: 加载配置失败: {e}") + return None + + async def save_config(self, data: Dict[str, Any]): + """保存配置到 Redis Hash""" + try: + mapping = {} + for section, items in data.items(): + if not isinstance(items, dict): + continue + for key, val in items.items(): + composite_key = f"{section}.{key}" + mapping[composite_key] = json_dumps(val) + + await self.redis.delete(self.config_key) + if mapping: + await self.redis.hset(self.config_key, mapping=mapping) + except Exception as e: + logger.error(f"RedisStorage: 保存配置失败: {e}") + raise + + async def load_tokens(self) -> Dict[str, Any]: + """加载所有 Token""" + try: + pool_names = await self.redis.smembers(self.key_pools) + if not pool_names: + return None + + pools = {} + async with self.redis.pipeline() as pipe: + for pool_name in pool_names: + # 获取该池下所有 Token ID + pipe.smembers(f"{self.prefix_pool_set}{pool_name}") + pool_tokens_res = await pipe.execute() + + # 收集所有 Token ID 以便批量查询 + all_token_ids = [] + pool_map = {} # pool_name -> list[token_id] + + for i, pool_name in enumerate(pool_names): + tids = list(pool_tokens_res[i]) + pool_map[pool_name] = tids + all_token_ids.extend(tids) + + if not all_token_ids: + return {name: [] for name in pool_names} + + # 批量获取 Token 详情 (Hash) + async with self.redis.pipeline() as pipe: + for tid in all_token_ids: + pipe.hgetall(f"{self.prefix_token_hash}{tid}") + token_data_list = await pipe.execute() + + # 重组数据结构 + token_lookup = {} + for i, tid in enumerate(all_token_ids): + t_data = token_data_list[i] + if not t_data: + continue + + # 恢复 tags (JSON -> List) + if "tags" in t_data: + try: + t_data["tags"] = json_loads(t_data["tags"]) + except Exception: + t_data["tags"] = [] + + # 类型转换 (Redis 返回全 string) + for int_field in [ + "quota", + "created_at", + "use_count", + "fail_count", + "last_used_at", + "last_fail_at", + "last_sync_at", + ]: + if t_data.get(int_field) and t_data[int_field] != "None": + try: + t_data[int_field] = int(t_data[int_field]) + except Exception: + pass + + token_lookup[tid] = t_data + + # 按 Pool 分组返回 + for pool_name in pool_names: + pools[pool_name] = [] + for tid in pool_map[pool_name]: + if tid in token_lookup: + pools[pool_name].append(token_lookup[tid]) + + return pools + + except Exception as e: + logger.error(f"RedisStorage: 加载 Token 失败: {e}") + return None + + async def save_tokens(self, data: Dict[str, Any]): + """保存所有 Token""" + if data is None: + return + try: + new_pools = set(data.keys()) if isinstance(data, dict) else set() + pool_tokens_map = {} + new_token_ids = set() + + for pool_name, tokens in (data or {}).items(): + tids_in_pool = [] + for t in tokens: + token_str = t.get("token") + if not token_str: + continue + tids_in_pool.append(token_str) + new_token_ids.add(token_str) + pool_tokens_map[pool_name] = tids_in_pool + + existing_pools = await self.redis.smembers(self.key_pools) + existing_pools = set(existing_pools) if existing_pools else set() + + existing_token_ids = set() + if existing_pools: + async with self.redis.pipeline() as pipe: + for pool_name in existing_pools: + pipe.smembers(f"{self.prefix_pool_set}{pool_name}") + pool_tokens_res = await pipe.execute() + for tokens in pool_tokens_res: + existing_token_ids.update(list(tokens or [])) + + tokens_to_delete = existing_token_ids - new_token_ids + all_pools = existing_pools.union(new_pools) + + async with self.redis.pipeline() as pipe: + # Reset pool index + pipe.delete(self.key_pools) + if new_pools: + pipe.sadd(self.key_pools, *new_pools) + + # Reset pool sets + for pool_name in all_pools: + pipe.delete(f"{self.prefix_pool_set}{pool_name}") + for pool_name, tids_in_pool in pool_tokens_map.items(): + if tids_in_pool: + pipe.sadd(f"{self.prefix_pool_set}{pool_name}", *tids_in_pool) + + # Remove deleted token hashes + for token_str in tokens_to_delete: + pipe.delete(f"{self.prefix_token_hash}{token_str}") + + # Upsert token hashes + for pool_name, tokens in (data or {}).items(): + for t in tokens: + token_str = t.get("token") + if not token_str: + continue + t_flat = t.copy() + if "tags" in t_flat: + t_flat["tags"] = json_dumps(t_flat["tags"]) + status = t_flat.get("status") + if isinstance(status, str) and status.startswith( + "TokenStatus." + ): + t_flat["status"] = status.split(".", 1)[1].lower() + elif isinstance(status, Enum): + t_flat["status"] = status.value + t_flat = {k: str(v) for k, v in t_flat.items() if v is not None} + pipe.hset( + f"{self.prefix_token_hash}{token_str}", mapping=t_flat + ) + + await pipe.execute() + + except Exception as e: + logger.error(f"RedisStorage: 保存 Token 失败: {e}") + raise + + async def close(self): + try: + await self.redis.close() + except (RuntimeError, asyncio.CancelledError, Exception): + # 忽略关闭时的 Event loop is closed 错误 + pass + + +class SQLStorage(BaseStorage): + """ + SQL 数据库存储 (MySQL/PgSQL) + - 使用 SQLAlchemy 异步引擎 + - 自动 Schema 初始化 + - 内置连接池 (QueuePool) + """ + + def __init__(self, url: str, connect_args: dict | None = None): + try: + from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + except ImportError: + raise ImportError( + "需要安装 sqlalchemy 和 async 驱动: pip install sqlalchemy[asyncio]" + ) + + self.dialect = url.split(":", 1)[0].split("+", 1)[0].lower() + + # 配置 robust 的连接池 + self.engine = create_async_engine( + url, + echo=False, + pool_size=20, + max_overflow=10, + pool_recycle=3600, + pool_pre_ping=True, + **({"connect_args": connect_args} if connect_args else {}), + ) + self.async_session = async_sessionmaker(self.engine, expire_on_commit=False) + self._initialized = False + + async def _ensure_schema(self): + """确保数据库表存在""" + if self._initialized: + return + try: + async with self.engine.begin() as conn: + from sqlalchemy import text + + # Tokens 表 (通用 SQL) + await conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS tokens ( + token VARCHAR(512) PRIMARY KEY, + pool_name VARCHAR(64) NOT NULL, + status VARCHAR(16), + quota INT, + created_at BIGINT, + last_used_at BIGINT, + use_count INT, + fail_count INT, + last_fail_at BIGINT, + last_fail_reason TEXT, + last_sync_at BIGINT, + tags TEXT, + note TEXT, + last_asset_clear_at BIGINT, + data TEXT, + data_hash CHAR(64), + updated_at BIGINT + ) + """) + ) + + # 配置表 + await conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS app_config ( + section VARCHAR(64) NOT NULL, + key_name VARCHAR(64) NOT NULL, + value TEXT, + PRIMARY KEY (section, key_name) + ) + """) + ) + + # 索引 + if self.dialect in ("postgres", "postgresql", "pgsql"): + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_tokens_pool ON tokens (pool_name)" + ) + ) + else: + try: + await conn.execute( + text("CREATE INDEX idx_tokens_pool ON tokens (pool_name)") + ) + except Exception: + pass + + # 补齐旧表字段 + columns = [ + ("status", "VARCHAR(16)"), + ("quota", "INT"), + ("created_at", "BIGINT"), + ("last_used_at", "BIGINT"), + ("use_count", "INT"), + ("fail_count", "INT"), + ("last_fail_at", "BIGINT"), + ("last_fail_reason", "TEXT"), + ("last_sync_at", "BIGINT"), + ("tags", "TEXT"), + ("note", "TEXT"), + ("last_asset_clear_at", "BIGINT"), + ("data", "TEXT"), + ("data_hash", "CHAR(64)"), + ("updated_at", "BIGINT"), + ] + if self.dialect in ("postgres", "postgresql", "pgsql"): + for col_name, col_type in columns: + await conn.execute( + text( + f"ALTER TABLE tokens ADD COLUMN IF NOT EXISTS {col_name} {col_type}" + ) + ) + else: + for col_name, col_type in columns: + try: + await conn.execute( + text( + f"ALTER TABLE tokens ADD COLUMN {col_name} {col_type}" + ) + ) + except Exception: + pass + + # 尝试兼容旧表结构 + try: + if self.dialect in ("mysql", "mariadb"): + await conn.execute( + text("ALTER TABLE tokens MODIFY token VARCHAR(512)") + ) + await conn.execute(text("ALTER TABLE tokens MODIFY data TEXT")) + elif self.dialect in ("postgres", "postgresql", "pgsql"): + await conn.execute( + text( + "ALTER TABLE tokens ALTER COLUMN token TYPE VARCHAR(512)" + ) + ) + await conn.execute( + text("ALTER TABLE tokens ALTER COLUMN data TYPE TEXT") + ) + except Exception: + pass + + await self._migrate_legacy_tokens() + self._initialized = True + except Exception as e: + logger.error(f"SQLStorage: Schema 初始化失败: {e}") + raise + + def _normalize_status(self, status: Any) -> Any: + if isinstance(status, str) and status.startswith("TokenStatus."): + return status.split(".", 1)[1].lower() + if isinstance(status, Enum): + return status.value + return status + + def _normalize_tags(self, tags: Any) -> Optional[str]: + if tags is None: + return None + if isinstance(tags, str): + try: + parsed = json_loads(tags) + if isinstance(parsed, list): + return tags + except Exception: + pass + return json_dumps([tags]) + return json_dumps(tags) + + def _parse_tags(self, tags: Any) -> Optional[list]: + if tags is None: + return None + if isinstance(tags, str): + try: + parsed = json_loads(tags) + if isinstance(parsed, list): + return parsed + except Exception: + return [] + if isinstance(tags, list): + return tags + return [] + + def _token_to_row(self, token_data: Dict[str, Any], pool_name: str) -> Dict[str, Any]: + token_str = token_data.get("token") + if isinstance(token_str, str) and token_str.startswith("sso="): + token_str = token_str[4:] + + status = self._normalize_status(token_data.get("status")) + tags_json = self._normalize_tags(token_data.get("tags")) + data_json = json_dumps_sorted(token_data) + data_hash = hashlib.sha256(data_json.encode("utf-8")).hexdigest() + note = token_data.get("note") + if note is None: + note = "" + + return { + "token": token_str, + "pool_name": pool_name, + "status": status, + "quota": token_data.get("quota"), + "created_at": token_data.get("created_at"), + "last_used_at": token_data.get("last_used_at"), + "use_count": token_data.get("use_count"), + "fail_count": token_data.get("fail_count"), + "last_fail_at": token_data.get("last_fail_at"), + "last_fail_reason": token_data.get("last_fail_reason"), + "last_sync_at": token_data.get("last_sync_at"), + "tags": tags_json, + "note": note, + "last_asset_clear_at": token_data.get("last_asset_clear_at"), + "data": data_json, + "data_hash": data_hash, + "updated_at": 0, + } + + async def _migrate_legacy_tokens(self): + """将旧版 data JSON 回填到平铺字段""" + from sqlalchemy import text + + try: + async with self.async_session() as session: + try: + res = await session.execute( + text( + "SELECT token FROM tokens " + "WHERE data IS NOT NULL AND " + "(status IS NULL OR quota IS NULL OR created_at IS NULL) " + "LIMIT 1" + ) + ) + if not res.first(): + return + except Exception as e: + msg = str(e).lower() + if "undefinedcolumn" in msg or "undefined column" in msg: + return + raise + + res = await session.execute( + text( + "SELECT token, pool_name, data FROM tokens " + "WHERE data IS NOT NULL AND " + "(status IS NULL OR quota IS NULL OR created_at IS NULL)" + ) + ) + rows = res.fetchall() + if not rows: + return + + params = [] + for token_str, pool_name, data_json in rows: + if not data_json: + continue + try: + if isinstance(data_json, str): + t_data = json_loads(data_json) + else: + t_data = data_json + if not isinstance(t_data, dict): + continue + t_data = dict(t_data) + t_data["token"] = token_str + row = self._token_to_row(t_data, pool_name) + params.append(row) + except Exception: + continue + + if not params: + return + + await session.execute( + text( + "UPDATE tokens SET " + "pool_name=:pool_name, " + "status=:status, " + "quota=:quota, " + "created_at=:created_at, " + "last_used_at=:last_used_at, " + "use_count=:use_count, " + "fail_count=:fail_count, " + "last_fail_at=:last_fail_at, " + "last_fail_reason=:last_fail_reason, " + "last_sync_at=:last_sync_at, " + "tags=:tags, " + "note=:note, " + "last_asset_clear_at=:last_asset_clear_at, " + "data=:data, " + "data_hash=:data_hash, " + "updated_at=:updated_at " + "WHERE token=:token" + ), + params, + ) + await session.commit() + except Exception as e: + logger.warning(f"SQLStorage: 旧数据回填失败: {e}") + + @asynccontextmanager + async def acquire_lock(self, name: str, timeout: int = 10): + # SQL 分布式锁: MySQL GET_LOCK / PG advisory_lock + from sqlalchemy import text + + lock_name = f"g2a:{hashlib.sha1(name.encode('utf-8')).hexdigest()[:24]}" + if self.dialect in ("mysql", "mariadb"): + async with self.async_session() as session: + res = await session.execute( + text("SELECT GET_LOCK(:name, :timeout)"), + {"name": lock_name, "timeout": timeout}, + ) + got = res.scalar() + if got != 1: + raise StorageError(f"SQLStorage: 无法获取锁 '{name}'") + try: + yield + finally: + try: + await session.execute( + text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name} + ) + await session.commit() + except Exception: + pass + elif self.dialect in ("postgres", "postgresql", "pgsql"): + lock_key = int.from_bytes( + hashlib.sha256(name.encode("utf-8")).digest()[:8], "big", signed=True + ) + async with self.async_session() as session: + start = time.monotonic() + while True: + res = await session.execute( + text("SELECT pg_try_advisory_lock(:key)"), {"key": lock_key} + ) + if res.scalar(): + break + if time.monotonic() - start >= timeout: + raise StorageError(f"SQLStorage: 无法获取锁 '{name}'") + await asyncio.sleep(0.1) + try: + yield + finally: + try: + await session.execute( + text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key} + ) + await session.commit() + except Exception: + pass + else: + yield + + async def load_config(self) -> Dict[str, Any]: + await self._ensure_schema() + from sqlalchemy import text + + try: + async with self.async_session() as session: + res = await session.execute( + text("SELECT section, key_name, value FROM app_config") + ) + rows = res.fetchall() + if not rows: + return None + + config = {} + for section, key, val_str in rows: + if section not in config: + config[section] = {} + try: + val = json_loads(val_str) + except Exception: + val = val_str + config[section][key] = val + return config + except Exception as e: + logger.error(f"SQLStorage: 加载配置失败: {e}") + return None + + async def save_config(self, data: Dict[str, Any]): + await self._ensure_schema() + from sqlalchemy import text + + try: + async with self.async_session() as session: + await session.execute(text("DELETE FROM app_config")) + + params = [] + for section, items in data.items(): + if not isinstance(items, dict): + continue + for key, val in items.items(): + params.append( + { + "s": section, + "k": key, + "v": json_dumps(val), + } + ) + + if params: + await session.execute( + text( + "INSERT INTO app_config (section, key_name, value) VALUES (:s, :k, :v)" + ), + params, + ) + await session.commit() + except Exception as e: + logger.error(f"SQLStorage: 保存配置失败: {e}") + raise + + async def load_tokens(self) -> Dict[str, Any]: + await self._ensure_schema() + from sqlalchemy import text + + try: + async with self.async_session() as session: + res = await session.execute( + text( + "SELECT token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data " + "FROM tokens" + ) + ) + rows = res.fetchall() + if not rows: + return None + + pools = {} + for ( + token_str, + pool_name, + status, + quota, + created_at, + last_used_at, + use_count, + fail_count, + last_fail_at, + last_fail_reason, + last_sync_at, + tags, + note, + last_asset_clear_at, + data_json, + ) in rows: + if pool_name not in pools: + pools[pool_name] = [] + + try: + token_data = {} + if token_str: + token_data["token"] = token_str + if status is not None: + token_data["status"] = self._normalize_status(status) + if quota is not None: + token_data["quota"] = int(quota) + if created_at is not None: + token_data["created_at"] = int(created_at) + if last_used_at is not None: + token_data["last_used_at"] = int(last_used_at) + if use_count is not None: + token_data["use_count"] = int(use_count) + if fail_count is not None: + token_data["fail_count"] = int(fail_count) + if last_fail_at is not None: + token_data["last_fail_at"] = int(last_fail_at) + if last_fail_reason is not None: + token_data["last_fail_reason"] = last_fail_reason + if last_sync_at is not None: + token_data["last_sync_at"] = int(last_sync_at) + if tags is not None: + token_data["tags"] = self._parse_tags(tags) + if note is not None: + token_data["note"] = note + if last_asset_clear_at is not None: + token_data["last_asset_clear_at"] = int( + last_asset_clear_at + ) + + legacy_data = None + if data_json: + if isinstance(data_json, str): + legacy_data = json_loads(data_json) + else: + legacy_data = data_json + if isinstance(legacy_data, dict): + for key, val in legacy_data.items(): + if key not in token_data or token_data[key] is None: + token_data[key] = val + + pools[pool_name].append(token_data) + except Exception: + pass + return pools + except Exception as e: + logger.error(f"SQLStorage: 加载 Token 失败: {e}") + return None + + async def save_tokens(self, data: Dict[str, Any]): + await self._ensure_schema() + from sqlalchemy import text + + if data is None: + return + + updates = [] + new_tokens = set() + for pool_name, tokens in (data or {}).items(): + for t in tokens: + if isinstance(t, dict): + token_data = dict(t) + elif isinstance(t, str): + token_data = {"token": t} + else: + continue + token_str = token_data.get("token") + if not token_str: + continue + if token_str.startswith("sso="): + token_str = token_str[4:] + token_data["token"] = token_str + token_data["pool_name"] = pool_name + token_data["_update_kind"] = "state" + updates.append(token_data) + new_tokens.add(token_str) + + try: + existing_tokens = set() + async with self.async_session() as session: + res = await session.execute(text("SELECT token FROM tokens")) + rows = res.fetchall() + existing_tokens = {row[0] for row in rows} + tokens_to_delete = list(existing_tokens - new_tokens) + await self.save_tokens_delta(updates, tokens_to_delete) + except Exception as e: + logger.error(f"SQLStorage: 保存 Token 失败: {e}") + raise + + async def save_tokens_delta( + self, updated: list[Dict[str, Any]], deleted: Optional[list[str]] = None + ): + await self._ensure_schema() + from sqlalchemy import bindparam, text + + try: + async with self.async_session() as session: + deleted_set = set(deleted or []) + if deleted_set: + delete_stmt = text( + "DELETE FROM tokens WHERE token IN :tokens" + ).bindparams(bindparam("tokens", expanding=True)) + chunk_size = 500 + deleted_list = list(deleted_set) + for i in range(0, len(deleted_list), chunk_size): + chunk = deleted_list[i : i + chunk_size] + await session.execute(delete_stmt, {"tokens": chunk}) + + updates = [] + usage_updates = [] + + for item in updated or []: + if not isinstance(item, dict): + continue + pool_name = item.get("pool_name") + token_str = item.get("token") + if not pool_name or not token_str: + continue + if token_str in deleted_set: + continue + update_kind = item.get("_update_kind", "state") + token_data = { + k: v + for k, v in item.items() + if k not in ("pool_name", "_update_kind") + } + row = self._token_to_row(token_data, pool_name) + if update_kind == "usage": + usage_updates.append(row) + else: + updates.append(row) + + if updates: + if self.dialect in ("mysql", "mariadb"): + upsert_stmt = text( + "INSERT INTO tokens (token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data, data_hash, updated_at) " + "VALUES (:token, :pool_name, :status, :quota, :created_at, " + ":last_used_at, :use_count, :fail_count, :last_fail_at, " + ":last_fail_reason, :last_sync_at, :tags, :note, " + ":last_asset_clear_at, :data, :data_hash, :updated_at) " + "ON DUPLICATE KEY UPDATE " + "pool_name=VALUES(pool_name), " + "status=VALUES(status), " + "quota=VALUES(quota), " + "created_at=VALUES(created_at), " + "last_used_at=VALUES(last_used_at), " + "use_count=VALUES(use_count), " + "fail_count=VALUES(fail_count), " + "last_fail_at=VALUES(last_fail_at), " + "last_fail_reason=VALUES(last_fail_reason), " + "last_sync_at=VALUES(last_sync_at), " + "tags=VALUES(tags), " + "note=VALUES(note), " + "last_asset_clear_at=VALUES(last_asset_clear_at), " + "data=VALUES(data), " + "data_hash=VALUES(data_hash), " + "updated_at=VALUES(updated_at)" + ) + elif self.dialect in ("postgres", "postgresql", "pgsql"): + upsert_stmt = text( + "INSERT INTO tokens (token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data, data_hash, updated_at) " + "VALUES (:token, :pool_name, :status, :quota, :created_at, " + ":last_used_at, :use_count, :fail_count, :last_fail_at, " + ":last_fail_reason, :last_sync_at, :tags, :note, " + ":last_asset_clear_at, :data, :data_hash, :updated_at) " + "ON CONFLICT (token) DO UPDATE SET " + "pool_name=EXCLUDED.pool_name, " + "status=EXCLUDED.status, " + "quota=EXCLUDED.quota, " + "created_at=EXCLUDED.created_at, " + "last_used_at=EXCLUDED.last_used_at, " + "use_count=EXCLUDED.use_count, " + "fail_count=EXCLUDED.fail_count, " + "last_fail_at=EXCLUDED.last_fail_at, " + "last_fail_reason=EXCLUDED.last_fail_reason, " + "last_sync_at=EXCLUDED.last_sync_at, " + "tags=EXCLUDED.tags, " + "note=EXCLUDED.note, " + "last_asset_clear_at=EXCLUDED.last_asset_clear_at, " + "data=EXCLUDED.data, " + "data_hash=EXCLUDED.data_hash, " + "updated_at=EXCLUDED.updated_at" + ) + else: + upsert_stmt = text( + "INSERT INTO tokens (token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data, data_hash, updated_at) " + "VALUES (:token, :pool_name, :status, :quota, :created_at, " + ":last_used_at, :use_count, :fail_count, :last_fail_at, " + ":last_fail_reason, :last_sync_at, :tags, :note, " + ":last_asset_clear_at, :data, :data_hash, :updated_at)" + ) + await session.execute(upsert_stmt, updates) + + if usage_updates: + if self.dialect in ("mysql", "mariadb"): + usage_stmt = text( + "INSERT INTO tokens (token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data, data_hash, updated_at) " + "VALUES (:token, :pool_name, :status, :quota, :created_at, " + ":last_used_at, :use_count, :fail_count, :last_fail_at, " + ":last_fail_reason, :last_sync_at, :tags, :note, " + ":last_asset_clear_at, :data, :data_hash, :updated_at) " + "ON DUPLICATE KEY UPDATE " + "pool_name=VALUES(pool_name), " + "status=VALUES(status), " + "quota=VALUES(quota), " + "last_used_at=VALUES(last_used_at), " + "use_count=VALUES(use_count), " + "fail_count=VALUES(fail_count), " + "last_fail_at=VALUES(last_fail_at), " + "last_fail_reason=VALUES(last_fail_reason), " + "last_sync_at=VALUES(last_sync_at), " + "updated_at=VALUES(updated_at)" + ) + elif self.dialect in ("postgres", "postgresql", "pgsql"): + usage_stmt = text( + "INSERT INTO tokens (token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data, data_hash, updated_at) " + "VALUES (:token, :pool_name, :status, :quota, :created_at, " + ":last_used_at, :use_count, :fail_count, :last_fail_at, " + ":last_fail_reason, :last_sync_at, :tags, :note, " + ":last_asset_clear_at, :data, :data_hash, :updated_at) " + "ON CONFLICT (token) DO UPDATE SET " + "pool_name=EXCLUDED.pool_name, " + "status=EXCLUDED.status, " + "quota=EXCLUDED.quota, " + "last_used_at=EXCLUDED.last_used_at, " + "use_count=EXCLUDED.use_count, " + "fail_count=EXCLUDED.fail_count, " + "last_fail_at=EXCLUDED.last_fail_at, " + "last_fail_reason=EXCLUDED.last_fail_reason, " + "last_sync_at=EXCLUDED.last_sync_at, " + "updated_at=EXCLUDED.updated_at" + ) + else: + usage_stmt = text( + "INSERT INTO tokens (token, pool_name, status, quota, created_at, " + "last_used_at, use_count, fail_count, last_fail_at, " + "last_fail_reason, last_sync_at, tags, note, " + "last_asset_clear_at, data, data_hash, updated_at) " + "VALUES (:token, :pool_name, :status, :quota, :created_at, " + ":last_used_at, :use_count, :fail_count, :last_fail_at, " + ":last_fail_reason, :last_sync_at, :tags, :note, " + ":last_asset_clear_at, :data, :data_hash, :updated_at)" + ) + await session.execute(usage_stmt, usage_updates) + + await session.commit() + except Exception as e: + logger.error(f"SQLStorage: 增量保存 Token 失败: {e}") + raise + + async def close(self): + await self.engine.dispose() + + +class StorageFactory: + """存储后端工厂""" + + _instance: Optional[BaseStorage] = None + + # SSL-related query parameters that async drivers (asyncpg, aiomysql) + # cannot accept via the URL and must be passed as connect_args instead. + _SQL_SSL_PARAM_KEYS = ("sslmode", "ssl-mode", "ssl") + + # Canonical postgres ssl modes (asyncpg accepts libpq-style mode strings). + _PG_SSL_MODE_ALIASES: ClassVar[dict[str, str]] = { + "disable": "disable", + "disabled": "disable", + "false": "disable", + "0": "disable", + "no": "disable", + "off": "disable", + "prefer": "prefer", + "preferred": "prefer", + "allow": "allow", + "require": "require", + "required": "require", + "true": "require", + "1": "require", + "yes": "require", + "on": "require", + "verify-ca": "verify-ca", + "verify_ca": "verify-ca", + "verify-full": "verify-full", + "verify_full": "verify-full", + "verify-identity": "verify-full", + "verify_identity": "verify-full", + } + + # Canonical mysql ssl modes (aiomysql accepts SSLContext, not mode strings). + _MY_SSL_MODE_ALIASES: ClassVar[dict[str, str]] = { + "disable": "disabled", + "disabled": "disabled", + "false": "disabled", + "0": "disabled", + "no": "disabled", + "off": "disabled", + "prefer": "preferred", + "preferred": "preferred", + "allow": "preferred", + "require": "required", + "required": "required", + "true": "required", + "1": "required", + "yes": "required", + "on": "required", + "verify-ca": "verify_ca", + "verify_ca": "verify_ca", + "verify-full": "verify_identity", + "verify_full": "verify_identity", + "verify-identity": "verify_identity", + "verify_identity": "verify_identity", + } + + @classmethod + def _normalize_ssl_mode(cls, storage_type: str, mode: str) -> str: + """Normalize SSL mode aliases for the target storage backend.""" + if not mode: + raise ValueError("SSL mode cannot be empty") + + normalized = mode.strip().lower().replace(" ", "") + if storage_type == "pgsql": + canonical = cls._PG_SSL_MODE_ALIASES.get(normalized) + elif storage_type == "mysql": + canonical = cls._MY_SSL_MODE_ALIASES.get(normalized) + else: + canonical = None + + if not canonical: + raise ValueError( + f"Unsupported SSL mode '{mode}' for storage type '{storage_type}'" + ) + return canonical + + @classmethod + def _build_mysql_ssl_context(cls, mode: str): + """Build SSLContext for aiomysql according to normalized mysql mode. + + Note: aiomysql enforces SSL whenever an SSLContext is provided — there + is no "try SSL, fall back to plaintext" behaviour. As a result the + ``preferred`` mode is treated identically to ``required`` (encrypted, + no cert verification). Connections to MySQL servers that do not + support SSL will fail rather than degrade gracefully. + """ + import ssl as _ssl + + if mode == "disabled": + return None + + ctx = _ssl.create_default_context() + if mode in ("preferred", "required"): + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + elif mode == "verify_ca": + # verify CA, but do not enforce hostname match. + ctx.check_hostname = False + # verify_identity keeps defaults: verify cert + hostname. + return ctx + + @classmethod + def _build_sql_connect_args( + cls, storage_type: str, raw_ssl_mode: Optional[str] + ) -> Optional[dict]: + """Build SQLAlchemy connect_args for SQL SSL modes.""" + if not raw_ssl_mode: + return None + + mode = cls._normalize_ssl_mode(storage_type, raw_ssl_mode) + if storage_type == "pgsql": + # asyncpg accepts libpq-style ssl mode strings via ssl=... + return {"ssl": mode} + if storage_type == "mysql": + ctx = cls._build_mysql_ssl_context(mode) + if ctx is None: + return None + return {"ssl": ctx} + return None + + @classmethod + def _normalize_sql_url(cls, storage_type: str, url: str) -> str: + """Rewrite scheme prefix to the SQLAlchemy async dialect form.""" + if not url or "://" not in url: + return url + if storage_type == "mysql": + if url.startswith("mysql://"): + url = f"mysql+aiomysql://{url[len('mysql://') :]}" + elif url.startswith("mariadb://"): + # Use mysql+aiomysql for both MySQL and MariaDB endpoints. + # The mariadb dialect enforces strict MariaDB server detection. + url = f"mysql+aiomysql://{url[len('mariadb://') :]}" + elif url.startswith("mariadb+aiomysql://"): + url = f"mysql+aiomysql://{url[len('mariadb+aiomysql://') :]}" + elif storage_type == "pgsql": + if url.startswith("postgres://"): + url = f"postgresql+asyncpg://{url[len('postgres://') :]}" + elif url.startswith("postgresql://"): + url = f"postgresql+asyncpg://{url[len('postgresql://') :]}" + elif url.startswith("pgsql://"): + url = f"postgresql+asyncpg://{url[len('pgsql://') :]}" + return url + + @classmethod + def _prepare_sql_url_and_connect_args( + cls, storage_type: str, url: str + ) -> tuple[str, Optional[dict]]: + """Normalize SQL URL and build connect_args from SSL query params.""" + from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse + + normalized_url = cls._normalize_sql_url(storage_type, url) + if "://" not in normalized_url: + return normalized_url, None + + parsed = urlparse(normalized_url) + ssl_mode: Optional[str] = None + filtered_query_items = [] + ssl_param_keys = {k.lower() for k in cls._SQL_SSL_PARAM_KEYS} + for key, value in parse_qsl(parsed.query, keep_blank_values=True): + if key.lower() in ssl_param_keys: + if ssl_mode is None and value: + ssl_mode = value + continue + filtered_query_items.append((key, value)) + + cleaned_url = urlunparse( + parsed._replace(query=urlencode(filtered_query_items, doseq=True)) + ) + connect_args = cls._build_sql_connect_args(storage_type, ssl_mode) + return cleaned_url, connect_args + + @classmethod + def get_storage(cls) -> BaseStorage: + """获取全局存储实例 (单例)""" + if cls._instance: + return cls._instance + + storage_type = os.getenv("SERVER_STORAGE_TYPE", "local").lower() + storage_url = os.getenv("SERVER_STORAGE_URL", "") + + logger.info(f"StorageFactory: 初始化存储后端: {storage_type}") + + if storage_type == "redis": + if not storage_url: + raise ValueError("Redis 存储需要设置 SERVER_STORAGE_URL") + cls._instance = RedisStorage(storage_url) + + elif storage_type in ("mysql", "pgsql"): + if not storage_url: + raise ValueError("SQL 存储需要设置 SERVER_STORAGE_URL") + # Drivers reject SSL query params in URL. Normalize URL and pass + # backend-specific SSL handling through connect_args. + storage_url, connect_args = cls._prepare_sql_url_and_connect_args( + storage_type, storage_url + ) + cls._instance = SQLStorage(storage_url, connect_args=connect_args) + + else: + cls._instance = LocalStorage() + + return cls._instance + + +def get_storage() -> BaseStorage: + return StorageFactory.get_storage() diff --git a/app/services/cf_refresh/README.md b/app/services/cf_refresh/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ffe74e049419a28f59cbd156f4b4963898cf7999 --- /dev/null +++ b/app/services/cf_refresh/README.md @@ -0,0 +1,49 @@ +# cf_refresh - Cloudflare cf_clearance 自动刷新 + +通过 [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) 自动获取 Cloudflare `cf_clearance` cookie 和 `user_agent`,并更新到 Grok2API 服务配置中。 + +全自动、无需 GUI、服务器友好。 + +## 工作原理 + +1. FlareSolverr(独立 Docker 容器)内部运行 Chrome,自动通过 CF 挑战 +2. cf_refresh 作为 grok2api 的后台任务,调用 FlareSolverr HTTP API 获取 `cf_clearance` 和 `user_agent` +3. 直接在进程内调用 `config.update()` 更新运行时配置并持久化到 `data/config.toml` +4. 按设定间隔重复以上步骤 + +## 配置方式 + +所有配置均可在管理面板 `/admin/config` 的 **CF 自动刷新** 区域中设置,也可通过环境变量初始化: + +| 配置项 | 环境变量 | 默认值 | 说明 | +|--------|----------|--------|------| +| 启用自动刷新 | `FLARESOLVERR_URL`(非空即启用) | `false` | 是否开启自动刷新 | +| FlareSolverr 地址 | `FLARESOLVERR_URL` | — | FlareSolverr 服务的 HTTP 地址 | +| 刷新间隔(秒) | `CF_REFRESH_INTERVAL` | `600` | 定期刷新间隔 | +| 挑战超时(秒) | `CF_TIMEOUT` | `60` | CF 挑战等待超时 | + +> **代理**:自动使用「代理配置 → 基础代理 URL」,无需单独设置,保证出口 IP 一致。 + +## 使用方式 + +### Docker Compose 部署 + +已集成在项目根目录 `docker-compose.yml` 中。只需在 grok2api 服务的环境变量中设置 `FLARESOLVERR_URL`,并添加 `flaresolverr` 服务即可: + +```yaml +services: + grok2api: + environment: + FLARESOLVERR_URL: http://flaresolverr:8191 + + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + restart: unless-stopped +``` + +## 注意事项 + +- `cf_clearance` 与请求来源 IP 绑定,FlareSolverr 自动使用代理配置中的基础代理 URL 保证出口 IP 一致 +- 启用自动刷新后,代理配置中的 CF Clearance、浏览器指纹和 User-Agent 由系统自动管理(面板中变灰) +- 建议刷新间隔不低于 5 分钟,避免触发 Cloudflare 频率限制 +- FlareSolverr 需要约 500MB 内存(内部运行 Chrome) diff --git a/app/services/cf_refresh/__init__.py b/app/services/cf_refresh/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b971ce04d340ad7688122c4a402767519ace77d1 --- /dev/null +++ b/app/services/cf_refresh/__init__.py @@ -0,0 +1,5 @@ +"""cf_refresh - Cloudflare cf_clearance 自动刷新模块""" + +from .scheduler import start, stop + +__all__ = ["start", "stop"] diff --git a/app/services/cf_refresh/config.py b/app/services/cf_refresh/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e09854e5e382d269bf2ed4d2b5b189efbd96d3ce --- /dev/null +++ b/app/services/cf_refresh/config.py @@ -0,0 +1,41 @@ +"""配置管理 — 从 app config 的 proxy.* 读取,支持面板修改实时生效""" + +GROK_URL = "https://grok.com" + + +def _get(key: str, default=None): + """从 app config 读取 proxy.* 配置""" + from app.core.config import get_config + return get_config(f"proxy.{key}", default) + + +def get_flaresolverr_url() -> str: + return _get("flaresolverr_url", "") or "" + + +def _get_int(key: str, default: int, min_value: int) -> int: + raw = _get(key, default) + try: + value = int(raw) + except (TypeError, ValueError): + return max(default, min_value) + if value < min_value: + return min_value + return value + + +def get_refresh_interval() -> int: + return _get_int("refresh_interval", 600, 60) + + +def get_timeout() -> int: + return _get_int("timeout", 60, 60) + + +def get_proxy() -> str: + """使用基础代理 URL,保证出口 IP 一致""" + return _get("base_proxy_url", "") or "" + + +def is_enabled() -> bool: + return bool(_get("enabled", False)) diff --git a/app/services/cf_refresh/scheduler.py b/app/services/cf_refresh/scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..11c398b3a1f90f61f8cb767a63890f4749fa9604 --- /dev/null +++ b/app/services/cf_refresh/scheduler.py @@ -0,0 +1,98 @@ +"""定时调度:周期性刷新 cf_clearance(集成到 grok2api 进程内)""" + +import asyncio + +from loguru import logger + +from .config import get_refresh_interval, get_flaresolverr_url, is_enabled +from .solver import solve_cf_challenge + +_task: asyncio.Task | None = None + + +async def _update_app_config( + cf_cookies: str, + user_agent: str = "", + browser: str = "", + cf_clearance: str = "", +) -> bool: + """直接更新 grok2api 的运行时配置""" + try: + from app.core.config import config + + proxy_update = {"cf_cookies": cf_cookies} + if cf_clearance: + proxy_update["cf_clearance"] = cf_clearance + if user_agent: + proxy_update["user_agent"] = user_agent + if browser: + proxy_update["browser"] = browser + + await config.update({"proxy": proxy_update}) + + logger.info(f"配置已更新: cf_cookies (长度 {len(cf_cookies)}), 指纹: {browser}") + if user_agent: + logger.info(f"配置已更新: user_agent = {user_agent}") + return True + except Exception as e: + logger.error(f"更新配置失败: {e}") + return False + + +async def refresh_once() -> bool: + """执行一次刷新流程""" + logger.info("=" * 50) + logger.info("开始刷新 cf_clearance...") + + result = await solve_cf_challenge() + if not result: + logger.error("刷新失败:无法获取 cf_clearance") + return False + + success = await _update_app_config( + cf_cookies=result["cookies"], + cf_clearance=result.get("cf_clearance", ""), + user_agent=result.get("user_agent", ""), + browser=result.get("browser", ""), + ) + + if success: + logger.info("刷新完成") + else: + logger.error("刷新失败: 更新配置失败") + + return success + + +async def _scheduler_loop(): + """后台调度循环""" + logger.info( + f"cf_refresh scheduler started (FlareSolverr: {get_flaresolverr_url()}, interval: {get_refresh_interval()}s)" + ) + + # 周期性刷新(每次循环重新读取配置,支持面板修改实时生效) + while True: + if is_enabled(): + await refresh_once() + else: + logger.debug("cf_refresh disabled, skip refresh") + interval = get_refresh_interval() + await asyncio.sleep(interval) + + +def start(): + """启动后台刷新任务""" + global _task + if _task is not None: + return + _task = asyncio.get_event_loop().create_task(_scheduler_loop()) + logger.info("cf_refresh background task started") + + +def stop(): + """停止后台刷新任务""" + global _task + if _task is not None: + _task.cancel() + _task = None + logger.info("cf_refresh background task stopped") diff --git a/app/services/cf_refresh/solver.py b/app/services/cf_refresh/solver.py new file mode 100644 index 0000000000000000000000000000000000000000..c6787f8dc418124001769afc1cebba9e70268c2f --- /dev/null +++ b/app/services/cf_refresh/solver.py @@ -0,0 +1,122 @@ +""" +通过 FlareSolverr 自动获取 cf_clearance + +FlareSolverr 是一个 Docker 服务,内部运行 Chrome 浏览器, +自动处理 Cloudflare 挑战(包括 Turnstile),无需 GUI。 +""" + +import asyncio +import json +from typing import Optional, Dict +from urllib import request as urllib_request +from urllib.error import HTTPError, URLError + +from loguru import logger + +from .config import GROK_URL, get_timeout, get_proxy, get_flaresolverr_url + + +def _extract_all_cookies(cookies: list[dict]) -> str: + """将 FlareSolverr 返回 of cookie 列表转换为字符串格式""" + return "; ".join([f"{c.get('name')}={c.get('value')}" for c in cookies]) + + +def _extract_cookie_value(cookies: list[dict], name: str) -> str: + for cookie in cookies: + if cookie.get("name") == name: + return cookie.get("value") or "" + return "" + + +def _extract_user_agent(solution: dict) -> str: + """从 FlareSolverr 的 solution 中提取 User-Agent""" + return solution.get("userAgent", "") + + +def _extract_browser_profile(user_agent: str) -> str: + """从 User-Agent 提取 chromeXXX 格式的指纹识别号""" + import re + match = re.search(r"Chrome/(\d+)", user_agent) + if match: + return f"chrome{match.group(1)}" + return "chrome120" + + +async def solve_cf_challenge() -> Optional[Dict[str, str]]: + """ + 通过 FlareSolverr 访问 grok.com,自动过 CF 挑战,提取 cf_clearance。 + + Returns: + 成功时返回 {"cookies": "...", "user_agent": "..."},失败返回 None + """ + flaresolverr_url = get_flaresolverr_url() + cf_timeout = get_timeout() + proxy = get_proxy() + + if not flaresolverr_url: + logger.error("FlareSolverr 地址未配置,无法刷新 cf_clearance") + return None + + url = f"{flaresolverr_url.rstrip('/')}/v1" + + payload = { + "cmd": "request.get", + "url": GROK_URL, + "maxTimeout": cf_timeout * 1000, + } + + if proxy: + payload["proxy"] = {"url": proxy} + + body = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + + logger.info(f"正在通过 FlareSolverr 访问 {GROK_URL} ...") + logger.debug(f"FlareSolverr 地址: {url}") + + req = urllib_request.Request(url, data=body, method="POST", headers=headers) + + try: + def _post(): + with urllib_request.urlopen(req, timeout=cf_timeout + 30) as resp: + return json.loads(resp.read().decode("utf-8")) + + result = await asyncio.to_thread(_post) + + status = result.get("status", "") + if status != "ok": + message = result.get("message", "unknown error") + logger.error(f"FlareSolverr 返回错误: {status} - {message}") + return None + + solution = result.get("solution", {}) + cookies = solution.get("cookies", []) + + if not cookies: + logger.error("FlareSolverr 成功访问但没有返回 cookies") + return None + + cookie_str = _extract_all_cookies(cookies) + clearance = _extract_cookie_value(cookies, "cf_clearance") + ua = _extract_user_agent(solution) + browser = _extract_browser_profile(ua) + logger.info(f"成功获取 cookies (数量: {len(cookies)}), 指纹: {browser}") + + return { + "cookies": cookie_str, + "cf_clearance": clearance, + "user_agent": ua, + "browser": browser, + } + + except HTTPError as e: + body_text = e.read().decode("utf-8", "replace")[:300] + logger.error(f"FlareSolverr 请求失败: {e.code} - {body_text}") + return None + except URLError as e: + logger.error(f"无法连接 FlareSolverr ({flaresolverr_url}): {e.reason}") + logger.info("请确认 FlareSolverr 服务已启动: docker run -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest") + return None + except Exception as e: + logger.error(f"请求异常: {e}") + return None diff --git a/app/services/grok/batch_services/assets.py b/app/services/grok/batch_services/assets.py new file mode 100644 index 0000000000000000000000000000000000000000..0b9989b09f9ae831c327c498186bd67b40215325 --- /dev/null +++ b/app/services/grok/batch_services/assets.py @@ -0,0 +1,234 @@ +""" +Batch assets service. +""" + +import asyncio +from typing import Dict, List, Optional + +from app.core.config import get_config +from app.core.logger import logger +from app.services.reverse.assets_list import AssetsListReverse +from app.services.reverse.assets_delete import AssetsDeleteReverse +from app.services.reverse.utils.session import ResettableSession +from app.core.batch import run_batch + + +class BaseAssetsService: + """Base assets service.""" + + def __init__(self): + self._session: Optional[ResettableSession] = None + + async def _get_session(self) -> ResettableSession: + if self._session is None: + browser = get_config("proxy.browser") + if browser: + self._session = ResettableSession(impersonate=browser) + else: + self._session = ResettableSession() + return self._session + + async def close(self): + if self._session: + await self._session.close() + self._session = None + + +_LIST_SEMAPHORE = None +_LIST_SEM_VALUE = None +_DELETE_SEMAPHORE = None +_DELETE_SEM_VALUE = None + + +def _get_list_semaphore() -> asyncio.Semaphore: + value = max(1, int(get_config("asset.list_concurrent"))) + global _LIST_SEMAPHORE, _LIST_SEM_VALUE + if _LIST_SEMAPHORE is None or value != _LIST_SEM_VALUE: + _LIST_SEM_VALUE = value + _LIST_SEMAPHORE = asyncio.Semaphore(value) + return _LIST_SEMAPHORE + + +def _get_delete_semaphore() -> asyncio.Semaphore: + value = max(1, int(get_config("asset.delete_concurrent"))) + global _DELETE_SEMAPHORE, _DELETE_SEM_VALUE + if _DELETE_SEMAPHORE is None or value != _DELETE_SEM_VALUE: + _DELETE_SEM_VALUE = value + _DELETE_SEMAPHORE = asyncio.Semaphore(value) + return _DELETE_SEMAPHORE + + +class ListService(BaseAssetsService): + """Assets list service.""" + + async def list(self, token: str) -> Dict[str, List[str] | int]: + params = { + "pageSize": 50, + "orderBy": "ORDER_BY_LAST_USE_TIME", + "source": "SOURCE_ANY", + "isLatest": "true", + } + page_token = None + seen_tokens = set() + asset_ids: List[str] = [] + session = await self._get_session() + while True: + if page_token: + if page_token in seen_tokens: + logger.warning("Pagination stopped: repeated page token") + break + seen_tokens.add(page_token) + params["pageToken"] = page_token + else: + params.pop("pageToken", None) + + async with _get_list_semaphore(): + response = await AssetsListReverse.request( + session, + token, + params, + ) + + result = response.json() + page_assets = result.get("assets", []) + if page_assets: + for asset in page_assets: + asset_id = asset.get("assetId") + if asset_id: + asset_ids.append(asset_id) + + page_token = result.get("nextPageToken") + if not page_token: + break + + logger.info(f"List success: {len(asset_ids)} files") + return {"asset_ids": asset_ids, "count": len(asset_ids)} + + @staticmethod + async def fetch_assets_details( + tokens: List[str], + account_map: dict, + *, + include_ok: bool = False, + on_item=None, + should_cancel=None, + ) -> dict: + """Batch fetch assets details for tokens.""" + account_map = account_map or {} + shared_service = ListService() + batch_size = max(1, int(get_config("asset.list_batch_size"))) + + async def _fetch_detail(token: str): + account = account_map.get(token) + try: + result = await shared_service.list(token) + asset_ids = result.get("asset_ids", []) + count = result.get("count", len(asset_ids)) + detail = { + "token": token, + "token_masked": account["token_masked"] if account else token, + "count": count, + "status": "ok", + "last_asset_clear_at": account["last_asset_clear_at"] + if account + else None, + } + if include_ok: + return {"ok": True, "detail": detail, "count": count} + return {"detail": detail, "count": count} + except Exception as e: + detail = { + "token": token, + "token_masked": account["token_masked"] if account else token, + "count": 0, + "status": f"error: {str(e)}", + "last_asset_clear_at": account["last_asset_clear_at"] + if account + else None, + } + if include_ok: + return {"ok": False, "detail": detail, "count": 0} + return {"detail": detail, "count": 0} + + try: + return await run_batch( + tokens, + _fetch_detail, + batch_size=batch_size, + on_item=on_item, + should_cancel=should_cancel, + ) + finally: + await shared_service.close() + + +class DeleteService(BaseAssetsService): + """Assets delete service.""" + + async def delete(self, token: str, asset_ids: List[str]) -> Dict[str, int]: + if not asset_ids: + logger.info("No assets to delete") + return {"total": 0, "success": 0, "failed": 0, "skipped": True} + + total = len(asset_ids) + success = 0 + failed = 0 + session = await self._get_session() + + async def _delete_one(asset_id: str): + async with _get_delete_semaphore(): + await AssetsDeleteReverse.request(session, token, asset_id) + + tasks = [_delete_one(asset_id) for asset_id in asset_ids if asset_id] + results = await asyncio.gather(*tasks, return_exceptions=True) + for res in results: + if isinstance(res, Exception): + failed += 1 + else: + success += 1 + + logger.info(f"Delete all: total={total}, success={success}, failed={failed}") + return {"total": total, "success": success, "failed": failed} + + @staticmethod + async def clear_assets( + tokens: List[str], + mgr, + *, + include_ok: bool = False, + on_item=None, + should_cancel=None, + ) -> dict: + """Batch clear assets for tokens.""" + delete_service = DeleteService() + list_service = ListService() + batch_size = max(1, int(get_config("asset.delete_batch_size"))) + + async def _clear_one(token: str): + try: + result = await list_service.list(token) + asset_ids = result.get("asset_ids", []) + result = await delete_service.delete(token, asset_ids) + await mgr.mark_asset_clear(token) + if include_ok: + return {"ok": True, "result": result} + return {"status": "success", "result": result} + except Exception as e: + if include_ok: + return {"ok": False, "error": str(e)} + return {"status": "error", "error": str(e)} + + try: + return await run_batch( + tokens, + _clear_one, + batch_size=batch_size, + on_item=on_item, + should_cancel=should_cancel, + ) + finally: + await delete_service.close() + await list_service.close() + + +__all__ = ["ListService", "DeleteService"] diff --git a/app/services/grok/batch_services/nsfw.py b/app/services/grok/batch_services/nsfw.py new file mode 100644 index 0000000000000000000000000000000000000000..f9dca66818b6d78645fffc40457b850bef8349e8 --- /dev/null +++ b/app/services/grok/batch_services/nsfw.py @@ -0,0 +1,112 @@ +""" +Batch NSFW service. +""" + +import asyncio +from typing import Callable, Awaitable, Dict, Any, Optional + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import UpstreamException +from app.services.reverse.accept_tos import AcceptTosReverse +from app.services.reverse.nsfw_mgmt import NsfwMgmtReverse +from app.services.reverse.set_birth import SetBirthReverse +from app.services.reverse.utils.session import ResettableSession +from app.core.batch import run_batch + + +_NSFW_SEMAPHORE = None +_NSFW_SEM_VALUE = None + + +def _get_nsfw_semaphore() -> asyncio.Semaphore: + value = max(1, int(get_config("nsfw.concurrent"))) + global _NSFW_SEMAPHORE, _NSFW_SEM_VALUE + if _NSFW_SEMAPHORE is None or value != _NSFW_SEM_VALUE: + _NSFW_SEM_VALUE = value + _NSFW_SEMAPHORE = asyncio.Semaphore(value) + return _NSFW_SEMAPHORE + + +class NSFWService: + """NSFW 模式服务""" + @staticmethod + async def batch( + tokens: list[str], + mgr, + *, + on_item: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None, + should_cancel: Optional[Callable[[], bool]] = None, + ) -> Dict[str, Dict[str, Any]]: + """Batch enable NSFW.""" + batch_size = get_config("nsfw.batch_size") + async def _enable(token: str): + try: + browser = get_config("proxy.browser") + async with ResettableSession(impersonate=browser) as session: + async def _record_fail(err: UpstreamException, reason: str): + status = None + if err.details and "status" in err.details: + status = err.details["status"] + else: + status = getattr(err, "status_code", None) + if status == 401: + await mgr.record_fail(token, status, reason) + return status or 0 + + try: + async with _get_nsfw_semaphore(): + await AcceptTosReverse.request(session, token) + except UpstreamException as e: + status = await _record_fail(e, "tos_auth_failed") + return { + "success": False, + "http_status": status, + "error": f"Accept ToS failed: {str(e)}", + } + + try: + async with _get_nsfw_semaphore(): + await SetBirthReverse.request(session, token) + except UpstreamException as e: + status = await _record_fail(e, "set_birth_auth_failed") + return { + "success": False, + "http_status": status, + "error": f"Set birth date failed: {str(e)}", + } + + try: + async with _get_nsfw_semaphore(): + grpc_status = await NsfwMgmtReverse.request(session, token) + success = grpc_status.code in (-1, 0) + except UpstreamException as e: + status = await _record_fail(e, "nsfw_mgmt_auth_failed") + return { + "success": False, + "http_status": status, + "error": f"NSFW enable failed: {str(e)}", + } + if success: + await mgr.add_tag(token, "nsfw") + return { + "success": success, + "http_status": 200, + "grpc_status": grpc_status.code, + "grpc_message": grpc_status.message or None, + "error": None, + } + except Exception as e: + logger.error(f"NSFW enable failed: {e}") + return {"success": False, "http_status": 0, "error": str(e)[:100]} + + return await run_batch( + tokens, + _enable, + batch_size=batch_size, + on_item=on_item, + should_cancel=should_cancel, + ) + + +__all__ = ["NSFWService"] diff --git a/app/services/grok/batch_services/usage.py b/app/services/grok/batch_services/usage.py new file mode 100644 index 0000000000000000000000000000000000000000..fa4dd76c2ac7a949cd8d9967186d32b5669c70d3 --- /dev/null +++ b/app/services/grok/batch_services/usage.py @@ -0,0 +1,89 @@ +""" +Batch usage service. +""" + +import asyncio +from typing import Callable, Awaitable, Dict, Any, Optional, List + +from app.core.logger import logger +from app.core.config import get_config +from app.services.reverse.rate_limits import RateLimitsReverse +from app.services.reverse.utils.session import ResettableSession +from app.core.batch import run_batch + +_USAGE_SEMAPHORE = None +_USAGE_SEM_VALUE = None + + +def _get_usage_semaphore() -> asyncio.Semaphore: + value = max(1, int(get_config("usage.concurrent"))) + global _USAGE_SEMAPHORE, _USAGE_SEM_VALUE + if _USAGE_SEMAPHORE is None or value != _USAGE_SEM_VALUE: + _USAGE_SEM_VALUE = value + _USAGE_SEMAPHORE = asyncio.Semaphore(value) + return _USAGE_SEMAPHORE + + +class UsageService: + """用量查询服务""" + + async def get(self, token: str) -> Dict: + """ + 获取速率限制信息 + + Args: + token: 认证 Token + + Returns: + 响应数据 + + Raises: + UpstreamException: 当获取失败且重试耗尽时 + """ + async with _get_usage_semaphore(): + try: + browser = get_config("proxy.browser") + if browser: + session_ctx = ResettableSession(impersonate=browser) + else: + session_ctx = ResettableSession() + async with session_ctx as session: + response = await RateLimitsReverse.request(session, token) + data = response.json() + remaining = data.get("remainingTokens") + if remaining is None: + remaining = data.get("remainingQueries") + if remaining is not None: + data["remainingTokens"] = remaining + logger.info( + f"Usage sync success: remaining={remaining}, token={token[:10]}..." + ) + return data + + except Exception: + # 最后一次失败已经被记录 + raise + + + @staticmethod + async def batch( + tokens: List[str], + mgr, + *, + on_item: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None, + should_cancel: Optional[Callable[[], bool]] = None, + ) -> Dict[str, Dict[str, Any]]: + batch_size = get_config("usage.batch_size") + async def _refresh_one(t: str): + return await mgr.sync_usage(t, consume_on_fail=False, is_usage=False) + + return await run_batch( + tokens, + _refresh_one, + batch_size=batch_size, + on_item=on_item, + should_cancel=should_cancel, + ) + + +__all__ = ["UsageService"] diff --git a/app/services/grok/defaults.py b/app/services/grok/defaults.py new file mode 100644 index 0000000000000000000000000000000000000000..d7af7eb7daf00499e779430271df9db47b05ae65 --- /dev/null +++ b/app/services/grok/defaults.py @@ -0,0 +1,34 @@ +""" +Grok 服务默认配置 + +此文件读取 config.defaults.toml,作为 Grok 服务的默认值来源。 +""" + +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 + + +__all__ = ["GROK_DEFAULTS", "get_grok_defaults"] diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..a80865310d132a639daa23248488d6fdb66c00d0 --- /dev/null +++ b/app/services/grok/services/chat.py @@ -0,0 +1,1115 @@ +""" +Grok Chat 服务 +""" + +import asyncio +import re +import uuid +from typing import Dict, List, Any, AsyncGenerator, AsyncIterable + +import orjson +from curl_cffi.requests.errors import RequestsError + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import ( + AppException, + ValidationException, + ErrorType, + UpstreamException, + StreamIdleTimeoutError, +) +from app.services.grok.services.model import ModelService +from app.services.grok.utils.upload import UploadService +from app.services.grok.utils import process as proc_base +from app.services.grok.utils.retry import pick_token, rate_limited, transient_upstream +from app.services.reverse.app_chat import AppChatReverse +from app.services.reverse.utils.session import ResettableSession +from app.services.grok.utils.stream import wrap_stream_with_usage +from app.services.grok.utils.tool_call import ( + build_tool_prompt, + parse_tool_calls, + parse_tool_call_block, + format_tool_history, +) +from app.services.token import get_token_manager, EffortType + + +_CHAT_SEMAPHORE = None +_CHAT_SEM_VALUE = None + + +def extract_tool_text(raw: str, rollout_id: str = "") -> str: + if not raw: + return "" + name_match = re.search( + r"(.*?)", raw, flags=re.DOTALL + ) + args_match = re.search( + r"(.*?)", raw, flags=re.DOTALL + ) + + name = name_match.group(1) if name_match else "" + if name: + name = re.sub(r"", r"\1", name, flags=re.DOTALL).strip() + + args = args_match.group(1) if args_match else "" + if args: + args = re.sub(r"", r"\1", args, flags=re.DOTALL).strip() + + payload = None + if args: + try: + payload = orjson.loads(args) + except orjson.JSONDecodeError: + payload = None + + label = name + text = args + prefix = f"[{rollout_id}]" if rollout_id else "" + + if name == "web_search": + label = f"{prefix}[WebSearch]" + if isinstance(payload, dict): + text = payload.get("query") or payload.get("q") or "" + elif name == "search_images": + label = f"{prefix}[SearchImage]" + if isinstance(payload, dict): + text = ( + payload.get("image_description") + or payload.get("description") + or payload.get("query") + or "" + ) + elif name == "chatroom_send": + label = f"{prefix}[AgentThink]" + if isinstance(payload, dict): + text = payload.get("message") or "" + + if label and text: + return f"{label} {text}".strip() + if label: + return label + if text: + return text + # Fallback: strip tags to keep any raw text. + return re.sub(r"<[^>]+>", "", raw, flags=re.DOTALL).strip() + + +def _get_chat_semaphore() -> asyncio.Semaphore: + global _CHAT_SEMAPHORE, _CHAT_SEM_VALUE + value = max(1, int(get_config("chat.concurrent"))) + if value != _CHAT_SEM_VALUE: + _CHAT_SEM_VALUE = value + _CHAT_SEMAPHORE = asyncio.Semaphore(value) + return _CHAT_SEMAPHORE + + +class MessageExtractor: + """消息内容提取器""" + + @staticmethod + def extract( + messages: List[Dict[str, Any]], + tools: List[Dict[str, Any]] = None, + tool_choice: Any = None, + parallel_tool_calls: bool = True, + ) -> tuple[str, List[str], List[str]]: + """从 OpenAI 消息格式提取内容,返回 (text, file_attachments, image_attachments)""" + # Pre-process: convert tool-related messages to text format + if tools: + messages = format_tool_history(messages) + + texts = [] + file_attachments: List[str] = [] + image_attachments: List[str] = [] + extracted = [] + + for msg in messages: + role = msg.get("role", "") or "user" + content = msg.get("content", "") + parts = [] + + if isinstance(content, str): + if content.strip(): + parts.append(content) + elif isinstance(content, dict): + content = [content] + for item in content: + if not isinstance(item, dict): + continue + item_type = item.get("type", "") + if item_type == "text": + if text := item.get("text", "").strip(): + parts.append(text) + elif item_type == "image_url": + image_data = item.get("image_url", {}) + url = image_data.get("url", "") + if url: + image_attachments.append(url) + elif item_type == "input_audio": + audio_data = item.get("input_audio", {}) + data = audio_data.get("data", "") + if data: + file_attachments.append(data) + elif item_type == "file": + file_data = item.get("file", {}) + raw = file_data.get("file_data", "") + if raw: + file_attachments.append(raw) + elif isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + item_type = item.get("type", "") + + if item_type == "text": + if text := item.get("text", "").strip(): + parts.append(text) + + elif item_type == "image_url": + image_data = item.get("image_url", {}) + url = image_data.get("url", "") + if url: + image_attachments.append(url) + + elif item_type == "input_audio": + audio_data = item.get("input_audio", {}) + data = audio_data.get("data", "") + if data: + file_attachments.append(data) + + elif item_type == "file": + file_data = item.get("file", {}) + raw = file_data.get("file_data", "") + if raw: + file_attachments.append(raw) + + # 保留工具调用轨迹,避免部分客户端在多轮工具会话中丢失上下文顺序 + tool_calls = msg.get("tool_calls") + if role == "assistant" and not parts and isinstance(tool_calls, list): + for call in tool_calls: + if not isinstance(call, dict): + continue + fn = call.get("function", {}) + if not isinstance(fn, dict): + fn = {} + name = fn.get("name") or call.get("name") or "tool" + arguments = fn.get("arguments", "") + if isinstance(arguments, (dict, list)): + try: + arguments = orjson.dumps(arguments).decode() + except Exception: + arguments = str(arguments) + if not isinstance(arguments, str): + arguments = str(arguments) + arguments = arguments.strip() + parts.append( + f"[tool_call] {name} {arguments}".strip() + ) + + if parts: + role_label = role + if role == "tool": + name = msg.get("name") + call_id = msg.get("tool_call_id") + if isinstance(name, str) and name.strip(): + role_label = f"tool[{name.strip()}]" + if isinstance(call_id, str) and call_id.strip(): + role_label = f"{role_label}#{call_id.strip()}" + extracted.append({"role": role_label, "text": "\n".join(parts)}) + + # 找到最后一条 user 消息 + last_user_index = next( + ( + i + for i in range(len(extracted) - 1, -1, -1) + if extracted[i]["role"] == "user" + ), + None, + ) + + for i, item in enumerate(extracted): + role = item["role"] or "user" + text = item["text"] + texts.append(text if i == last_user_index else f"{role}: {text}") + + combined = "\n\n".join(texts) + + # If there are attachments but no text, inject a fallback prompt. + if (not combined.strip()) and (file_attachments or image_attachments): + combined = "Refer to the following content:" + + # Prepend tool system prompt if tools are provided + if tools: + tool_prompt = build_tool_prompt(tools, tool_choice, parallel_tool_calls) + if tool_prompt: + combined = f"{tool_prompt}\n\n{combined}" + + return combined, file_attachments, image_attachments + + +class GrokChatService: + """Grok API 调用服务""" + + async def chat( + self, + token: str, + message: str, + model: str, + mode: str = None, + stream: bool = None, + file_attachments: List[str] = None, + tool_overrides: Dict[str, Any] = None, + model_config_override: Dict[str, Any] = None, + ): + """发送聊天请求""" + if stream is None: + stream = get_config("app.stream") + + logger.debug( + f"Chat request: model={model}, mode={mode}, stream={stream}, attachments={len(file_attachments or [])}" + ) + + browser = get_config("proxy.browser") + semaphore = _get_chat_semaphore() + await semaphore.acquire() + session = ResettableSession(impersonate=browser) + try: + stream_response = await AppChatReverse.request( + session, + token, + message=message, + model=model, + mode=mode, + file_attachments=file_attachments, + tool_overrides=tool_overrides, + model_config_override=model_config_override, + ) + logger.info(f"Chat connected: model={model}, stream={stream}") + except Exception: + try: + await session.close() + except Exception: + pass + semaphore.release() + raise + + async def _stream(): + try: + async for line in stream_response: + yield line + finally: + semaphore.release() + + return _stream() + + async def chat_openai( + self, + token: str, + model: str, + messages: List[Dict[str, Any]], + stream: bool = None, + reasoning_effort: str | None = None, + temperature: float = 0.8, + top_p: float = 0.95, + tools: List[Dict[str, Any]] = None, + tool_choice: Any = None, + parallel_tool_calls: bool = True, + ): + """OpenAI 兼容接口""" + model_info = ModelService.get(model) + if not model_info: + raise ValidationException(f"Unknown model: {model}") + + grok_model = model_info.grok_model + mode = model_info.model_mode + # 提取消息和附件 + message, file_attachments, image_attachments = MessageExtractor.extract( + messages, tools=tools, tool_choice=tool_choice, parallel_tool_calls=parallel_tool_calls + ) + logger.debug( + "Extracted message length=%s, files=%s, images=%s", + len(message), + len(file_attachments), + len(image_attachments), + ) + + # 上传附件 + file_ids: List[str] = [] + image_ids: List[str] = [] + if file_attachments or image_attachments: + upload_service = UploadService() + try: + for attach_data in file_attachments: + file_id, _ = await upload_service.upload_file(attach_data, token) + file_ids.append(file_id) + logger.debug(f"Attachment uploaded: type=file, file_id={file_id}") + for attach_data in image_attachments: + file_id, _ = await upload_service.upload_file(attach_data, token) + image_ids.append(file_id) + logger.debug(f"Attachment uploaded: type=image, file_id={file_id}") + finally: + await upload_service.close() + + all_attachments = file_ids + image_ids + stream = stream if stream is not None else get_config("app.stream") + + model_config_override = { + "temperature": temperature, + "topP": top_p, + } + if reasoning_effort is not None: + model_config_override["reasoningEffort"] = reasoning_effort + + response = await self.chat( + token, + message, + grok_model, + mode, + stream, + file_attachments=all_attachments, + tool_overrides=None, + model_config_override=model_config_override, + ) + + return response, stream, model + + +class ChatService: + """Chat 业务服务""" + + @staticmethod + async def completions( + model: str, + messages: List[Dict[str, Any]], + stream: bool = None, + reasoning_effort: str | None = None, + temperature: float = 0.8, + top_p: float = 0.95, + tools: List[Dict[str, Any]] = None, + tool_choice: Any = None, + parallel_tool_calls: bool = True, + ): + """Chat Completions 入口""" + # 获取 token + token_mgr = await get_token_manager() + await token_mgr.reload_if_stale() + + # 解析参数 + if reasoning_effort is None: + show_think = get_config("app.thinking") + else: + show_think = reasoning_effort != "none" + is_stream = stream if stream is not None else get_config("app.stream") + + # 跨 Token 重试循环 + tried_tokens = set() + max_token_retries = int(get_config("retry.max_retry") or 3) + last_error = None + + for attempt in range(max_token_retries): + # 选择 token + token = await pick_token(token_mgr, model, tried_tokens) + if not token: + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + tried_tokens.add(token) + + try: + # 请求 Grok + service = GrokChatService() + response, _, model_name = await service.chat_openai( + token, + model, + messages, + stream=is_stream, + reasoning_effort=reasoning_effort, + temperature=temperature, + top_p=top_p, + tools=tools, + tool_choice=tool_choice, + parallel_tool_calls=parallel_tool_calls, + ) + + # 处理响应 + if is_stream: + logger.debug(f"Processing stream response: model={model}") + processor = StreamProcessor(model_name, token, show_think, tools=tools, tool_choice=tool_choice) + return wrap_stream_with_usage( + processor.process(response), token_mgr, token, model + ) + + # 非流式 + logger.debug(f"Processing non-stream response: model={model}") + result = await CollectProcessor(model_name, token, tools=tools, tool_choice=tool_choice).process(response) + try: + model_info = ModelService.get(model) + effort = ( + EffortType.HIGH + if (model_info and model_info.cost.value == "high") + else EffortType.LOW + ) + await token_mgr.consume(token, effort) + logger.info(f"Chat completed: model={model}, effort={effort.value}") + except Exception as e: + logger.warning(f"Failed to record usage: {e}") + return result + + except UpstreamException as e: + last_error = e + + if rate_limited(e): + # 配额不足,标记 token 为 cooling 并换 token 重试 + await token_mgr.mark_rate_limited(token) + logger.warning( + f"Token {token[:10]}... rate limited (429), " + f"trying next token (attempt {attempt + 1}/{max_token_retries})" + ) + continue + + if transient_upstream(e): + has_alternative_token = False + for pool_name in ModelService.pool_candidates_for_model(model): + if token_mgr.get_token(pool_name, exclude=tried_tokens): + has_alternative_token = True + break + if not has_alternative_token: + raise + logger.warning( + f"Transient upstream error for token {token[:10]}..., " + f"trying next token (attempt {attempt + 1}/{max_token_retries}): {e}" + ) + continue + + # 非 429 错误,不换 token,直接抛出 + raise + + # 所有 token 都 429,抛出最后的错误 + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + +class StreamProcessor(proc_base.BaseProcessor): + """Stream response processor.""" + + def __init__(self, model: str, token: str = "", show_think: bool = None, tools: List[Dict[str, Any]] = None, tool_choice: Any = None): + super().__init__(model, token) + self.response_id: str = None + self.fingerprint: str = "" + self.rollout_id: str = "" + self.think_opened: bool = False + self.image_think_active: bool = False + self.role_sent: bool = False + self.filter_tags = get_config("app.filter_tags") + self.tool_usage_enabled = ( + "xai:tool_usage_card" in (self.filter_tags or []) + ) + self._tool_usage_opened = False + self._tool_usage_buffer = "" + + self.show_think = bool(show_think) + self.tools = tools + self.tool_choice = tool_choice + self._tool_stream_enabled = bool(tools) and tool_choice != "none" + self._tool_state = "text" + self._tool_buffer = "" + self._tool_partial = "" + self._tool_calls_seen = False + self._tool_call_index = 0 + + def _with_tool_index(self, tool_call: Any) -> Any: + if not isinstance(tool_call, dict): + return tool_call + if tool_call.get("index") is None: + tool_call = dict(tool_call) + tool_call["index"] = self._tool_call_index + self._tool_call_index += 1 + return tool_call + + def _filter_tool_card(self, token: str) -> str: + if not token or not self.tool_usage_enabled: + return token + + output_parts: list[str] = [] + rest = token + start_tag = " 0: + output_parts.append(rest[:start_idx]) + + end_idx = rest.find(end_tag, start_idx) + if end_idx == -1: + self._tool_usage_opened = True + self._tool_usage_buffer = rest[start_idx:] + break + + end_pos = end_idx + len(end_tag) + raw_card = rest[start_idx:end_pos] + line = extract_tool_text(raw_card, self.rollout_id) + if line: + if output_parts and not output_parts[-1].endswith("\n"): + output_parts[-1] += "\n" + output_parts.append(f"{line}\n") + rest = rest[end_pos:] + + return "".join(output_parts) + + def _filter_token(self, token: str) -> str: + """Filter special tags in current token only.""" + if not token: + return token + + if self.tool_usage_enabled: + token = self._filter_tool_card(token) + if not token: + return "" + + if not self.filter_tags: + return token + + for tag in self.filter_tags: + if tag == "xai:tool_usage_card": + continue + if f"<{tag}" in token or f" int: + if not text or not tag: + return 0 + max_keep = min(len(text), len(tag) - 1) + for keep in range(max_keep, 0, -1): + if text.endswith(tag[:keep]): + return keep + return 0 + + def _handle_tool_stream(self, chunk: str) -> list[tuple[str, Any]]: + events: list[tuple[str, Any]] = [] + if not chunk: + return events + + start_tag = "" + end_tag = "" + data = f"{self._tool_partial}{chunk}" + self._tool_partial = "" + + while data: + if self._tool_state == "text": + start_idx = data.find(start_tag) + if start_idx == -1: + keep = self._suffix_prefix(data, start_tag) + emit = data[:-keep] if keep else data + if emit: + events.append(("text", emit)) + self._tool_partial = data[-keep:] if keep else "" + break + + before = data[:start_idx] + if before: + events.append(("text", before)) + data = data[start_idx + len(start_tag) :] + self._tool_state = "tool" + continue + + end_idx = data.find(end_tag) + if end_idx == -1: + keep = self._suffix_prefix(data, end_tag) + append = data[:-keep] if keep else data + if append: + self._tool_buffer += append + self._tool_partial = data[-keep:] if keep else "" + break + + self._tool_buffer += data[:end_idx] + data = data[end_idx + len(end_tag) :] + tool_call = parse_tool_call_block(self._tool_buffer, self.tools) + if tool_call: + events.append(("tool", self._with_tool_index(tool_call))) + self._tool_calls_seen = True + self._tool_buffer = "" + self._tool_state = "text" + + return events + + def _flush_tool_stream(self) -> list[tuple[str, Any]]: + events: list[tuple[str, Any]] = [] + if self._tool_state == "text": + if self._tool_partial: + events.append(("text", self._tool_partial)) + self._tool_partial = "" + return events + + raw = f"{self._tool_buffer}{self._tool_partial}" + tool_call = parse_tool_call_block(raw, self.tools) + if tool_call: + events.append(("tool", self._with_tool_index(tool_call))) + self._tool_calls_seen = True + elif raw: + events.append(("text", f"{raw}")) + self._tool_buffer = "" + self._tool_partial = "" + self._tool_state = "text" + return events + + def _sse(self, content: str = "", role: str = None, finish: str = None, tool_calls: list = None) -> str: + """Build SSE response.""" + delta = {} + if role: + delta["role"] = role + delta["content"] = "" + elif tool_calls is not None: + delta["tool_calls"] = tool_calls + elif content: + delta["content"] = content + + chunk = { + "id": self.response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}", + "object": "chat.completion.chunk", + "created": self.created, + "model": self.model, + "system_fingerprint": self.fingerprint, + "choices": [ + {"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish} + ], + } + return f"data: {orjson.dumps(chunk).decode()}\n\n" + + async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]: + """Process stream response. + + Args: + response: AsyncIterable[bytes], async iterable of bytes + + Returns: + AsyncGenerator[str, None], async generator of strings + """ + idle_timeout = get_config("chat.stream_timeout") + + try: + async for line in proc_base._with_idle_timeout( + response, idle_timeout, self.model + ): + line = proc_base._normalize_line(line) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + resp = data.get("result", {}).get("response", {}) + is_thinking = bool(resp.get("isThinking")) + # isThinking controls tagging + # when absent, treat as False + + if (llm := resp.get("llmInfo")) and not self.fingerprint: + self.fingerprint = llm.get("modelHash", "") + if rid := resp.get("responseId"): + self.response_id = rid + if rid := resp.get("rolloutId"): + self.rollout_id = str(rid) + + if not self.role_sent: + yield self._sse(role="assistant") + self.role_sent = True + + if img := resp.get("streamingImageGenerationResponse"): + if not self.show_think: + continue + self.image_think_active = True + if not self.think_opened: + yield self._sse("\n") + self.think_opened = True + idx = img.get("imageIndex", 0) + 1 + progress = img.get("progress", 0) + yield self._sse( + f"正在生成第{idx}张图片中,当前进度{progress}%\n" + ) + continue + + if mr := resp.get("modelResponse"): + if self.image_think_active and self.think_opened: + yield self._sse("\n\n") + self.think_opened = False + self.image_think_active = False + for url in proc_base._collect_images(mr): + parts = url.split("/") + img_id = parts[-2] if len(parts) >= 2 else "image" + dl_service = self._get_dl() + rendered = await dl_service.render_image( + url, self.token, img_id + ) + yield self._sse(f"{rendered}\n") + + if ( + (meta := mr.get("metadata", {})) + .get("llm_info", {}) + .get("modelHash") + ): + self.fingerprint = meta["llm_info"]["modelHash"] + continue + + if card := resp.get("cardAttachment"): + json_data = card.get("jsonData") + if isinstance(json_data, str) and json_data.strip(): + try: + card_data = orjson.loads(json_data) + except orjson.JSONDecodeError: + card_data = None + if isinstance(card_data, dict): + image = card_data.get("image") or {} + original = image.get("original") + title = image.get("title") or "" + if original: + title_safe = title.replace("\n", " ").strip() + if title_safe: + yield self._sse(f"![{title_safe}]({original})\n") + else: + yield self._sse(f"![image]({original})\n") + continue + + if (token := resp.get("token")) is not None: + if not token: + continue + filtered = self._filter_token(token) + if not filtered: + continue + in_think = is_thinking or self.image_think_active + if in_think: + if not self.show_think: + continue + if not self.think_opened: + yield self._sse("\n") + self.think_opened = True + else: + if self.think_opened: + yield self._sse("\n\n") + self.think_opened = False + + if in_think: + yield self._sse(filtered) + continue + + if self._tool_stream_enabled: + for kind, payload in self._handle_tool_stream(filtered): + if kind == "text": + yield self._sse(payload) + elif kind == "tool": + yield self._sse(tool_calls=[payload]) + continue + + yield self._sse(filtered) + + if self.think_opened: + yield self._sse("\n") + + if self._tool_stream_enabled: + for kind, payload in self._flush_tool_stream(): + if kind == "text": + yield self._sse(payload) + elif kind == "tool": + yield self._sse(tool_calls=[payload]) + finish_reason = "tool_calls" if self._tool_calls_seen else "stop" + yield self._sse(finish=finish_reason) + else: + yield self._sse(finish="stop") + + yield "data: [DONE]\n\n" + except asyncio.CancelledError: + logger.debug("Stream cancelled by client", extra={"model": self.model}) + except StreamIdleTimeoutError as e: + raise UpstreamException( + message=f"Stream idle timeout after {e.idle_seconds}s", + status_code=504, + details={ + "error": str(e), + "type": "stream_idle_timeout", + "idle_seconds": e.idle_seconds, + }, + ) + except RequestsError as e: + if proc_base._is_http2_error(e): + logger.warning(f"HTTP/2 stream error: {e}", extra={"model": self.model}) + raise UpstreamException( + message="Upstream connection closed unexpectedly", + status_code=502, + details={"error": str(e), "type": "http2_stream_error"}, + ) + logger.error(f"Stream request error: {e}", extra={"model": self.model}) + raise UpstreamException( + message=f"Upstream request failed: {e}", + status_code=502, + details={"error": str(e)}, + ) + except Exception as e: + logger.error( + f"Stream processing error: {e}", + extra={"model": self.model, "error_type": type(e).__name__}, + ) + raise + finally: + await self.close() + + +class CollectProcessor(proc_base.BaseProcessor): + """Non-stream response processor.""" + + def __init__(self, model: str, token: str = "", tools: List[Dict[str, Any]] = None, tool_choice: Any = None): + super().__init__(model, token) + self.filter_tags = get_config("app.filter_tags") + self.tools = tools + self.tool_choice = tool_choice + + def _filter_content(self, content: str) -> str: + """Filter special tags in content.""" + if not content or not self.filter_tags: + return content + + result = content + if "xai:tool_usage_card" in self.filter_tags: + rollout_id = "" + rollout_match = re.search( + r"(.*?)", result, flags=re.DOTALL + ) + if rollout_match: + rollout_id = rollout_match.group(1).strip() + + result = re.sub( + r"]*>.*?", + lambda match: ( + f"{extract_tool_text(match.group(0), rollout_id)}\n" + if extract_tool_text(match.group(0), rollout_id) + else "" + ), + result, + flags=re.DOTALL, + ) + + for tag in self.filter_tags: + if tag == "xai:tool_usage_card": + continue + pattern = rf"<{re.escape(tag)}[^>]*>.*?|<{re.escape(tag)}[^>]*/>" + result = re.sub(pattern, "", result, flags=re.DOTALL) + + return result + + async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]: + """Process and collect full response.""" + response_id = "" + fingerprint = "" + content = "" + idle_timeout = get_config("chat.stream_timeout") + + try: + async for line in proc_base._with_idle_timeout( + response, idle_timeout, self.model + ): + line = proc_base._normalize_line(line) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + resp = data.get("result", {}).get("response", {}) + + if (llm := resp.get("llmInfo")) and not fingerprint: + fingerprint = llm.get("modelHash", "") + + if mr := resp.get("modelResponse"): + response_id = mr.get("responseId", "") + content = mr.get("message", "") + + card_map: dict[str, tuple[str, str]] = {} + for raw in mr.get("cardAttachmentsJson") or []: + if not isinstance(raw, str) or not raw.strip(): + continue + try: + card_data = orjson.loads(raw) + except orjson.JSONDecodeError: + continue + if not isinstance(card_data, dict): + continue + card_id = card_data.get("id") + image = card_data.get("image") or {} + original = image.get("original") + if not card_id or not original: + continue + title = image.get("title") or "" + card_map[card_id] = (title, original) + + if content and card_map: + def _render_card(match: re.Match) -> str: + card_id = match.group(1) + item = card_map.get(card_id) + if not item: + return "" + title, original = item + title_safe = title.replace("\n", " ").strip() or "image" + prefix = "" + if match.start() > 0: + prev = content[match.start() - 1] + if prev not in ("\n", "\r"): + prefix = "\n" + return f"{prefix}![{title_safe}]({original})" + + content = re.sub( + r']*card_id="([^"]+)"[^>]*>.*?', + _render_card, + content, + flags=re.DOTALL, + ) + + if urls := proc_base._collect_images(mr): + content += "\n" + for url in urls: + parts = url.split("/") + img_id = parts[-2] if len(parts) >= 2 else "image" + dl_service = self._get_dl() + rendered = await dl_service.render_image( + url, self.token, img_id + ) + content += f"{rendered}\n" + + if ( + (meta := mr.get("metadata", {})) + .get("llm_info", {}) + .get("modelHash") + ): + fingerprint = meta["llm_info"]["modelHash"] + + except asyncio.CancelledError: + logger.debug("Collect cancelled by client", extra={"model": self.model}) + raise + except StreamIdleTimeoutError as e: + logger.warning(f"Collect idle timeout: {e}", extra={"model": self.model}) + raise UpstreamException( + message=f"Collect stream idle timeout after {e.idle_seconds}s", + details={ + "error": str(e), + "type": "stream_idle_timeout", + "idle_seconds": e.idle_seconds, + "status": 504, + }, + ) + except RequestsError as e: + if proc_base._is_http2_error(e): + logger.warning( + f"HTTP/2 stream error in collect: {e}", extra={"model": self.model} + ) + raise UpstreamException( + message="Upstream connection closed unexpectedly", + details={"error": str(e), "type": "http2_stream_error", "status": 502}, + ) + logger.error(f"Collect request error: {e}", extra={"model": self.model}) + raise UpstreamException( + message=f"Upstream request failed: {e}", + details={"error": str(e), "status": 502}, + ) + except Exception as e: + logger.error( + f"Collect processing error: {e}", + extra={"model": self.model, "error_type": type(e).__name__}, + ) + raise + finally: + await self.close() + + content = self._filter_content(content) + + # Parse for tool calls if tools were provided + finish_reason = "stop" + tool_calls_result = None + if self.tools and self.tool_choice != "none": + text_content, tool_calls_list = parse_tool_calls(content, self.tools) + if tool_calls_list: + tool_calls_result = tool_calls_list + content = text_content # May be None + finish_reason = "tool_calls" + + message_obj = { + "role": "assistant", + "content": content, + "refusal": None, + "annotations": [], + } + if tool_calls_result: + message_obj["tool_calls"] = tool_calls_result + + return { + "id": response_id, + "object": "chat.completion", + "created": self.created, + "model": self.model, + "system_fingerprint": fingerprint, + "choices": [ + { + "index": 0, + "message": message_obj, + "finish_reason": finish_reason, + } + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "prompt_tokens_details": { + "cached_tokens": 0, + "text_tokens": 0, + "audio_tokens": 0, + "image_tokens": 0, + }, + "completion_tokens_details": { + "text_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + }, + }, + } + + +__all__ = [ + "GrokChatService", + "MessageExtractor", + "ChatService", +] diff --git a/app/services/grok/services/image.py b/app/services/grok/services/image.py new file mode 100644 index 0000000000000000000000000000000000000000..e60b5783ea0f53486390b34acb2f8b932d5e780e --- /dev/null +++ b/app/services/grok/services/image.py @@ -0,0 +1,794 @@ +""" +Grok image services. +""" + +import asyncio +import base64 +import math +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncGenerator, AsyncIterable, Dict, List, Optional, Union + +import orjson + +from app.core.config import get_config +from app.core.logger import logger +from app.core.storage import DATA_DIR +from app.core.exceptions import AppException, ErrorType, UpstreamException +from app.services.grok.utils.process import BaseProcessor +from app.services.grok.utils.retry import pick_token, rate_limited +from app.services.grok.utils.response import make_response_id, make_chat_chunk, wrap_image_content +from app.services.grok.utils.stream import wrap_stream_with_usage +from app.services.token import EffortType +from app.services.reverse.ws_imagine import ImagineWebSocketReverse + + +image_service = ImagineWebSocketReverse() + + +@dataclass +class ImageGenerationResult: + stream: bool + data: Union[AsyncGenerator[str, None], List[str]] + usage_override: Optional[dict] = None + + +class ImageGenerationService: + """Image generation orchestration service.""" + + async def generate( + self, + *, + token_mgr: Any, + token: str, + model_info: Any, + prompt: str, + n: int, + response_format: str, + size: str, + aspect_ratio: str, + stream: bool, + enable_nsfw: Optional[bool] = None, + chat_format: bool = False, + ) -> ImageGenerationResult: + max_token_retries = int(get_config("retry.max_retry") or 3) + tried_tokens: set[str] = set() + last_error: Optional[Exception] = None + + # resolve nsfw once for routing and upstream + if enable_nsfw is None: + enable_nsfw = bool(get_config("image.nsfw")) + prefer_tags = {"nsfw"} if enable_nsfw else None + + if stream: + + async def _stream_retry() -> AsyncGenerator[str, None]: + nonlocal last_error + for attempt in range(max_token_retries): + preferred = token if (attempt == 0 and not prefer_tags) else None + current_token = await pick_token( + token_mgr, + model_info.model_id, + tried_tokens, + preferred=preferred, + prefer_tags=prefer_tags, + ) + if not current_token: + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + tried_tokens.add(current_token) + yielded = False + try: + result = await self._stream_ws( + token_mgr=token_mgr, + token=current_token, + model_info=model_info, + prompt=prompt, + n=n, + response_format=response_format, + size=size, + aspect_ratio=aspect_ratio, + enable_nsfw=enable_nsfw, + chat_format=chat_format, + ) + async for chunk in result.data: + yielded = True + yield chunk + return + except UpstreamException as e: + last_error = e + if rate_limited(e): + if yielded: + raise + await token_mgr.mark_rate_limited(current_token) + logger.warning( + f"Token {current_token[:10]}... rate limited (429), " + f"trying next token (attempt {attempt + 1}/{max_token_retries})" + ) + continue + raise + + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + return ImageGenerationResult(stream=True, data=_stream_retry()) + + for attempt in range(max_token_retries): + preferred = token if (attempt == 0 and not prefer_tags) else None + current_token = await pick_token( + token_mgr, + model_info.model_id, + tried_tokens, + preferred=preferred, + prefer_tags=prefer_tags, + ) + if not current_token: + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + tried_tokens.add(current_token) + try: + return await self._collect_ws( + token_mgr=token_mgr, + token=current_token, + model_info=model_info, + tried_tokens=tried_tokens, + prompt=prompt, + n=n, + response_format=response_format, + aspect_ratio=aspect_ratio, + enable_nsfw=enable_nsfw, + ) + except UpstreamException as e: + last_error = e + if rate_limited(e): + await token_mgr.mark_rate_limited(current_token) + logger.warning( + f"Token {current_token[:10]}... rate limited (429), " + f"trying next token (attempt {attempt + 1}/{max_token_retries})" + ) + continue + raise + + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + async def _stream_ws( + self, + *, + token_mgr: Any, + token: str, + model_info: Any, + prompt: str, + n: int, + response_format: str, + size: str, + aspect_ratio: str, + enable_nsfw: Optional[bool] = None, + chat_format: bool = False, + ) -> ImageGenerationResult: + if enable_nsfw is None: + enable_nsfw = bool(get_config("image.nsfw")) + stream_retries = int(get_config("image.blocked_parallel_attempts") or 5) + 1 + stream_retries = max(1, min(stream_retries, 10)) + upstream = image_service.stream( + token=token, + prompt=prompt, + aspect_ratio=aspect_ratio, + n=n, + enable_nsfw=enable_nsfw, + max_retries=stream_retries, + ) + processor = ImageWSStreamProcessor( + model_info.model_id, + token, + n=n, + response_format=response_format, + size=size, + chat_format=chat_format, + ) + stream = wrap_stream_with_usage( + processor.process(upstream), + token_mgr, + token, + model_info.model_id, + ) + return ImageGenerationResult(stream=True, data=stream) + + async def _collect_ws( + self, + *, + token_mgr: Any, + token: str, + model_info: Any, + tried_tokens: set[str], + prompt: str, + n: int, + response_format: str, + aspect_ratio: str, + enable_nsfw: Optional[bool] = None, + ) -> ImageGenerationResult: + if enable_nsfw is None: + enable_nsfw = bool(get_config("image.nsfw")) + all_images: List[str] = [] + seen = set() + expected_per_call = 6 + calls_needed = max(1, int(math.ceil(n / expected_per_call))) + calls_needed = min(calls_needed, n) + + async def _fetch_batch(call_target: int, call_token: str): + stream_retries = int(get_config("image.blocked_parallel_attempts") or 5) + 1 + stream_retries = max(1, min(stream_retries, 10)) + upstream = image_service.stream( + token=call_token, + prompt=prompt, + aspect_ratio=aspect_ratio, + n=call_target, + enable_nsfw=enable_nsfw, + max_retries=stream_retries, + ) + processor = ImageWSCollectProcessor( + model_info.model_id, + token, + n=call_target, + response_format=response_format, + ) + return await processor.process(upstream) + + tasks = [] + for i in range(calls_needed): + remaining = n - (i * expected_per_call) + call_target = min(expected_per_call, remaining) + tasks.append(_fetch_batch(call_target, token)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for batch in results: + if isinstance(batch, Exception): + logger.warning(f"WS batch failed: {batch}") + continue + for img in batch: + if img not in seen: + seen.add(img) + all_images.append(img) + if len(all_images) >= n: + break + if len(all_images) >= n: + break + + # If upstream likely blocked/reviewed some images, run extra parallel attempts + # and only keep valid finals selected by ws_imagine classification. + if len(all_images) < n: + remaining = n - len(all_images) + extra_attempts = int(get_config("image.blocked_parallel_attempts") or 5) + extra_attempts = max(0, min(extra_attempts, 10)) + parallel_enabled = bool(get_config("image.blocked_parallel_enabled", True)) + if extra_attempts > 0: + logger.warning( + f"Image finals insufficient ({len(all_images)}/{n}), running " + f"{extra_attempts} recovery attempts for remaining={remaining}, " + f"parallel_enabled={parallel_enabled}" + ) + extra_tasks = [] + if parallel_enabled: + recovery_tried = set(tried_tokens) + recovery_tokens: List[str] = [] + for _ in range(extra_attempts): + recovery_token = await pick_token( + token_mgr, + model_info.model_id, + recovery_tried, + ) + if not recovery_token: + break + recovery_tried.add(recovery_token) + recovery_tokens.append(recovery_token) + + if recovery_tokens: + logger.info( + f"Recovery using {len(recovery_tokens)} distinct tokens" + ) + for recovery_token in recovery_tokens: + extra_tasks.append( + _fetch_batch(min(expected_per_call, remaining), recovery_token) + ) + else: + extra_tasks = [ + _fetch_batch(min(expected_per_call, remaining), token) + for _ in range(extra_attempts) + ] + + if not extra_tasks: + logger.warning("No tokens available for recovery attempts") + extra_results = [] + else: + extra_results = await asyncio.gather(*extra_tasks, return_exceptions=True) + for batch in extra_results: + if isinstance(batch, Exception): + logger.warning(f"WS recovery batch failed: {batch}") + continue + for img in batch: + if img not in seen: + seen.add(img) + all_images.append(img) + if len(all_images) >= n: + break + if len(all_images) >= n: + break + logger.info( + f"Image recovery attempts completed: finals={len(all_images)}/{n}, " + f"attempts={extra_attempts}" + ) + + if len(all_images) < n: + logger.error( + f"Image generation failed after recovery attempts: finals={len(all_images)}/{n}, " + f"blocked_parallel_attempts={int(get_config('image.blocked_parallel_attempts') or 5)}" + ) + raise UpstreamException( + "Image generation blocked or no valid final image", + details={ + "error_code": "blocked_no_final_image", + "final_images": len(all_images), + "requested": n, + }, + ) + + try: + await token_mgr.consume(token, self._get_effort(model_info)) + except Exception as e: + logger.warning(f"Failed to consume token: {e}") + + selected = self._select_images(all_images, n) + usage_override = { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": {"text_tokens": 0, "image_tokens": 0}, + } + return ImageGenerationResult( + stream=False, data=selected, usage_override=usage_override + ) + + @staticmethod + def _get_effort(model_info: Any) -> EffortType: + return ( + EffortType.HIGH + if (model_info and model_info.cost.value == "high") + else EffortType.LOW + ) + + @staticmethod + def _select_images(images: List[str], n: int) -> List[str]: + if len(images) >= n: + return images[:n] + selected = images.copy() + while len(selected) < n: + selected.append("error") + return selected + + +class ImageWSBaseProcessor(BaseProcessor): + """WebSocket image processor base.""" + + def __init__(self, model: str, token: str = "", response_format: str = "b64_json"): + if response_format == "base64": + response_format = "b64_json" + super().__init__(model, token) + self.response_format = response_format + if response_format == "url": + self.response_field = "url" + elif response_format == "base64": + self.response_field = "base64" + else: + self.response_field = "b64_json" + self._image_dir: Optional[Path] = None + + def _ensure_image_dir(self) -> Path: + if self._image_dir is None: + base_dir = DATA_DIR / "tmp" / "image" + base_dir.mkdir(parents=True, exist_ok=True) + self._image_dir = base_dir + return self._image_dir + + def _strip_base64(self, blob: str) -> str: + if not blob: + return "" + if "," in blob and "base64" in blob.split(",", 1)[0]: + return blob.split(",", 1)[1] + return blob + + def _guess_ext(self, blob: str) -> Optional[str]: + if not blob: + return None + header = "" + data = blob + if "," in blob and "base64" in blob.split(",", 1)[0]: + header, data = blob.split(",", 1) + header = header.lower() + if "image/png" in header: + return "png" + if "image/jpeg" in header or "image/jpg" in header: + return "jpg" + if data.startswith("iVBORw0KGgo"): + return "png" + if data.startswith("/9j/"): + return "jpg" + return None + + def _filename(self, image_id: str, is_final: bool, ext: Optional[str] = None) -> str: + if ext: + ext = ext.lower() + if ext == "jpeg": + ext = "jpg" + if not ext: + ext = "jpg" if is_final else "png" + return f"{image_id}.{ext}" + + def _build_file_url(self, filename: str) -> str: + app_url = get_config("app.app_url") + if app_url: + return f"{app_url.rstrip('/')}/v1/files/image/{filename}" + return f"/v1/files/image/{filename}" + + async def _save_blob( + self, image_id: str, blob: str, is_final: bool, ext: Optional[str] = None + ) -> str: + data = self._strip_base64(blob) + if not data: + return "" + image_dir = self._ensure_image_dir() + ext = ext or self._guess_ext(blob) + filename = self._filename(image_id, is_final, ext=ext) + filepath = image_dir / filename + + def _write_file(): + with open(filepath, "wb") as f: + f.write(base64.b64decode(data)) + + await asyncio.to_thread(_write_file) + return self._build_file_url(filename) + + def _pick_best(self, existing: Optional[Dict], incoming: Dict) -> Dict: + if not existing: + return incoming + if incoming.get("is_final") and not existing.get("is_final"): + return incoming + if existing.get("is_final") and not incoming.get("is_final"): + return existing + if incoming.get("blob_size", 0) > existing.get("blob_size", 0): + return incoming + return existing + + async def _to_output(self, image_id: str, item: Dict) -> str: + try: + if self.response_format == "url": + return await self._save_blob( + image_id, + item.get("blob", ""), + item.get("is_final", False), + ext=item.get("ext"), + ) + return self._strip_base64(item.get("blob", "")) + except Exception as e: + logger.warning(f"Image output failed: {e}") + return "" + + +class ImageWSStreamProcessor(ImageWSBaseProcessor): + """WebSocket image stream processor.""" + + def __init__( + self, + model: str, + token: str = "", + n: int = 1, + response_format: str = "b64_json", + size: str = "1024x1024", + chat_format: bool = False, + ): + super().__init__(model, token, response_format) + self.n = n + self.size = size + self.chat_format = chat_format + self._target_id: Optional[str] = None + self._index_map: Dict[str, int] = {} + self._partial_map: Dict[str, int] = {} + self._initial_sent: set[str] = set() + self._id_generated: bool = False + self._response_id: str = "" + + def _assign_index(self, image_id: str) -> Optional[int]: + if image_id in self._index_map: + return self._index_map[image_id] + if len(self._index_map) >= self.n: + return None + self._index_map[image_id] = len(self._index_map) + return self._index_map[image_id] + + def _sse(self, event: str, data: dict) -> str: + return f"event: {event}\ndata: {orjson.dumps(data).decode()}\n\n" + + async def process(self, response: AsyncIterable[dict]) -> AsyncGenerator[str, None]: + images: Dict[str, Dict] = {} + emitted_chat_chunk = False + + async for item in response: + if item.get("type") == "error": + message = item.get("error") or "Upstream error" + code = item.get("error_code") or "upstream_error" + status = item.get("status") + if code == "rate_limit_exceeded" or status == 429: + raise UpstreamException(message, details=item) + yield self._sse( + "error", + { + "error": { + "message": message, + "type": "server_error", + "code": code, + } + }, + ) + return + if item.get("type") != "image": + continue + + image_id = item.get("image_id") + if not image_id: + continue + + if self.n == 1: + if self._target_id is None: + self._target_id = image_id + index = 0 if image_id == self._target_id else None + else: + index = self._assign_index(image_id) + + images[image_id] = self._pick_best(images.get(image_id), item) + + if index is None: + continue + + if item.get("stage") != "final": + # Chat Completions image stream should only expose final results. + if self.chat_format: + continue + if image_id not in self._initial_sent: + self._initial_sent.add(image_id) + stage = item.get("stage") or "preview" + if stage == "medium": + partial_index = 1 + self._partial_map[image_id] = 1 + else: + partial_index = 0 + self._partial_map[image_id] = 0 + else: + stage = item.get("stage") or "partial" + if stage == "preview": + continue + partial_index = self._partial_map.get(image_id, 0) + if stage == "medium": + partial_index = max(partial_index, 1) + self._partial_map[image_id] = partial_index + + if self.response_format == "url": + partial_id = f"{image_id}-{stage}-{partial_index}" + partial_out = await self._save_blob( + partial_id, + item.get("blob", ""), + False, + ext=item.get("ext"), + ) + else: + partial_out = self._strip_base64(item.get("blob", "")) + + if self.chat_format and partial_out: + partial_out = wrap_image_content(partial_out, self.response_format) + + if not partial_out: + continue + + if self.chat_format: + # OpenAI ChatCompletion chunk format for partial + if not self._id_generated: + self._response_id = make_response_id() + self._id_generated = True + emitted_chat_chunk = True + yield self._sse( + "chat.completion.chunk", + make_chat_chunk( + self._response_id, + self.model, + partial_out, + index=index, + ), + ) + else: + # Original image_generation format + yield self._sse( + "image_generation.partial_image", + { + "type": "image_generation.partial_image", + self.response_field: partial_out, + "created_at": int(time.time()), + "size": self.size, + "index": index, + "partial_image_index": partial_index, + "image_id": image_id, + "stage": stage, + }, + ) + + if self.n == 1: + target_item = images.get(self._target_id) if self._target_id else None + if target_item and target_item.get("is_final", False): + selected = [(self._target_id, target_item)] + elif images: + selected = [ + max( + images.items(), + key=lambda x: ( + x[1].get("is_final", False), + x[1].get("blob_size", 0), + ), + ) + ] + else: + selected = [] + else: + selected = [ + (image_id, images[image_id]) + for image_id in self._index_map + if image_id in images and images[image_id].get("is_final", False) + ] + + for image_id, item in selected: + if self.response_format == "url": + final_image_id = image_id + # Keep original imagine image name for imagine chat stream output. + if self.model != "grok-imagine-1.0-fast": + final_image_id = f"{image_id}-final" + output = await self._save_blob( + final_image_id, + item.get("blob", ""), + item.get("is_final", False), + ext=item.get("ext"), + ) + if self.chat_format and output: + output = wrap_image_content(output, self.response_format) + else: + output = await self._to_output(image_id, item) + if self.chat_format and output: + output = wrap_image_content(output, self.response_format) + + if not output: + continue + + if self.n == 1: + index = 0 + else: + index = self._index_map.get(image_id, 0) + + if not self._id_generated: + self._response_id = make_response_id() + self._id_generated = True + + if self.chat_format: + # OpenAI ChatCompletion chunk format + emitted_chat_chunk = True + yield self._sse( + "chat.completion.chunk", + make_chat_chunk( + self._response_id, + self.model, + output, + index=index, + is_final=True, + ), + ) + else: + # Original image_generation format + yield self._sse( + "image_generation.completed", + { + "type": "image_generation.completed", + self.response_field: output, + "created_at": int(time.time()), + "size": self.size, + "index": index, + "image_id": image_id, + "stage": "final", + "usage": { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": {"text_tokens": 0, "image_tokens": 0}, + }, + }, + ) + + if self.chat_format: + if not self._id_generated: + self._response_id = make_response_id() + self._id_generated = True + if not emitted_chat_chunk: + yield self._sse( + "chat.completion.chunk", + make_chat_chunk( + self._response_id, + self.model, + "", + index=0, + is_final=True, + ), + ) + yield "data: [DONE]\n\n" + + +class ImageWSCollectProcessor(ImageWSBaseProcessor): + """WebSocket image non-stream processor.""" + + def __init__( + self, model: str, token: str = "", n: int = 1, response_format: str = "b64_json" + ): + super().__init__(model, token, response_format) + self.n = n + + async def process(self, response: AsyncIterable[dict]) -> List[str]: + images: Dict[str, Dict] = {} + + async for item in response: + if item.get("type") == "error": + message = item.get("error") or "Upstream error" + raise UpstreamException(message, details=item) + if item.get("type") != "image": + continue + image_id = item.get("image_id") + if not image_id: + continue + images[image_id] = self._pick_best(images.get(image_id), item) + + selected = sorted( + [item for item in images.values() if item.get("is_final", False)], + key=lambda x: x.get("blob_size", 0), + reverse=True, + ) + if self.n: + selected = selected[: self.n] + + results: List[str] = [] + for item in selected: + output = await self._to_output(item.get("image_id", ""), item) + if output: + results.append(output) + + return results + + +__all__ = ["ImageGenerationService"] diff --git a/app/services/grok/services/image_edit.py b/app/services/grok/services/image_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..0684a557d7fa161a9357c26637d86386cfb016b0 --- /dev/null +++ b/app/services/grok/services/image_edit.py @@ -0,0 +1,567 @@ +""" +Grok image edit service. +""" + +import asyncio +import os +import random +import re +import time +from dataclasses import dataclass +from typing import AsyncGenerator, AsyncIterable, List, Union, Any + +import orjson +from curl_cffi.requests.errors import RequestsError + +from app.core.config import get_config +from app.core.exceptions import ( + AppException, + ErrorType, + UpstreamException, + StreamIdleTimeoutError, +) +from app.core.logger import logger +from app.services.grok.utils.process import ( + BaseProcessor, + _with_idle_timeout, + _normalize_line, + _collect_images, + _is_http2_error, +) +from app.services.grok.utils.upload import UploadService +from app.services.grok.utils.retry import pick_token, rate_limited +from app.services.grok.utils.response import make_response_id, make_chat_chunk, wrap_image_content +from app.services.grok.services.chat import GrokChatService +from app.services.grok.services.video import VideoService +from app.services.grok.utils.stream import wrap_stream_with_usage +from app.services.token import EffortType + + +@dataclass +class ImageEditResult: + stream: bool + data: Union[AsyncGenerator[str, None], List[str]] + + +class ImageEditService: + """Image edit orchestration service.""" + + async def edit( + self, + *, + token_mgr: Any, + token: str, + model_info: Any, + prompt: str, + images: List[str], + n: int, + response_format: str, + stream: bool, + chat_format: bool = False, + ) -> ImageEditResult: + if len(images) > 3: + logger.info( + "Image edit received %d references; using the most recent 3", + len(images), + ) + images = images[-3:] + + max_token_retries = int(get_config("retry.max_retry") or 3) + tried_tokens: set[str] = set() + last_error: Exception | None = None + + for attempt in range(max_token_retries): + preferred = token if attempt == 0 else None + current_token = await pick_token( + token_mgr, model_info.model_id, tried_tokens, preferred=preferred + ) + if not current_token: + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + tried_tokens.add(current_token) + try: + image_urls = await self._upload_images(images, current_token) + parent_post_id = await self._get_parent_post_id( + current_token, image_urls + ) + + model_config_override = { + "modelMap": { + "imageEditModel": "imagine", + "imageEditModelConfig": { + "imageReferences": image_urls, + }, + } + } + if parent_post_id: + model_config_override["modelMap"]["imageEditModelConfig"][ + "parentPostId" + ] = parent_post_id + + tool_overrides = {"imageGen": True} + + if stream: + response = await GrokChatService().chat( + token=current_token, + message=prompt, + model=model_info.grok_model, + mode=None, + stream=True, + tool_overrides=tool_overrides, + model_config_override=model_config_override, + ) + processor = ImageStreamProcessor( + model_info.model_id, + current_token, + n=n, + response_format=response_format, + chat_format=chat_format, + ) + return ImageEditResult( + stream=True, + data=wrap_stream_with_usage( + processor.process(response), + token_mgr, + current_token, + model_info.model_id, + ), + ) + + images_out = await self._collect_images( + token=current_token, + prompt=prompt, + model_info=model_info, + n=n, + response_format=response_format, + tool_overrides=tool_overrides, + model_config_override=model_config_override, + ) + try: + effort = ( + EffortType.HIGH + if (model_info and model_info.cost.value == "high") + else EffortType.LOW + ) + await token_mgr.consume(current_token, effort) + logger.debug( + f"Image edit completed, recorded usage (effort={effort.value})" + ) + except Exception as e: + logger.warning(f"Failed to record image edit usage: {e}") + return ImageEditResult(stream=False, data=images_out) + + except UpstreamException as e: + last_error = e + if rate_limited(e): + await token_mgr.mark_rate_limited(current_token) + logger.warning( + f"Token {current_token[:10]}... rate limited (429), " + f"trying next token (attempt {attempt + 1}/{max_token_retries})" + ) + continue + raise + + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + async def _upload_images(self, images: List[str], token: str) -> List[str]: + image_urls: List[str] = [] + upload_service = UploadService() + try: + for image in images: + _, file_uri = await upload_service.upload_file(image, token) + if file_uri: + if file_uri.startswith("http"): + image_urls.append(file_uri) + else: + image_urls.append( + f"https://assets.grok.com/{file_uri.lstrip('/')}" + ) + finally: + await upload_service.close() + + if not image_urls: + raise AppException( + message="Image upload failed", + error_type=ErrorType.SERVER.value, + code="upload_failed", + ) + + return image_urls + + async def _get_parent_post_id(self, token: str, image_urls: List[str]) -> str: + parent_post_id = None + try: + media_service = VideoService() + parent_post_id = await media_service.create_image_post(token, image_urls[0]) + logger.debug(f"Parent post ID: {parent_post_id}") + except Exception as e: + logger.warning(f"Create image post failed: {e}") + + if parent_post_id: + return parent_post_id + + for url in image_urls: + match = re.search(r"/generated/([a-f0-9-]+)/", url) + if match: + parent_post_id = match.group(1) + logger.debug(f"Parent post ID: {parent_post_id}") + break + match = re.search(r"/users/[^/]+/([a-f0-9-]+)/content", url) + if match: + parent_post_id = match.group(1) + logger.debug(f"Parent post ID: {parent_post_id}") + break + + return parent_post_id or "" + + async def _collect_images( + self, + *, + token: str, + prompt: str, + model_info: Any, + n: int, + response_format: str, + tool_overrides: dict, + model_config_override: dict, + ) -> List[str]: + calls_needed = (n + 1) // 2 + + async def _call_edit(): + response = await GrokChatService().chat( + token=token, + message=prompt, + model=model_info.grok_model, + mode=None, + stream=True, + tool_overrides=tool_overrides, + model_config_override=model_config_override, + ) + processor = ImageCollectProcessor( + model_info.model_id, token, response_format=response_format + ) + return await processor.process(response) + + last_error: Exception | None = None + rate_limit_error: Exception | None = None + + if calls_needed == 1: + all_images = await _call_edit() + else: + tasks = [_call_edit() for _ in range(calls_needed)] + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_images: List[str] = [] + for result in results: + if isinstance(result, Exception): + logger.error(f"Concurrent call failed: {result}") + last_error = result + if rate_limited(result): + rate_limit_error = result + elif isinstance(result, list): + all_images.extend(result) + + if not all_images: + if rate_limit_error: + raise rate_limit_error + if last_error: + raise last_error + raise UpstreamException( + "Image edit returned no results", details={"error": "empty_result"} + ) + + if len(all_images) >= n: + return all_images[:n] + + selected_images = all_images.copy() + while len(selected_images) < n: + selected_images.append("error") + return selected_images + + +class ImageStreamProcessor(BaseProcessor): + """HTTP image stream processor.""" + + def __init__( + self, model: str, token: str = "", n: int = 1, response_format: str = "b64_json", chat_format: bool = False + ): + super().__init__(model, token) + self.partial_index = 0 + self.n = n + self.target_index = 0 if n == 1 else None + self.response_format = response_format + self.chat_format = chat_format + self._id_generated = False + self._response_id = "" + if response_format == "url": + self.response_field = "url" + elif response_format == "base64": + self.response_field = "base64" + else: + self.response_field = "b64_json" + + def _sse(self, event: str, data: dict) -> str: + """Build SSE response.""" + return f"event: {event}\ndata: {orjson.dumps(data).decode()}\n\n" + + async def process( + self, response: AsyncIterable[bytes] + ) -> AsyncGenerator[str, None]: + """Process stream response.""" + final_images = [] + emitted_chat_chunk = False + idle_timeout = get_config("image.stream_timeout") + + try: + async for line in _with_idle_timeout(response, idle_timeout, self.model): + line = _normalize_line(line) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + resp = data.get("result", {}).get("response", {}) + + # Image generation progress + if img := resp.get("streamingImageGenerationResponse"): + image_index = img.get("imageIndex", 0) + progress = img.get("progress", 0) + + if self.n == 1 and image_index != self.target_index: + continue + + out_index = 0 if self.n == 1 else image_index + + if not self.chat_format: + yield self._sse( + "image_generation.partial_image", + { + "type": "image_generation.partial_image", + self.response_field: "", + "index": out_index, + "progress": progress, + }, + ) + continue + + # modelResponse + if mr := resp.get("modelResponse"): + if urls := _collect_images(mr): + for url in urls: + if self.response_format == "url": + processed = await self.process_url(url, "image") + if processed: + final_images.append(processed) + continue + try: + dl_service = self._get_dl() + base64_data = await dl_service.parse_b64( + url, self.token, "image" + ) + if base64_data: + if "," in base64_data: + b64 = base64_data.split(",", 1)[1] + else: + b64 = base64_data + final_images.append(b64) + except Exception as e: + logger.warning( + f"Failed to convert image to base64, falling back to URL: {e}" + ) + processed = await self.process_url(url, "image") + if processed: + final_images.append(processed) + continue + + for index, img_data in enumerate(final_images): + if self.n == 1: + if index != self.target_index: + continue + out_index = 0 + else: + out_index = index + + # Wrap in markdown format for chat + output = img_data + if self.chat_format and output: + output = wrap_image_content(output, self.response_format) + + if not self._id_generated: + self._response_id = make_response_id() + self._id_generated = True + + if self.chat_format: + # OpenAI ChatCompletion chunk format + emitted_chat_chunk = True + yield self._sse( + "chat.completion.chunk", + make_chat_chunk( + self._response_id, + self.model, + output, + index=out_index, + is_final=True, + ), + ) + else: + # Original image_generation format + yield self._sse( + "image_generation.completed", + { + "type": "image_generation.completed", + self.response_field: img_data, + "index": out_index, + "usage": { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": { + "text_tokens": 0, + "image_tokens": 0, + }, + }, + }, + ) + + if self.chat_format: + if not self._id_generated: + self._response_id = make_response_id() + self._id_generated = True + if not emitted_chat_chunk: + yield self._sse( + "chat.completion.chunk", + make_chat_chunk( + self._response_id, + self.model, + "", + index=0, + is_final=True, + ), + ) + yield "data: [DONE]\n\n" + except asyncio.CancelledError: + logger.debug("Image stream cancelled by client") + except StreamIdleTimeoutError as e: + raise UpstreamException( + message=f"Image stream idle timeout after {e.idle_seconds}s", + status_code=504, + details={ + "error": str(e), + "type": "stream_idle_timeout", + "idle_seconds": e.idle_seconds, + }, + ) + except RequestsError as e: + if _is_http2_error(e): + logger.warning(f"HTTP/2 stream error in image: {e}") + raise UpstreamException( + message="Upstream connection closed unexpectedly", + status_code=502, + details={"error": str(e), "type": "http2_stream_error"}, + ) + logger.error(f"Image stream request error: {e}") + raise UpstreamException( + message=f"Upstream request failed: {e}", + status_code=502, + details={"error": str(e)}, + ) + except Exception as e: + logger.error( + f"Image stream processing error: {e}", + extra={"error_type": type(e).__name__}, + ) + raise + finally: + await self.close() + + +class ImageCollectProcessor(BaseProcessor): + """HTTP image non-stream processor.""" + + def __init__(self, model: str, token: str = "", response_format: str = "b64_json"): + if response_format == "base64": + response_format = "b64_json" + super().__init__(model, token) + self.response_format = response_format + + async def process(self, response: AsyncIterable[bytes]) -> List[str]: + """Process and collect images.""" + images = [] + idle_timeout = get_config("image.stream_timeout") + + try: + async for line in _with_idle_timeout(response, idle_timeout, self.model): + line = _normalize_line(line) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + resp = data.get("result", {}).get("response", {}) + + if mr := resp.get("modelResponse"): + if urls := _collect_images(mr): + for url in urls: + if self.response_format == "url": + processed = await self.process_url(url, "image") + if processed: + images.append(processed) + continue + try: + dl_service = self._get_dl() + base64_data = await dl_service.parse_b64( + url, self.token, "image" + ) + if base64_data: + if "," in base64_data: + b64 = base64_data.split(",", 1)[1] + else: + b64 = base64_data + images.append(b64) + except Exception as e: + logger.warning( + f"Failed to convert image to base64, falling back to URL: {e}" + ) + processed = await self.process_url(url, "image") + if processed: + images.append(processed) + + except asyncio.CancelledError: + logger.debug("Image collect cancelled by client") + except StreamIdleTimeoutError as e: + logger.warning(f"Image collect idle timeout: {e}") + except RequestsError as e: + if _is_http2_error(e): + logger.warning(f"HTTP/2 stream error in image collect: {e}") + else: + logger.error(f"Image collect request error: {e}") + except Exception as e: + logger.error( + f"Image collect processing error: {e}", + extra={"error_type": type(e).__name__}, + ) + finally: + await self.close() + + return images + + +__all__ = ["ImageEditService", "ImageEditResult"] diff --git a/app/services/grok/services/model.py b/app/services/grok/services/model.py new file mode 100644 index 0000000000000000000000000000000000000000..b46081b607d99c7bd6bbb117d4d40f051b3b9c8e --- /dev/null +++ b/app/services/grok/services/model.py @@ -0,0 +1,270 @@ +""" +Grok 模型管理服务 +""" + +from enum import Enum +from typing import Optional, Tuple, List +from pydantic import BaseModel, Field + +from app.core.exceptions import ValidationException + + +class Tier(str, Enum): + """模型档位""" + + BASIC = "basic" + SUPER = "super" + + +class Cost(str, Enum): + """计费类型""" + + LOW = "low" + HIGH = "high" + + +class ModelInfo(BaseModel): + """模型信息""" + + model_id: str + grok_model: str + model_mode: str + tier: Tier = Field(default=Tier.BASIC) + cost: Cost = Field(default=Cost.LOW) + display_name: str + description: str = "" + is_image: bool = False + is_image_edit: bool = False + is_video: bool = False + + +class ModelService: + """模型管理服务""" + + MODELS = [ + ModelInfo( + model_id="grok-3", + grok_model="grok-3", + model_mode="MODEL_MODE_GROK_3", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-3", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-3-mini", + grok_model="grok-3", + model_mode="MODEL_MODE_GROK_3_MINI_THINKING", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-3-MINI", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-3-thinking", + grok_model="grok-3", + model_mode="MODEL_MODE_GROK_3_THINKING", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-3-THINKING", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4", + grok_model="grok-4", + model_mode="MODEL_MODE_GROK_4", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4-mini", + grok_model="grok-4-mini", + model_mode="MODEL_MODE_GROK_4_MINI_THINKING", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4-MINI", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4-thinking", + grok_model="grok-4", + model_mode="MODEL_MODE_GROK_4_THINKING", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4-THINKING", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4-heavy", + grok_model="grok-4", + model_mode="MODEL_MODE_HEAVY", + tier=Tier.SUPER, + cost=Cost.HIGH, + display_name="GROK-4-HEAVY", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.1-mini", + grok_model="grok-4-1-thinking-1129", + model_mode="MODEL_MODE_GROK_4_1_MINI_THINKING", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4.1-MINI", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.1-fast", + grok_model="grok-4-1-thinking-1129", + model_mode="MODEL_MODE_FAST", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4.1-FAST", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.1-expert", + grok_model="grok-4-1-thinking-1129", + model_mode="MODEL_MODE_EXPERT", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="GROK-4.1-EXPERT", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.1-thinking", + grok_model="grok-4-1-thinking-1129", + model_mode="MODEL_MODE_GROK_4_1_THINKING", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="GROK-4.1-THINKING", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.20-beta", + grok_model="grok-420", + model_mode="MODEL_MODE_GROK_420", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4.20-BETA", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-imagine-1.0-fast", + grok_model="grok-3", + model_mode="MODEL_MODE_FAST", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="Grok Image Fast", + description="Imagine waterfall image generation model for chat completions", + is_image=True, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-imagine-1.0", + grok_model="grok-3", + model_mode="MODEL_MODE_FAST", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="Grok Image", + description="Image generation model", + is_image=True, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-imagine-1.0-edit", + grok_model="imagine-image-edit", + model_mode="MODEL_MODE_FAST", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="Grok Image Edit", + description="Image edit model", + is_image=False, + is_image_edit=True, + is_video=False, + ), + ModelInfo( + model_id="grok-imagine-1.0-video", + grok_model="grok-3", + model_mode="MODEL_MODE_FAST", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="Grok Video", + description="Video generation model", + is_image=False, + is_image_edit=False, + is_video=True, + ), + ] + + _map = {m.model_id: m for m in MODELS} + + @classmethod + def get(cls, model_id: str) -> Optional[ModelInfo]: + """获取模型信息""" + return cls._map.get(model_id) + + @classmethod + def list(cls) -> list[ModelInfo]: + """获取所有模型""" + return list(cls._map.values()) + + @classmethod + def valid(cls, model_id: str) -> bool: + """模型是否有效""" + return model_id in cls._map + + @classmethod + def to_grok(cls, model_id: str) -> Tuple[str, str]: + """转换为 Grok 参数""" + model = cls.get(model_id) + if not model: + raise ValidationException(f"Invalid model ID: {model_id}") + return model.grok_model, model.model_mode + + @classmethod + def pool_for_model(cls, model_id: str) -> str: + """根据模型选择 Token 池""" + model = cls.get(model_id) + if model and model.tier == Tier.SUPER: + return "ssoSuper" + return "ssoBasic" + + @classmethod + def pool_candidates_for_model(cls, model_id: str) -> List[str]: + """按优先级返回可用 Token 池列表""" + model = cls.get(model_id) + if model and model.tier == Tier.SUPER: + return ["ssoSuper"] + # 基础模型优先使用 basic 池,缺失时可回退到 super 池 + return ["ssoBasic", "ssoSuper"] + + +__all__ = ["ModelService"] diff --git a/app/services/grok/services/responses.py b/app/services/grok/services/responses.py new file mode 100644 index 0000000000000000000000000000000000000000..66cb23424f825f464b29a78bc3eaef7246c7f966 --- /dev/null +++ b/app/services/grok/services/responses.py @@ -0,0 +1,824 @@ +""" +Responses API bridge service (OpenAI-compatible). +""" + +import time +import uuid +from typing import Any, AsyncGenerator, Dict, List, Optional + +import orjson + +from app.services.grok.services.chat import ChatService +from app.services.grok.utils import process as proc_base + + +_TOOL_OUTPUT_TYPES = { + "tool_output", + "function_call_output", + "tool_call_output", + "input_tool_output", +} + +_BUILTIN_TOOL_TYPES = { + "web_search", + "web_search_2025_08_26", + "file_search", + "code_interpreter", +} + + +def _now_ts() -> int: + return int(time.time()) + + +def _new_response_id() -> str: + return f"resp_{uuid.uuid4().hex[:24]}" + + +def _new_message_id() -> str: + return f"msg_{uuid.uuid4().hex[:24]}" + + +def _new_tool_call_id() -> str: + return f"call_{uuid.uuid4().hex[:24]}" + + +def _new_function_call_id() -> str: + return f"fc_{uuid.uuid4().hex[:24]}" + + +def _normalize_tool_choice(tool_choice: Any) -> Any: + if isinstance(tool_choice, dict): + t_type = tool_choice.get("type") + if t_type and t_type != "function": + return {"type": "function", "function": {"name": t_type}} + return tool_choice + + +def _normalize_tools_for_chat(tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]: + if not tools: + return None + normalized: List[Dict[str, Any]] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + tool_type = tool.get("type") + if tool_type == "function": + normalized.append(tool) + continue + if tool_type in _BUILTIN_TOOL_TYPES: + if tool_type.startswith("web_search"): + normalized.append( + { + "type": "function", + "function": { + "name": tool_type, + "description": "Search the web for information and return results.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + }, + } + ) + elif tool_type == "file_search": + normalized.append( + { + "type": "function", + "function": { + "name": tool_type, + "description": "Search provided files for relevant information.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + }, + } + ) + elif tool_type == "code_interpreter": + normalized.append( + { + "type": "function", + "function": { + "name": tool_type, + "description": "Execute code to solve tasks and return results.", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "required": ["code"], + }, + }, + } + ) + return normalized or None + + +def _content_item_from_input(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not isinstance(item, dict): + return None + item_type = item.get("type") + + if item_type in {"input_text", "text", "output_text"}: + text = item.get("text") or item.get("content") or "" + return {"type": "text", "text": text} + + if item_type in {"input_image", "image", "image_url", "output_image"}: + image_url = item.get("image_url") + url = "" + detail = None + if isinstance(image_url, dict): + url = image_url.get("url") or "" + detail = image_url.get("detail") + elif isinstance(image_url, str): + url = image_url + else: + url = item.get("url") or item.get("image") or "" + + if not url: + return None + image_payload = {"url": url} + if detail: + image_payload["detail"] = detail + return {"type": "image_url", "image_url": image_payload} + + if item_type in {"input_file", "file"}: + file_data = item.get("file_data") + file_id = item.get("file_id") + if not file_data and isinstance(item.get("file"), dict): + file_data = item["file"].get("file_data") + file_id = item["file"].get("file_id") + file_payload: Dict[str, Any] = {} + if file_data: + file_payload["file_data"] = file_data + if file_id: + file_payload["file_id"] = file_id + if not file_payload: + return None + return {"type": "file", "file": file_payload} + + if item_type in {"input_audio", "audio"}: + audio = item.get("audio") or {} + data = audio.get("data") or item.get("data") + if not data: + return None + return {"type": "input_audio", "input_audio": {"data": data}} + + return None + + +def _message_from_item(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not isinstance(item, dict): + return None + + if item.get("type") == "message": + role = item.get("role") or "user" + content = item.get("content") + return {"role": role, "content": _coerce_content(content)} + + if "role" in item and "content" in item: + return {"role": item.get("role") or "user", "content": _coerce_content(item.get("content"))} + + return None + + +def _coerce_content(content: Any) -> Any: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, dict): + content = [content] + if isinstance(content, list): + blocks: List[Dict[str, Any]] = [] + for item in content: + if isinstance(item, dict) and item.get("type") in {"input_text", "output_text"}: + blocks.append({"type": "text", "text": item.get("text", "")}) + continue + block = _content_item_from_input(item) if isinstance(item, dict) else None + if block: + blocks.append(block) + return blocks if blocks else "" + return str(content) + + +def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]: + if input_value is None: + return [] + if isinstance(input_value, str): + return [{"role": "user", "content": input_value}] + + if isinstance(input_value, dict): + msg = _message_from_item(input_value) + if msg: + return [msg] + content_item = _content_item_from_input(input_value) + if content_item: + return [{"role": "user", "content": [content_item]}] + return [] + + if not isinstance(input_value, list): + return [{"role": "user", "content": str(input_value)}] + + messages: List[Dict[str, Any]] = [] + pending_blocks: List[Dict[str, Any]] = [] + + def _flush_pending(): + nonlocal pending_blocks + if pending_blocks: + messages.append({"role": "user", "content": pending_blocks}) + pending_blocks = [] + + for item in input_value: + if isinstance(item, dict): + msg = _message_from_item(item) + if msg: + _flush_pending() + messages.append(msg) + continue + + item_type = item.get("type") + if item_type in _TOOL_OUTPUT_TYPES: + _flush_pending() + call_id = ( + item.get("call_id") + or item.get("tool_call_id") + or item.get("id") + or _new_tool_call_id() + ) + output = item.get("output") or item.get("content") or "" + messages.append({"role": "tool", "tool_call_id": call_id, "content": output}) + continue + + block = _content_item_from_input(item) + if block: + pending_blocks.append(block) + continue + + if isinstance(item, str): + pending_blocks.append({"type": "text", "text": item}) + + _flush_pending() + return messages + + +def _build_output_message( + text: str, + *, + message_id: Optional[str] = None, + status: str = "completed", +) -> Dict[str, Any]: + message_id = message_id or _new_message_id() + return { + "id": message_id, + "type": "message", + "role": "assistant", + "status": status, + "content": [ + { + "type": "output_text", + "text": text, + "annotations": [], + } + ], + } + + +def _build_output_tool_call( + tool_call: Dict[str, Any], + *, + item_id: Optional[str] = None, + status: str = "completed", +) -> Dict[str, Any]: + fn = tool_call.get("function") or {} + call_id = tool_call.get("id") or _new_tool_call_id() + item_id = item_id or _new_function_call_id() + return { + "id": item_id, + "type": "function_call", + "status": status, + "call_id": call_id, + "name": fn.get("name"), + "arguments": fn.get("arguments"), + } + + +def _build_response_object( + *, + model: str, + output_text: Optional[str] = None, + tool_calls: Optional[List[Dict[str, Any]]] = None, + response_id: Optional[str] = None, + usage: Optional[Dict[str, Any]] = None, + created_at: Optional[int] = None, + completed_at: Optional[int] = None, + status: str = "completed", + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + parallel_tool_calls: Optional[bool] = None, + previous_response_id: Optional[str] = None, + reasoning_effort: Optional[str] = None, + store: Optional[bool] = None, + temperature: Optional[float] = None, + tool_choice: Optional[Any] = None, + tools: Optional[List[Dict[str, Any]]] = None, + top_p: Optional[float] = None, + truncation: Optional[str] = None, + user: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + response_id = response_id or _new_response_id() + created_at = created_at or _now_ts() + if status == "completed" and completed_at is None: + completed_at = _now_ts() + + output: List[Dict[str, Any]] = [] + if output_text is not None: + output.append(_build_output_message(output_text)) + + if tool_calls: + for call in tool_calls: + output.append(_build_output_tool_call(call)) + + return { + "id": response_id, + "object": "response", + "created_at": created_at, + "completed_at": completed_at, + "status": status, + "error": None, + "incomplete_details": None, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "model": model, + "output": output, + "parallel_tool_calls": True if parallel_tool_calls is None else parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": {"effort": reasoning_effort, "summary": None}, + "store": True if store is None else store, + "temperature": 1.0 if temperature is None else temperature, + "text": {"format": {"type": "text"}}, + "tool_choice": tool_choice or "auto", + "tools": tools or [], + "top_p": 1.0 if top_p is None else top_p, + "truncation": truncation or "disabled", + "usage": usage, + "user": user, + "metadata": metadata or {}, + } + + +class ResponseStreamAdapter: + def __init__( + self, + *, + model: str, + response_id: str, + created_at: int, + instructions: Optional[str], + max_output_tokens: Optional[int], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning_effort: Optional[str], + store: Optional[bool], + temperature: Optional[float], + tool_choice: Optional[Any], + tools: Optional[List[Dict[str, Any]]], + top_p: Optional[float], + truncation: Optional[str], + user: Optional[str], + metadata: Optional[Dict[str, Any]], + ): + self.model = model + self.response_id = response_id + self.created_at = created_at + self.instructions = instructions + self.max_output_tokens = max_output_tokens + self.parallel_tool_calls = parallel_tool_calls + self.previous_response_id = previous_response_id + self.reasoning_effort = reasoning_effort + self.store = store + self.temperature = temperature + self.tool_choice = tool_choice + self.tools = tools + self.top_p = top_p + self.truncation = truncation + self.user = user + self.metadata = metadata + + self.output_text_parts: List[str] = [] + self.tool_calls_by_index: Dict[int, Dict[str, Any]] = {} + self.tool_items: Dict[int, Dict[str, Any]] = {} + self.next_output_index = 0 + self.content_index = 0 + self.message_id = _new_message_id() + self.message_started = False + self.message_output_index: Optional[int] = None + + def _event(self, event_type: str, payload: Dict[str, Any]) -> str: + return f"event: {event_type}\ndata: {orjson.dumps(payload).decode()}\n\n" + + def _response_payload(self, *, status: str, output_text: Optional[str], usage: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + tool_calls = None + if status == "completed" and self.tool_calls_by_index: + tool_calls = [ + self.tool_calls_by_index[idx] + for idx in sorted(self.tool_calls_by_index.keys()) + ] + return _build_response_object( + model=self.model, + output_text=output_text, + tool_calls=tool_calls, + response_id=self.response_id, + usage=usage, + created_at=self.created_at, + status=status, + instructions=self.instructions, + max_output_tokens=self.max_output_tokens, + parallel_tool_calls=self.parallel_tool_calls, + previous_response_id=self.previous_response_id, + reasoning_effort=self.reasoning_effort, + store=self.store, + temperature=self.temperature, + tool_choice=self.tool_choice, + tools=self.tools, + top_p=self.top_p, + truncation=self.truncation, + user=self.user, + metadata=self.metadata, + ) + + def _alloc_output_index(self) -> int: + idx = self.next_output_index + self.next_output_index += 1 + return idx + + def created_event(self) -> str: + payload = { + "type": "response.created", + "response": self._response_payload(status="in_progress", output_text=None, usage=None), + } + return self._event("response.created", payload) + + def in_progress_event(self) -> str: + payload = { + "type": "response.in_progress", + "response": self._response_payload(status="in_progress", output_text=None, usage=None), + } + return self._event("response.in_progress", payload) + + def ensure_message_started(self) -> List[str]: + if self.message_started: + return [] + self.message_started = True + self.message_output_index = self._alloc_output_index() + item = _build_output_message("", message_id=self.message_id, status="in_progress") + item["content"] = [] + events = [ + self._event( + "response.output_item.added", + { + "type": "response.output_item.added", + "response_id": self.response_id, + "output_index": self.message_output_index, + "item": item, + }, + ), + self._event( + "response.content_part.added", + { + "type": "response.content_part.added", + "response_id": self.response_id, + "item_id": self.message_id, + "output_index": self.message_output_index, + "content_index": self.content_index, + "part": {"type": "output_text", "text": "", "annotations": []}, + }, + ), + ] + return events + + def output_delta_event(self, delta: str) -> str: + return self._event( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "response_id": self.response_id, + "item_id": self.message_id, + "output_index": self.message_output_index, + "content_index": self.content_index, + "delta": delta, + }, + ) + + def output_done_events(self, text: str) -> List[str]: + if self.message_output_index is None: + return [] + return [ + self._event( + "response.output_text.done", + { + "type": "response.output_text.done", + "response_id": self.response_id, + "item_id": self.message_id, + "output_index": self.message_output_index, + "content_index": self.content_index, + "text": text, + }, + ), + self._event( + "response.content_part.done", + { + "type": "response.content_part.done", + "response_id": self.response_id, + "item_id": self.message_id, + "output_index": self.message_output_index, + "content_index": self.content_index, + "part": {"type": "output_text", "text": text, "annotations": []}, + }, + ), + self._event( + "response.output_item.done", + { + "type": "response.output_item.done", + "response_id": self.response_id, + "output_index": self.message_output_index, + "item": _build_output_message( + text, message_id=self.message_id, status="completed" + ), + }, + ), + ] + + def ensure_tool_item(self, tool_index: int, call_id: str, name: Optional[str]) -> List[str]: + if tool_index in self.tool_items: + item = self.tool_items[tool_index] + if name and not item.get("name"): + item["name"] = name + return [] + output_index = self._alloc_output_index() + item_id = _new_function_call_id() + self.tool_items[tool_index] = { + "item_id": item_id, + "output_index": output_index, + "call_id": call_id, + "name": name, + "arguments": "", + } + tool_item = _build_output_tool_call( + {"id": call_id, "function": {"name": name, "arguments": ""}}, + item_id=item_id, + status="in_progress", + ) + return [ + self._event( + "response.output_item.added", + { + "type": "response.output_item.added", + "response_id": self.response_id, + "output_index": output_index, + "item": tool_item, + }, + ) + ] + + def tool_arguments_delta_event(self, tool_index: int, delta: str) -> Optional[str]: + if not delta: + return None + item = self.tool_items.get(tool_index) + if not item: + return None + item["arguments"] += delta + return self._event( + "response.function_call_arguments.delta", + { + "type": "response.function_call_arguments.delta", + "response_id": self.response_id, + "item_id": item["item_id"], + "output_index": item["output_index"], + "delta": delta, + }, + ) + + def tool_arguments_done_events(self) -> List[str]: + events: List[str] = [] + for tool_index, item in sorted( + self.tool_items.items(), key=lambda kv: kv[1]["output_index"] + ): + events.append( + self._event( + "response.function_call_arguments.done", + { + "type": "response.function_call_arguments.done", + "response_id": self.response_id, + "item_id": item["item_id"], + "output_index": item["output_index"], + "arguments": item["arguments"], + }, + ) + ) + tool_item = _build_output_tool_call( + { + "id": item["call_id"], + "function": {"name": item.get("name"), "arguments": item["arguments"]}, + }, + item_id=item["item_id"], + status="completed", + ) + events.append( + self._event( + "response.output_item.done", + { + "type": "response.output_item.done", + "response_id": self.response_id, + "output_index": item["output_index"], + "item": tool_item, + }, + ) + ) + return events + + def record_tool_call(self, tool_index: int, call_id: str, name: Optional[str], arguments_delta: str) -> None: + tool_call = self.tool_calls_by_index.get(tool_index) + if not tool_call: + tool_call = { + "id": call_id or _new_tool_call_id(), + "type": "function", + "function": {"name": name, "arguments": ""}, + } + self.tool_calls_by_index[tool_index] = tool_call + if name and not tool_call["function"].get("name"): + tool_call["function"]["name"] = name + if arguments_delta: + tool_call["function"]["arguments"] += arguments_delta + + def completed_event(self, usage: Optional[Dict[str, Any]] = None) -> str: + response = self._response_payload( + status="completed", + output_text="".join(self.output_text_parts) if self.message_started else None, + usage=usage + or {"total_tokens": 0, "input_tokens": 0, "output_tokens": 0}, + ) + payload = {"type": "response.completed", "response": response} + return self._event("response.completed", payload) + + +class ResponsesService: + @staticmethod + async def create( + *, + model: str, + input_value: Any, + instructions: Optional[str] = None, + stream: bool = False, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Any = None, + parallel_tool_calls: Optional[bool] = None, + reasoning_effort: Optional[str] = None, + max_output_tokens: Optional[int] = None, + metadata: Optional[Dict[str, Any]] = None, + user: Optional[str] = None, + store: Optional[bool] = None, + previous_response_id: Optional[str] = None, + truncation: Optional[str] = None, + ) -> Any: + messages = _coerce_input_to_messages(input_value) + if instructions: + messages = [{"role": "system", "content": instructions}] + messages + + if not messages: + raise ValueError("input is required") + + normalized_tools = _normalize_tools_for_chat(tools) + normalized_tool_choice = _normalize_tool_choice(tool_choice) + + chat_kwargs: Dict[str, Any] = { + "model": model, + "messages": messages, + "stream": stream, + } + if temperature is not None: + chat_kwargs["temperature"] = temperature + if top_p is not None: + chat_kwargs["top_p"] = top_p + if normalized_tools is not None: + chat_kwargs["tools"] = normalized_tools + if normalized_tool_choice is not None: + chat_kwargs["tool_choice"] = normalized_tool_choice + if parallel_tool_calls is not None: + chat_kwargs["parallel_tool_calls"] = parallel_tool_calls + if reasoning_effort is not None: + chat_kwargs["reasoning_effort"] = reasoning_effort + + result = await ChatService.completions(**chat_kwargs) + + if not stream: + if not isinstance(result, dict): + raise ValueError("Unexpected stream response for non-stream request") + choice = (result.get("choices") or [{}])[0] + message = choice.get("message") or {} + content = message.get("content") or "" + tool_calls = message.get("tool_calls") + return _build_response_object( + model=model, + output_text=content, + tool_calls=tool_calls, + usage=result.get("usage") + or {"total_tokens": 0, "input_tokens": 0, "output_tokens": 0}, + status="completed", + instructions=instructions, + max_output_tokens=max_output_tokens, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning_effort=reasoning_effort, + store=store, + temperature=temperature, + tool_choice=tool_choice, + tools=tools, + top_p=top_p, + truncation=truncation, + user=user, + metadata=metadata, + ) + + if not hasattr(result, "__aiter__"): + raise ValueError("Unexpected non-stream response for stream request") + + created_at = _now_ts() + response_id = _new_response_id() + adapter = ResponseStreamAdapter( + model=model, + response_id=response_id, + created_at=created_at, + instructions=instructions, + max_output_tokens=max_output_tokens, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning_effort=reasoning_effort, + store=store, + temperature=temperature, + tool_choice=tool_choice, + tools=tools, + top_p=top_p, + truncation=truncation, + user=user, + metadata=metadata, + ) + + async def _stream() -> AsyncGenerator[str, None]: + yield adapter.created_event() + yield adapter.in_progress_event() + async for chunk in result: + line = proc_base._normalize_line(chunk) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + if data.get("object") == "chat.completion.chunk": + delta = (data.get("choices") or [{}])[0].get("delta") or {} + if "content" in delta and delta["content"]: + for event in adapter.ensure_message_started(): + yield event + adapter.output_text_parts.append(delta["content"]) + yield adapter.output_delta_event(delta["content"]) + tool_calls = delta.get("tool_calls") + if isinstance(tool_calls, list): + for tool in tool_calls: + if not isinstance(tool, dict): + continue + tool_index = tool.get("index", 0) + call_id = tool.get("id") or _new_tool_call_id() + fn = tool.get("function") or {} + name = fn.get("name") + args_delta = fn.get("arguments") or "" + adapter.record_tool_call( + tool_index, call_id, name, args_delta + ) + for event in adapter.ensure_tool_item( + tool_index, call_id, name + ): + yield event + delta_event = adapter.tool_arguments_delta_event( + tool_index, args_delta + ) + if delta_event: + yield delta_event + + full_text = "".join(adapter.output_text_parts) + if full_text and adapter.message_started: + for event in adapter.output_done_events(full_text): + yield event + for event in adapter.tool_arguments_done_events(): + yield event + yield adapter.completed_event() + + return _stream() + + +__all__ = ["ResponsesService"] diff --git a/app/services/grok/services/video.py b/app/services/grok/services/video.py new file mode 100644 index 0000000000000000000000000000000000000000..ca9fb7f820fb348fec178c8f62d55b018e34d3ad --- /dev/null +++ b/app/services/grok/services/video.py @@ -0,0 +1,688 @@ +""" +Grok video generation service. +""" + +import asyncio +import uuid +import re +from typing import Any, AsyncGenerator, AsyncIterable, Optional + +import orjson +from curl_cffi.requests.errors import RequestsError + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import ( + UpstreamException, + AppException, + ValidationException, + ErrorType, + StreamIdleTimeoutError, +) +from app.services.grok.services.model import ModelService +from app.services.token import get_token_manager, EffortType +from app.services.grok.utils.stream import wrap_stream_with_usage +from app.services.grok.utils.process import ( + BaseProcessor, + _with_idle_timeout, + _normalize_line, + _is_http2_error, +) +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.reverse.utils.session import ResettableSession +from app.services.token.manager import BASIC_POOL_NAME + +_VIDEO_SEMAPHORE = None +_VIDEO_SEM_VALUE = 0 + +def _get_video_semaphore() -> asyncio.Semaphore: + """Reverse 接口并发控制(video 服务)。""" + global _VIDEO_SEMAPHORE, _VIDEO_SEM_VALUE + value = max(1, int(get_config("video.concurrent"))) + if value != _VIDEO_SEM_VALUE: + _VIDEO_SEM_VALUE = value + _VIDEO_SEMAPHORE = asyncio.Semaphore(value) + return _VIDEO_SEMAPHORE + + +def _new_session() -> ResettableSession: + browser = get_config("proxy.browser") + if browser: + return ResettableSession(impersonate=browser) + return ResettableSession() + + +class VideoService: + """Video generation service.""" + + def __init__(self): + self.timeout = None + + async def create_post( + self, + token: str, + prompt: str, + media_type: str = "MEDIA_POST_TYPE_VIDEO", + media_url: str = None, + ) -> str: + """Create media post and return post ID.""" + try: + 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 _new_session() as session: + async with _get_video_semaphore(): + response = await MediaPostReverse.request( + session, + token, + media_type, + media_value, + prompt=prompt_value, + ) + + post_id = response.json().get("post", {}).get("id", "") + if not post_id: + raise UpstreamException("No post ID in response") + + logger.info(f"Media post created: {post_id} (type={media_type})") + return post_id + + except AppException: + raise + except Exception as e: + logger.error(f"Create post error: {e}") + raise UpstreamException(f"Create post error: {str(e)}") + + async def create_image_post(self, token: str, image_url: str) -> str: + """Create image post and return post ID.""" + return await self.create_post( + token, prompt="", media_type="MEDIA_POST_TYPE_IMAGE", media_url=image_url + ) + + async def generate( + self, + token: str, + prompt: str, + aspect_ratio: str = "3:2", + video_length: int = 6, + resolution_name: str = "480p", + preset: str = "normal", + ) -> AsyncGenerator[bytes, None]: + """Generate video.""" + logger.info( + f"Video generation: prompt='{prompt[:50]}...', ratio={aspect_ratio}, length={video_length}s, preset={preset}" + ) + post_id = await self.create_post(token, prompt) + mode_map = { + "fun": "--mode=extremely-crazy", + "normal": "--mode=normal", + "spicy": "--mode=extremely-spicy-or-crazy", + } + mode_flag = mode_map.get(preset, "--mode=custom") + message = f"{prompt} {mode_flag}" + model_config_override = { + "modelMap": { + "videoGenModelConfig": { + "aspectRatio": aspect_ratio, + "parentPostId": post_id, + "resolutionName": resolution_name, + "videoLength": video_length, + } + } + } + + async def _stream(): + session = _new_session() + try: + async with _get_video_semaphore(): + stream_response = await AppChatReverse.request( + session, + token, + message=message, + model="grok-3", + tool_overrides={"videoGen": True}, + model_config_override=model_config_override, + ) + logger.info(f"Video generation started: post_id={post_id}") + async for line in stream_response: + yield line + except Exception as e: + try: + await session.close() + except Exception: + pass + logger.error(f"Video generation error: {e}") + if isinstance(e, AppException): + raise + raise UpstreamException(f"Video generation error: {str(e)}") + + return _stream() + + async def generate_from_image( + self, + token: str, + prompt: str, + image_url: str, + aspect_ratio: str = "3:2", + video_length: int = 6, + resolution: str = "480p", + preset: str = "normal", + ) -> AsyncGenerator[bytes, None]: + """Generate video from image.""" + logger.info( + f"Image to video: prompt='{prompt[:50]}...', image={image_url[:80]}" + ) + post_id = await self.create_image_post(token, image_url) + mode_map = { + "fun": "--mode=extremely-crazy", + "normal": "--mode=normal", + "spicy": "--mode=extremely-spicy-or-crazy", + } + mode_flag = mode_map.get(preset, "--mode=custom") + message = f"{prompt} {mode_flag}" + model_config_override = { + "modelMap": { + "videoGenModelConfig": { + "aspectRatio": aspect_ratio, + "parentPostId": post_id, + "resolutionName": resolution, + "videoLength": video_length, + } + } + } + + async def _stream(): + session = _new_session() + try: + async with _get_video_semaphore(): + stream_response = await AppChatReverse.request( + session, + token, + message=message, + model="grok-3", + tool_overrides={"videoGen": True}, + model_config_override=model_config_override, + ) + logger.info(f"Video generation started: post_id={post_id}") + async for line in stream_response: + yield line + except Exception as e: + try: + await session.close() + except Exception: + pass + logger.error(f"Video generation error: {e}") + if isinstance(e, AppException): + raise + raise UpstreamException(f"Video generation error: {str(e)}") + + return _stream() + + @staticmethod + async def completions( + model: str, + messages: list, + stream: bool = None, + reasoning_effort: str | None = None, + aspect_ratio: str = "3:2", + video_length: int = 6, + resolution: str = "480p", + preset: str = "normal", + ): + """Video generation entrypoint.""" + # Get token via intelligent routing. + token_mgr = await get_token_manager() + await token_mgr.reload_if_stale() + + max_token_retries = int(get_config("retry.max_retry")) + last_error: Exception | None = None + + if reasoning_effort is None: + show_think = get_config("app.thinking") + else: + show_think = reasoning_effort != "none" + is_stream = stream if stream is not None else get_config("app.stream") + + # Extract content. + from app.services.grok.services.chat import MessageExtractor + from app.services.grok.utils.upload import UploadService + + prompt, file_attachments, image_attachments = MessageExtractor.extract(messages) + + for attempt in range(max_token_retries): + # Select token based on video requirements and pool candidates. + pool_candidates = ModelService.pool_candidates_for_model(model) + token_info = token_mgr.get_token_for_video( + resolution=resolution, + video_length=video_length, + pool_candidates=pool_candidates, + ) + + if not token_info: + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + # Extract token string from TokenInfo. + 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. + image_url = None + if image_attachments: + upload_service = UploadService() + try: + if len(image_attachments) > 1: + logger.info( + "Video generation supports a single reference image; using the first one." + ) + attach_data = image_attachments[0] + _, file_uri = await upload_service.upload_file( + attach_data, token + ) + image_url = f"https://assets.grok.com/{file_uri}" + logger.info(f"Image uploaded for video: {image_url}") + finally: + await upload_service.close() + + # Generate video. + service = VideoService() + if image_url: + response = await service.generate_from_image( + token, + prompt, + image_url, + aspect_ratio, + video_length, + resolution, + preset, + ) + else: + response = await service.generate( + token, + prompt, + aspect_ratio, + video_length, + resolution, + preset, + ) + + # Process response. + if is_stream: + 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, upscale_on_finish=should_upscale + ).process(response) + try: + model_info = ModelService.get(model) + effort = ( + EffortType.HIGH + if (model_info and model_info.cost.value == "high") + else EffortType.LOW + ) + await token_mgr.consume(token, effort) + logger.debug( + f"Video completed, recorded usage (effort={effort.value})" + ) + except Exception as e: + logger.warning(f"Failed to record video usage: {e}") + return result + + except UpstreamException as e: + last_error = e + if rate_limited(e): + await token_mgr.mark_rate_limited(token) + logger.warning( + f"Token {token[:10]}... rate limited (429), " + f"trying next token (attempt {attempt + 1}/{max_token_retries})" + ) + continue + raise + + if last_error: + raise last_error + raise AppException( + message="No available tokens. Please try again later.", + error_type=ErrorType.RATE_LIMIT.value, + code="rate_limit_exceeded", + status_code=429, + ) + + +class VideoStreamProcessor(BaseProcessor): + """Video stream response processor.""" + + 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 _new_session() 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.""" + delta = {} + if role: + delta["role"] = role + delta["content"] = "" + elif content: + delta["content"] = content + + chunk = { + "id": self.response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}", + "object": "chat.completion.chunk", + "created": self.created, + "model": self.model, + "choices": [ + {"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish} + ], + } + return f"data: {orjson.dumps(chunk).decode()}\n\n" + + async def process( + self, response: AsyncIterable[bytes] + ) -> AsyncGenerator[str, None]: + """Process video stream response.""" + idle_timeout = get_config("video.stream_timeout") + + try: + async for line in _with_idle_timeout(response, idle_timeout, self.model): + line = _normalize_line(line) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + resp = data.get("result", {}).get("response", {}) + is_thinking = bool(resp.get("isThinking")) + + if rid := resp.get("responseId"): + self.response_id = rid + + if not self.role_sent: + yield self._sse(role="assistant") + self.role_sent = True + + if token := resp.get("token"): + if is_thinking: + if not self.show_think: + continue + if not self.think_opened: + yield self._sse("\n") + self.think_opened = True + else: + if self.think_opened: + yield self._sse("\n\n") + self.think_opened = False + yield self._sse(token) + continue + + if video_resp := resp.get("streamingVideoGenerationResponse"): + progress = video_resp.get("progress", 0) + + if is_thinking: + if not self.show_think: + continue + if not self.think_opened: + yield self._sse("\n") + self.think_opened = True + else: + if self.think_opened: + yield self._sse("\n\n") + self.think_opened = False + if self.show_think: + yield self._sse(f"正在生成视频中,当前进度{progress}%\n") + + if progress == 100: + video_url = video_resp.get("videoUrl", "") + thumbnail_url = video_resp.get("thumbnailImageUrl", "") + + if self.think_opened: + yield self._sse("\n\n") + 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 + ) + yield self._sse(rendered) + + logger.info(f"Video generated: {video_url}") + continue + + if self.think_opened: + yield self._sse("\n") + yield self._sse(finish="stop") + yield "data: [DONE]\n\n" + except asyncio.CancelledError: + logger.debug( + "Video stream cancelled by client", extra={"model": self.model} + ) + except StreamIdleTimeoutError as e: + raise UpstreamException( + message=f"Video stream idle timeout after {e.idle_seconds}s", + status_code=504, + details={ + "error": str(e), + "type": "stream_idle_timeout", + "idle_seconds": e.idle_seconds, + }, + ) + except RequestsError as e: + if _is_http2_error(e): + logger.warning( + f"HTTP/2 stream error in video: {e}", extra={"model": self.model} + ) + raise UpstreamException( + message="Upstream connection closed unexpectedly", + status_code=502, + details={"error": str(e), "type": "http2_stream_error"}, + ) + logger.error( + f"Video stream request error: {e}", extra={"model": self.model} + ) + raise UpstreamException( + message=f"Upstream request failed: {e}", + status_code=502, + details={"error": str(e)}, + ) + except Exception as e: + logger.error( + f"Video stream processing error: {e}", + extra={"model": self.model, "error_type": type(e).__name__}, + ) + finally: + await self.close() + + +class VideoCollectProcessor(BaseProcessor): + """Video non-stream response processor.""" + + 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 _new_session() 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.""" + response_id = "" + content = "" + idle_timeout = get_config("video.stream_timeout") + + try: + async for line in _with_idle_timeout(response, idle_timeout, self.model): + line = _normalize_line(line) + if not line: + continue + try: + data = orjson.loads(line) + except orjson.JSONDecodeError: + continue + + resp = data.get("result", {}).get("response", {}) + + if video_resp := resp.get("streamingVideoGenerationResponse"): + if video_resp.get("progress") == 100: + response_id = resp.get("responseId", "") + video_url = video_resp.get("videoUrl", "") + 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 + ) + logger.info(f"Video generated: {video_url}") + + except asyncio.CancelledError: + logger.debug( + "Video collect cancelled by client", extra={"model": self.model} + ) + except StreamIdleTimeoutError as e: + logger.warning( + f"Video collect idle timeout: {e}", extra={"model": self.model} + ) + except RequestsError as e: + if _is_http2_error(e): + logger.warning( + f"HTTP/2 stream error in video collect: {e}", + extra={"model": self.model}, + ) + else: + logger.error( + f"Video collect request error: {e}", extra={"model": self.model} + ) + except Exception as e: + logger.error( + f"Video collect processing error: {e}", + extra={"model": self.model, "error_type": type(e).__name__}, + ) + finally: + await self.close() + + return { + "id": response_id, + "object": "chat.completion", + "created": self.created, + "model": self.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": content, + "refusal": None, + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + + +__all__ = ["VideoService"] diff --git a/app/services/grok/services/voice.py b/app/services/grok/services/voice.py new file mode 100644 index 0000000000000000000000000000000000000000..1a08e0165c4de2c36e039119dd88cacd5c35a70e --- /dev/null +++ b/app/services/grok/services/voice.py @@ -0,0 +1,31 @@ +""" +Grok Voice Mode Service +""" + +from typing import Any, Dict + +from app.core.config import get_config +from app.services.reverse.ws_livekit import LivekitTokenReverse +from app.services.reverse.utils.session import ResettableSession + + +class VoiceService: + """Voice Mode Service (LiveKit)""" + + async def get_token( + self, + token: str, + voice: str = "ara", + personality: str = "assistant", + speed: float = 1.0, + ) -> Dict[str, Any]: + browser = get_config("proxy.browser") + async with ResettableSession(impersonate=browser) as session: + response = await LivekitTokenReverse.request( + session, + token=token, + voice=voice, + personality=personality, + speed=speed, + ) + return response.json() diff --git a/app/services/grok/utils/cache.py b/app/services/grok/utils/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..a728df15bad2cba78674da2bffe677e7a18a4e4b --- /dev/null +++ b/app/services/grok/utils/cache.py @@ -0,0 +1,110 @@ +""" +Local cache utilities. +""" + +from typing import Any, Dict + +from app.core.storage import DATA_DIR + +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} +VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"} + + +class CacheService: + """Local cache service.""" + + def __init__(self): + base_dir = DATA_DIR / "tmp" + self.image_dir = base_dir / "image" + self.video_dir = base_dir / "video" + self.image_dir.mkdir(parents=True, exist_ok=True) + self.video_dir.mkdir(parents=True, exist_ok=True) + + def _cache_dir(self, media_type: str): + return self.image_dir if media_type == "image" else self.video_dir + + def _allowed_exts(self, media_type: str): + return IMAGE_EXTS if media_type == "image" else VIDEO_EXTS + + def get_stats(self, media_type: str = "image") -> Dict[str, Any]: + cache_dir = self._cache_dir(media_type) + if not cache_dir.exists(): + return {"count": 0, "size_mb": 0.0} + + allowed = self._allowed_exts(media_type) + files = [ + f for f in cache_dir.glob("*") if f.is_file() and f.suffix.lower() in allowed + ] + total_size = sum(f.stat().st_size for f in files) + return {"count": len(files), "size_mb": round(total_size / 1024 / 1024, 2)} + + def list_files( + self, media_type: str = "image", page: int = 1, page_size: int = 1000 + ) -> Dict[str, Any]: + cache_dir = self._cache_dir(media_type) + if not cache_dir.exists(): + return {"total": 0, "page": page, "page_size": page_size, "items": []} + + allowed = self._allowed_exts(media_type) + files = [ + f for f in cache_dir.glob("*") if f.is_file() and f.suffix.lower() in allowed + ] + + items = [] + for f in files: + try: + stat = f.stat() + items.append( + { + "name": f.name, + "size_bytes": stat.st_size, + "mtime_ms": int(stat.st_mtime * 1000), + } + ) + except Exception: + continue + + items.sort(key=lambda x: x["mtime_ms"], reverse=True) + + total = len(items) + start = max(0, (page - 1) * page_size) + paged = items[start : start + page_size] + + for item in paged: + item["view_url"] = f"/v1/files/{media_type}/{item['name']}" + + return {"total": total, "page": page, "page_size": page_size, "items": paged} + + def delete_file(self, media_type: str, name: str) -> Dict[str, Any]: + cache_dir = self._cache_dir(media_type) + file_path = cache_dir / name.replace("/", "-") + + if file_path.exists(): + try: + file_path.unlink() + return {"deleted": True} + except Exception: + pass + return {"deleted": False} + + def clear(self, media_type: str = "image") -> Dict[str, Any]: + cache_dir = self._cache_dir(media_type) + if not cache_dir.exists(): + return {"count": 0, "size_mb": 0.0} + + files = list(cache_dir.glob("*")) + total_size = sum(f.stat().st_size for f in files if f.is_file()) + count = 0 + + for f in files: + if f.is_file(): + try: + f.unlink() + count += 1 + except Exception: + pass + + return {"count": count, "size_mb": round(total_size / 1024 / 1024, 2)} + + +__all__ = ["CacheService"] diff --git a/app/services/grok/utils/download.py b/app/services/grok/utils/download.py new file mode 100644 index 0000000000000000000000000000000000000000..37b4e99ec6ddf25108753624567de981dd96236f --- /dev/null +++ b/app/services/grok/utils/download.py @@ -0,0 +1,298 @@ +""" +Download service. + +Download service for assets.grok.com. +""" + +import asyncio +import base64 +import hashlib +import os +from pathlib import Path +from typing import List, Optional, Tuple +from urllib.parse import urlparse + +import aiofiles + +from app.core.logger import logger +from app.core.storage import DATA_DIR +from app.core.config import get_config +from app.core.exceptions import AppException +from app.services.reverse.assets_download import AssetsDownloadReverse +from app.services.reverse.utils.session import ResettableSession +from app.services.grok.utils.locks import _get_download_semaphore, _file_lock + + +class DownloadService: + """Assets download service.""" + + def __init__(self): + self._session: Optional[ResettableSession] = None + base_dir = DATA_DIR / "tmp" + self.image_dir = base_dir / "image" + self.video_dir = base_dir / "video" + self.image_dir.mkdir(parents=True, exist_ok=True) + self.video_dir.mkdir(parents=True, exist_ok=True) + self._cleanup_running = False + + async def create(self) -> ResettableSession: + """Create or reuse a session.""" + if self._session is None: + browser = get_config("proxy.browser") + if browser: + self._session = ResettableSession(impersonate=browser) + else: + self._session = ResettableSession() + return self._session + + async def close(self): + """Close the session.""" + if self._session: + await self._session.close() + self._session = None + + async def resolve_url( + self, path_or_url: str, token: str, media_type: str = "image" + ) -> str: + asset_url = path_or_url + path = path_or_url + if path_or_url.startswith("http"): + parsed = urlparse(path_or_url) + path = parsed.path or "" + asset_url = path_or_url + else: + if not path_or_url.startswith("/"): + path_or_url = f"/{path_or_url}" + path = path_or_url + asset_url = f"https://assets.grok.com{path_or_url}" + + app_url = get_config("app.app_url") + if app_url: + await self.download_file(asset_url, token, media_type) + return f"{app_url.rstrip('/')}/v1/files/{media_type}{path}" + return asset_url + + async def render_image( + self, url: str, token: str, image_id: str = "image" + ) -> str: + fmt = get_config("app.image_format") + fmt = fmt.lower() if isinstance(fmt, str) else "url" + if fmt not in ("base64", "url", "markdown"): + fmt = "url" + try: + if fmt == "base64": + data_uri = await self.parse_b64(url, token, "image") + return f"![{image_id}]({data_uri})" + final_url = await self.resolve_url(url, token, "image") + return f"![{image_id}]({final_url})" + except Exception as e: + logger.warning(f"Image render failed, fallback to URL: {e}") + final_url = await self.resolve_url(url, token, "image") + return f"![{image_id}]({final_url})" + + async def render_video( + self, video_url: str, token: str, thumbnail_url: str = "" + ) -> str: + fmt = get_config("app.video_format") + fmt = fmt.lower() if isinstance(fmt, str) else "url" + if fmt not in ("url", "markdown", "html"): + fmt = "url" + final_video_url = await self.resolve_url(video_url, token, "video") + final_thumb_url = "" + if thumbnail_url: + final_thumb_url = await self.resolve_url(thumbnail_url, token, "image") + if fmt == "url": + return f"{final_video_url}\n" + if fmt == "markdown": + return f"[video]({final_video_url})" + import html + + safe_video_url = html.escape(final_video_url) + safe_thumbnail_url = html.escape(final_thumb_url) + poster_attr = f' poster="{safe_thumbnail_url}"' if safe_thumbnail_url else "" + return f'''''' + + async def parse_b64(self, file_path: str, token: str, media_type: str = "image") -> str: + """Download and return data URI.""" + try: + if not isinstance(file_path, str) or not file_path.strip(): + raise AppException("Invalid file path", code="invalid_file_path") + if file_path.startswith("data:"): + raise AppException("Invalid file path", code="invalid_file_path") + file_path = self._normalize_path(file_path) + lock_name = f"dl_b64_{hashlib.sha1(file_path.encode()).hexdigest()[:16]}" + lock_timeout = max(1, int(get_config("asset.download_timeout"))) + async with _get_download_semaphore(): + async with _file_lock(lock_name, timeout=lock_timeout): + session = await self.create() + response = await AssetsDownloadReverse.request( + session, token, file_path + ) + + if hasattr(response, "aiter_content"): + data = bytearray() + async for chunk in response.aiter_content(): + if chunk: + data.extend(chunk) + raw = bytes(data) + else: + raw = response.content + + content_type = response.headers.get( + "content-type", "application/octet-stream" + ).split(";")[0] + data_uri = f"data:{content_type};base64,{base64.b64encode(raw).decode()}" + + return data_uri + except Exception as e: + logger.error(f"Failed to convert {file_path} to base64: {e}") + raise + + def _normalize_path(self, file_path: str) -> str: + """Normalize URL or path to assets path for download.""" + if not isinstance(file_path, str) or not file_path.strip(): + raise AppException("Invalid file path", code="invalid_file_path") + + value = file_path.strip() + if value.startswith("data:"): + raise AppException("Invalid file path", code="invalid_file_path") + + parsed = urlparse(value) + if parsed.scheme or parsed.netloc: + if not ( + parsed.scheme and parsed.netloc and parsed.scheme in ["http", "https"] + ): + raise AppException("Invalid file path", code="invalid_file_path") + path = parsed.path or "" + if parsed.query: + path = f"{path}?{parsed.query}" + else: + path = value + + if not path: + raise AppException("Invalid file path", code="invalid_file_path") + if not path.startswith("/"): + path = f"/{path}" + + return path + + async def download_file(self, file_path: str, token: str, media_type: str = "image") -> Tuple[Optional[Path], str]: + """Download asset to local cache. + + Args: + file_path: str, the path of the file to download. + token: str, the SSO token. + media_type: str, the media type of the file. + + Returns: + Tuple[Optional[Path], str]: The path of the downloaded file and the MIME type. + """ + async with _get_download_semaphore(): + file_path = self._normalize_path(file_path) + cache_dir = self.image_dir if media_type == "image" else self.video_dir + filename = file_path.lstrip("/").replace("/", "-") + cache_path = cache_dir / filename + + lock_name = ( + f"dl_{media_type}_{hashlib.sha1(str(cache_path).encode()).hexdigest()[:16]}" + ) + lock_timeout = max(1, int(get_config("asset.download_timeout"))) + async with _file_lock(lock_name, timeout=lock_timeout): + session = await self.create() + response = await AssetsDownloadReverse.request(session, token, file_path) + + tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp") + try: + async with aiofiles.open(tmp_path, "wb") as f: + if hasattr(response, "aiter_content"): + async for chunk in response.aiter_content(): + if chunk: + await f.write(chunk) + else: + await f.write(response.content) + os.replace(tmp_path, cache_path) + finally: + if tmp_path.exists() and not cache_path.exists(): + try: + tmp_path.unlink() + except Exception: + pass + + mime = response.headers.get( + "content-type", "application/octet-stream" + ).split(";")[0] + logger.info(f"Downloaded: {file_path}") + + asyncio.create_task(self._check_limit()) + + return cache_path, mime + + async def _check_limit(self): + """Check cache limit and cleanup. + + Args: + self: DownloadService, the download service instance. + + Returns: + None + """ + if self._cleanup_running or not get_config("cache.enable_auto_clean"): + return + + self._cleanup_running = True + try: + try: + async with _file_lock("cache_cleanup", timeout=5): + limit_mb = get_config("cache.limit_mb") + total_size = 0 + all_files: List[Tuple[Path, float, int]] = [] + + for d in [self.image_dir, self.video_dir]: + if d.exists(): + for f in d.glob("*"): + if f.is_file(): + try: + stat = f.stat() + total_size += stat.st_size + all_files.append( + (f, stat.st_mtime, stat.st_size) + ) + except Exception: + pass + current_mb = total_size / 1024 / 1024 + + if current_mb <= limit_mb: + return + + logger.info( + f"Cache limit exceeded ({current_mb:.2f}MB > {limit_mb}MB), cleaning..." + ) + all_files.sort(key=lambda x: x[1]) + + deleted_count = 0 + deleted_size = 0 + target_mb = limit_mb * 0.8 + + for f, _, size in all_files: + try: + f.unlink() + deleted_count += 1 + deleted_size += size + total_size -= size + if (total_size / 1024 / 1024) <= target_mb: + break + except Exception: + pass + + logger.info( + f"Cache cleanup: {deleted_count} files ({deleted_size / 1024 / 1024:.2f}MB)" + ) + except Exception as e: + logger.warning(f"Cache cleanup failed: {e}") + finally: + self._cleanup_running = False + + +__all__ = ["DownloadService"] diff --git a/app/services/grok/utils/locks.py b/app/services/grok/utils/locks.py new file mode 100644 index 0000000000000000000000000000000000000000..0ad227f5ca778a2141a31809c8d3dbf492042a59 --- /dev/null +++ b/app/services/grok/utils/locks.py @@ -0,0 +1,86 @@ +""" +Shared locking helpers for assets operations. +""" + +import asyncio +import time +from contextlib import asynccontextmanager +from pathlib import Path + +from app.core.config import get_config +from app.core.storage import DATA_DIR + +try: + import fcntl +except ImportError: + fcntl = None + + +LOCK_DIR = DATA_DIR / ".locks" + +_UPLOAD_SEMAPHORE = None +_UPLOAD_SEM_VALUE = None +_DOWNLOAD_SEMAPHORE = None +_DOWNLOAD_SEM_VALUE = None + + +def _get_upload_semaphore() -> asyncio.Semaphore: + """Return global semaphore for upload operations.""" + value = max(1, int(get_config("asset.upload_concurrent"))) + + global _UPLOAD_SEMAPHORE, _UPLOAD_SEM_VALUE + if _UPLOAD_SEMAPHORE is None or value != _UPLOAD_SEM_VALUE: + _UPLOAD_SEM_VALUE = value + _UPLOAD_SEMAPHORE = asyncio.Semaphore(value) + return _UPLOAD_SEMAPHORE + + +def _get_download_semaphore() -> asyncio.Semaphore: + """Return global semaphore for download operations.""" + value = max(1, int(get_config("asset.download_concurrent"))) + + global _DOWNLOAD_SEMAPHORE, _DOWNLOAD_SEM_VALUE + if _DOWNLOAD_SEMAPHORE is None or value != _DOWNLOAD_SEM_VALUE: + _DOWNLOAD_SEM_VALUE = value + _DOWNLOAD_SEMAPHORE = asyncio.Semaphore(value) + return _DOWNLOAD_SEMAPHORE + + +@asynccontextmanager +async def _file_lock(name: str, timeout: int = 10): + """File lock guard.""" + if fcntl is None: + yield + return + + LOCK_DIR.mkdir(parents=True, exist_ok=True) + lock_path = Path(LOCK_DIR) / f"{name}.lock" + fd = None + locked = False + start = time.monotonic() + + try: + fd = open(lock_path, "a+") + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + break + except BlockingIOError: + if time.monotonic() - start >= timeout: + break + await asyncio.sleep(0.05) + if not locked: + raise TimeoutError(f"Failed to acquire lock: {name}") + yield + finally: + if fd: + if locked: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except Exception: + pass + fd.close() + + +__all__ = ["_get_upload_semaphore", "_get_download_semaphore", "_file_lock"] diff --git a/app/services/grok/utils/process.py b/app/services/grok/utils/process.py new file mode 100644 index 0000000000000000000000000000000000000000..69353c6514a837ba747cda87813fbd7ec1b140c8 --- /dev/null +++ b/app/services/grok/utils/process.py @@ -0,0 +1,152 @@ +""" +响应处理器基类和通用工具 +""" + +import asyncio +import time +from typing import Any, AsyncGenerator, Optional, AsyncIterable, List, TypeVar + +from app.core.config import get_config +from app.core.logger import logger +from app.core.exceptions import StreamIdleTimeoutError +from app.services.grok.utils.download import DownloadService + + +T = TypeVar("T") + + +def _is_http2_error(e: Exception) -> bool: + """检查是否为 HTTP/2 流错误""" + err_str = str(e).lower() + return "http/2" in err_str or "curl: (92)" in err_str or "stream" in err_str + + +def _normalize_line(line: Any) -> Optional[str]: + """规范化流式响应行,兼容 SSE data 前缀与空行""" + if line is None: + return None + if isinstance(line, (bytes, bytearray)): + text = line.decode("utf-8", errors="ignore") + else: + text = str(line) + text = text.strip() + if not text: + return None + if text.startswith("data:"): + text = text[5:].strip() + if text == "[DONE]": + return None + return text + + +def _collect_images(obj: Any) -> List[str]: + """递归收集响应中的图片 URL""" + urls: List[str] = [] + seen = set() + + def add(url: str): + if not url or url in seen: + return + seen.add(url) + urls.append(url) + + def walk(value: Any): + if isinstance(value, dict): + for key, item in value.items(): + if key in {"generatedImageUrls", "imageUrls", "imageURLs"}: + if isinstance(item, list): + for url in item: + if isinstance(url, str): + add(url) + elif isinstance(item, str): + add(item) + continue + walk(item) + elif isinstance(value, list): + for item in value: + walk(item) + + walk(obj) + return urls + + +async def _with_idle_timeout( + iterable: AsyncIterable[T], idle_timeout: float, model: str = "" +) -> AsyncGenerator[T, None]: + """ + 包装异步迭代器,添加空闲超时检测 + + Args: + iterable: 原始异步迭代器 + idle_timeout: 空闲超时时间(秒),0 表示禁用 + model: 模型名称(用于日志) + """ + if idle_timeout <= 0: + async for item in iterable: + yield item + return + + iterator = iterable.__aiter__() + + async def _maybe_aclose(it): + aclose = getattr(it, "aclose", None) + if not aclose: + return + try: + await aclose() + except Exception: + pass + + while True: + try: + item = await asyncio.wait_for(iterator.__anext__(), timeout=idle_timeout) + yield item + except asyncio.TimeoutError: + logger.warning( + f"Stream idle timeout after {idle_timeout}s", + extra={"model": model, "idle_timeout": idle_timeout}, + ) + await _maybe_aclose(iterator) + raise StreamIdleTimeoutError(idle_timeout) + except asyncio.CancelledError: + await _maybe_aclose(iterator) + raise + except StopAsyncIteration: + break + + +class BaseProcessor: + """基础处理器""" + + def __init__(self, model: str, token: str = ""): + self.model = model + self.token = token + self.created = int(time.time()) + self.app_url = get_config("app.app_url") + self._dl_service: Optional[DownloadService] = None + + def _get_dl(self) -> DownloadService: + """获取下载服务实例(复用)""" + if self._dl_service is None: + self._dl_service = DownloadService() + return self._dl_service + + async def close(self): + """释放下载服务资源""" + if self._dl_service: + await self._dl_service.close() + self._dl_service = None + + async def process_url(self, path: str, media_type: str = "image") -> str: + """处理资产 URL""" + dl_service = self._get_dl() + return await dl_service.resolve_url(path, self.token, media_type) + + +__all__ = [ + "BaseProcessor", + "_with_idle_timeout", + "_normalize_line", + "_collect_images", + "_is_http2_error", +] diff --git a/app/services/grok/utils/response.py b/app/services/grok/utils/response.py new file mode 100644 index 0000000000000000000000000000000000000000..572c676ff5bde4fad4131f55d30ca2bf9ccf05ea --- /dev/null +++ b/app/services/grok/utils/response.py @@ -0,0 +1,144 @@ +""" +Response formatting utilities for OpenAI-compatible API responses. +""" + +import os +import time +import uuid +from typing import Optional + + +def make_response_id() -> str: + """Generate a unique response ID.""" + return f"chatcmpl-{int(time.time() * 1000)}{os.urandom(4).hex()}" + + +def make_chat_chunk( + response_id: str, + model: str, + content: str, + index: int = 0, + role: str = "assistant", + is_final: bool = False, +) -> dict: + """ + Create an OpenAI-compatible chat completion chunk. + + Args: + response_id: Unique response ID + model: Model name + content: Content to send + index: Choice index + role: Role (assistant) + is_final: Whether this is the final chunk (includes finish_reason) + + Returns: + Chat completion chunk dict + """ + choice: dict = { + "index": index, + "delta": { + "role": role, + "content": content, + }, + } + + if is_final: + choice["finish_reason"] = "stop" + + chunk: dict = { + "id": response_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [choice], + } + + if is_final: + chunk["usage"] = { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": {"text_tokens": 0, "image_tokens": 0}, + } + + return chunk + + +def make_chat_response( + model: str, + content: str, + response_id: Optional[str] = None, + index: int = 0, + usage: Optional[dict] = None, +) -> dict: + """ + Create an OpenAI-compatible non-streaming chat completion response. + + Args: + model: Model name + content: Response content + response_id: Unique response ID (generated if not provided) + index: Choice index + usage: Custom usage dict (defaults to zeros) + + Returns: + Chat completion response dict + """ + if response_id is None: + response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}" + + if usage is None: + usage = { + "total_tokens": 0, + "input_tokens": 0, + "output_tokens": 0, + "input_tokens_details": {"text_tokens": 0, "image_tokens": 0}, + } + + return { + "id": response_id, + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": index, + "message": { + "role": "assistant", + "content": content, + "refusal": None, + }, + "finish_reason": "stop", + } + ], + "usage": usage, + } + + +def wrap_image_content(content: str, response_format: str = "url") -> str: + """ + Wrap image content in markdown format for chat interface. + + Args: + content: Image URL or base64 data + response_format: "url" or "b64_json"/"base64" + + Returns: + Markdown-wrapped image content + """ + if not content: + return content + + if response_format == "url": + return f"![image]({content})" + else: + return f"![image](data:image/png;base64,{content})" + + +__all__ = [ + "make_response_id", + "make_chat_chunk", + "make_chat_response", + "wrap_image_content", +] diff --git a/app/services/grok/utils/retry.py b/app/services/grok/utils/retry.py new file mode 100644 index 0000000000000000000000000000000000000000..bcb3a01f0e782a7054e195e0066bcbfa85f35f7b --- /dev/null +++ b/app/services/grok/utils/retry.py @@ -0,0 +1,66 @@ +""" +Retry helpers for token switching. +""" + +from typing import Optional, Set + +from app.core.exceptions import UpstreamException +from app.services.grok.services.model import ModelService + + +async def pick_token( + token_mgr, + model_id: str, + tried: Set[str], + preferred: Optional[str] = None, + prefer_tags: Optional[Set[str]] = None, +) -> Optional[str]: + if preferred and preferred not in tried: + return preferred + + token = None + for pool_name in ModelService.pool_candidates_for_model(model_id): + token = token_mgr.get_token(pool_name, exclude=tried, prefer_tags=prefer_tags) + if token: + break + + if not token and not tried: + result = await token_mgr.refresh_cooling_tokens() + if result.get("recovered", 0) > 0: + for pool_name in ModelService.pool_candidates_for_model(model_id): + token = token_mgr.get_token(pool_name, prefer_tags=prefer_tags) + if token: + break + + return token + + +def rate_limited(error: Exception) -> bool: + if not isinstance(error, UpstreamException): + return False + status = error.details.get("status") if error.details else None + code = error.details.get("error_code") if error.details else None + return status == 429 or code == "rate_limit_exceeded" + + +def transient_upstream(error: Exception) -> bool: + """Whether error is likely transient and safe to retry with another token.""" + if not isinstance(error, UpstreamException): + return False + details = error.details or {} + status = details.get("status") + err = str(details.get("error") or error).lower() + transient_status = {408, 500, 502, 503, 504} + if status in transient_status: + return True + timeout_markers = ( + "timed out", + "timeout", + "connection reset", + "temporarily unavailable", + "http2", + ) + return any(marker in err for marker in timeout_markers) + + +__all__ = ["pick_token", "rate_limited", "transient_upstream"] diff --git a/app/services/grok/utils/stream.py b/app/services/grok/utils/stream.py new file mode 100644 index 0000000000000000000000000000000000000000..053c18d9eebc0caa87665725ae2ebb1ea7798679 --- /dev/null +++ b/app/services/grok/utils/stream.py @@ -0,0 +1,46 @@ +""" +流式响应通用工具 +""" + +from typing import AsyncGenerator + +from app.core.logger import logger +from app.services.grok.services.model import ModelService +from app.services.token import EffortType + + +async def wrap_stream_with_usage( + stream: AsyncGenerator, token_mgr, token: str, model: str +) -> AsyncGenerator: + """ + 包装流式响应,在完成时记录使用 + + Args: + stream: 原始 AsyncGenerator + token_mgr: TokenManager 实例 + token: Token 字符串 + model: 模型名称 + """ + success = False + try: + async for chunk in stream: + yield chunk + success = True + finally: + if success: + try: + model_info = ModelService.get(model) + effort = ( + EffortType.HIGH + if (model_info and model_info.cost.value == "high") + else EffortType.LOW + ) + await token_mgr.consume(token, effort) + logger.debug( + f"Stream completed, recorded usage for token {token[:10]}... (effort={effort.value})" + ) + except Exception as e: + logger.warning(f"Failed to record stream usage: {e}") + + +__all__ = ["wrap_stream_with_usage"] diff --git a/app/services/grok/utils/tool_call.py b/app/services/grok/utils/tool_call.py new file mode 100644 index 0000000000000000000000000000000000000000..11fef2ab9d9c0895a966a529d51a16d63bc1d47c --- /dev/null +++ b/app/services/grok/utils/tool_call.py @@ -0,0 +1,319 @@ +""" +Tool call utilities for OpenAI-compatible function calling. + +Provides prompt-based emulation of tool calls by injecting tool definitions +into the system prompt and parsing structured responses. +""" + +import json +import re +import uuid +from typing import Any, Dict, List, Optional, Tuple + + +def build_tool_prompt( + tools: List[Dict[str, Any]], + tool_choice: Optional[Any] = None, + parallel_tool_calls: bool = True, +) -> str: + """Generate a system prompt block describing available tools. + + Args: + tools: List of OpenAI-format tool definitions. + tool_choice: "auto", "required", "none", or {"type":"function","function":{"name":"..."}}. + parallel_tool_calls: Whether multiple tool calls are allowed. + + Returns: + System prompt string to prepend to the conversation. + """ + if not tools: + return "" + + # tool_choice="none" means don't mention tools at all + if tool_choice == "none": + return "" + + lines = [ + "# Available Tools", + "", + "You have access to the following tools. To call a tool, output a block with a JSON object containing \"name\" and \"arguments\".", + "", + "Format:", + "", + '{"name": "function_name", "arguments": {"param": "value"}}', + "", + "", + ] + + if parallel_tool_calls: + lines.append("You may make multiple tool calls in a single response by using multiple blocks.") + lines.append("") + + # Describe each tool + lines.append("## Tool Definitions") + lines.append("") + for tool in tools: + if tool.get("type") != "function": + continue + func = tool.get("function", {}) + name = func.get("name", "") + desc = func.get("description", "") + params = func.get("parameters", {}) + + lines.append(f"### {name}") + if desc: + lines.append(f"{desc}") + if params: + lines.append(f"Parameters: {json.dumps(params, ensure_ascii=False)}") + lines.append("") + + # Handle tool_choice directives + if tool_choice == "required": + lines.append("IMPORTANT: You MUST call at least one tool in your response. Do not respond with only text.") + elif isinstance(tool_choice, dict): + func_info = tool_choice.get("function", {}) + forced_name = func_info.get("name", "") + if forced_name: + lines.append(f"IMPORTANT: You MUST call the tool \"{forced_name}\" in your response.") + else: + # "auto" or default + lines.append("Decide whether to call a tool based on the user's request. If you don't need a tool, respond normally with text only.") + + lines.append("") + lines.append("When you call a tool, you may include text before or after the blocks, but the tool call blocks must be valid JSON.") + + return "\n".join(lines) + + +_TOOL_CALL_RE = re.compile( + r"\s*(.*?)\s*", + re.DOTALL, +) + + +def _strip_code_fences(text: str) -> str: + if not text: + return text + cleaned = text.strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", cleaned) + cleaned = re.sub(r"\s*```$", "", cleaned) + return cleaned.strip() + + +def _extract_json_object(text: str) -> str: + if not text: + return text + start = text.find("{") + if start == -1: + return text + end = text.rfind("}") + if end == -1: + return text[start:] + if end < start: + return text + return text[start : end + 1] + + +def _remove_trailing_commas(text: str) -> str: + if not text: + return text + return re.sub(r",\s*([}\]])", r"\1", text) + + +def _balance_braces(text: str) -> str: + if not text: + return text + open_count = 0 + close_count = 0 + in_string = False + escape = False + for ch in text: + if escape: + escape = False + continue + if ch == "\\" and in_string: + escape = True + continue + if ch == '"': + in_string = not in_string + continue + if in_string: + continue + if ch == "{": + open_count += 1 + elif ch == "}": + close_count += 1 + if open_count > close_count: + text = text + ("}" * (open_count - close_count)) + return text + + +def _repair_json(text: str) -> Optional[Any]: + if not text: + return None + cleaned = _strip_code_fences(text) + cleaned = _extract_json_object(cleaned) + cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n") + cleaned = cleaned.replace("\n", " ") + cleaned = _remove_trailing_commas(cleaned) + cleaned = _balance_braces(cleaned) + try: + return json.loads(cleaned) + except json.JSONDecodeError: + return None + + +def parse_tool_call_block( + raw_json: str, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Optional[Dict[str, Any]]: + if not raw_json: + return None + parsed = None + try: + parsed = json.loads(raw_json) + except json.JSONDecodeError: + parsed = _repair_json(raw_json) + if not isinstance(parsed, dict): + return None + + name = parsed.get("name") + arguments = parsed.get("arguments", {}) + if not name: + return None + + valid_names = set() + if tools: + for tool in tools: + func = tool.get("function", {}) + tool_name = func.get("name") + if tool_name: + valid_names.add(tool_name) + if valid_names and name not in valid_names: + return None + + if isinstance(arguments, dict): + arguments_str = json.dumps(arguments, ensure_ascii=False) + elif isinstance(arguments, str): + arguments_str = arguments + else: + arguments_str = json.dumps(arguments, ensure_ascii=False) + + return { + "id": f"call_{uuid.uuid4().hex[:24]}", + "type": "function", + "function": {"name": name, "arguments": arguments_str}, + } + + +def parse_tool_calls( + content: str, + tools: Optional[List[Dict[str, Any]]] = None, +) -> Tuple[Optional[str], Optional[List[Dict[str, Any]]]]: + """Parse tool call blocks from model output. + + Detects ``...`` blocks, parses JSON from each block, + and returns OpenAI-format tool call objects. + + Args: + content: Raw model output text. + tools: Optional list of tool definitions for name validation. + + Returns: + Tuple of (text_content, tool_calls_list). + - text_content: text outside blocks (None if empty). + - tool_calls_list: list of OpenAI tool call dicts, or None if no calls found. + """ + if not content: + return content, None + + matches = list(_TOOL_CALL_RE.finditer(content)) + if not matches: + return content, None + + tool_calls = [] + for match in matches: + raw_json = match.group(1).strip() + tool_call = parse_tool_call_block(raw_json, tools) + if tool_call: + tool_calls.append(tool_call) + + if not tool_calls: + return content, None + + # Extract text outside of tool_call blocks + text_parts = [] + last_end = 0 + for match in matches: + before = content[last_end:match.start()] + if before.strip(): + text_parts.append(before.strip()) + last_end = match.end() + trailing = content[last_end:] + if trailing.strip(): + text_parts.append(trailing.strip()) + + text_content = "\n".join(text_parts) if text_parts else None + + return text_content, tool_calls + + +def format_tool_history(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert assistant messages with tool_calls and tool role messages into text format. + + Since Grok's web API only accepts a single message string, this converts + tool-related messages back to a text representation for multi-turn conversations. + + Args: + messages: List of OpenAI-format messages that may contain tool_calls and tool roles. + + Returns: + List of messages with tool content converted to text format. + """ + result = [] + for msg in messages: + role = msg.get("role", "") + content = msg.get("content") + tool_calls = msg.get("tool_calls") + tool_call_id = msg.get("tool_call_id") + name = msg.get("name") + + if role == "assistant" and tool_calls: + # Convert assistant tool_calls to text representation + parts = [] + if content: + parts.append(content if isinstance(content, str) else str(content)) + for tc in tool_calls: + func = tc.get("function", {}) + tc_name = func.get("name", "") + tc_args = func.get("arguments", "{}") + tc_id = tc.get("id", "") + parts.append(f'{{"name":"{tc_name}","arguments":{tc_args}}}') + result.append({ + "role": "assistant", + "content": "\n".join(parts), + }) + + elif role == "tool": + # Convert tool result to text format + tool_name = name or "unknown" + call_id = tool_call_id or "" + content_str = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) if content else "" + result.append({ + "role": "user", + "content": f"tool ({tool_name}, {call_id}): {content_str}", + }) + + else: + result.append(msg) + + return result + + +__all__ = [ + "build_tool_prompt", + "parse_tool_calls", + "format_tool_history", + "parse_tool_call_block", +] diff --git a/app/services/grok/utils/upload.py b/app/services/grok/utils/upload.py new file mode 100644 index 0000000000000000000000000000000000000000..0d05fe99d51fabc23b9b76a4f917eace9495225b --- /dev/null +++ b/app/services/grok/utils/upload.py @@ -0,0 +1,248 @@ +""" +Upload service. + +Upload service for assets.grok.com. +""" + +import base64 +import hashlib +import mimetypes +import re +from pathlib import Path +from typing import AsyncIterator, Optional, Tuple +from urllib.parse import urlparse + +import aiofiles + +from app.core.config import get_config +from app.core.exceptions import AppException, UpstreamException, ValidationException +from app.core.logger import logger +from app.core.storage import DATA_DIR +from app.services.reverse.assets_upload import AssetsUploadReverse +from app.services.reverse.utils.session import ResettableSession +from app.services.grok.utils.locks import _get_upload_semaphore, _file_lock + + +class UploadService: + """Assets upload service.""" + + def __init__(self): + self._session: Optional[ResettableSession] = None + self._chunk_size = 64 * 1024 + + async def create(self) -> ResettableSession: + """Create or reuse a session.""" + if self._session is None: + browser = get_config("proxy.browser") + if browser: + self._session = ResettableSession(impersonate=browser) + else: + self._session = ResettableSession() + return self._session + + async def close(self): + """Close the session.""" + if self._session: + await self._session.close() + self._session = None + + @staticmethod + def _is_url(value: str) -> bool: + """Check if the value is a URL.""" + try: + parsed = urlparse(value) + return bool( + parsed.scheme and parsed.netloc and parsed.scheme in ["http", "https"] + ) + except Exception: + return False + + @staticmethod + def _infer_mime(filename: str, fallback: str = "application/octet-stream") -> str: + mime, _ = mimetypes.guess_type(filename) + return mime or fallback + + @staticmethod + async def _encode_b64_stream(chunks: AsyncIterator[bytes]) -> str: + parts = [] + remain = b"" + async for chunk in chunks: + if not chunk: + continue + chunk = remain + chunk + keep = len(chunk) % 3 + if keep: + remain = chunk[-keep:] + chunk = chunk[:-keep] + else: + remain = b"" + if chunk: + parts.append(base64.b64encode(chunk).decode()) + if remain: + parts.append(base64.b64encode(remain).decode()) + return "".join(parts) + + async def _read_local_file(self, local_type: str, name: str) -> Tuple[str, str, str]: + base_dir = DATA_DIR / "tmp" + if local_type == "video": + local_dir = base_dir / "video" + mime = "video/mp4" + else: + local_dir = base_dir / "image" + suffix = Path(name).suffix.lower() + if suffix == ".png": + mime = "image/png" + elif suffix == ".webp": + mime = "image/webp" + elif suffix == ".gif": + mime = "image/gif" + else: + mime = "image/jpeg" + + local_path = local_dir / name + lock_name = f"ul_local_{hashlib.sha1(str(local_path).encode()).hexdigest()[:16]}" + lock_timeout = max(1, int(get_config("asset.upload_timeout"))) + async with _file_lock(lock_name, timeout=lock_timeout): + if not local_path.exists(): + raise ValidationException(f"Local file not found: {local_path}") + if not local_path.is_file(): + raise ValidationException(f"Invalid local file: {local_path}") + + async def _iter_file() -> AsyncIterator[bytes]: + async with aiofiles.open(local_path, "rb") as f: + while True: + chunk = await f.read(self._chunk_size) + if not chunk: + break + yield chunk + + b64 = await self._encode_b64_stream(_iter_file()) + filename = name or "file" + return filename, b64, mime + + async def parse_b64(self, url: str) -> Tuple[str, str, str]: + """Fetch URL content and return (filename, base64, mime).""" + try: + app_url = get_config("app.app_url") or "" + if app_url and self._is_url(url): + parsed = urlparse(url) + app_parsed = urlparse(app_url) + if ( + parsed.scheme == app_parsed.scheme + and parsed.netloc == app_parsed.netloc + and parsed.path.startswith("/v1/files/") + ): + parts = parsed.path.strip("/").split("/", 3) + if len(parts) >= 4: + local_type = parts[2] + name = parts[3].replace("/", "-") + return await self._read_local_file(local_type, name) + + lock_name = f"ul_url_{hashlib.sha1(url.encode()).hexdigest()[:16]}" + timeout = float(get_config("asset.upload_timeout")) + proxy_url = get_config("proxy.base_proxy_url") + proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None + + lock_timeout = max(1, int(get_config("asset.upload_timeout"))) + async with _file_lock(lock_name, timeout=lock_timeout): + session = await self.create() + response = await session.get( + url, timeout=timeout, proxies=proxies, stream=True + ) + if response.status_code >= 400: + raise UpstreamException( + message=f"Failed to fetch: {response.status_code}", + details={"url": url, "status": response.status_code}, + ) + + filename = url.split("/")[-1].split("?")[0] or "download" + content_type = response.headers.get( + "content-type", "" + ).split(";")[0].strip() + if not content_type: + content_type = self._infer_mime(filename) + if hasattr(response, "aiter_content"): + b64 = await self._encode_b64_stream(response.aiter_content()) + else: + b64 = base64.b64encode(response.content).decode() + + logger.debug(f"Fetched: {url}") + return filename, b64, content_type + except Exception as e: + if isinstance(e, AppException): + raise + logger.error(f"Fetch failed: {url} - {e}") + raise UpstreamException(f"Fetch failed: {str(e)}", details={"url": url}) + + @staticmethod + def format_b64(data_uri: str) -> Tuple[str, str, str]: + """Format data URI to (filename, base64, mime).""" + if not data_uri.startswith("data:"): + raise ValidationException("Invalid file input: not a data URI") + + try: + header, b64 = data_uri.split(",", 1) + except ValueError: + raise ValidationException("Invalid data URI format") + + if ";base64" not in header: + raise ValidationException("Invalid data URI: missing base64 marker") + + mime = header[5:].split(";", 1)[0] or "application/octet-stream" + b64 = re.sub(r"\s+", "", b64) + if not mime or not b64: + raise ValidationException("Invalid data URI: empty content") + ext = mime.split("/")[-1] if "/" in mime else "bin" + return f"file.{ext}", b64, mime + + async def check_format(self, file_input: str) -> Tuple[str, str, str]: + """Check file input format and return (filename, base64, mime).""" + if not isinstance(file_input, str) or not file_input.strip(): + raise ValidationException("Invalid file input: empty content") + + if self._is_url(file_input): + return await self.parse_b64(file_input) + + if file_input.startswith("data:"): + return self.format_b64(file_input) + + raise ValidationException("Invalid file input: must be URL or base64") + + async def upload_file(self, file_input: str, token: str) -> Tuple[str, str]: + """ + Upload file to Grok. + + Args: + file_input: str, the file input. + token: str, the SSO token. + + Returns: + Tuple[str, str]: The file ID and URI. + """ + async with _get_upload_semaphore(): + filename, b64, mime = await self.check_format(file_input) + + logger.debug( + f"Upload prepare: filename={filename}, type={mime}, size={len(b64)}" + ) + + if not b64: + raise ValidationException("Invalid file input: empty content") + + session = await self.create() + response = await AssetsUploadReverse.request( + session, + token, + filename, + mime, + b64, + ) + + result = response.json() + file_id = result.get("fileMetadataId", "") + file_uri = result.get("fileUri", "") + logger.info(f"Upload success: {filename} -> {file_id}") + return file_id, file_uri + + +__all__ = ["UploadService"] diff --git a/app/services/reverse/__init__.py b/app/services/reverse/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6e8aebfa2729d1e0d8e061ad9703c18a56dbb016 --- /dev/null +++ b/app/services/reverse/__init__.py @@ -0,0 +1,34 @@ +"""Reverse interfaces for Grok endpoints.""" + +from .app_chat import AppChatReverse +from .assets_delete import AssetsDeleteReverse +from .assets_download import AssetsDownloadReverse +from .assets_list import AssetsListReverse +from .assets_upload import AssetsUploadReverse +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 +from .utils.statsig import StatsigGenerator + +__all__ = [ + "AppChatReverse", + "AssetsDeleteReverse", + "AssetsDownloadReverse", + "AssetsListReverse", + "AssetsUploadReverse", + "MediaPostReverse", + "NsfwMgmtReverse", + "RateLimitsReverse", + "SetBirthReverse", + "VideoUpscaleReverse", + "LivekitTokenReverse", + "LivekitWebSocketReverse", + "ImagineWebSocketReverse", + "StatsigGenerator", + "build_headers", +] diff --git a/app/services/reverse/accept_tos.py b/app/services/reverse/accept_tos.py new file mode 100644 index 0000000000000000000000000000000000000000..8459be462592ffaea5d0c383efff0d316557c921 --- /dev/null +++ b/app/services/reverse/accept_tos.py @@ -0,0 +1,118 @@ +""" +Reverse interface: accept ToS (gRPC-Web). +""" + +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.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status +from app.services.reverse.utils.grpc import GrpcClient, GrpcStatus + +ACCEPT_TOS_API = "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion" + + +class AcceptTosReverse: + """/auth_mgmt.AuthManagement/SetTosAcceptedVersion reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str) -> GrpcStatus: + """Accept ToS via gRPC-Web. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + + Returns: + GrpcStatus: Parsed gRPC status. + """ + 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, + origin="https://accounts.x.ai", + referer="https://accounts.x.ai/accept-tos", + ) + headers["Content-Type"] = "application/grpc-web+proto" + headers["Accept"] = "*/*" + headers["Sec-Fetch-Dest"] = "empty" + headers["x-grpc-web"] = "1" + headers["x-user-agent"] = "connect-es/2.1.1" + headers["Cache-Control"] = "no-cache" + headers["Pragma"] = "no-cache" + + # Build payload + payload = GrpcClient.encode_payload(b"\x10\x01") + + # Curl Config + timeout = get_config("nsfw.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + ACCEPT_TOS_API, + headers=headers, + data=payload, + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + logger.error( + f"AcceptTosReverse: Request failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"AcceptTosReverse: Request failed, {response.status_code}", + details={"status": response.status_code}, + ) + + logger.debug(f"AcceptTosReverse: Request successful, {response.status_code}") + + return response + + response = await retry_on_status(_do_request) + + _, trailers = GrpcClient.parse_response( + response.content, + content_type=response.headers.get("content-type"), + headers=response.headers, + ) + grpc_status = GrpcClient.get_status(trailers) + + if grpc_status.code not in (-1, 0): + raise UpstreamException( + message=f"AcceptTosReverse: gRPC failed, {grpc_status.code}", + details={ + "status": grpc_status.http_equiv, + "grpc_status": grpc_status.code, + "grpc_message": grpc_status.message, + }, + ) + + return grpc_status + + except Exception as e: + # Handle upstream exception + if isinstance(e, UpstreamException): + raise + + # Handle other non-upstream exceptions + logger.error( + f"AcceptTosReverse: Request failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"AcceptTosReverse: Request failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["AcceptTosReverse"] diff --git a/app/services/reverse/app_chat.py b/app/services/reverse/app_chat.py new file mode 100644 index 0000000000000000000000000000000000000000..227992a5ca7ad876d6884869f8e97e2ea8d955e8 --- /dev/null +++ b/app/services/reverse/app_chat.py @@ -0,0 +1,249 @@ +""" +Reverse interface: app chat conversations. +""" + +import orjson +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse +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 + +CHAT_API = "https://grok.com/rest/app-chat/conversations/new" + + +def _normalize_chat_proxy(proxy_url: str) -> str: + """Normalize proxy URL for curl-cffi app-chat requests.""" + if not proxy_url: + return proxy_url + parsed = urlparse(proxy_url) + scheme = parsed.scheme.lower() + if scheme == "socks5": + return proxy_url.replace("socks5://", "socks5h://", 1) + if scheme == "socks4": + return proxy_url.replace("socks4://", "socks4a://", 1) + return proxy_url + + +class AppChatReverse: + """/rest/app-chat/conversations/new reverse interface.""" + + @staticmethod + def build_payload( + message: str, + model: str, + mode: str = None, + file_attachments: List[str] = None, + tool_overrides: Dict[str, Any] = None, + model_config_override: Dict[str, Any] = None, + ) -> Dict[str, Any]: + """Build chat payload for Grok app-chat API.""" + + attachments = file_attachments or [] + + payload = { + "deviceEnvInfo": { + "darkModeEnabled": False, + "devicePixelRatio": 2, + "screenWidth": 2056, + "screenHeight": 1329, + "viewportWidth": 2056, + "viewportHeight": 1083, + }, + "disableMemory": get_config("app.disable_memory"), + "disableSearch": False, + "disableSelfHarmShortCircuit": False, + "disableTextFollowUps": False, + "enableImageGeneration": True, + "enableImageStreaming": True, + "enableSideBySide": True, + "fileAttachments": attachments, + "forceConcise": False, + "forceSideBySide": False, + "imageAttachments": [], + "imageGenerationCount": 2, + "isAsyncChat": False, + "isReasoning": False, + "message": message, + "modelMode": mode, + "modelName": model, + "responseMetadata": { + "requestModelDetails": {"modelId": model}, + }, + "returnImageBytes": False, + "returnRawGrokInXaiRequest": False, + "sendFinalMetadata": True, + "temporary": get_config("app.temporary"), + "toolOverrides": tool_overrides or {}, + } + + if model_config_override: + payload["responseMetadata"]["modelConfigOverride"] = model_config_override + + return payload + + @staticmethod + async def request( + session: AsyncSession, + token: str, + message: str, + model: str, + mode: str = None, + file_attachments: List[str] = None, + tool_overrides: Dict[str, Any] = None, + model_config_override: Dict[str, Any] = None, + ) -> Any: + """Send app chat request to Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + message: str, the message to send. + model: str, the model to use. + mode: str, the mode to use. + file_attachments: List[str], the file attachments to send. + tool_overrides: Dict[str, Any], the tool overrides to use. + model_config_override: Dict[str, Any], the model config override to use. + + Returns: + Any: The response from the request. + """ + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + proxy = None + proxies = None + if base_proxy: + normalized_proxy = _normalize_chat_proxy(base_proxy) + scheme = urlparse(normalized_proxy).scheme.lower() + if scheme.startswith("socks"): + # curl_cffi 对 SOCKS 代理优先使用 proxy 参数,避免被按 HTTP CONNECT 处理 + proxy = normalized_proxy + else: + proxies = {"http": normalized_proxy, "https": normalized_proxy} + logger.info( + f"AppChatReverse proxy enabled: scheme={scheme}, target={normalized_proxy}" + ) + else: + logger.warning("AppChatReverse proxy is empty, request will use direct network") + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com/", + ) + + # Build payload + payload = AppChatReverse.build_payload( + message=message, + model=model, + mode=mode, + file_attachments=file_attachments, + tool_overrides=tool_overrides, + model_config_override=model_config_override, + ) + + # Curl Config + timeout = float(get_config("chat.timeout") or 0) + if timeout <= 0: + timeout = max( + float(get_config("video.timeout") or 0), + float(get_config("image.timeout") or 0), + ) + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + CHAT_API, + headers=headers, + data=orjson.dumps(payload), + timeout=timeout, + stream=True, + proxy=proxy, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + + # Get response content + content = "" + try: + content = await response.text() + except Exception: + pass + + logger.debug( + "AppChatReverse: Chat failed response body: %s", + content, + ) + logger.error( + f"AppChatReverse: Chat failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"AppChatReverse: Chat failed, {response.status_code}", + details={"status": response.status_code, "body": content}, + ) + + return response + + def extract_status(e: Exception) -> Optional[int]: + if isinstance(e, UpstreamException): + if e.details and "status" in e.details: + status = e.details["status"] + else: + status = getattr(e, "status_code", None) + if status == 429: + return None + return status + return None + + response = await retry_on_status(_do_request, extract_status=extract_status) + + # Stream response + async def stream_response(): + try: + async for line in response.aiter_lines(): + yield line + finally: + await session.close() + + return stream_response() + + 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, "app_chat_auth_failed" + ) + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"AppChatReverse: Chat failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"AppChatReverse: Chat failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["AppChatReverse"] diff --git a/app/services/reverse/assets_delete.py b/app/services/reverse/assets_delete.py new file mode 100644 index 0000000000000000000000000000000000000000..79423107f0de282d30ade83b0262b25ebe8dd2ad --- /dev/null +++ b/app/services/reverse/assets_delete.py @@ -0,0 +1,102 @@ +""" +Reverse interface: delete asset metadata. +""" + +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 + +DELETE_API = "https://grok.com/rest/assets-metadata" + + +class AssetsDeleteReverse: + """/rest/assets-metadata/{file_id} reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str, asset_id: str) -> Any: + """Delete asset from Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + asset_id: str, the ID of the asset to delete. + + Returns: + Any: The response from the request. + """ + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + assert_proxy = get_config("proxy.asset_proxy_url") + if assert_proxy: + proxies = {"http": assert_proxy, "https": assert_proxy} + else: + proxies = {"http": base_proxy, "https": base_proxy} + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com/files", + ) + + # Curl Config + timeout = get_config("asset.delete_timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.delete( + f"{DELETE_API}/{asset_id}", + headers=headers, + proxies=proxies, + timeout=timeout, + impersonate=browser, + ) + + if response.status_code != 200: + logger.error( + f"AssetsDeleteReverse: Delete failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"AssetsDeleteReverse: Delete failed, {response.status_code}", + details={"status": response.status_code}, + ) + + 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, "assets_delete_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"AssetsDeleteReverse: Delete failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"AssetsDeleteReverse: Delete failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + +__all__ = ["AssetsDeleteReverse"] diff --git a/app/services/reverse/assets_download.py b/app/services/reverse/assets_download.py new file mode 100644 index 0000000000000000000000000000000000000000..ec03794db00bdfeb756405f0ced121a741e15326 --- /dev/null +++ b/app/services/reverse/assets_download.py @@ -0,0 +1,132 @@ +""" +Reverse interface: download asset. +""" + +import urllib.parse +from typing import Any +from pathlib import Path +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 + +DOWNLOAD_API = "https://assets.grok.com" + +_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".webm": "video/webm", +} + + +class AssetsDownloadReverse: + """assets.grok.com/{path} reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str, file_path: str) -> Any: + """Download asset from Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + file_path: str, the path of the file to download. + + Returns: + Any: The response from the request. + """ + try: + # Normalize path + if not file_path.startswith("/"): + file_path = f"/{file_path}" + url = f"{DOWNLOAD_API}{file_path}" + + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + assert_proxy = get_config("proxy.asset_proxy_url") + if assert_proxy: + proxies = {"http": assert_proxy, "https": assert_proxy} + else: + proxies = {"http": base_proxy, "https": base_proxy} + + # Guess content type by extension for Accept/Sec-Fetch-Dest + content_type = _CONTENT_TYPES.get(Path(urllib.parse.urlparse(file_path).path).suffix.lower()) + + # Build headers + headers = build_headers( + cookie_token=token, + content_type=content_type, + origin="https://assets.grok.com", + referer="https://grok.com/", + ) + ## Align with browser download navigation headers + headers["Cache-Control"] = "no-cache" + headers["Pragma"] = "no-cache" + headers["Priority"] = "u=0, i" + headers["Sec-Fetch-Mode"] = "navigate" + headers["Sec-Fetch-User"] = "?1" + headers["Upgrade-Insecure-Requests"] = "1" + + # Curl Config + timeout = get_config("asset.download_timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.get( + url, + headers=headers, + proxies=proxies, + timeout=timeout, + allow_redirects=True, + impersonate=browser, + stream=True, + ) + + if response.status_code != 200: + logger.error( + f"AssetsDownloadReverse: Download failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"AssetsDownloadReverse: Download failed, {response.status_code}", + details={"status": response.status_code}, + ) + + 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, "assets_download_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"AssetsDownloadReverse: Download failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"AssetsDownloadReverse: Download failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["AssetsDownloadReverse"] diff --git a/app/services/reverse/assets_list.py b/app/services/reverse/assets_list.py new file mode 100644 index 0000000000000000000000000000000000000000..5c84fe9900e7e97c009062585fc4feaacbaeb0ff --- /dev/null +++ b/app/services/reverse/assets_list.py @@ -0,0 +1,104 @@ +""" +Reverse interface: list assets. +""" + +from typing import Any, Dict +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 + +LIST_API = "https://grok.com/rest/assets" + + +class AssetsListReverse: + """/rest/assets reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str, params: Dict[str, Any]) -> Any: + """List assets from Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + params: Dict[str, Any], the parameters for the request. + + Returns: + Any: The response from the request. + """ + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + assert_proxy = get_config("proxy.asset_proxy_url") + if assert_proxy: + proxies = {"http": assert_proxy, "https": assert_proxy} + else: + proxies = {"http": base_proxy, "https": base_proxy} + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com/files", + ) + + # Curl Config + timeout = get_config("asset.list_timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.get( + LIST_API, + headers=headers, + params=params, + proxies=proxies, + timeout=timeout, + impersonate=browser, + ) + + if response.status_code != 200: + logger.error( + f"AssetsListReverse: List failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"AssetsListReverse: List failed, {response.status_code}", + details={"status": response.status_code}, + ) + + 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, "assets_list_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"AssetsListReverse: List failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"AssetsListReverse: List failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["AssetsListReverse"] diff --git a/app/services/reverse/assets_upload.py b/app/services/reverse/assets_upload.py new file mode 100644 index 0000000000000000000000000000000000000000..b9d96731a2718bbfd355d61e11bb9ba5c19fe8d0 --- /dev/null +++ b/app/services/reverse/assets_upload.py @@ -0,0 +1,111 @@ +""" +Reverse interface: upload asset. +""" + +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 + +UPLOAD_API = "https://grok.com/rest/app-chat/upload-file" + + +class AssetsUploadReverse: + """/rest/app-chat/upload-file reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str, fileName: str, fileMimeType: str, content: str) -> Any: + """Upload asset to Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + fileName: str, the name of the file. + fileMimeType: str, the MIME type of the file. + content: str, the content of the file. + + Returns: + Any: The response from the request. + """ + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + assert_proxy = get_config("proxy.asset_proxy_url") + if assert_proxy: + proxies = {"http": assert_proxy, "https": assert_proxy} + else: + proxies = {"http": base_proxy, "https": base_proxy} + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com/", + ) + + # Build payload + payload = { + "fileName": fileName, + "fileMimeType": fileMimeType, + "content": content, + } + + # Curl Config + timeout = get_config("asset.upload_timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + UPLOAD_API, + headers=headers, + json=payload, + proxies=proxies, + timeout=timeout, + impersonate=browser, + ) + if response.status_code != 200: + logger.error( + f"AssetsUploadReverse: Upload failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"AssetsUploadReverse: Upload failed, {response.status_code}", + details={"status": response.status_code}, + ) + 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, "assets_upload_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"AssetsUploadReverse: Upload failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"AssetsUploadReverse: Upload failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["AssetsUploadReverse"] diff --git a/app/services/reverse/media_post.py b/app/services/reverse/media_post.py new file mode 100644 index 0000000000000000000000000000000000000000..6e70d539c0134b24e48159d70f2b645d18f0be03 --- /dev/null +++ b/app/services/reverse/media_post.py @@ -0,0 +1,120 @@ +""" +Reverse interface: media post create. +""" + +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 + +MEDIA_POST_API = "https://grok.com/rest/media/post/create" + + +class MediaPostReverse: + """/rest/media/post/create reverse interface.""" + + @staticmethod + async def request( + session: AsyncSession, + token: str, + mediaType: str, + mediaUrl: str, + prompt: str = "", + ) -> Any: + """Create media post in Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + mediaType: str, the media type. + mediaUrl: str, the media URL. + + 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 = {"mediaType": mediaType} + if mediaUrl: + payload["mediaUrl"] = mediaUrl + if prompt: + payload["prompt"] = prompt + + # Curl Config + timeout = get_config("video.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + MEDIA_POST_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"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, "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, "media_post_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"MediaPostReverse: Media post create failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"MediaPostReverse: Media post create failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["MediaPostReverse"] diff --git a/app/services/reverse/nsfw_mgmt.py b/app/services/reverse/nsfw_mgmt.py new file mode 100644 index 0000000000000000000000000000000000000000..ca5afc468f61969c13bf1a503a58a7bf3c872684 --- /dev/null +++ b/app/services/reverse/nsfw_mgmt.py @@ -0,0 +1,126 @@ +""" +Reverse interface: NSFW feature controls (gRPC-Web). +""" + +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.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status +from app.services.reverse.utils.grpc import GrpcClient, GrpcStatus + +NSFW_MGMT_API = "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls" + + +class NsfwMgmtReverse: + """/auth_mgmt.AuthManagement/UpdateUserFeatureControls reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str) -> GrpcStatus: + """Enable NSFW feature control via gRPC-Web. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + + Returns: + GrpcStatus: Parsed gRPC status. + """ + 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, + origin="https://grok.com", + referer="https://grok.com/?_s=data", + ) + headers["Content-Type"] = "application/grpc-web+proto" + headers["Accept"] = "*/*" + headers["Sec-Fetch-Dest"] = "empty" + headers["x-grpc-web"] = "1" + headers["x-user-agent"] = "connect-es/2.1.1" + headers["Cache-Control"] = "no-cache" + headers["Pragma"] = "no-cache" + + # Build payload + name = "always_show_nsfw_content".encode("utf-8") + inner = b"\x0a" + bytes([len(name)]) + name + protobuf = b"\x0a\x02\x10\x01\x12" + bytes([len(inner)]) + inner + payload = GrpcClient.encode_payload(protobuf) + + # Curl Config + timeout = get_config("nsfw.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + NSFW_MGMT_API, + headers=headers, + data=payload, + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + logger.error( + f"NsfwMgmtReverse: Request failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"NsfwMgmtReverse: Request failed, {response.status_code}", + details={"status": response.status_code}, + ) + + logger.debug(f"NsfwMgmtReverse: Request successful, {response.status_code}") + + return response + + response = await retry_on_status(_do_request) + + _, trailers = GrpcClient.parse_response( + response.content, + content_type=response.headers.get("content-type"), + headers=response.headers, + ) + grpc_status = GrpcClient.get_status(trailers) + + if grpc_status.code not in (-1, 0): + raise UpstreamException( + message=f"NsfwMgmtReverse: gRPC failed, {grpc_status.code}", + details={ + "status": grpc_status.http_equiv, + "grpc_status": grpc_status.code, + "grpc_message": grpc_status.message, + }, + ) + + return grpc_status + + 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) + raise + + # Handle other non-upstream exceptions + logger.error( + f"NsfwMgmtReverse: Request failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"NsfwMgmtReverse: Request failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["NsfwMgmtReverse"] diff --git a/app/services/reverse/rate_limits.py b/app/services/reverse/rate_limits.py new file mode 100644 index 0000000000000000000000000000000000000000..10e6d71f6f4f86c55d06b031c2ab4ef3a696eadf --- /dev/null +++ b/app/services/reverse/rate_limits.py @@ -0,0 +1,100 @@ +""" +Reverse interface: rate limits. +""" + +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.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status + +RATE_LIMITS_API = "https://grok.com/rest/rate-limits" + + +class RateLimitsReverse: + """/rest/rate-limits reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str) -> Any: + """Fetch rate limits from Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + + 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 = { + "requestKind": "DEFAULT", + "modelName": "grok-4-1-thinking-1129", + } + + # Curl Config + timeout = get_config("usage.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + RATE_LIMITS_API, + headers=headers, + data=orjson.dumps(payload), + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + logger.error( + f"RateLimitsReverse: Request failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"RateLimitsReverse: Request failed, {response.status_code}", + details={"status": response.status_code}, + ) + + 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) + raise + + # Handle other non-upstream exceptions + logger.error( + f"RateLimitsReverse: Request failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"RateLimitsReverse: Request failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["RateLimitsReverse"] diff --git a/app/services/reverse/set_birth.py b/app/services/reverse/set_birth.py new file mode 100644 index 0000000000000000000000000000000000000000..d76c4c6074b6284b1f39e0c566ba8408fa67fe6c --- /dev/null +++ b/app/services/reverse/set_birth.py @@ -0,0 +1,111 @@ +""" +Reverse interface: set birth date. +""" + +import datetime +import random +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.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status + +SET_BIRTH_API = "https://grok.com/rest/auth/set-birth-date" + + +class SetBirthReverse: + """/rest/auth/set-birth-date reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str) -> Any: + """Set birth date in Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + + 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/?_s=home", + ) + + # Build payload + today = datetime.date.today() + birth_year = today.year - random.randint(20, 48) + birth_month = random.randint(1, 12) + birth_day = random.randint(1, 28) + hour = random.randint(0, 23) + minute = random.randint(0, 59) + second = random.randint(0, 59) + microsecond = random.randint(0, 999) + payload = { + "birthDate": f"{birth_year:04d}-{birth_month:02d}-{birth_day:02d}" + f"T{hour:02d}:{minute:02d}:{second:02d}.{microsecond:03d}Z" + } + + # Curl Config + timeout = get_config("nsfw.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + SET_BIRTH_API, + headers=headers, + json=payload, + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code not in (200, 204): + logger.error( + f"SetBirthReverse: Request failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"SetBirthReverse: Request failed, {response.status_code}", + details={"status": response.status_code}, + ) + + logger.debug(f"SetBirthReverse: Request successful, {response.status_code}") + + 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) + raise + + # Handle other non-upstream exceptions + logger.error( + f"SetBirthReverse: Request failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"SetBirthReverse: Request failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["SetBirthReverse"] diff --git a/app/services/reverse/utils/grpc.py b/app/services/reverse/utils/grpc.py new file mode 100644 index 0000000000000000000000000000000000000000..39eb678756f8776c793b33c0b3a953df5a0daa94 --- /dev/null +++ b/app/services/reverse/utils/grpc.py @@ -0,0 +1,185 @@ +""" +gRPC-Web helpers for reverse interfaces. +""" + +import base64 +import json +import re +import struct +from dataclasses import dataclass +from typing import Dict, List, Mapping, Optional, Tuple +from urllib.parse import unquote + +from app.core.logger import logger + +# Base64 正则 +B64_RE = re.compile(rb"^[A-Za-z0-9+/=\r\n]+$") + + +@dataclass(frozen=True) +class GrpcStatus: + code: int + message: str = "" + + @property + def ok(self) -> bool: + return self.code == 0 + + @property + def http_equiv(self) -> int: + mapping = { + 0: 200, + 16: 401, + 7: 403, + 8: 429, + 4: 504, + 14: 503, + } + return mapping.get(self.code, 502) + + +class GrpcClient: + """gRPC-Web helpers wrapper.""" + + @staticmethod + def _safe_headers(headers: Optional[Mapping[str, str]]) -> Dict[str, str]: + if not headers: + return {} + safe: Dict[str, str] = {} + for k, v in headers.items(): + if k.lower() in ("set-cookie", "cookie", "authorization"): + safe[k] = "" + else: + safe[k] = str(v) + return safe + + @staticmethod + def _b64(data: bytes) -> str: + return base64.b64encode(data).decode() + + @staticmethod + def encode_payload(data: bytes) -> bytes: + """Encode gRPC-Web data frame.""" + return b"\x00" + struct.pack(">I", len(data)) + data + + @staticmethod + def _maybe_decode_grpc_web_text(body: bytes, content_type: Optional[str]) -> bytes: + ct = (content_type or "").lower() + if "grpc-web-text" in ct: + compact = b"".join(body.split()) + return base64.b64decode(compact, validate=False) + + head = body[: min(len(body), 2048)] + if head and B64_RE.fullmatch(head): + compact = b"".join(body.split()) + try: + return base64.b64decode(compact, validate=True) + except Exception: + return body + return body + + @staticmethod + def _parse_trailer_block(payload: bytes) -> Dict[str, str]: + text = payload.decode("utf-8", errors="replace") + lines = [ln for ln in re.split(r"\r\n|\n", text) if ln] + + trailers: Dict[str, str] = {} + for ln in lines: + if ":" not in ln: + continue + k, v = ln.split(":", 1) + trailers[k.strip().lower()] = v.strip() + + if "grpc-message" in trailers: + trailers["grpc-message"] = unquote(trailers["grpc-message"]) + + return trailers + + @classmethod + def parse_response( + cls, + body: bytes, + content_type: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + ) -> Tuple[List[bytes], Dict[str, str]]: + decoded = cls._maybe_decode_grpc_web_text(body, content_type) + + messages: List[bytes] = [] + trailers: Dict[str, str] = {} + + i = 0 + n = len(decoded) + while i < n: + if n - i < 5: + break + + flag = decoded[i] + length = int.from_bytes(decoded[i + 1 : i + 5], "big") + i += 5 + + if n - i < length: + break + + payload = decoded[i : i + length] + i += length + + if flag & 0x80: + trailers.update(cls._parse_trailer_block(payload)) + elif flag & 0x01: + raise ValueError("grpc-web compressed flag not supported") + else: + messages.append(payload) + + if headers: + lower = {k.lower(): v for k, v in headers.items()} + if "grpc-status" in lower and "grpc-status" not in trailers: + trailers["grpc-status"] = str(lower["grpc-status"]).strip() + if "grpc-message" in lower and "grpc-message" not in trailers: + trailers["grpc-message"] = unquote(str(lower["grpc-message"]).strip()) + + # Log full response details on gRPC error + raw_status = str(trailers.get("grpc-status", "")).strip() + try: + status_code = int(raw_status) + except Exception: + status_code = -1 + + if status_code not in (0, -1): + try: + payload = { + "grpc_status": status_code, + "grpc_message": trailers.get("grpc-message", ""), + "content_type": content_type or "", + "headers": cls._safe_headers(headers), + "trailers": trailers, + "messages_b64": [cls._b64(m) for m in messages], + "body_b64": cls._b64(body), + } + logger.error( + "gRPC response error: {}", + json.dumps(payload, ensure_ascii=False), + extra={"error_type": "GrpcError"}, + ) + except Exception as e: + logger.error( + f"gRPC response error: failed to log payload ({e})", + extra={"error_type": "GrpcError"}, + ) + + return messages, trailers + + @staticmethod + def get_status(trailers: Mapping[str, str]) -> GrpcStatus: + raw = str(trailers.get("grpc-status", "")).strip() + msg = str(trailers.get("grpc-message", "")).strip() + try: + code = int(raw) + except Exception: + code = -1 + return GrpcStatus(code=code, message=msg) + + +__all__ = [ + "GrpcStatus", + "GrpcClient", +] diff --git a/app/services/reverse/utils/headers.py b/app/services/reverse/utils/headers.py new file mode 100644 index 0000000000000000000000000000000000000000..d1c22a29a15369966a1ddbc152cf67df5b30072a --- /dev/null +++ b/app/services/reverse/utils/headers.py @@ -0,0 +1,234 @@ +"""Shared header builders for reverse interfaces.""" + +import re +import uuid +import orjson +from urllib.parse import urlparse +from typing import Dict, Optional + +from app.core.logger import logger +from app.core.config import get_config +from app.services.reverse.utils.statsig import StatsigGenerator + + +def build_sso_cookie(sso_token: str) -> str: + """ + Build SSO Cookie string. + + Args: + sso_token: str, the SSO token. + + Returns: + str: The SSO Cookie string. + """ + # Format + sso_token = sso_token[4:] if sso_token.startswith("sso=") else sso_token + + # SSO Cookie + cookie = f"sso={sso_token}; sso-rw={sso_token}" + + # CF Cookies + cf_cookies = get_config("proxy.cf_cookies") or "" + if not cf_cookies: + cf_clearance = get_config("proxy.cf_clearance") + if cf_clearance: + cf_cookies = f"cf_clearance={cf_clearance}" + if cf_cookies: + if cookie and not cookie.endswith(";"): + cookie += "; " + cookie += cf_cookies + + return cookie + + +def _extract_major_version(browser: Optional[str], user_agent: Optional[str]) -> Optional[str]: + if browser: + match = re.search(r"(\d{2,3})", browser) + if match: + return match.group(1) + if user_agent: + for pattern in [r"Edg/(\d+)", r"Chrome/(\d+)", r"Chromium/(\d+)"]: + match = re.search(pattern, user_agent) + if match: + return match.group(1) + return None + + +def _detect_platform(user_agent: str) -> Optional[str]: + ua = user_agent.lower() + if "windows" in ua: + return "Windows" + if "mac os x" in ua or "macintosh" in ua: + return "macOS" + if "android" in ua: + return "Android" + if "iphone" in ua or "ipad" in ua: + return "iOS" + if "linux" in ua: + return "Linux" + return None + + +def _detect_arch(user_agent: str) -> Optional[str]: + ua = user_agent.lower() + if "aarch64" in ua or "arm" in ua: + return "arm" + if "x86_64" in ua or "x64" in ua or "win64" in ua or "intel" in ua: + return "x86" + return None + + +def _build_client_hints(browser: Optional[str], user_agent: Optional[str]) -> Dict[str, str]: + browser = (browser or "").strip().lower() + user_agent = user_agent or "" + ua = user_agent.lower() + + is_edge = "edge" in browser or "edg" in ua + is_brave = "brave" in browser + is_chromium = any(key in browser for key in ["chrome", "chromium", "edge", "brave"]) or ( + "chrome" in ua or "chromium" in ua or "edg" in ua + ) + is_firefox = "firefox" in ua or "firefox" in browser + is_safari = ("safari" in ua and "chrome" not in ua and "chromium" not in ua and "edg" not in ua) or "safari" in browser + + if not is_chromium or is_firefox or is_safari: + return {} + + version = _extract_major_version(browser, user_agent) + if not version: + return {} + + if is_edge: + brand = "Microsoft Edge" + elif "chromium" in browser: + brand = "Chromium" + elif is_brave: + brand = "Brave" + else: + brand = "Google Chrome" + + sec_ch_ua = ( + f"\"{brand}\";v=\"{version}\", " + f"\"Chromium\";v=\"{version}\", " + "\"Not(A:Brand\";v=\"24\"" + ) + + platform = _detect_platform(user_agent) + arch = _detect_arch(user_agent) + mobile = "?1" if ("mobile" in ua or platform in ("Android", "iOS")) else "?0" + + hints = { + "Sec-Ch-Ua": sec_ch_ua, + "Sec-Ch-Ua-Mobile": mobile, + } + if platform: + hints["Sec-Ch-Ua-Platform"] = f"\"{platform}\"" + if arch: + hints["Sec-Ch-Ua-Arch"] = arch + hints["Sec-Ch-Ua-Bitness"] = "64" + hints["Sec-Ch-Ua-Model"] = "" if mobile == "?0" else "" + return hints + + +def build_ws_headers(token: Optional[str] = None, origin: Optional[str] = None, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + Build headers for WebSocket requests. + + Args: + token: Optional[str], the SSO token for Cookie. Defaults to None. + origin: Optional[str], the Origin value. Defaults to "https://grok.com" if not provided. + extra: Optional[Dict[str, str]], extra headers to merge. Defaults to None. + + Returns: + Dict[str, str]: The headers dictionary. + """ + user_agent = get_config("proxy.user_agent") + headers = { + "Origin": origin or "https://grok.com", + "User-Agent": user_agent, + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + } + + client_hints = _build_client_hints(get_config("proxy.browser"), user_agent) + if client_hints: + headers.update(client_hints) + + if token: + headers["Cookie"] = build_sso_cookie(token) + + if extra: + headers.update(extra) + + return headers + + +def build_headers(cookie_token: str, content_type: Optional[str] = None, origin: Optional[str] = None, referer: Optional[str] = None) -> Dict[str, str]: + """ + Build headers for reverse interfaces. + + Args: + cookie_token: str, the SSO token. + content_type: Optional[str], the Content-Type value. + origin: Optional[str], the Origin value. Defaults to "https://grok.com" if not provided. + referer: Optional[str], the Referer value. Defaults to "https://grok.com/" if not provided. + + Returns: + Dict[str, str]: The headers dictionary. + """ + user_agent = get_config("proxy.user_agent") + headers = { + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Baggage": "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", + "Origin": origin or "https://grok.com", + "Priority": "u=1, i", + "Referer": referer or "https://grok.com/", + "Sec-Fetch-Mode": "cors", + "User-Agent": user_agent, + } + + client_hints = _build_client_hints(get_config("proxy.browser"), user_agent) + if client_hints: + headers.update(client_hints) + + # Cookie + headers["Cookie"] = build_sso_cookie(cookie_token) + + # Content-Type and Accept/Sec-Fetch-Dest + if content_type and content_type == "application/json": + headers["Content-Type"] = "application/json" + headers["Accept"] = "*/*" + headers["Sec-Fetch-Dest"] = "empty" + elif content_type in ["image/jpeg", "image/png", "video/mp4", "video/webm"]: + headers["Content-Type"] = content_type + headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" + headers["Sec-Fetch-Dest"] = "document" + else: + headers["Content-Type"] = "application/json" + headers["Accept"] = "*/*" + headers["Sec-Fetch-Dest"] = "empty" + + # Sec-Fetch-Site + origin_domain = urlparse(headers.get("Origin", "")).hostname + referer_domain = urlparse(headers.get("Referer", "")).hostname + if origin_domain and referer_domain and origin_domain == referer_domain: + headers["Sec-Fetch-Site"] = "same-origin" + else: + headers["Sec-Fetch-Site"] = "same-site" + + # X-Statsig-ID and X-XAI-Request-ID + headers["x-statsig-id"] = StatsigGenerator.gen_id() + headers["x-xai-request-id"] = str(uuid.uuid4()) + + # Print headers without Cookie + safe_headers = dict(headers) + if "Cookie" in safe_headers: + safe_headers["Cookie"] = "" + logger.debug(f"Built headers: {orjson.dumps(safe_headers).decode()}") + + return headers + + +__all__ = ["build_headers", "build_sso_cookie", "build_ws_headers"] diff --git a/app/services/reverse/utils/retry.py b/app/services/reverse/utils/retry.py new file mode 100644 index 0000000000000000000000000000000000000000..971eab05b656493680758a718852caa9d1cb29e1 --- /dev/null +++ b/app/services/reverse/utils/retry.py @@ -0,0 +1,233 @@ +""" +Reverse retry utilities. +""" + +import asyncio +import inspect +import random +from typing import Callable, Any, Optional + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import UpstreamException + + +class RetryContext: + """Retry context.""" + + def __init__(self): + self.attempt = 0 + self.max_retry = int(get_config("retry.max_retry")) + self.retry_codes = get_config("retry.retry_status_codes") + self.last_error = None + self.last_status = None + self.total_delay = 0.0 + self.retry_budget = float(get_config("retry.retry_budget")) + + # Backoff parameters + self.backoff_base = float(get_config("retry.retry_backoff_base")) + self.backoff_factor = float(get_config("retry.retry_backoff_factor")) + self.backoff_max = float(get_config("retry.retry_backoff_max")) + + # Decorrelated jitter state + self._last_delay = self.backoff_base + + def should_retry(self, status_code: int) -> bool: + """Check if should retry.""" + if self.attempt >= self.max_retry: + return False + if status_code not in self.retry_codes: + return False + if self.total_delay >= self.retry_budget: + return False + return True + + def record_error(self, status_code: int, error: Exception): + """Record error information.""" + self.last_status = status_code + self.last_error = error + self.attempt += 1 + + def calculate_delay(self, status_code: int, retry_after: Optional[float] = None) -> float: + """ + Calculate backoff delay time. + + Args: + status_code: HTTP status code + retry_after: Retry-After header value (seconds) + + Returns: + Delay time (seconds) + """ + # Use Retry-After if available + if retry_after is not None and retry_after > 0: + delay = min(retry_after, self.backoff_max) + self._last_delay = delay + return delay + + # Use decorrelated jitter for 429 + if status_code == 429: + # decorrelated jitter: delay = random(base, last_delay * 3) + delay = random.uniform(self.backoff_base, self._last_delay * 3) + delay = min(delay, self.backoff_max) + self._last_delay = delay + return delay + + # Use exponential backoff + full jitter for other status codes + exp_delay = self.backoff_base * (self.backoff_factor**self.attempt) + delay = random.uniform(0, min(exp_delay, self.backoff_max)) + return delay + + def record_delay(self, delay: float): + """Record delay time.""" + self.total_delay += delay + + +def extract_retry_after(error: Exception) -> Optional[float]: + """ + Extract Retry-After value from exception. + + Args: + error: Exception object + + Returns: + Retry-After value (seconds), or None + """ + if not isinstance(error, UpstreamException): + return None + + details = error.details or {} + + # Try to get Retry-After from details + retry_after = details.get("retry_after") + if retry_after is not None: + try: + return float(retry_after) + except (ValueError, TypeError): + pass + + # Try to get Retry-After from headers + headers = details.get("headers", {}) + if isinstance(headers, dict): + retry_after = headers.get("Retry-After") or headers.get("retry-after") + if retry_after is not None: + try: + return float(retry_after) + except (ValueError, TypeError): + pass + + return None + + +async def retry_on_status( + func: Callable, + *args, + extract_status: Callable[[Exception], Optional[int]] = None, + on_retry: Callable[[int, int, Exception, float], Any] = None, + **kwargs, +) -> Any: + """ + Generic retry function. + + Args: + func: Retry function + *args: Function arguments + extract_status: Function to extract status code from exception + on_retry: Callback function for retry (attempt, status_code, error, delay). + Can be sync or async. + **kwargs: Function keyword arguments + + Returns: + Function execution result + + Raises: + Last failed exception + """ + ctx = RetryContext() + + # Status code extractor + if extract_status is None: + + def extract_status(e: Exception) -> Optional[int]: + if isinstance(e, UpstreamException): + # Try to get status code from details, fallback to status_code attribute + if e.details and "status" in e.details: + return e.details["status"] + return getattr(e, "status_code", None) + return None + + while ctx.attempt <= ctx.max_retry: + try: + result = await func(*args, **kwargs) + + # Record log + if ctx.attempt > 0: + logger.info( + f"Retry succeeded after {ctx.attempt} attempts, " + f"total delay: {ctx.total_delay:.2f}s" + ) + + return result + + except Exception as e: + # Extract status code + status_code = extract_status(e) + + if status_code is None: + # Error cannot be identified as retryable + logger.error(f"Non-retryable error: {e}") + raise + + # Record error + ctx.record_error(status_code, e) + + # Check if should retry + if ctx.should_retry(status_code): + # Extract Retry-After + retry_after = extract_retry_after(e) + + # Calculate delay + delay = ctx.calculate_delay(status_code, retry_after) + + # Check if exceeds budget + if ctx.total_delay + delay > ctx.retry_budget: + logger.warning( + f"Retry budget exhausted: {ctx.total_delay:.2f}s + {delay:.2f}s > {ctx.retry_budget}s" + ) + raise + + ctx.record_delay(delay) + + logger.warning( + f"Retry {ctx.attempt}/{ctx.max_retry} for status {status_code}, " + f"waiting {delay:.2f}s (total: {ctx.total_delay:.2f}s)" + + (f", Retry-After: {retry_after}s" if retry_after else "") + ) + + # Callback + if on_retry: + result = on_retry(ctx.attempt, status_code, e, delay) + if inspect.isawaitable(result): + await result + + await asyncio.sleep(delay) + continue + else: + # Not retryable or retry budget exhausted + if status_code in ctx.retry_codes: + logger.error( + f"Retry exhausted after {ctx.attempt} attempts, " + f"last status: {status_code}, total delay: {ctx.total_delay:.2f}s" + ) + else: + logger.error(f"Non-retryable status code: {status_code}") + + # Raise last failed exception + raise + + +__all__ = [ + "RetryContext", + "retry_on_status", + "extract_retry_after", +] diff --git a/app/services/reverse/utils/session.py b/app/services/reverse/utils/session.py new file mode 100644 index 0000000000000000000000000000000000000000..a2b6eb0a0d27605f5e79fc6cdc8cad5535aebd87 --- /dev/null +++ b/app/services/reverse/utils/session.py @@ -0,0 +1,90 @@ +""" +Resettable session wrapper for reverse requests. +""" + +import asyncio +from typing import Any, Iterable, Optional + +from curl_cffi.requests import AsyncSession + +from app.core.config import get_config +from app.core.logger import logger + + +class ResettableSession: + """AsyncSession wrapper that resets connection on specific HTTP status codes.""" + + def __init__( + self, + *, + reset_on_status: Optional[Iterable[int]] = None, + **session_kwargs: Any, + ): + self._session_kwargs = dict(session_kwargs) + if not self._session_kwargs.get("impersonate"): + browser = get_config("proxy.browser") + if browser: + self._session_kwargs["impersonate"] = browser + if reset_on_status is None: + reset_on_status = [403] + if isinstance(reset_on_status, int): + reset_on_status = [reset_on_status] + self._reset_on_status = ( + {int(code) for code in reset_on_status} if reset_on_status else set() + ) + self._reset_requested = False + self._reset_lock = asyncio.Lock() + self._session = AsyncSession(**self._session_kwargs) + + async def _maybe_reset(self) -> None: + if not self._reset_requested: + return + async with self._reset_lock: + if not self._reset_requested: + return + self._reset_requested = False + old_session = self._session + self._session = AsyncSession(**self._session_kwargs) + try: + await old_session.close() + except Exception: + pass + logger.debug("ResettableSession: session reset") + + async def _request(self, method: str, *args: Any, **kwargs: Any): + await self._maybe_reset() + response = await getattr(self._session, method)(*args, **kwargs) + if self._reset_on_status and response.status_code in self._reset_on_status: + self._reset_requested = True + return response + + async def get(self, *args: Any, **kwargs: Any): + return await self._request("get", *args, **kwargs) + + async def post(self, *args: Any, **kwargs: Any): + return await self._request("post", *args, **kwargs) + + async def reset(self) -> None: + self._reset_requested = True + await self._maybe_reset() + + async def close(self) -> None: + if self._session is None: + return + try: + await self._session.close() + finally: + self._session = None + self._reset_requested = False + + async def __aenter__(self) -> "ResettableSession": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._session, name) + + +__all__ = ["ResettableSession"] diff --git a/app/services/reverse/utils/statsig.py b/app/services/reverse/utils/statsig.py new file mode 100644 index 0000000000000000000000000000000000000000..485885f1a7a985767a97ee8a859489e7c531abb4 --- /dev/null +++ b/app/services/reverse/utils/statsig.py @@ -0,0 +1,56 @@ +""" +Statsig ID generator for reverse interfaces. +""" + +import base64 +import random +import string + +from app.core.logger import logger +from app.core.config import get_config + + +class StatsigGenerator: + """Statsig ID generator for reverse interfaces.""" + + @staticmethod + def _rand(length: int, alphanumeric: bool = False) -> str: + """Generate random string.""" + chars = ( + string.ascii_lowercase + string.digits + if alphanumeric + else string.ascii_lowercase + ) + return "".join(random.choices(chars, k=length)) + + @staticmethod + def gen_id() -> str: + """ + Generate Statsig ID. + + Returns: + Base64 encoded ID. + """ + dynamic = get_config("app.dynamic_statsig") + + # Dynamic Statsig ID + if dynamic: + logger.debug("Generating dynamic Statsig ID") + + if random.choice([True, False]): + rand = StatsigGenerator._rand(5, alphanumeric=True) + message = f"e:TypeError: Cannot read properties of null (reading 'children['{rand}']')" + else: + rand = StatsigGenerator._rand(10) + message = ( + f"e:TypeError: Cannot read properties of undefined (reading '{rand}')" + ) + + return base64.b64encode(message.encode()).decode() + + # Static Statsig ID + logger.debug("Generating static Statsig ID") + return "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk=" + + +__all__ = ["StatsigGenerator"] diff --git a/app/services/reverse/utils/websocket.py b/app/services/reverse/utils/websocket.py new file mode 100644 index 0000000000000000000000000000000000000000..9f8907e2535da9b122840560996a74d9a5227f9d --- /dev/null +++ b/app/services/reverse/utils/websocket.py @@ -0,0 +1,145 @@ +""" +WebSocket helpers for reverse interfaces. +""" + +import ssl +import certifi +import aiohttp +from aiohttp_socks import ProxyConnector +from typing import Mapping, Optional, Any +from urllib.parse import urlparse + +from app.core.logger import logger +from app.core.config import get_config + + +def _default_ssl_context() -> ssl.SSLContext: + context = ssl.create_default_context() + context.load_verify_locations(certifi.where()) + return context + + +def _normalize_socks_proxy(proxy_url: str) -> tuple[str, Optional[bool]]: + scheme = urlparse(proxy_url).scheme.lower() + rdns: Optional[bool] = None + base_scheme = scheme + + if scheme == "socks5h": + base_scheme = "socks5" + rdns = True + elif scheme == "socks4a": + base_scheme = "socks4" + rdns = True + + if base_scheme != scheme: + proxy_url = proxy_url.replace(f"{scheme}://", f"{base_scheme}://", 1) + + return proxy_url, rdns + + +def resolve_proxy(proxy_url: Optional[str] = None, ssl_context: ssl.SSLContext = _default_ssl_context()) -> tuple[aiohttp.BaseConnector, Optional[str]]: + """Resolve proxy connector. + + Args: + proxy_url: Optional[str], the proxy URL. Defaults to None. + ssl_context: ssl.SSLContext, the SSL context. Defaults to _default_ssl_context(). + + Returns: + tuple[aiohttp.BaseConnector, Optional[str]]: The proxy connector and the proxy URL. + """ + if not proxy_url: + return aiohttp.TCPConnector(ssl=ssl_context), None + + scheme = urlparse(proxy_url).scheme.lower() + if scheme.startswith("socks"): + normalized, rdns = _normalize_socks_proxy(proxy_url) + logger.info(f"Using SOCKS proxy: {proxy_url}") + try: + if rdns is not None: + return ( + ProxyConnector.from_url(normalized, rdns=rdns, ssl=ssl_context), + None, + ) + except TypeError: + return ProxyConnector.from_url(normalized, ssl=ssl_context), None + return ProxyConnector.from_url(normalized, ssl=ssl_context), None + + logger.info(f"Using HTTP proxy: {proxy_url}") + return aiohttp.TCPConnector(ssl=ssl_context), proxy_url + + +class WebSocketConnection: + """WebSocket connection wrapper.""" + + def __init__(self, session: aiohttp.ClientSession, ws: aiohttp.ClientWebSocketResponse) -> None: + self.session = session + self.ws = ws + + async def close(self) -> None: + if not self.ws.closed: + await self.ws.close() + await self.session.close() + + async def __aenter__(self) -> aiohttp.ClientWebSocketResponse: + return self.ws + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() + + +class WebSocketClient: + """WebSocket client with proxy support.""" + + def __init__(self, proxy: Optional[str] = None) -> None: + self._proxy_override = proxy + self._ssl_context = _default_ssl_context() + + async def connect( + self, + url: str, + headers: Optional[Mapping[str, str]] = None, + timeout: Optional[float] = None, + ws_kwargs: Optional[Mapping[str, object]] = None, + ) -> WebSocketConnection: + """Connect to the WebSocket. + + Args: + url: str, the URL to connect to. + headers: Optional[Mapping[str, str]], the headers to send. Defaults to None. + ws_kwargs: Optional[Mapping[str, object]], extra ws_connect kwargs. Defaults to None. + + Returns: + WebSocketConnection: The WebSocket connection. + """ + # Resolve proxy dynamically from config if not overridden + proxy_url = self._proxy_override or get_config("proxy.base_proxy_url") + connector, resolved_proxy = resolve_proxy(proxy_url, self._ssl_context) + logger.debug(f"WebSocket connect: proxy_url={proxy_url}, resolved_proxy={resolved_proxy}, connector={type(connector).__name__}") + + # Build client timeout + total_timeout = ( + float(timeout) + if timeout is not None + else float(get_config("voice.timeout") or 120) + ) + client_timeout = aiohttp.ClientTimeout(total=total_timeout) + + # Create session + session = aiohttp.ClientSession(connector=connector, timeout=client_timeout) + try: + # Cast to Any to avoid Pylance errors with **extra_kwargs + extra_kwargs: dict[str, Any] = dict(ws_kwargs or {}) + ws = await session.ws_connect( + url, + headers=headers, + proxy=resolved_proxy, + ssl=self._ssl_context, + **extra_kwargs, + ) + return WebSocketConnection(session, ws) + except Exception: + await session.close() + raise + + +__all__ = ["WebSocketClient", "WebSocketConnection", "resolve_proxy"] diff --git a/app/services/reverse/video_upscale.py b/app/services/reverse/video_upscale.py new file mode 100644 index 0000000000000000000000000000000000000000..f6c70e17c59b27d8fb3d688b56763a613e1af989 --- /dev/null +++ b/app/services/reverse/video_upscale.py @@ -0,0 +1,109 @@ +""" +Reverse interface: video upscale. +""" + +import orjson +from typing import Any +from curl_cffi.requests import AsyncSession + +from app.core.logger import logger +from app.core.config import get_config +from app.core.exceptions import UpstreamException +from app.services.token.service import TokenService +from app.services.reverse.utils.headers import build_headers +from app.services.reverse.utils.retry import retry_on_status + +VIDEO_UPSCALE_API = "https://grok.com/rest/media/video/upscale" + + +class VideoUpscaleReverse: + """/rest/media/video/upscale reverse interface.""" + + @staticmethod + async def request(session: AsyncSession, token: str, video_id: str) -> Any: + """Upscale video (image upscaling endpoint) in Grok. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + video_id: str, the video id. + + Returns: + Any: The response from the request. + """ + try: + # Get proxies + base_proxy = get_config("proxy.base_proxy_url") + proxies = {"http": base_proxy, "https": base_proxy} if base_proxy else None + + # Build headers + headers = build_headers( + cookie_token=token, + content_type="application/json", + origin="https://grok.com", + referer="https://grok.com", + ) + + # Build payload + payload = {"videoId": video_id} + + # Curl Config + timeout = get_config("video.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + VIDEO_UPSCALE_API, + headers=headers, + data=orjson.dumps(payload), + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + content = "" + try: + content = await response.text() + except Exception: + pass + logger.error( + f"VideoUpscaleReverse: Upscale failed, {response.status_code}", + extra={"error_type": "UpstreamException"}, + ) + raise UpstreamException( + message=f"VideoUpscaleReverse: Upscale failed, {response.status_code}", + details={"status": response.status_code, "body": content}, + ) + + return response + + return await retry_on_status(_do_request) + + except Exception as e: + # Handle upstream exception + if isinstance(e, UpstreamException): + status = None + if e.details and "status" in e.details: + status = e.details["status"] + else: + status = getattr(e, "status_code", None) + if status == 401: + try: + await TokenService.record_fail(token, status, "video_upscale_auth_failed") + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"VideoUpscaleReverse: Upscale failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"VideoUpscaleReverse: Upscale failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +__all__ = ["VideoUpscaleReverse"] diff --git a/app/services/reverse/ws_imagine.py b/app/services/reverse/ws_imagine.py new file mode 100644 index 0000000000000000000000000000000000000000..c123324dabeff0280b0c5fc40f1fc0f4e7ad0c16 --- /dev/null +++ b/app/services/reverse/ws_imagine.py @@ -0,0 +1,326 @@ +""" +Reverse interface: Imagine WebSocket image stream. +""" + +import asyncio +import orjson +import re +import time +import uuid +from typing import AsyncGenerator, Dict, Optional + +import aiohttp + +from app.core.config import get_config +from app.core.logger import logger +from app.services.reverse.utils.headers import build_ws_headers +from app.services.reverse.utils.websocket import WebSocketClient + +WS_IMAGINE_URL = "wss://grok.com/ws/imagine/listen" + + +class _BlockedError(Exception): + pass + + +class ImagineWebSocketReverse: + """Imagine WebSocket reverse interface.""" + + def __init__(self) -> None: + self._url_pattern = re.compile(r"/images/([a-f0-9-]+)\.(png|jpg|jpeg)") + self._client = WebSocketClient() + + def _parse_image_url(self, url: str) -> tuple[Optional[str], Optional[str]]: + match = self._url_pattern.search(url or "") + if not match: + return None, None + return match.group(1), match.group(2).lower() + + def _is_final_image(self, url: str, blob_size: int, final_min_bytes: int) -> bool: + # Final image must satisfy byte-size threshold to avoid tiny preview + # images being treated as final outputs. + return blob_size >= final_min_bytes + + def _classify_image(self, url: str, blob: str, final_min_bytes: int, medium_min_bytes: int) -> Optional[Dict[str, object]]: + if not url or not blob: + return None + + image_id, ext = self._parse_image_url(url) + image_id = image_id or uuid.uuid4().hex + blob_size = len(blob) + is_final = self._is_final_image(url, blob_size, final_min_bytes) + + stage = ( + "final" + if is_final + else ("medium" if blob_size > medium_min_bytes else "preview") + ) + + return { + "type": "image", + "image_id": image_id, + "ext": ext, + "stage": stage, + "blob": blob, + "blob_size": blob_size, + "url": url, + "is_final": is_final, + } + + def _build_request_message(self, request_id: str, prompt: str, aspect_ratio: str, enable_nsfw: bool) -> Dict[str, object]: + return { + "type": "conversation.item.create", + "timestamp": int(time.time() * 1000), + "item": { + "type": "message", + "content": [ + { + "requestId": request_id, + "text": prompt, + "type": "input_text", + "properties": { + "section_count": 0, + "is_kids_mode": False, + "enable_nsfw": enable_nsfw, + "skip_upsampler": False, + "is_initial": False, + "aspect_ratio": aspect_ratio, + }, + } + ], + }, + } + + async def stream( + self, + token: str, + prompt: str, + aspect_ratio: str = "2:3", + n: int = 1, + enable_nsfw: bool = True, + max_retries: Optional[int] = None, + ) -> AsyncGenerator[Dict[str, object], None]: + retries = max(1, max_retries if max_retries is not None else 1) + parallel_enabled = bool(get_config("image.blocked_parallel_enabled", True)) + logger.info( + f"Image generation: prompt='{prompt[:50]}...', n={n}, ratio={aspect_ratio}, nsfw={enable_nsfw}" + ) + + async def _collect_once() -> list[Dict[str, object]]: + items: list[Dict[str, object]] = [] + async for item in self._stream_once( + token, prompt, aspect_ratio, n, enable_nsfw + ): + items.append(item) + return items + + for attempt in range(retries): + try: + items = await _collect_once() + for item in items: + yield item + return + except _BlockedError: + retries_left = retries - (attempt + 1) + if retries_left > 0 and parallel_enabled: + logger.warning( + f"WebSocket blocked/reviewed, launching {retries_left} parallel retries" + ) + tasks = [asyncio.create_task(_collect_once()) for _ in range(retries_left)] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + continue + has_final = any( + isinstance(item, dict) + and item.get("type") == "image" + and item.get("is_final") + for item in result + ) + if has_final: + for item in result: + yield item + return + yield { + "type": "error", + "error_code": "blocked", + "error": "blocked_no_final_image", + "parallel_attempts": retries_left, + } + return + if attempt + 1 < retries: + logger.warning( + f"WebSocket blocked/reviewed, retry {attempt + 1}/{retries}" + ) + continue + yield { + "type": "error", + "error_code": "blocked", + "error": "blocked_no_final_image", + } + return + except Exception as e: + logger.error(f"WebSocket stream failed: {e}") + yield { + "type": "error", + "error_code": "ws_stream_failed", + "error": str(e), + } + return + + async def _stream_once( + self, + token: str, + prompt: str, + aspect_ratio: str, + n: int, + enable_nsfw: bool, + ) -> AsyncGenerator[Dict[str, object], None]: + request_id = str(uuid.uuid4()) + headers = build_ws_headers(token=token) + timeout = float(get_config("image.timeout")) + stream_timeout = float(get_config("image.stream_timeout")) + final_timeout = float(get_config("image.final_timeout")) + blocked_grace_cfg = get_config("image.blocked_grace_seconds") + blocked_grace = float(blocked_grace_cfg) if blocked_grace_cfg is not None else 10.0 + blocked_grace = max(1.0, min(blocked_grace, final_timeout)) + final_min_bytes = int(get_config("image.final_min_bytes")) + medium_min_bytes = int(get_config("image.medium_min_bytes")) + + try: + conn = await self._client.connect( + WS_IMAGINE_URL, + headers=headers, + timeout=timeout, + ws_kwargs={ + "heartbeat": 20, + "receive_timeout": stream_timeout, + }, + ) + except Exception as e: + status = getattr(e, "status", None) + error_code = ( + "rate_limit_exceeded" if status == 429 else "connection_failed" + ) + logger.error(f"WebSocket connect failed: {e}") + yield { + "type": "error", + "error_code": error_code, + "status": status, + "error": str(e), + } + return + + try: + async with conn as ws: + message = self._build_request_message( + request_id, prompt, aspect_ratio, enable_nsfw + ) + await ws.send_json(message) + logger.info(f"WebSocket request sent: {prompt[:80]}...") + + final_ids: set[str] = set() + completed = 0 + start_time = last_activity = time.monotonic() + medium_received_time: Optional[float] = None + + while time.monotonic() - start_time < timeout: + try: + ws_msg = await asyncio.wait_for(ws.receive(), timeout=5.0) + except asyncio.TimeoutError: + now = time.monotonic() + if ( + medium_received_time + and completed == 0 + and now - medium_received_time > blocked_grace + ): + logger.warning( + "Imagine stream blocked suspected: received medium preview but no valid final image " + f"within {blocked_grace:.1f}s (request_id={request_id})" + ) + raise _BlockedError() + if completed > 0 and now - last_activity > 10: + logger.info( + f"WebSocket idle timeout, collected {completed} images" + ) + break + continue + + if ws_msg.type == aiohttp.WSMsgType.TEXT: + last_activity = time.monotonic() + try: + msg = orjson.loads(ws_msg.data) + except orjson.JSONDecodeError as e: + logger.warning(f"WebSocket message decode failed: {e}") + continue + + msg_type = msg.get("type") + + if msg_type == "image": + info = self._classify_image( + msg.get("url", ""), + msg.get("blob", ""), + final_min_bytes, + medium_min_bytes, + ) + if not info: + continue + + image_id = info["image_id"] + if info["stage"] == "medium" and medium_received_time is None: + medium_received_time = time.monotonic() + + if info["is_final"] and image_id not in final_ids: + final_ids.add(image_id) + completed += 1 + logger.debug( + f"Final image received: id={image_id}, size={info['blob_size']}" + ) + + yield info + + elif msg_type == "error": + logger.warning( + f"WebSocket error: {msg.get('err_code', '')} - {msg.get('err_msg', '')}" + ) + yield { + "type": "error", + "error_code": msg.get("err_code", ""), + "error": msg.get("err_msg", ""), + } + return + + if completed >= n: + logger.info(f"WebSocket collected {completed} final images") + break + + if ( + medium_received_time + and completed == 0 + and time.monotonic() - medium_received_time > final_timeout + ): + logger.warning( + "Imagine stream final-timeout suspected review/block: " + f"no final image reached threshold in {final_timeout:.1f}s " + f"(request_id={request_id})" + ) + raise _BlockedError() + + elif ws_msg.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + ): + logger.warning(f"WebSocket closed/error: {ws_msg.type}") + yield { + "type": "error", + "error_code": "ws_closed", + "error": f"websocket closed: {ws_msg.type}", + } + break + + except aiohttp.ClientError as e: + logger.error(f"WebSocket connection error: {e}") + yield {"type": "error", "error_code": "connection_failed", "error": str(e)} + + +__all__ = ["ImagineWebSocketReverse", "WS_IMAGINE_URL"] diff --git a/app/services/reverse/ws_livekit.py b/app/services/reverse/ws_livekit.py new file mode 100644 index 0000000000000000000000000000000000000000..bf3d92aeb235479f7802b461c6290dfbd16f7648 --- /dev/null +++ b/app/services/reverse/ws_livekit.py @@ -0,0 +1,182 @@ +""" +Reverse interface: LiveKit token + WebSocket. +""" + +import orjson +from typing import Any, Dict +from urllib.parse import urlencode +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, build_ws_headers +from app.services.reverse.utils.retry import retry_on_status +from app.services.reverse.utils.websocket import WebSocketClient, WebSocketConnection + +LIVEKIT_TOKEN_API = "https://grok.com/rest/livekit/tokens" +LIVEKIT_WS_URL = "wss://livekit.grok.com" + + +class LivekitTokenReverse: + """/rest/livekit/tokens reverse interface.""" + + @staticmethod + async def request( + session: AsyncSession, + token: str, + voice: str = "ara", + personality: str = "assistant", + speed: float = 1.0, + ) -> Dict[str, Any]: + """Fetch LiveKit token. + + Args: + session: AsyncSession, the session to use for the request. + token: str, the SSO token. + voice: str, the voice to use for the request. + personality: str, the personality to use for the request. + speed: float, the speed to use for the request. + + Returns: + Dict[str, Any]: The LiveKit token. + """ + 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 = { + "sessionPayload": orjson.dumps( + { + "voice": voice, + "personality": personality, + "playback_speed": speed, + "enable_vision": False, + "turn_detection": {"type": "server_vad"}, + } + ).decode(), + "requestAgentDispatch": False, + "livekitUrl": LIVEKIT_WS_URL, + "params": {"enable_markdown_transcript": "true"}, + } + + # Curl Config + timeout = get_config("voice.timeout") + browser = get_config("proxy.browser") + + async def _do_request(): + response = await session.post( + LIVEKIT_TOKEN_API, + headers=headers, + data=orjson.dumps(payload), + timeout=timeout, + proxies=proxies, + impersonate=browser, + ) + + if response.status_code != 200: + body = response.text[:200] + logger.error( + f"LivekitTokenReverse: Request failed, {response.status_code}, body={body}" + ) + raise UpstreamException( + message=f"LivekitTokenReverse: Request failed, {response.status_code}", + details={"status": response.status_code, "body": response.text}, + ) + + return response + + response = await retry_on_status(_do_request) + return response + + 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, "livekit_token_auth_failed" + ) + except Exception: + pass + raise + + # Handle other non-upstream exceptions + logger.error( + f"LivekitTokenReverse: Request failed, {str(e)}", + extra={"error_type": type(e).__name__}, + ) + raise UpstreamException( + message=f"LivekitTokenReverse: Request failed, {str(e)}", + details={"status": 502, "error": str(e)}, + ) + + +class LivekitWebSocketReverse: + """LiveKit WebSocket reverse interface.""" + + def __init__(self) -> None: + self._client = WebSocketClient() + + async def connect(self, token: str) -> WebSocketConnection: + """Connect to the LiveKit WebSocket. + + Args: + token: str, the SSO token. + + Returns: + WebSocketConnection: The LiveKit WebSocket connection. + """ + # Format URL + base = LIVEKIT_WS_URL.rstrip("/") + if not base.endswith("/rtc"): + base = f"{base}/rtc" + + # Build parameters + params = { + "access_token": token, + "auto_subscribe": "1", + "sdk": "js", + "version": "2.11.4", + "protocol": "15", + } + + # Build URL + url = f"{base}?{urlencode(params)}" + + # Build WebSocket headers + ws_headers = build_ws_headers() + + try: + return await self._client.connect( + url, headers=ws_headers, timeout=get_config("voice.timeout") + ) + except Exception as e: + logger.error(f"LivekitWebSocketReverse: Connect failed, {e}") + raise UpstreamException( + f"LivekitWebSocketReverse: Connect failed, {str(e)}" + ) + + +__all__ = [ + "LivekitTokenReverse", + "LivekitWebSocketReverse", + "LIVEKIT_TOKEN_API", + "LIVEKIT_WS_URL", +] diff --git a/app/services/token/__init__.py b/app/services/token/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ef0d6eae7518a313af0cb82f30c52794b09078be --- /dev/null +++ b/app/services/token/__init__.py @@ -0,0 +1,35 @@ +"""Token 服务模块""" + +from app.services.token.models import ( + TokenInfo, + TokenStatus, + TokenPoolStats, + EffortType, + BASIC__DEFAULT_QUOTA, + SUPER_DEFAULT_QUOTA, + EFFORT_COST, +) +from app.services.token.pool import TokenPool +from app.services.token.manager import TokenManager, get_token_manager +from app.services.token.service import TokenService +from app.services.token.scheduler import TokenRefreshScheduler, get_scheduler + +__all__ = [ + # Models + "TokenInfo", + "TokenStatus", + "TokenPoolStats", + "EffortType", + "BASIC__DEFAULT_QUOTA", + "SUPER_DEFAULT_QUOTA", + "EFFORT_COST", + # Core + "TokenPool", + "TokenManager", + # API + "TokenService", + "get_token_manager", + # Scheduler + "TokenRefreshScheduler", + "get_scheduler", +] diff --git a/app/services/token/manager.py b/app/services/token/manager.py new file mode 100644 index 0000000000000000000000000000000000000000..c5e435dabcf0f8771c5cec65742d92edaa76680a --- /dev/null +++ b/app/services/token/manager.py @@ -0,0 +1,1008 @@ +"""Token 管理服务""" + +import asyncio +import time +from datetime import datetime +from typing import Dict, List, Optional, Set + +from app.core.logger import logger +from app.services.token.models import ( + TokenInfo, + EffortType, + FAIL_THRESHOLD, + TokenStatus, + BASIC__DEFAULT_QUOTA, + SUPER_DEFAULT_QUOTA, +) +from app.core.storage import get_storage, LocalStorage +from app.core.config import get_config +from app.core.exceptions import UpstreamException +from app.services.token.pool import TokenPool +from app.services.grok.batch_services.usage import UsageService + + +DEFAULT_REFRESH_BATCH_SIZE = 10 +DEFAULT_REFRESH_CONCURRENCY = 5 +DEFAULT_SUPER_REFRESH_INTERVAL_HOURS = 2 +DEFAULT_REFRESH_INTERVAL_HOURS = 8 +DEFAULT_RELOAD_INTERVAL_SEC = 30 +DEFAULT_SAVE_DELAY_MS = 500 +DEFAULT_USAGE_FLUSH_INTERVAL_SEC = 5 +SUPER_WINDOW_THRESHOLD_SECONDS = 14400 + +SUPER_POOL_NAME = "ssoSuper" +BASIC_POOL_NAME = "ssoBasic" + + +def _default_quota_for_pool(pool_name: str) -> int: + if pool_name == SUPER_POOL_NAME: + return SUPER_DEFAULT_QUOTA + return BASIC__DEFAULT_QUOTA + + +class TokenManager: + """管理 Token 的增删改查和配额同步""" + + _instance: Optional["TokenManager"] = None + _lock = asyncio.Lock() + + def __init__(self): + self.pools: Dict[str, TokenPool] = {} + self.initialized = False + self._save_lock = asyncio.Lock() + self._dirty = False + self._save_task: Optional[asyncio.Task] = None + self._save_delay = DEFAULT_SAVE_DELAY_MS / 1000.0 + self._last_reload_at = 0.0 + self._has_state_changes = False + self._has_usage_changes = False + self._state_change_seq = 0 + self._usage_change_seq = 0 + self._last_usage_flush_at = 0.0 + self._dirty_tokens = {} + self._dirty_deletes = set() + + @classmethod + async def get_instance(cls) -> "TokenManager": + """获取单例实例""" + if cls._instance is None: + async with cls._lock: + if cls._instance is None: + cls._instance = cls() + await cls._instance._load() + return cls._instance + + async def _load(self): + """初始化加载""" + if not self.initialized: + try: + storage = get_storage() + data = await storage.load_tokens() + + # 如果后端返回 None 或空数据,尝试从本地 data/token.json 初始化后端 + if not data: + local_storage = LocalStorage() + local_data = await local_storage.load_tokens() + if local_data: + data = local_data + await storage.save_tokens(local_data) + logger.info( + f"Initialized remote token storage ({storage.__class__.__name__}) with local tokens." + ) + else: + data = {} + + self.pools = {} + for pool_name, tokens in data.items(): + pool = TokenPool(pool_name) + for token_data in tokens: + quota_missing = not ( + isinstance(token_data, dict) and "quota" in token_data + ) + try: + # 统一存储裸 token + if isinstance(token_data, dict): + raw_token = token_data.get("token") + if isinstance(raw_token, str) and raw_token.startswith( + "sso=" + ): + token_data["token"] = raw_token[4:] + token_info = TokenInfo(**token_data) + if quota_missing and pool_name == SUPER_POOL_NAME: + token_info.quota = SUPER_DEFAULT_QUOTA + pool.add(token_info) + except Exception as e: + logger.warning( + f"Failed to load token in pool '{pool_name}': {e}" + ) + continue + pool._rebuild_index() + self.pools[pool_name] = pool + + self.initialized = True + self._last_reload_at = time.monotonic() + total = sum(p.count() for p in self.pools.values()) + logger.info( + f"TokenManager initialized: {len(self.pools)} pools with {total} tokens" + ) + except Exception as e: + logger.error(f"Failed to initialize TokenManager: {e}") + self.pools = {} + self.initialized = True + + async def reload(self): + """重新加载 Token 池数据""" + async with self.__class__._lock: + self.initialized = False + await self._load() + + async def reload_if_stale(self): + """在多 worker 场景下保持短周期一致性""" + interval = get_config("token.reload_interval_sec", DEFAULT_RELOAD_INTERVAL_SEC) + try: + interval = float(interval) + except Exception: + interval = float(DEFAULT_RELOAD_INTERVAL_SEC) + if interval <= 0: + return + if time.monotonic() - self._last_reload_at < interval: + return + await self.reload() + + def _mark_state_change(self): + self._has_state_changes = True + self._state_change_seq += 1 + + def _mark_usage_change(self): + self._has_usage_changes = True + self._usage_change_seq += 1 + + def _track_token_change( + self, token: TokenInfo, pool_name: str, change_kind: str + ): + token_key = token.token + if token_key.startswith("sso="): + token_key = token_key[4:] + if token_key in self._dirty_deletes: + self._dirty_deletes.remove(token_key) + existing = self._dirty_tokens.get(token_key) + if existing and existing[1] == "state": + change_kind = "state" + self._dirty_tokens[token_key] = (pool_name, change_kind) + if change_kind == "state": + self._mark_state_change() + else: + self._mark_usage_change() + + def _track_token_delete(self, token_str: str): + token_key = token_str + if token_key.startswith("sso="): + token_key = token_key[4:] + self._dirty_deletes.add(token_key) + if token_key in self._dirty_tokens: + del self._dirty_tokens[token_key] + self._mark_state_change() + + def _extract_window_size_seconds(self, result: dict) -> Optional[int]: + if not isinstance(result, dict): + return None + for key in ("windowSizeSeconds", "window_size_seconds"): + if key in result: + try: + return int(result.get(key)) + except (TypeError, ValueError): + return None + limits = result.get("limits") or result.get("rateLimits") + if isinstance(limits, dict): + for key in ("windowSizeSeconds", "window_size_seconds"): + if key in limits: + try: + return int(limits.get(key)) + except (TypeError, ValueError): + return None + return None + + def _move_token_pool( + self, + token: TokenInfo, + from_pool: str, + to_pool: str, + reason: str = "", + ) -> str: + if from_pool == to_pool: + return from_pool + if to_pool not in self.pools: + self.pools[to_pool] = TokenPool(to_pool) + logger.info(f"Pool '{to_pool}': created") + if from_pool in self.pools: + self.pools[from_pool].remove(token.token) + self.pools[to_pool].add(token) + self._track_token_change(token, to_pool, "state") + self._schedule_save() + extra = f" ({reason})" if reason else "" + logger.warning( + f"Token {token.token[:10]}... moved pool {from_pool} -> {to_pool}{extra}" + ) + return to_pool + + async def _save(self, force: bool = False): + """保存变更""" + async with self._save_lock: + try: + if not self._dirty_tokens and not self._dirty_deletes: + return + + if not force and not self._has_state_changes: + interval_sec = get_config( + "token.usage_flush_interval_sec", + DEFAULT_USAGE_FLUSH_INTERVAL_SEC, + ) + try: + interval_sec = float(interval_sec) + except Exception: + interval_sec = float(DEFAULT_USAGE_FLUSH_INTERVAL_SEC) + if interval_sec > 0: + now = time.monotonic() + if now - self._last_usage_flush_at < interval_sec: + self._dirty = True + return + + state_seq = self._state_change_seq + usage_seq = self._usage_change_seq + + dirty_tokens = self._dirty_tokens + dirty_deletes = self._dirty_deletes + self._dirty_tokens = {} + self._dirty_deletes = set() + + updates = [] + deleted = list(dirty_deletes) + for token_key, meta in dirty_tokens.items(): + if token_key in dirty_deletes: + continue + pool_name, change_kind = meta + pool = self.pools.get(pool_name) + if not pool: + continue + info = pool.get(token_key) + if not info: + continue + payload = info.model_dump() + payload["pool_name"] = pool_name + payload["_update_kind"] = change_kind + updates.append(payload) + + storage = get_storage() + async with storage.acquire_lock("tokens_save", timeout=10): + await storage.save_tokens_delta(updates, deleted) + + if state_seq == self._state_change_seq: + self._has_state_changes = False + if usage_seq == self._usage_change_seq: + self._has_usage_changes = False + self._last_usage_flush_at = time.monotonic() + except Exception as e: + logger.error(f"Failed to save tokens: {e}") + self._dirty = True + if 'dirty_tokens' in locals(): + for token_key, meta in dirty_tokens.items(): + existing = self._dirty_tokens.get(token_key) + if existing and existing[1] == "state": + continue + if meta[1] == "state" and existing: + self._dirty_tokens[token_key] = (meta[0], "state") + else: + self._dirty_tokens[token_key] = meta + self._dirty_deletes.update(dirty_deletes) + for token_key in dirty_deletes: + if token_key in self._dirty_tokens: + del self._dirty_tokens[token_key] + + def _schedule_save(self): + """合并高频保存请求,减少写入开销""" + delay_ms = get_config("token.save_delay_ms", DEFAULT_SAVE_DELAY_MS) + try: + delay_ms = float(delay_ms) + except Exception: + delay_ms = float(DEFAULT_SAVE_DELAY_MS) + self._save_delay = max(0.0, delay_ms / 1000.0) + self._dirty = True + if self._save_delay == 0: + if self._save_task and not self._save_task.done(): + return + self._save_task = asyncio.create_task(self._save()) + return + if self._save_task and not self._save_task.done(): + return + self._save_task = asyncio.create_task(self._flush_loop()) + + async def _flush_loop(self): + try: + while True: + await asyncio.sleep(self._save_delay) + if not self._dirty: + break + self._dirty = False + await self._save() + finally: + self._save_task = None + if self._dirty: + self._schedule_save() + + def get_token(self, pool_name: str = "ssoBasic", exclude: set = None, prefer_tags: Optional[Set[str]] = None) -> Optional[str]: + """ + 获取可用 Token + + Args: + pool_name: Token 池名称 + exclude: 需要排除的 token 字符串集合 + + Returns: + Token 字符串或 None + """ + pool = self.pools.get(pool_name) + if not pool: + logger.warning(f"Pool '{pool_name}' not found") + return None + + token_info = pool.select(exclude=exclude, prefer_tags=prefer_tags) + if not token_info: + logger.warning(f"No available token in pool '{pool_name}'") + return None + + token = token_info.token + if token.startswith("sso="): + return token[4:] + return token + + def get_token_info(self, pool_name: str = "ssoBasic", prefer_tags: Optional[Set[str]] = None) -> Optional["TokenInfo"]: + """ + 获取可用 Token 的完整信息 + + Args: + pool_name: Token 池名称 + + Returns: + TokenInfo 对象或 None + """ + pool = self.pools.get(pool_name) + if not pool: + logger.warning(f"Pool '{pool_name}' not found") + return None + + token_info = pool.select(prefer_tags=prefer_tags) + if not token_info: + logger.warning(f"No available token in pool '{pool_name}'") + return None + + return token_info + + def get_token_for_video( + self, + resolution: str = "480p", + video_length: int = 6, + pool_candidates: Optional[List[str]] = None, + ) -> Optional["TokenInfo"]: + """ + 根据视频需求智能选择 Token 池 + + 路由策略: + - 如果 resolution 是 "720p" 或 video_length > 6: 优先使用 "ssoSuper" 池 + - 否则优先使用 "ssoBasic" 池 + - 当提供 pool_candidates 时,按候选池顺序回退 + + Args: + resolution: 视频分辨率 ("480p" 或 "720p") + video_length: 视频时长(秒) + pool_candidates: 候选 Token 池(按优先级) + + Returns: + TokenInfo 对象或 None(无可用 token) + """ + # 确定首选池 + requires_super = resolution == "720p" or video_length > 6 + primary_pool = SUPER_POOL_NAME if requires_super else BASIC_POOL_NAME + + if pool_candidates: + ordered_pools = list(pool_candidates) + if primary_pool in ordered_pools: + ordered_pools.remove(primary_pool) + ordered_pools.insert(0, primary_pool) + else: + fallback_pool = BASIC_POOL_NAME if requires_super else SUPER_POOL_NAME + ordered_pools = [primary_pool, fallback_pool] + + for idx, pool_name in enumerate(ordered_pools): + token_info = self.get_token_info(pool_name) + if token_info: + if idx == 0: + logger.info( + f"Video token routing: resolution={resolution}, length={video_length}s -> " + f"pool={pool_name} (token={token_info.token[:10]}...)" + ) + else: + logger.info( + f"Video token routing: fallback from {ordered_pools[0]} -> {pool_name} " + f"(token={token_info.token[:10]}...)" + ) + return token_info + + if idx == 0 and requires_super and pool_name == primary_pool: + next_pool = ordered_pools[1] if len(ordered_pools) > 1 else None + if next_pool: + logger.warning( + f"Video token routing: {primary_pool} pool has no available token for " + f"resolution={resolution}, length={video_length}s. " + f"Falling back to {next_pool} pool." + ) + + # 两个池都没有可用 token + logger.warning( + f"Video token routing: no available token in any pool " + f"(resolution={resolution}, length={video_length}s)" + ) + 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: + """ + 消耗配额(本地预估) + + Args: + token_str: Token 字符串 + effort: 消耗力度 + + Returns: + 是否成功 + """ + raw_token = token_str.replace("sso=", "") + + for pool in self.pools.values(): + token = pool.get(raw_token) + if token: + old_status = token.status + consumed = token.consume(effort) + logger.debug( + f"Token {raw_token[:10]}...: consumed {consumed} quota, use_count={token.use_count}" + ) + change_kind = "state" if token.status != old_status else "usage" + self._track_token_change(token, pool.name, change_kind) + self._schedule_save() + return True + + logger.warning(f"Token {raw_token[:10]}...: not found for consumption") + return False + + async def sync_usage( + self, + token_str: str, + fallback_effort: EffortType = EffortType.LOW, + consume_on_fail: bool = True, + is_usage: bool = True, + ) -> bool: + """ + 同步 Token 用量 + + 优先从 API 获取最新配额,失败则降级到本地预估 + + Args: + token_str: Token 字符串(可带 sso= 前缀) + fallback_effort: 降级时的消耗力度 + consume_on_fail: 失败时是否降级扣费 + is_usage: 是否记录为一次使用(影响 use_count) + + Returns: + 是否成功 + """ + raw_token = token_str.replace("sso=", "") + + # 查找 Token 对象 + target_token: Optional[TokenInfo] = None + target_pool_name: Optional[str] = None + for pool in self.pools.values(): + target_token = pool.get(raw_token) + if target_token: + target_pool_name = pool.name + break + + if not target_token: + logger.warning(f"Token {raw_token[:10]}...: not found for sync") + return False + + # 尝试 API 同步 + try: + usage_service = UsageService() + result = await usage_service.get(token_str) + + if result and "remainingTokens" in result: + new_quota = result.get("remainingTokens") + if new_quota is None: + new_quota = result.get("remainingQueries") + if new_quota is None: + return False + old_quota = target_token.quota + old_status = target_token.status + + target_token.update_quota(new_quota) + target_token.record_success(is_usage=is_usage) + target_token.mark_synced() + + window_size = self._extract_window_size_seconds(result) + if window_size is not None: + if ( + target_pool_name == SUPER_POOL_NAME + and window_size >= SUPER_WINDOW_THRESHOLD_SECONDS + ): + target_pool_name = self._move_token_pool( + target_token, + SUPER_POOL_NAME, + BASIC_POOL_NAME, + reason=f"windowSizeSeconds={window_size}", + ) + elif ( + target_pool_name == BASIC_POOL_NAME + and window_size < SUPER_WINDOW_THRESHOLD_SECONDS + ): + target_pool_name = self._move_token_pool( + target_token, + BASIC_POOL_NAME, + SUPER_POOL_NAME, + reason=f"windowSizeSeconds={window_size}", + ) + + consumed = max(0, old_quota - new_quota) + logger.info( + f"Token {raw_token[:10]}...: synced quota " + f"{old_quota} -> {new_quota} (consumed: {consumed}, use_count: {target_token.use_count})" + ) + + if target_pool_name: + change_kind = "state" if target_token.status != old_status else "usage" + self._track_token_change( + target_token, target_pool_name, change_kind + ) + self._schedule_save() + return True + + except Exception as e: + 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: + await self.record_fail(token_str, status, "rate_limits_auth_failed") + logger.warning( + f"Token {raw_token[:10]}...: API sync failed, fallback to local ({e})" + ) + + # 降级:本地预估扣费 + if consume_on_fail: + logger.debug(f"Token {raw_token[:10]}...: using local consumption") + return await self.consume(token_str, fallback_effort) + else: + logger.debug( + f"Token {raw_token[:10]}...: sync failed, skipping local consumption" + ) + return False + + async def record_fail( + self, token_str: str, status_code: int = 401, reason: str = "" + ) -> bool: + """ + 记录 Token 失败 + + Args: + token_str: Token 字符串 + status_code: HTTP 状态码 + reason: 失败原因 + + Returns: + 是否成功 + """ + raw_token = token_str.replace("sso=", "") + + for pool in self.pools.values(): + token = pool.get(raw_token) + if token: + if status_code == 401: + threshold = get_config("token.fail_threshold", FAIL_THRESHOLD) + try: + threshold = int(threshold) + except (TypeError, ValueError): + threshold = FAIL_THRESHOLD + if threshold < 1: + threshold = 1 + + token.record_fail(status_code, reason, threshold=threshold) + logger.warning( + f"Token {raw_token[:10]}...: recorded {status_code} failure " + f"({token.fail_count}/{threshold}) - {reason}" + ) + self._track_token_change(token, pool.name, "state") + self._schedule_save() + else: + logger.info( + f"Token {raw_token[:10]}...: non-auth error ({status_code}) - {reason} (not counted)" + ) + return True + + logger.warning(f"Token {raw_token[:10]}...: not found for failure record") + return False + + async def mark_rate_limited(self, token_str: str) -> bool: + """ + 将 Token 标记为配额耗尽(COOLING) + + 当 Grok API 返回 429 时调用,将 quota 设为 0 并标记 COOLING, + 使该 Token 不再被选中,等待下次 Scheduler 刷新恢复。 + + Args: + token_str: Token 字符串 + + Returns: + 是否成功 + """ + raw_token = token_str.removeprefix("sso=") + + for pool in self.pools.values(): + token = pool.get(raw_token) + if token: + old_quota = token.quota + token.quota = 0 + token.status = TokenStatus.COOLING + logger.warning( + f"Token {raw_token[:10]}...: marked as rate limited " + f"(quota {old_quota} -> 0, status -> cooling)" + ) + self._track_token_change(token, pool.name, "state") + self._schedule_save() + return True + + logger.warning(f"Token {raw_token[:10]}...: not found for rate limit marking") + return False + + # ========== 管理功能 ========== + + async def add(self, token: str, pool_name: str = "ssoBasic") -> bool: + """ + 添加 Token + + Args: + token: Token 字符串(不含 sso= 前缀) + pool_name: 池名称 + + Returns: + 是否成功 + """ + if pool_name not in self.pools: + self.pools[pool_name] = TokenPool(pool_name) + logger.info(f"Pool '{pool_name}': created") + + pool = self.pools[pool_name] + + token = token[4:] if token.startswith("sso=") else token + if pool.get(token): + logger.warning(f"Pool '{pool_name}': token already exists") + return False + + token_info = TokenInfo(token=token, quota=_default_quota_for_pool(pool_name)) + pool.add(token_info) + self._track_token_change(token_info, pool_name, "state") + await self._save(force=True) + logger.info(f"Pool '{pool_name}': token added") + return True + + async def mark_asset_clear(self, token: str) -> bool: + """记录在线资产清理时间""" + raw_token = token[4:] if token.startswith("sso=") else token + for pool in self.pools.values(): + info = pool.get(raw_token) + if info: + info.last_asset_clear_at = int(datetime.now().timestamp() * 1000) + self._track_token_change(info, pool.name, "state") + self._schedule_save() + return True + return False + + async def add_tag(self, token: str, tag: str) -> bool: + """ + 给 Token 添加标签 + + Args: + token: Token 字符串 + tag: 标签名称 + + Returns: + 是否成功 + """ + raw_token = token[4:] if token.startswith("sso=") else token + for pool in self.pools.values(): + info = pool.get(raw_token) + if info: + if tag not in info.tags: + info.tags.append(tag) + self._track_token_change(info, pool.name, "state") + self._schedule_save() + logger.debug(f"Token {raw_token[:10]}...: added tag '{tag}'") + return True + return False + + async def remove_tag(self, token: str, tag: str) -> bool: + """ + 移除 Token 标签 + + Args: + token: Token 字符串 + tag: 标签名称 + + Returns: + 是否成功 + """ + raw_token = token[4:] if token.startswith("sso=") else token + for pool in self.pools.values(): + info = pool.get(raw_token) + if info: + if tag in info.tags: + info.tags.remove(tag) + self._track_token_change(info, pool.name, "state") + self._schedule_save() + logger.debug(f"Token {raw_token[:10]}...: removed tag '{tag}'") + return True + return False + + async def remove(self, token: str) -> bool: + """ + 删除 Token + + Args: + token: Token 字符串 + + Returns: + 是否成功 + """ + for pool_name, pool in self.pools.items(): + if pool.remove(token): + self._track_token_delete(token) + await self._save(force=True) + logger.info(f"Pool '{pool_name}': token removed") + return True + + logger.warning("Token not found for removal") + return False + + async def reset_all(self): + """重置所有 Token 配额""" + count = 0 + for pool_name, pool in self.pools.items(): + default_quota = _default_quota_for_pool(pool_name) + for token in pool: + token.reset(default_quota) + self._track_token_change(token, pool_name, "state") + count += 1 + + await self._save(force=True) + logger.info(f"Reset all: {count} tokens updated") + + async def reset_token(self, token_str: str) -> bool: + """ + 重置单个 Token + + Args: + token_str: Token 字符串 + + Returns: + 是否成功 + """ + raw_token = token_str.replace("sso=", "") + + for pool in self.pools.values(): + token = pool.get(raw_token) + if token: + default_quota = _default_quota_for_pool(pool.name) + token.reset(default_quota) + self._track_token_change(token, pool.name, "state") + await self._save(force=True) + logger.info(f"Token {raw_token[:10]}...: reset completed") + return True + + logger.warning(f"Token {raw_token[:10]}...: not found for reset") + return False + + def get_stats(self) -> Dict[str, dict]: + """获取统计信息""" + stats = {} + for name, pool in self.pools.items(): + pool_stats = pool.get_stats() + stats[name] = pool_stats.model_dump() + return stats + + def get_pool_tokens(self, pool_name: str = "ssoBasic") -> List[TokenInfo]: + """ + 获取指定池的所有 Token + + Args: + pool_name: 池名称 + + Returns: + Token 列表 + """ + pool = self.pools.get(pool_name) + if not pool: + return [] + return pool.list() + + async def refresh_cooling_tokens(self) -> Dict[str, int]: + """ + 批量刷新 cooling 状态的 Token 配额 + + Returns: + {"checked": int, "refreshed": int, "recovered": int, "expired": int} + """ + # 收集需要刷新的 token + to_refresh: List[tuple[str, TokenInfo]] = [] + for pool in self.pools.values(): + if pool.name == SUPER_POOL_NAME: + interval_hours = get_config( + "token.super_refresh_interval_hours", + DEFAULT_SUPER_REFRESH_INTERVAL_HOURS, + ) + else: + interval_hours = get_config( + "token.refresh_interval_hours", + DEFAULT_REFRESH_INTERVAL_HOURS, + ) + for token in pool: + if token.need_refresh(interval_hours): + to_refresh.append((pool.name, token)) + + if not to_refresh: + logger.debug("Refresh check: no tokens need refresh") + return {"checked": 0, "refreshed": 0, "recovered": 0, "expired": 0} + + logger.info(f"Refresh check: found {len(to_refresh)} cooling tokens to refresh") + + # 批量并发刷新 + semaphore = asyncio.Semaphore(DEFAULT_REFRESH_CONCURRENCY) + usage_service = UsageService() + refreshed = 0 + recovered = 0 + expired = 0 + + async def _refresh_one(item: tuple[str, TokenInfo]) -> dict: + """刷新单个 token""" + _, token_info = item + async with semaphore: + token_str = token_info.token + if token_str.startswith("sso="): + token_str = token_str[4:] + + # 重试逻辑:最多 2 次重试 + for retry in range(3): # 0, 1, 2 + try: + result = await usage_service.get(token_str) + + if result and "remainingTokens" in result: + new_quota = result.get("remainingTokens") + if new_quota is None: + new_quota = result.get("remainingQueries") + if new_quota is None: + return {"recovered": False, "expired": False} + old_quota = token_info.quota + old_status = token_info.status + + token_info.update_quota(new_quota) + token_info.mark_synced() + + window_size = self._extract_window_size_seconds(result) + if window_size is not None: + current_pool = self.get_pool_name_for_token(token_info.token) + if ( + current_pool == SUPER_POOL_NAME + and window_size >= SUPER_WINDOW_THRESHOLD_SECONDS + ): + self._move_token_pool( + token_info, + SUPER_POOL_NAME, + BASIC_POOL_NAME, + reason=f"windowSizeSeconds={window_size}", + ) + elif ( + current_pool == BASIC_POOL_NAME + and window_size < SUPER_WINDOW_THRESHOLD_SECONDS + ): + self._move_token_pool( + token_info, + BASIC_POOL_NAME, + SUPER_POOL_NAME, + reason=f"windowSizeSeconds={window_size}", + ) + + logger.info( + f"Token {token_info.token[:10]}...: refreshed " + f"{old_quota} -> {new_quota}, status: {old_status} -> {token_info.status}" + ) + + return { + "recovered": new_quota > 0 and old_quota == 0, + "expired": False, + } + + return {"recovered": False, "expired": False} + + except Exception as e: + error_str = str(e) + + # 检查是否为 401 错误 + if "401" in error_str or "Unauthorized" in error_str: + if retry < 2: + logger.warning( + f"Token {token_info.token[:10]}...: 401 error, " + f"retry {retry + 1}/2..." + ) + await asyncio.sleep(0.5) + continue + else: + # 重试 2 次后仍然 401,标记为 expired + logger.error( + f"Token {token_info.token[:10]}...: 401 after 2 retries, " + f"marking as expired" + ) + token_info.status = TokenStatus.EXPIRED + return {"recovered": False, "expired": True} + else: + logger.warning( + f"Token {token_info.token[:10]}...: refresh failed ({e})" + ) + return {"recovered": False, "expired": False} + + return {"recovered": False, "expired": False} + + # 批量处理 + for i in range(0, len(to_refresh), DEFAULT_REFRESH_BATCH_SIZE): + batch = to_refresh[i : i + DEFAULT_REFRESH_BATCH_SIZE] + results = await asyncio.gather(*[_refresh_one(t) for t in batch]) + refreshed += len(batch) + recovered += sum(r["recovered"] for r in results) + expired += sum(r["expired"] for r in results) + + # 批次间延迟 + if i + DEFAULT_REFRESH_BATCH_SIZE < len(to_refresh): + await asyncio.sleep(1) + + for pool_name, token_info in to_refresh: + current_pool = self.get_pool_name_for_token(token_info.token) or pool_name + self._track_token_change(token_info, current_pool, "state") + await self._save(force=True) + + logger.info( + f"Refresh completed: " + f"checked={len(to_refresh)}, refreshed={refreshed}, " + f"recovered={recovered}, expired={expired}" + ) + + return { + "checked": len(to_refresh), + "refreshed": refreshed, + "recovered": recovered, + "expired": expired, + } + + +# 便捷函数 +async def get_token_manager() -> TokenManager: + """获取 TokenManager 单例""" + return await TokenManager.get_instance() + + +__all__ = ["TokenManager", "get_token_manager"] diff --git a/app/services/token/models.py b/app/services/token/models.py new file mode 100644 index 0000000000000000000000000000000000000000..86300d907578331b218bb099df73052c002181f0 --- /dev/null +++ b/app/services/token/models.py @@ -0,0 +1,203 @@ +""" +Token 数据模型 + +额度规则: +- Basic 新号默认 80 配额 +- Super 新号默认 140 配额 +- 重置后恢复默认值 +- lowEffort 扣 1,highEffort 扣 4 +""" + +from enum import Enum +from typing import Optional, List +from pydantic import BaseModel, Field +from datetime import datetime + + +# 默认配额 +BASIC__DEFAULT_QUOTA = 80 +SUPER_DEFAULT_QUOTA = 140 + +# 失败阈值 +FAIL_THRESHOLD = 5 + + +class TokenStatus(str, Enum): + """Token 状态""" + + ACTIVE = "active" + DISABLED = "disabled" + EXPIRED = "expired" + COOLING = "cooling" + + +class EffortType(str, Enum): + """请求消耗类型""" + + LOW = "low" # 扣 1 + HIGH = "high" # 扣 4 + + +EFFORT_COST = { + EffortType.LOW: 1, + EffortType.HIGH: 4, +} + + +class TokenInfo(BaseModel): + """Token 信息""" + + token: str + status: TokenStatus = TokenStatus.ACTIVE + quota: int = BASIC__DEFAULT_QUOTA + + # 统计 + created_at: int = Field( + default_factory=lambda: int(datetime.now().timestamp() * 1000) + ) + last_used_at: Optional[int] = None + use_count: int = 0 + + # 失败追踪 + fail_count: int = 0 + last_fail_at: Optional[int] = None + last_fail_reason: Optional[str] = None + + # 冷却管理 + last_sync_at: Optional[int] = None # 上次同步时间 + + # 扩展 + tags: List[str] = Field(default_factory=list) + note: str = "" + last_asset_clear_at: Optional[int] = None + + def is_available(self) -> bool: + """检查是否可用(状态正常且配额 > 0)""" + return self.status == TokenStatus.ACTIVE and self.quota > 0 + + def consume(self, effort: EffortType = EffortType.LOW) -> int: + """ + 消耗配额 + + Args: + effort: LOW 扣 1 配额并计 1 次,HIGH 扣 4 配额并计 4 次 + + Returns: + 实际扣除的配额 + """ + cost = EFFORT_COST[effort] + actual_cost = min(cost, self.quota) + + self.last_used_at = int(datetime.now().timestamp() * 1000) + self.use_count += actual_cost # 使用 actual_cost 避免配额不足时过度计数 + self.quota = max(0, self.quota - actual_cost) + + # 注意:不在这里清零 fail_count,只有 record_success() 才清零 + # 这样可以避免失败后调用 consume 导致失败计数被重置 + + if self.quota == 0: + self.status = TokenStatus.COOLING + elif self.status == TokenStatus.COOLING: + # 只从 COOLING 恢复,不从 EXPIRED 恢复 + self.status = TokenStatus.ACTIVE + + return actual_cost + + def update_quota(self, new_quota: int): + """ + 更新配额(用于 API 同步) + + Args: + new_quota: 新的配额值 + """ + self.quota = max(0, new_quota) + + if self.quota == 0: + self.status = TokenStatus.COOLING + elif self.quota > 0 and self.status in [ + TokenStatus.COOLING, + TokenStatus.EXPIRED, + ]: + self.status = TokenStatus.ACTIVE + + def reset(self, default_quota: Optional[int] = None): + """重置配额到默认值""" + quota = BASIC__DEFAULT_QUOTA if default_quota is None else default_quota + self.quota = max(0, int(quota)) + self.status = TokenStatus.ACTIVE + self.fail_count = 0 + self.last_fail_reason = None + + def record_fail( + self, + status_code: int = 401, + reason: str = "", + threshold: Optional[int] = None, + ): + """记录失败,达到阈值后自动标记为 expired""" + # 仅 401 计入失败 + if status_code != 401: + return + + self.fail_count += 1 + self.last_fail_at = int(datetime.now().timestamp() * 1000) + self.last_fail_reason = reason + + limit = FAIL_THRESHOLD if threshold is None else threshold + if self.fail_count >= limit: + self.status = TokenStatus.EXPIRED + + def record_success(self, is_usage: bool = True): + """记录成功,清空失败计数并根据配额更新状态""" + self.fail_count = 0 + self.last_fail_at = None + self.last_fail_reason = None + + if is_usage: + self.use_count += 1 + self.last_used_at = int(datetime.now().timestamp() * 1000) + + if self.quota == 0: + self.status = TokenStatus.COOLING + else: + self.status = TokenStatus.ACTIVE + + def need_refresh(self, interval_hours: int = 8) -> bool: + """检查是否需要刷新配额""" + if self.status != TokenStatus.COOLING: + return False + + if self.last_sync_at is None: + return True + + now = int(datetime.now().timestamp() * 1000) + interval_ms = interval_hours * 3600 * 1000 + return (now - self.last_sync_at) >= interval_ms + + def mark_synced(self): + """标记已同步""" + self.last_sync_at = int(datetime.now().timestamp() * 1000) + + +class TokenPoolStats(BaseModel): + """Token 池统计""" + + total: int = 0 + active: int = 0 + disabled: int = 0 + expired: int = 0 + cooling: int = 0 + total_quota: int = 0 + avg_quota: float = 0.0 + + +__all__ = [ + "TokenStatus", + "TokenInfo", + "TokenPoolStats", + "EffortType", + "EFFORT_COST", + "BASIC__DEFAULT_QUOTA", + "SUPER_DEFAULT_QUOTA", + "FAIL_THRESHOLD", +] diff --git a/app/services/token/pool.py b/app/services/token/pool.py new file mode 100644 index 0000000000000000000000000000000000000000..ec43c75fda73044f4c299218cd4f20e874bd429b --- /dev/null +++ b/app/services/token/pool.py @@ -0,0 +1,106 @@ +"""Token 池管理""" + +import random +from typing import Dict, List, Optional, Iterator, Set + +from app.services.token.models import TokenInfo, TokenStatus, TokenPoolStats + + +class TokenPool: + """Token 池(管理一组 Token)""" + + def __init__(self, name: str): + self.name = name + self._tokens: Dict[str, TokenInfo] = {} + + def add(self, token: TokenInfo): + """添加 Token""" + self._tokens[token.token] = token + + def remove(self, token_str: str) -> bool: + """删除 Token""" + if token_str in self._tokens: + del self._tokens[token_str] + return True + return False + + def get(self, token_str: str) -> Optional[TokenInfo]: + """获取 Token""" + return self._tokens.get(token_str) + + def select(self, exclude: set = None, prefer_tags: Optional[Set[str]] = None) -> Optional[TokenInfo]: + """ + 选择一个可用 Token + 策略: + 1. 选择 active 状态且有配额的 token + 2. 优先选择剩余额度最多的 + 3. 如果额度相同,随机选择(避免并发冲突) + + Args: + exclude: 需要排除的 token 字符串集合 + prefer_tags: 优先选择包含这些 tag 的 token(若存在则仅在其子集中选择) + """ + # 选择 token + available = [ + t + for t in self._tokens.values() + if t.status == TokenStatus.ACTIVE and t.quota > 0 + and (not exclude or t.token not in exclude) + ] + + if not available: + return None + + # 优先选带指定标签的 token(若存在) + if prefer_tags: + preferred = [t for t in available if prefer_tags.issubset(set(t.tags or []))] + if preferred: + available = preferred + + # 找到最大额度 + max_quota = max(t.quota for t in available) + + # 筛选最大额度 + candidates = [t for t in available if t.quota == max_quota] + + # 随机选择 + return random.choice(candidates) + + def count(self) -> int: + """Token 数量""" + return len(self._tokens) + + def list(self) -> List[TokenInfo]: + """获取所有 Token""" + return list(self._tokens.values()) + + def get_stats(self) -> TokenPoolStats: + """获取池统计信息""" + stats = TokenPoolStats(total=len(self._tokens)) + + for token in self._tokens.values(): + stats.total_quota += token.quota + + if token.status == TokenStatus.ACTIVE: + stats.active += 1 + elif token.status == TokenStatus.DISABLED: + stats.disabled += 1 + elif token.status == TokenStatus.EXPIRED: + stats.expired += 1 + elif token.status == TokenStatus.COOLING: + stats.cooling += 1 + + if stats.total > 0: + stats.avg_quota = stats.total_quota / stats.total + + return stats + + def _rebuild_index(self): + """重建索引(预留接口,用于加载时调用)""" + pass + + def __iter__(self) -> Iterator[TokenInfo]: + return iter(self._tokens.values()) + + +__all__ = ["TokenPool"] diff --git a/app/services/token/scheduler.py b/app/services/token/scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..5ec8cafbae0ad0531258e95bf123467bf3ef69fe --- /dev/null +++ b/app/services/token/scheduler.py @@ -0,0 +1,109 @@ +"""Token 刷新调度器""" + +import asyncio +from typing import Optional + +from app.core.logger import logger +from app.core.storage import get_storage, StorageError, RedisStorage +from app.services.token.manager import get_token_manager + + +class TokenRefreshScheduler: + """Token 自动刷新调度器""" + + def __init__(self, interval_hours: int = 8): + self.interval_hours = interval_hours + self.interval_seconds = interval_hours * 3600 + self._task: Optional[asyncio.Task] = None + self._running = False + + async def _refresh_loop(self): + """刷新循环""" + logger.info(f"Scheduler: started (interval: {self.interval_hours}h)") + + while self._running: + try: + storage = get_storage() + lock_acquired = False + lock = None + + if isinstance(storage, RedisStorage): + # Redis: non-blocking lock to avoid multi-worker duplication + lock_key = "grok2api:lock:token_refresh" + lock = storage.redis.lock( + lock_key, timeout=self.interval_seconds + 60, blocking_timeout=0 + ) + lock_acquired = await lock.acquire(blocking=False) + else: + try: + async with storage.acquire_lock("token_refresh", timeout=1): + lock_acquired = True + except StorageError: + lock_acquired = False + + if not lock_acquired: + logger.info("Scheduler: skipped (lock not acquired)") + await asyncio.sleep(self.interval_seconds) + continue + + try: + logger.info("Scheduler: starting token refresh...") + manager = await get_token_manager() + result = await manager.refresh_cooling_tokens() + + logger.info( + f"Scheduler: refresh completed - " + f"checked={result['checked']}, " + f"refreshed={result['refreshed']}, " + f"recovered={result['recovered']}, " + f"expired={result['expired']}" + ) + finally: + if lock is not None and lock_acquired: + try: + await lock.release() + except Exception: + pass + + await asyncio.sleep(self.interval_seconds) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Scheduler: refresh error - {e}") + await asyncio.sleep(self.interval_seconds) + + def start(self): + """启动调度器""" + if self._running: + logger.warning("Scheduler: already running") + return + + self._running = True + self._task = asyncio.create_task(self._refresh_loop()) + logger.info("Scheduler: enabled") + + def stop(self): + """停止调度器""" + if not self._running: + return + + self._running = False + if self._task: + self._task.cancel() + logger.info("Scheduler: stopped") + + +# 全局单例 +_scheduler: Optional[TokenRefreshScheduler] = None + + +def get_scheduler(interval_hours: int = 8) -> TokenRefreshScheduler: + """获取调度器单例""" + global _scheduler + if _scheduler is None: + _scheduler = TokenRefreshScheduler(interval_hours) + return _scheduler + + +__all__ = ["TokenRefreshScheduler", "get_scheduler"] diff --git a/app/services/token/service.py b/app/services/token/service.py new file mode 100644 index 0000000000000000000000000000000000000000..b441fbeb573692daabab2345923b9a87f8bf3b1a --- /dev/null +++ b/app/services/token/service.py @@ -0,0 +1,156 @@ +"""Token 服务外观(Facade)""" + +from typing import List, Optional, Dict + +from app.services.token.models import TokenInfo, EffortType + + +class TokenService: + """ + Token 服务外观 + + 提供简化的 API,隐藏内部实现细节 + """ + + @staticmethod + async def _get_manager(): + from app.services.token.manager import get_token_manager + + return await get_token_manager() + + @staticmethod + async def get_token(pool_name: str = "ssoBasic") -> Optional[str]: + """ + 获取可用 Token + + Args: + pool_name: Token 池名称 + + Returns: + Token 字符串(不含 sso= 前缀)或 None + """ + manager = await TokenService._get_manager() + return manager.get_token(pool_name) + + @staticmethod + async def consume(token: str, effort: EffortType = EffortType.LOW) -> bool: + """ + 消耗 Token 配额(本地预估) + + Args: + token: Token 字符串 + effort: 消耗力度 + + Returns: + 是否成功 + """ + manager = await TokenService._get_manager() + return await manager.consume(token, effort) + + @staticmethod + async def sync_usage(token: str, effort: EffortType = EffortType.LOW) -> bool: + """ + 同步 Token 使用量(优先 API,降级本地) + + Args: + token: Token 字符串 + effort: 降级时的消耗力度 + + Returns: + 是否成功 + """ + manager = await TokenService._get_manager() + return await manager.sync_usage(token, effort) + + @staticmethod + async def record_fail(token: str, status_code: int = 401, reason: str = "") -> bool: + """ + 记录 Token 失败 + + Args: + token: Token 字符串 + status_code: HTTP 状态码 + reason: 失败原因 + + Returns: + 是否成功 + """ + manager = await TokenService._get_manager() + return await manager.record_fail(token, status_code, reason) + + @staticmethod + async def add_token(token: str, pool_name: str = "ssoBasic") -> bool: + """ + 添加 Token + + Args: + token: Token 字符串 + pool: Token 池名称 + + Returns: + 是否成功 + """ + manager = await TokenService._get_manager() + return await manager.add(token, pool_name) + + @staticmethod + async def remove_token(token: str) -> bool: + """ + 删除 Token + + Args: + token: Token 字符串 + + Returns: + 是否成功 + """ + manager = await TokenService._get_manager() + return await manager.remove(token) + + @staticmethod + async def reset_token(token: str) -> bool: + """ + 重置单个 Token + + Args: + token: Token 字符串 + + Returns: + 是否成功 + """ + manager = await TokenService._get_manager() + return await manager.reset_token(token) + + @staticmethod + async def reset_all(): + """重置所有 Token""" + manager = await TokenService._get_manager() + await manager.reset_all() + + @staticmethod + async def get_stats() -> Dict[str, dict]: + """ + 获取统计信息 + + Returns: + 各池的统计信息 + """ + manager = await TokenService._get_manager() + return manager.get_stats() + + @staticmethod + async def list_tokens(pool_name: str = "ssoBasic") -> List[TokenInfo]: + """ + 获取指定池的所有 Token + + Args: + pool_name: Token 池名称 + + Returns: + Token 列表 + """ + manager = await TokenService._get_manager() + return manager.get_pool_tokens(pool_name) + + +__all__ = ["TokenService"] diff --git a/app/static/.DS_Store b/app/static/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d875e4ab621f6a628903d4c7eeffeb3b7047a1d3 Binary files /dev/null and b/app/static/.DS_Store differ diff --git a/app/static/admin/.DS_Store b/app/static/admin/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..51707bc4eafe75f6d920425142e71d07f06b518f Binary files /dev/null and b/app/static/admin/.DS_Store differ diff --git a/app/static/admin/css/cache.css b/app/static/admin/css/cache.css new file mode 100644 index 0000000000000000000000000000000000000000..2b372953378fb9b0e1ff1908e47f25db9f329c81 --- /dev/null +++ b/app/static/admin/css/cache.css @@ -0,0 +1,304 @@ +.cache-stat-label { + font-size: 12px; + color: var(--accents-4); +} + +.cache-stat-value { + font-size: 20px; + font-weight: 600; + color: var(--fg); +} + +.cache-action-btn { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: none; + color: #e00; + background: #fff; + transition: all 0.2s; +} + +.cache-action-btn:hover { + background: #fef2f2; +} + +.cache-info-box { + border: 1px solid var(--border); + background: #fff; + border-radius: 8px; +} + +.cache-card { + cursor: pointer; +} + +.cache-card.selected { + border: 1px solid #000; +} + +.cache-list-actions { + display: inline-flex; + gap: 8px; + justify-content: center; + align-items: center; +} + +.cache-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: #9ca3af; + cursor: pointer; +} + +.cache-icon-button:hover { + color: #000; + border-color: #000; +} + +.cache-preview { + width: 28px; + height: 28px; + border-radius: 6px; + object-fit: cover; + border: 1px solid var(--border); +} + +#confirm-dialog.confirm-dialog { + border: none; + border-radius: 12px; + padding: 0; + width: min(420px, 90vw); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); +} + +#confirm-dialog::backdrop { + background: rgba(0, 0, 0, 0.35); +} + +.confirm-dialog-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.confirm-dialog-title { + font-size: 14px; + font-weight: 600; + color: var(--accents-7); +} + +.confirm-dialog-message { + font-size: 13px; + color: var(--accents-5); + line-height: 1.5; +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.batch-link { + font-size: 12px; + color: var(--accents-5); + text-decoration: underline; + background: transparent; + border: none; + padding: 0; + cursor: pointer; +} + +.batch-link:hover { + color: var(--accents-7); +} + +.toolbar-sep { + display: inline-block; + width: 1px; + height: 14px; + background: var(--border); + margin: 0 6px; +} + +.failure-list { + max-height: 260px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 4px; +} + +.failure-item { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + font-size: 12px; + color: var(--accents-6); + background: #fafafa; + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; +} + +.failure-token { + font-family: 'Geist Mono', monospace; + color: var(--accents-7); +} + +#batch-actions { + background: rgba(255, 255, 255, 0.92); + border: 1px solid var(--border); + backdrop-filter: blur(10px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); +} + +#batch-actions .geist-button-outline:hover { + background: #f3f4f6; +} + +/* Table Styles */ +.geist-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.geist-table th { + text-align: center; + padding: 0 16px; + border-bottom: 1px solid var(--accents-1); + color: var(--accents-5); + font-weight: 500; + height: 40px; + background: #fff; +} + +.geist-table th.text-left { + text-align: left; +} + +.geist-table th.text-right { + text-align: right; +} + +.geist-table td { + padding: 8px 14px; + border-bottom: 1px solid #fafafa; + background: #fff; + vertical-align: middle; + height: 44px; + color: var(--accents-6); + text-align: center; + transition: background 0.15s; + font-size: 12px; +} + +.geist-table td.text-left { + text-align: left; +} + +.geist-table td.text-right { + text-align: right; +} + +.geist-table tr:last-child td { + border-bottom: none; +} + +.geist-table tr:hover td { + background: #fafafa; +} + +.geist-table tr.row-selected td { + background: #f6f7f9; +} + +.geist-table tr.row-selected:hover td { + background: #f0f2f5; +} + +/* Elegant Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0 8px; + height: 20px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; +} + +.badge-gray { + background: #f3f4f6; + color: #4b5563; +} + +.badge-green { + background: #ecfdf5; + color: #059669; +} + +.badge-orange { + background: #fff7ed; + color: #d97706; +} + +.badge-red { + background: #fef2f2; + color: #dc2626; +} + +.checkbox { + width: 12px; + height: 12px; + border-radius: 4px; + border: 1px solid var(--accents-3); + appearance: none; + cursor: pointer; + position: relative; + background: #fff; + transition: all 0.2s; + margin: 0 auto; + display: block; +} + +.checkbox:checked { + background-color: #000; + border-color: #000; +} + +.checkbox:checked::after { + content: ''; + position: absolute; + top: 45%; + left: 50%; + width: 3px; + height: 6px; + border: solid white; + border-width: 0 2px 2px 0; + transform: translate(-50%, -60%) rotate(45deg); +} + +.checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #000; + border-color: #000; +} diff --git a/app/static/admin/css/config.css b/app/static/admin/css/config.css new file mode 100644 index 0000000000000000000000000000000000000000..8fa851be104f242a7b22843aee532246702791ab --- /dev/null +++ b/app/static/admin/css/config.css @@ -0,0 +1,59 @@ +/* Config page specific styles (shared styles moved to common.css). */ + +.config-section { + background: #fff; + border: 1px solid transparent; + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + transition: border-color 0.2s ease; +} + +.config-section:hover { + border-color: #000; +} + +.config-section-title { + font-size: 14px; + font-weight: 600; + color: var(--accents-7); +} + +.config-grid { + display: grid; + gap: 14px; +} + +.config-field { + padding-top: 2px; + position: relative; +} + +.config-field-title { + font-size: 13px; + font-weight: 600; + color: var(--accents-7); +} + +.config-field-desc { + font-size: 12px; + color: var(--accents-4); + line-height: 1.5; +} + +.config-field-input { + margin-top: 6px; +} + +.config-field.has-action { + padding-right: 44px; +} + +.config-field-action { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); +} diff --git a/app/static/admin/css/token.css b/app/static/admin/css/token.css new file mode 100644 index 0000000000000000000000000000000000000000..8bf11b84fa76c364c5e3bbceacd924641454ae58 --- /dev/null +++ b/app/static/admin/css/token.css @@ -0,0 +1,358 @@ + /* Shared styles moved to common.css */ + + /* Table Styles */ + .geist-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; + /* Unified font size */ + } + + .geist-table th { + text-align: center; + /* Default center */ + padding: 0 16px; + border-bottom: 1px solid var(--accents-1); + color: var(--accents-5); + font-weight: 500; + height: 40px; + /* Reduced height */ + background: #fff; + } + + .geist-table th.text-left { + text-align: left; + } + + .geist-table th.text-right { + text-align: right; + } + + .geist-table td { + padding: 8px 14px; + border-bottom: 1px solid #fafafa; + background: #fff; + vertical-align: middle; + height: 44px; + color: var(--accents-6); + text-align: center; + /* Default center */ + transition: background 0.15s; + font-size: 12px; + } + + .geist-table td.text-left { + text-align: left; + } + + .geist-table td.text-right { + text-align: right; + } + + .geist-table tr:last-child td { + border-bottom: none; + } + + .geist-table tr:hover td { + background: #fafafa; + } + + .geist-table tr.row-selected td { + background: #f6f7f9; + } + + .geist-table tr.row-selected:hover td { + background: #f0f2f5; + } + + .pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 4px; + } + + /* Elegant Badges */ + .badge { + display: inline-flex; + align-items: center; + padding: 0 8px; + height: 20px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + } + + .badge-gray { + background: #f3f4f6; + color: #4b5563; + } + + .badge-green { + background: #ecfdf5; + color: #059669; + } + + .badge-orange { + background: #fff7ed; + color: #d97706; + } + + .badge-red { + background: #fef2f2; + color: #dc2626; + } + + .badge-purple { + background: #f3e8ff; + color: #9333ea; + } + + .checkbox { + width: 12px; + height: 12px; + border-radius: 4px; + border: 1px solid var(--accents-3); + appearance: none; + cursor: pointer; + position: relative; + background: #fff; + transition: all 0.2s; + margin: 0 auto; + /* Center in cell */ + display: block; + } + + .checkbox:checked { + background-color: #000; + border-color: #000; + } + + .checkbox:checked::after { + content: ''; + position: absolute; + top: 45%; + left: 50%; + width: 3px; + height: 6px; + border: solid white; + border-width: 0 2px 2px 0; + transform: translate(-50%, -60%) rotate(45deg); + } + + .checkbox:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #000; + border-color: #000; + } + + /* Custom Scrollbar for Table Wrapper if needed */ + .table-wrapper { + border-radius: 8px; + overflow: hidden; + } + + .batch-link { + font-size: 12px; + color: var(--accents-5); + text-decoration: underline; + background: transparent; + border: none; + } + + .batch-link:hover { + color: #000; + } + + .toolbar-sep { + width: 1px; + height: 14px; + background: var(--border); + display: inline-block; + } + + #batch-actions { + background: rgba(255, 255, 255, 0.92); + border: 1px solid var(--border); + backdrop-filter: blur(10px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + } + + #batch-actions .geist-button-outline:hover { + background: #f3f4f6; + } + + .modal-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .modal-overlay.hidden { + display: none; + } + + .modal-overlay.is-open { + opacity: 1; + pointer-events: auto; + } + + .modal-content { + background: #fff; + border-radius: 12px; + padding: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + width: 100%; + transform: scale(0.96); + transition: transform 0.2s ease; + font-size: 13px; + color: var(--accents-6); + } + + .modal-overlay.is-open .modal-content { + transform: scale(1); + } + + .modal-lg { + max-width: 520px; + } + + .modal-md { + max-width: 420px; + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + } + + .modal-title { + font-size: 14px; + font-weight: 600; + color: var(--accents-7); + } + + .modal-label { + font-size: 13px; + font-weight: 500; + color: var(--accents-7); + } + + .modal-close { + color: var(--accents-4); + transition: color 0.15s; + } + + .modal-close:hover { + color: #000; + } + + .confirm-dialog-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + } + + .confirm-dialog-title { + font-size: 14px; + font-weight: 600; + color: var(--accents-7); + } + + .confirm-dialog-message { + font-size: 13px; + color: var(--accents-5); + line-height: 1.5; + } + + .confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border); + } + + .confirm-dialog .modal-content { + padding: 0; + } + + /* Tab Styles */ + #status-tabs { + border: none; + background: #f6f6f6; + padding: 6px; + gap: 6px; + border-radius: 999px; + } + + .tab-item { + position: relative; + padding: 6px 12px; + margin-right: 0; + font-size: 12.5px; + font-weight: 500; + color: var(--accents-5); + background: transparent; + border: none; + border-radius: 999px; + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .tab-item:hover { + color: var(--geist-foreground, #000); + background: rgba(0, 0, 0, 0.05); + } + + .tab-item.active { + color: var(--geist-foreground, #000); + background: #fff; + } + + #status-tabs .badge { + height: 20px; + line-height: 20px; + font-size: 11px; + padding: 0 8px; + } + + .tab-item:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + /* Hide scrollbar for tabs on mobile */ + .hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + .hide-scrollbar::-webkit-scrollbar { + display: none; + } + + .tab-sep { + width: 1px; + height: 16px; + background: #e5e5e5; + margin: 0 4px; + } diff --git a/app/static/admin/js/cache.js b/app/static/admin/js/cache.js new file mode 100644 index 0000000000000000000000000000000000000000..261cccd3c8c8e53d2cb932ca4d40e65e87349e5d --- /dev/null +++ b/app/static/admin/js/cache.js @@ -0,0 +1,1360 @@ +let apiKey = ''; +let currentScope = 'none'; +let currentToken = ''; +let currentSection = 'image'; +const accountMap = new Map(); +const selectedTokens = new Set(); +const selectedLocal = { + image: new Set(), + video: new Set() +}; +const ui = {}; +const byId = (id) => document.getElementById(id); +const loadFailed = new Map(); +const deleteFailed = new Map(); +let currentBatchAction = null; +let lastBatchAction = null; +let isLocalDeleting = false; +const cacheListState = { + image: { loaded: false, visible: false, items: [] }, + video: { loaded: false, visible: false, items: [] } +}; +const UI_MAP = { + imgCount: 'img-count', + imgSize: 'img-size', + videoCount: 'video-count', + videoSize: 'video-size', + onlineCount: 'online-count', + onlineStatus: 'online-status', + onlineLastClear: 'online-last-clear', + accountTableBody: 'account-table-body', + accountEmpty: 'account-empty', + selectAll: 'select-all', + localImageSelectAll: 'local-image-select-all', + localVideoSelectAll: 'local-video-select-all', + selectedCount: 'selected-count', + batchActions: 'batch-actions', + loadBtn: 'btn-load-stats', + deleteBtn: 'btn-delete-assets', + localCacheLists: 'local-cache-lists', + localImageList: 'local-image-list', + localVideoList: 'local-video-list', + localImageBody: 'local-image-body', + localVideoBody: 'local-video-body', + onlineAssetsTable: 'online-assets-table', + batchProgress: 'batch-progress', + batchProgressText: 'batch-progress-text', + pauseActionBtn: 'btn-pause-action', + stopActionBtn: 'btn-stop-action', + failureDetailsBtn: 'btn-failure-details', + confirmDialog: 'confirm-dialog', + confirmMessage: 'confirm-message', + confirmOk: 'confirm-ok', + confirmCancel: 'confirm-cancel', + failureDialog: 'failure-dialog', + failureList: 'failure-list', + failureClose: 'failure-close', + failureRetry: 'failure-retry' +}; + +function setText(el, text) { + if (el) el.textContent = text; +} + +function resolveOnlineStatus(status) { + if (status === 'ok') { + return { text: '连接正常', className: 'text-xs text-green-600 mt-1' }; + } + if (status === 'no_token') { + return { text: '无可用 Token', className: 'text-xs text-orange-500 mt-1' }; + } + if (status === 'not_loaded') { + return { text: '未加载', className: 'text-xs text-[var(--accents-4)] mt-1' }; + } + return { text: '无法连接', className: 'text-xs text-red-500 mt-1' }; +} + +function createIconButton(title, svg, onClick) { + const btn = document.createElement('button'); + btn.className = 'cache-icon-button'; + btn.title = title; + btn.innerHTML = svg; + btn.addEventListener('click', onClick); + return btn; +} + +async function init() { + apiKey = await ensureAdminKey(); + if (apiKey === null) return; + cacheUI(); + setupCacheCards(); + setupConfirmDialog(); + setupFailureDialog(); + setupBatchControls(); + await loadStats(); + await showCacheSection('image'); +} + +function setupCacheCards() { + if (!ui.cacheCards) return; + ui.cacheCards.forEach(card => { + card.addEventListener('click', () => { + const type = card.getAttribute('data-type'); + if (type) toggleCacheList(type); + }); + }); +} + +function cacheUI() { + Object.entries(UI_MAP).forEach(([key, id]) => { + ui[key] = byId(id); + }); + ui.cacheCards = document.querySelectorAll('.cache-card'); +} + +function ensureUI() { + if (!ui.batchActions) cacheUI(); +} + +let confirmResolver = null; + +function setupConfirmDialog() { + const dialog = ui.confirmDialog; + if (!dialog) return; + + dialog.addEventListener('close', () => { + if (!confirmResolver) return; + const ok = dialog.returnValue === 'ok'; + confirmResolver(ok); + confirmResolver = null; + }); + + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + dialog.close('cancel'); + }); + + dialog.addEventListener('click', (event) => { + if (event.target === dialog) { + dialog.close('cancel'); + } + }); + + if (ui.confirmOk) { + ui.confirmOk.addEventListener('click', () => dialog.close('ok')); + } + if (ui.confirmCancel) { + ui.confirmCancel.addEventListener('click', () => dialog.close('cancel')); + } +} + +function setupFailureDialog() { + const dialog = ui.failureDialog; + if (!dialog) return; + if (ui.failureClose) { + ui.failureClose.addEventListener('click', () => dialog.close()); + } + if (ui.failureRetry) { + ui.failureRetry.addEventListener('click', () => retryFailed()); + } + dialog.addEventListener('click', (event) => { + if (event.target === dialog) { + dialog.close(); + } + }); +} + +function setupBatchControls() { + if (ui.pauseActionBtn) { + ui.pauseActionBtn.addEventListener('click', () => togglePause()); + } + if (ui.stopActionBtn) { + ui.stopActionBtn.addEventListener('click', () => stopActiveBatch()); + } + if (ui.failureDetailsBtn) { + ui.failureDetailsBtn.addEventListener('click', () => showFailureDetails()); + } +} + +function confirmAction(message, options = {}) { + ensureUI(); + const dialog = ui.confirmDialog; + if (!dialog || typeof dialog.showModal !== 'function') { + return Promise.resolve(window.confirm(message)); + } + if (ui.confirmMessage) ui.confirmMessage.textContent = message; + if (ui.confirmOk) ui.confirmOk.textContent = options.okText || '确定'; + if (ui.confirmCancel) ui.confirmCancel.textContent = options.cancelText || '取消'; + return new Promise(resolve => { + confirmResolver = resolve; + dialog.showModal(); + }); +} + +function formatTime(ms) { + if (!ms) return ''; + const dt = new Date(ms); + return dt.toLocaleString('zh-CN', { hour12: false }); +} + +function calcPercent(processed, total) { + return total ? Math.floor((processed / total) * 100) : 0; +} + +const accountStates = new Map(); +let isBatchLoading = false; +let isLoadPaused = false; +let batchQueue = []; +let batchTokens = []; +let batchTotal = 0; +let batchProcessed = 0; +let isBatchDeleting = false; +let isDeletePaused = false; +let deleteTotal = 0; +let deleteProcessed = 0; +let currentBatchTaskId = null; +let batchEventSource = null; + +async function loadStats(options = {}) { + try { + ensureUI(); + const merge = options.merge === true; + const silent = options.silent === true; + const params = new URLSearchParams(); + if (options.tokens && options.tokens.length) { + params.set('tokens', options.tokens.join(',')); + currentScope = 'selected'; + } else if (options.scope === 'all') { + params.set('scope', 'all'); + currentScope = 'all'; + } else if (currentToken) { + params.set('token', currentToken); + currentScope = 'single'; + } else { + currentScope = 'none'; + } + const url = `/v1/admin/cache${params.toString() ? `?${params.toString()}` : ''}`; + const res = await fetch(url, { + headers: buildAuthHeaders(apiKey) + }); + + if (res.status === 401) { + logout(); + return; + } + const data = await res.json(); + applyStatsData(data, merge); + return data; + } catch (e) { + if (!silent) showToast('加载统计失败', 'error'); + return null; + } +} + +function applyStatsData(data, merge = false) { + if (!merge) { + accountStates.clear(); + } + + setText(ui.imgCount, data.local_image.count); + setText(ui.imgSize, `${data.local_image.size_mb} MB`); + setText(ui.videoCount, data.local_video.count); + setText(ui.videoSize, `${data.local_video.size_mb} MB`); + setText(ui.onlineCount, data.online.count); + + const online = data.online || {}; + const status = resolveOnlineStatus(online.status); + setOnlineStatus(status.text, status.className); + + // Update master accounts list + updateAccountSelect(data.online_accounts || []); + + // Update dynamic states + const details = Array.isArray(data.online_details) ? data.online_details : []; + details.forEach(detail => { + accountStates.set(detail.token, { + count: detail.count, + status: detail.status, + last_asset_clear_at: detail.last_asset_clear_at + }); + }); + if (online?.token) { + accountStates.set(online.token, { + count: online.count, + status: online.status, + last_asset_clear_at: online.last_asset_clear_at + }); + } + + if (data.online_scope === 'all') { + currentScope = 'all'; + currentToken = ''; + } else if (data.online_scope === 'selected') { + currentScope = 'selected'; + } else if (online.token) { + currentScope = 'single'; + currentToken = online.token; + } else { + currentScope = 'none'; + } + + const timeText = formatTime(online.last_asset_clear_at); + setText(ui.onlineLastClear, timeText ? `上次清空:${timeText}` : ''); + + renderAccountTable(data); +} + +function updateAccountSelect(accounts) { + accountMap.clear(); + accounts.forEach(account => { + accountMap.set(account.token, account); + }); +} + +function renderAccountTable(data) { + const tbody = ui.accountTableBody; + const empty = ui.accountEmpty; + if (!tbody || !empty) return; + + const details = Array.isArray(data.online_details) ? data.online_details : []; + const accounts = Array.isArray(data.online_accounts) ? data.online_accounts : []; + const detailsMap = new Map(details.map(item => [item.token, item])); + let rows = []; + + if (accounts.length > 0) { + rows = accounts.map(item => { + const detail = detailsMap.get(item.token); + const state = accountStates.get(item.token); + let count = '-'; + let status = 'not_loaded'; + let last_asset_clear_at = item.last_asset_clear_at; + + if (detail) { + count = detail.count; + status = detail.status; + last_asset_clear_at = detail.last_asset_clear_at ?? last_asset_clear_at; + } else if (item.token === data.online?.token) { + count = data.online.count; + status = data.online.status; + last_asset_clear_at = data.online.last_asset_clear_at ?? last_asset_clear_at; + } else if (state) { + count = state.count; + status = state.status; + last_asset_clear_at = state.last_asset_clear_at ?? last_asset_clear_at; + } + + return { + token: item.token, + token_masked: item.token_masked, + pool: item.pool, + count, + status, + last_asset_clear_at + }; + }); + } else if (details.length > 0) { + rows = details.map(item => ({ + token: item.token, + token_masked: item.token_masked, + pool: (accountMap.get(item.token) || {}).pool || '-', + count: item.count, + status: item.status, + last_asset_clear_at: item.last_asset_clear_at + })); + } + + if (rows.length === 0) { + tbody.replaceChildren(); + empty.classList.remove('hidden'); + return; + } + + empty.classList.add('hidden'); + const selected = selectedTokens; + const fragment = document.createDocumentFragment(); + rows.forEach(row => { + const tr = document.createElement('tr'); + const isSelected = selected.has(row.token); + if (isSelected) tr.classList.add('row-selected'); + + const tdCheck = document.createElement('td'); + tdCheck.className = 'text-center'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'checkbox'; + checkbox.checked = isSelected; + checkbox.setAttribute('data-token', row.token); + checkbox.addEventListener('change', () => toggleSelect(row.token, checkbox)); + tdCheck.appendChild(checkbox); + + const tdToken = document.createElement('td'); + tdToken.className = 'text-left'; + const tokenWrap = document.createElement('div'); + tokenWrap.className = 'flex items-center gap-2'; + const tokenText = document.createElement('span'); + tokenText.className = 'font-mono text-xs text-gray-500'; + tokenText.title = row.token; + tokenText.textContent = row.token_masked || row.token; + tokenWrap.appendChild(tokenText); + tdToken.appendChild(tokenWrap); + + const tdPool = document.createElement('td'); + tdPool.className = 'text-center'; + const poolBadge = document.createElement('span'); + poolBadge.className = 'badge badge-gray'; + poolBadge.textContent = row.pool || '-'; + tdPool.appendChild(poolBadge); + + const tdCount = document.createElement('td'); + tdCount.className = 'text-center'; + const countBadge = document.createElement('span'); + countBadge.className = 'badge badge-gray'; + countBadge.textContent = row.count === '-' ? '未加载' : row.count; + tdCount.appendChild(countBadge); + + const tdLast = document.createElement('td'); + tdLast.className = 'text-left text-xs text-gray-500'; + tdLast.textContent = formatTime(row.last_asset_clear_at) || '-'; + + const tdActions = document.createElement('td'); + tdActions.className = 'text-center'; + const actionsWrap = document.createElement('div'); + actionsWrap.className = 'flex items-center justify-center gap-2'; + actionsWrap.appendChild(createIconButton( + '清空', + ``, + () => clearOnlineCache(row.token) + )); + tdActions.appendChild(actionsWrap); + + tr.appendChild(tdCheck); + tr.appendChild(tdToken); + tr.appendChild(tdPool); + tr.appendChild(tdCount); + tr.appendChild(tdLast); + tr.appendChild(tdActions); + fragment.appendChild(tr); + }); + tbody.replaceChildren(fragment); + syncSelectAllState(); + updateSelectedCount(); + updateBatchActionsVisibility(); +} + +async function clearCache(type) { + const ok = await confirmAction(`确定要清空本地${type === 'image' ? '图片' : '视频'}缓存吗?`, { okText: '清空' }); + if (!ok) return; + + try { + const res = await fetch('/v1/admin/cache/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ type }) + }); + + const data = await res.json(); + if (data.status === 'success') { + showToast(`清理成功,释放 ${data.result.size_mb} MB`, 'success'); + const state = cacheListState[type]; + if (state) { + state.items = []; + state.loaded = true; + } + if (selectedLocal[type]) selectedLocal[type].clear(); + if (state && state.visible) { + renderLocalCacheList(type, []); + } else { + syncLocalSelectAllState(type); + updateSelectedCount(); + } + loadStats(); + } else { + showToast('清理失败', 'error'); + } + } catch (e) { + showToast('请求失败', 'error'); + } +} + +function toggleSelect(token, checkbox) { + if (checkbox && checkbox.checked) { + selectedTokens.add(token); + } else { + selectedTokens.delete(token); + } + if (checkbox) { + const row = checkbox.closest('tr'); + if (row) row.classList.toggle('row-selected', checkbox.checked); + } + syncSelectAllState(); + updateSelectedCount(); +} + +function toggleSelectAll(checkbox) { + const shouldSelect = checkbox.checked; + selectedTokens.clear(); + if (shouldSelect) { + accountMap.forEach((_, token) => selectedTokens.add(token)); + } + syncRowCheckboxes(); + updateSelectedCount(); +} + +function toggleLocalSelect(type, name, checkbox) { + const set = selectedLocal[type]; + if (!set) return; + if (checkbox && checkbox.checked) { + set.add(name); + } else { + set.delete(name); + } + if (checkbox) { + const row = checkbox.closest('tr'); + if (row) row.classList.toggle('row-selected', checkbox.checked); + } + syncLocalSelectAllState(type); + updateSelectedCount(); +} + +function toggleLocalSelectAll(type, checkbox) { + const set = selectedLocal[type]; + if (!set) return; + const shouldSelect = checkbox && checkbox.checked; + set.clear(); + if (shouldSelect) { + const items = cacheListState[type]?.items || []; + items.forEach(item => { + if (item && item.name) set.add(item.name); + }); + } + syncLocalRowCheckboxes(type); + updateSelectedCount(); +} + +function syncLocalRowCheckboxes(type) { + const body = type === 'image' ? ui.localImageBody : ui.localVideoBody; + if (!body) return; + const set = selectedLocal[type]; + const checkboxes = body.querySelectorAll('input[type="checkbox"].checkbox'); + checkboxes.forEach(cb => { + const name = cb.getAttribute('data-name'); + if (!name) return; + cb.checked = set.has(name); + const row = cb.closest('tr'); + if (row) row.classList.toggle('row-selected', cb.checked); + }); + syncLocalSelectAllState(type); +} + +function syncLocalSelectAllState(type) { + const selectAll = type === 'image' ? ui.localImageSelectAll : ui.localVideoSelectAll; + if (!selectAll) return; + const total = cacheListState[type]?.items?.length || 0; + const selected = selectedLocal[type]?.size || 0; + selectAll.checked = total > 0 && selected === total; + selectAll.indeterminate = selected > 0 && selected < total; +} + +function syncRowCheckboxes() { + const tbody = ui.accountTableBody; + if (!tbody) return; + const checkboxes = tbody.querySelectorAll('input[type="checkbox"].checkbox'); + checkboxes.forEach(cb => { + const token = cb.getAttribute('data-token'); + if (!token) return; + cb.checked = selectedTokens.has(token); + const row = cb.closest('tr'); + if (row) row.classList.toggle('row-selected', cb.checked); + }); +} + +function syncSelectAllState() { + const selectAll = ui.selectAll; + if (!selectAll) return; + const total = accountMap.size; + const selected = selectedTokens.size; + selectAll.checked = total > 0 && selected === total; + selectAll.indeterminate = selected > 0 && selected < total; +} + +function updateSelectedCount() { + const el = ui.selectedCount; + const selected = getActiveSelectedSet().size; + if (el) el.textContent = String(selected); + setActionButtonsState(); + updateBatchActionsVisibility(); +} + +function updateBatchActionsVisibility() { + const bar = ui.batchActions; + if (!bar) return; + bar.classList.remove('hidden'); +} + +function updateLoadButton() { + const btn = ui.loadBtn; + if (!btn) return; + if (currentSection === 'online') { + btn.textContent = '加载'; + btn.title = ''; + } else { + btn.textContent = '刷新'; + btn.title = ''; + } +} + +function updateDeleteButton() { + const btn = ui.deleteBtn; + if (!btn) return; + if (currentSection === 'online') { + btn.textContent = '清理'; + btn.title = ''; + } else { + btn.textContent = '删除'; + btn.title = ''; + } +} + + +function setActionButtonsState() { + const loadBtn = ui.loadBtn; + const deleteBtn = ui.deleteBtn; + const disabled = isBatchLoading || isBatchDeleting || isLocalDeleting; + const noSelection = getActiveSelectedSet().size === 0; + if (loadBtn) { + if (currentSection === 'online') { + loadBtn.disabled = disabled || noSelection; + } else { + loadBtn.disabled = disabled; + } + } + if (deleteBtn) { + if (currentSection === 'online') { + deleteBtn.disabled = disabled || noSelection; + } else { + deleteBtn.disabled = disabled || noSelection; + } + } +} + +function updateBatchProgress() { + const container = ui.batchProgress; + if (!container || !ui.batchProgressText) return; + if (currentSection !== 'online') { + container.classList.add('hidden'); + if (ui.pauseActionBtn) ui.pauseActionBtn.classList.add('hidden'); + if (ui.stopActionBtn) ui.stopActionBtn.classList.add('hidden'); + return; + } + if (!isBatchLoading && !isBatchDeleting) { + container.classList.add('hidden'); + if (ui.pauseActionBtn) ui.pauseActionBtn.classList.add('hidden'); + if (ui.stopActionBtn) ui.stopActionBtn.classList.add('hidden'); + return; + } + + const isLoading = isBatchLoading; + const processed = isLoading ? batchProcessed : deleteProcessed; + const total = isLoading ? batchTotal : deleteTotal; + const percent = calcPercent(processed, total); + ui.batchProgressText.textContent = `${percent}%`; + container.classList.remove('hidden'); + + if (ui.pauseActionBtn) { + ui.pauseActionBtn.classList.add('hidden'); + } + if (ui.stopActionBtn) { + ui.stopActionBtn.classList.remove('hidden'); + } +} + +function refreshBatchUI() { + setActionButtonsState(); + updateBatchActionsVisibility(); + updateBatchProgress(); +} + +function setOnlineStatus(text, className) { + const statusEl = ui.onlineStatus; + if (!statusEl) return; + statusEl.textContent = text; + statusEl.className = className; +} + +function getActiveSelectedSet() { + if (currentSection === 'online') return selectedTokens; + return selectedLocal[currentSection] || new Set(); +} + +function updateToolbarForSection() { + updateLoadButton(); + updateDeleteButton(); + updateSelectedCount(); + updateBatchProgress(); +} + +function updateOnlineCountFromTokens(tokens) { + let total = 0; + tokens.forEach(token => { + const state = accountStates.get(token); + if (state && typeof state.count === 'number') { + total += state.count; + } + }); + setText(ui.onlineCount, String(total)); +} + +function formatSize(bytes) { + if (bytes === 0 || bytes === null || bytes === undefined) return '-'; + const kb = 1024; + const mb = kb * 1024; + if (bytes >= mb) return `${(bytes / mb).toFixed(2)} MB`; + if (bytes >= kb) return `${(bytes / kb).toFixed(1)} KB`; + return `${bytes} B`; +} + +async function showCacheSection(type) { + ensureUI(); + currentSection = type; + if (ui.cacheCards) { + ui.cacheCards.forEach(card => { + const cardType = card.getAttribute('data-type'); + card.classList.toggle('selected', cardType === type); + }); + } + if (type === 'image') { + cacheListState.image.visible = true; + cacheListState.video.visible = false; + if (cacheListState.image.loaded) renderLocalCacheList('image', cacheListState.image.items); + else await loadLocalCacheList('image'); + if (ui.localCacheLists) ui.localCacheLists.classList.remove('hidden'); + if (ui.localImageList) ui.localImageList.classList.remove('hidden'); + if (ui.localVideoList) ui.localVideoList.classList.add('hidden'); + if (ui.onlineAssetsTable) ui.onlineAssetsTable.classList.add('hidden'); + updateToolbarForSection(); + return; + } + if (type === 'video') { + cacheListState.video.visible = true; + cacheListState.image.visible = false; + if (cacheListState.video.loaded) renderLocalCacheList('video', cacheListState.video.items); + else await loadLocalCacheList('video'); + if (ui.localCacheLists) ui.localCacheLists.classList.remove('hidden'); + if (ui.localVideoList) ui.localVideoList.classList.remove('hidden'); + if (ui.localImageList) ui.localImageList.classList.add('hidden'); + if (ui.onlineAssetsTable) ui.onlineAssetsTable.classList.add('hidden'); + updateToolbarForSection(); + return; + } + if (type === 'online') { + cacheListState.image.visible = false; + cacheListState.video.visible = false; + if (ui.localCacheLists) ui.localCacheLists.classList.add('hidden'); + if (ui.localImageList) ui.localImageList.classList.add('hidden'); + if (ui.localVideoList) ui.localVideoList.classList.add('hidden'); + if (ui.onlineAssetsTable) ui.onlineAssetsTable.classList.remove('hidden'); + updateToolbarForSection(); + } +} + +async function toggleCacheList(type) { + await showCacheSection(type); +} + +async function loadLocalCacheList(type) { + const body = type === 'image' ? ui.localImageBody : ui.localVideoBody; + if (!body) return; + body.innerHTML = `加载中...`; + try { + const params = new URLSearchParams({ type, page: '1', page_size: '1000' }); + const res = await fetch(`/v1/admin/cache/list?${params.toString()}`, { + headers: buildAuthHeaders(apiKey) + }); + if (!res.ok) { + body.innerHTML = `加载失败`; + return; + } + const data = await res.json(); + const items = Array.isArray(data.items) ? data.items : []; + cacheListState[type].items = items; + cacheListState[type].loaded = true; + const keep = new Set(items.map(item => item.name)); + const selected = selectedLocal[type]; + Array.from(selected).forEach(name => { + if (!keep.has(name)) selected.delete(name); + }); + renderLocalCacheList(type, items); + } catch (e) { + body.innerHTML = `加载失败`; + } +} + +function renderLocalCacheList(type, items) { + const body = type === 'image' ? ui.localImageBody : ui.localVideoBody; + if (!body) return; + if (!items || items.length === 0) { + body.innerHTML = `暂无文件`; + syncLocalSelectAllState(type); + return; + } + const selected = selectedLocal[type]; + const fragment = document.createDocumentFragment(); + items.forEach(item => { + const tr = document.createElement('tr'); + const isSelected = selected.has(item.name); + if (isSelected) tr.classList.add('row-selected'); + + const tdCheck = document.createElement('td'); + tdCheck.className = 'text-center'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'checkbox'; + checkbox.checked = isSelected; + checkbox.setAttribute('data-name', item.name); + checkbox.onchange = () => toggleLocalSelect(type, item.name, checkbox); + tdCheck.appendChild(checkbox); + + const tdName = document.createElement('td'); + tdName.className = 'text-left'; + const nameWrap = document.createElement('div'); + nameWrap.className = 'flex items-center gap-2'; + if (item.preview_url) { + const img = document.createElement('img'); + img.src = item.preview_url; + img.alt = ''; + img.className = 'cache-preview'; + nameWrap.appendChild(img); + } + const nameText = document.createElement('span'); + nameText.className = 'font-mono text-xs text-gray-500'; + nameText.textContent = item.name; + nameWrap.appendChild(nameText); + tdName.appendChild(nameWrap); + + const tdSize = document.createElement('td'); + tdSize.className = 'text-left'; + tdSize.textContent = formatSize(item.size_bytes); + + const tdTime = document.createElement('td'); + tdTime.className = 'text-left text-xs text-gray-500'; + tdTime.textContent = formatTime(item.mtime_ms); + + const tdActions = document.createElement('td'); + tdActions.className = 'text-center'; + tdActions.innerHTML = ` +
+ + +
+ `; + + tr.appendChild(tdCheck); + tr.appendChild(tdName); + tr.appendChild(tdSize); + tr.appendChild(tdTime); + tr.appendChild(tdActions); + fragment.appendChild(tr); + }); + body.replaceChildren(fragment); + syncLocalSelectAllState(type); + updateSelectedCount(); +} + +function viewLocalFile(type, name) { + const safeName = encodeURIComponent(name); + const url = type === 'image' ? `/v1/files/image/${safeName}` : `/v1/files/video/${safeName}`; + window.open(url, '_blank'); +} + +async function deleteLocalFile(type, name) { + const ok = await confirmAction(`确定要删除该文件吗?`, { okText: '删除' }); + if (!ok) return; + const okDelete = await requestDeleteLocalFile(type, name); + if (!okDelete) return; + showToast('删除成功', 'success'); + const state = cacheListState[type]; + if (state && Array.isArray(state.items)) { + state.items = state.items.filter(item => item.name !== name); + state.loaded = true; + selectedLocal[type]?.delete(name); + if (state.visible) renderLocalCacheList(type, state.items); + } + await loadStats(); +} + +async function requestDeleteLocalFile(type, name) { + try { + const res = await fetch('/v1/admin/cache/item/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ type, name }) + }); + return res.ok; + } catch (e) { + return false; + } +} + +async function deleteSelectedLocal(type) { + const selected = selectedLocal[type]; + const names = selected ? Array.from(selected) : []; + if (names.length === 0) { + showToast('未选择文件', 'info'); + return; + } + const ok = await confirmAction(`确定要删除选中的 ${names.length} 个文件吗?`, { okText: '删除' }); + if (!ok) return; + isLocalDeleting = true; + setActionButtonsState(); + let success = 0; + let failed = 0; + const batchSize = 10; + for (let i = 0; i < names.length; i += batchSize) { + const chunk = names.slice(i, i + batchSize); + const results = await Promise.all(chunk.map(name => requestDeleteLocalFile(type, name))); + results.forEach((ok, idx) => { + if (ok) { + success += 1; + } else { + failed += 1; + } + }); + } + const state = cacheListState[type]; + if (state && Array.isArray(state.items)) { + const toRemove = new Set(names); + state.items = state.items.filter(item => !toRemove.has(item.name)); + state.loaded = true; + } + selectedLocal[type].clear(); + if (state && state.visible) renderLocalCacheList(type, state.items); + await loadStats(); + isLocalDeleting = false; + setActionButtonsState(); + if (failed === 0) { + showToast(`已删除 ${success} 个文件`, 'success'); + } else { + showToast(`删除完成:成功 ${success},失败 ${failed}`, 'info'); + } +} + +function handleLoadClick() { + ensureUI(); + if (isBatchLoading || isBatchDeleting) { + showToast('当前有任务进行中', 'info'); + return; + } + if (currentSection === 'online') { + loadSelectedAccounts(); + } else { + loadLocalCacheList(currentSection); + } +} + +function handleDeleteClick() { + ensureUI(); + if (isBatchLoading || isBatchDeleting) { + showToast('当前有任务进行中', 'info'); + return; + } + if (currentSection === 'online') { + clearSelectedAccounts(); + } else { + deleteSelectedLocal(currentSection); + } +} + +function stopBatchLoad(options = {}) { + if (!isBatchLoading) return; + isBatchLoading = false; + isLoadPaused = false; + currentBatchAction = null; + batchQueue = []; + BatchSSE.close(batchEventSource); + batchEventSource = null; + currentBatchTaskId = null; + setOnlineStatus('已终止', 'text-xs text-[var(--accents-4)] mt-1'); + updateLoadButton(); + refreshBatchUI(); + if (!options.silent) showToast('已终止剩余加载请求', 'info'); +} + +function stopBatchDelete(options = {}) { + if (!isBatchDeleting) return; + isBatchDeleting = false; + isDeletePaused = false; + currentBatchAction = null; + batchQueue = []; + BatchSSE.close(batchEventSource); + batchEventSource = null; + currentBatchTaskId = null; + updateDeleteButton(); + refreshBatchUI(); + if (!options.silent) showToast('已终止剩余清理请求', 'info'); +} + +function togglePause() { + if (isBatchLoading || isBatchDeleting) { + showToast('当前批量任务不支持暂停', 'info'); + } +} + +function stopActiveBatch() { + if (isBatchLoading) { + BatchSSE.cancel(currentBatchTaskId, apiKey); + stopBatchLoad(); + } else if (isBatchDeleting) { + BatchSSE.cancel(currentBatchTaskId, apiKey); + stopBatchDelete(); + } +} + +function getMaskedToken(token) { + const meta = accountMap.get(token); + if (meta && meta.token_masked) return meta.token_masked; + if (!token) return ''; + return token.length > 12 ? `${token.slice(0, 6)}...${token.slice(-4)}` : token; +} + +function showFailureDetails() { + ensureUI(); + const dialog = ui.failureDialog; + if (!dialog || !ui.failureList) return; + let action = currentBatchAction || lastBatchAction; + if (!action) { + action = deleteFailed.size > 0 ? 'delete' : 'load'; + } + const failures = action === 'delete' ? deleteFailed : loadFailed; + ui.failureList.innerHTML = ''; + failures.forEach((reason, token) => { + const item = document.createElement('div'); + item.className = 'failure-item'; + const tokenEl = document.createElement('div'); + tokenEl.className = 'failure-token'; + tokenEl.textContent = getMaskedToken(token); + const reasonEl = document.createElement('div'); + reasonEl.textContent = reason; + item.appendChild(tokenEl); + item.appendChild(reasonEl); + ui.failureList.appendChild(item); + }); + dialog.showModal(); +} + +function retryFailed() { + const action = currentBatchAction || lastBatchAction || (deleteFailed.size > 0 ? 'delete' : 'load'); + const failures = action === 'delete' ? deleteFailed : loadFailed; + const tokens = Array.from(failures.keys()); + if (tokens.length === 0) return; + if (isBatchLoading || isBatchDeleting) { + showToast('请等待当前任务结束', 'info'); + return; + } + if (ui.failureDialog) ui.failureDialog.close(); + if (action === 'delete') { + startBatchDelete(tokens); + } else { + startBatchLoad(tokens); + } +} + +async function startBatchLoad(tokens) { + if (isBatchLoading) { + showToast('正在加载中,请稍候', 'info'); + return; + } + if (isBatchDeleting) { + showToast('正在清理中,请稍候', 'info'); + return; + } + if (!tokens || tokens.length === 0) return; + isBatchLoading = true; + isLoadPaused = false; + currentBatchAction = 'load'; + lastBatchAction = 'load'; + loadFailed.clear(); + batchTokens = tokens.slice(); + batchQueue = tokens.slice(); + batchTotal = batchQueue.length; + batchProcessed = 0; + + batchTokens.forEach(token => accountStates.delete(token)); + updateOnlineCountFromTokens(batchTokens); + setOnlineStatus('加载中', 'text-xs text-blue-600 mt-1'); + updateLoadButton(); + if (accountMap.size > 0) { + renderAccountTable({ online_accounts: Array.from(accountMap.values()), online_details: [], online: {} }); + } + refreshBatchUI(); + + try { + const res = await fetch('/v1/admin/cache/online/load/async', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ tokens }) + }); + const data = await res.json(); + if (!res.ok || data.status !== 'success') { + throw new Error(data.detail || '请求失败'); + } + + currentBatchTaskId = data.task_id; + BatchSSE.close(batchEventSource); + batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, { + onMessage: (msg) => { + if (msg.type === 'snapshot' || msg.type === 'progress') { + if (typeof msg.total === 'number') batchTotal = msg.total; + if (typeof msg.processed === 'number') batchProcessed = msg.processed; + updateBatchProgress(); + } else if (msg.type === 'done') { + if (typeof msg.total === 'number') batchTotal = msg.total; + batchProcessed = batchTotal; + updateBatchProgress(); + const result = msg.result; + if (result) { + applyStatsData(result, true); + const details = Array.isArray(result.online_details) ? result.online_details : []; + loadFailed.clear(); + details.forEach(detail => { + if (detail.status !== 'ok') loadFailed.set(detail.token, detail.status); + }); + } + finishBatchLoad(); + if (msg.warning) { + showToast(`加载完成\n⚠️ ${msg.warning}`, 'warning'); + } + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } else if (msg.type === 'cancelled') { + stopBatchLoad({ silent: true }); + showToast('已终止加载', 'info'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } else if (msg.type === 'error') { + stopBatchLoad({ silent: true }); + showToast('加载失败: ' + (msg.error || '未知错误'), 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } + }, + onError: () => { + stopBatchLoad({ silent: true }); + showToast('连接中断', 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } + }); + } catch (e) { + stopBatchLoad({ silent: true }); + showToast(e.message || '请求失败', 'error'); + } +} + +function finishBatchLoad() { + isBatchLoading = false; + isLoadPaused = false; + currentBatchAction = null; + updateOnlineCountFromTokens(batchTokens); + const hasError = batchTokens.some(token => { + const state = accountStates.get(token); + return !state || (state.status && state.status !== 'ok'); + }); + if (batchTokens.length === 0) { + setOnlineStatus('未加载', 'text-xs text-[var(--accents-4)] mt-1'); + } else if (hasError) { + setOnlineStatus('部分异常', 'text-xs text-orange-500 mt-1'); + } else { + setOnlineStatus('连接正常', 'text-xs text-green-600 mt-1'); + } + updateLoadButton(); + refreshBatchUI(); +} + +async function loadSelectedAccounts() { + if (selectedTokens.size === 0) { + showToast('请选择要加载的账号', 'error'); + return; + } + startBatchLoad(Array.from(selectedTokens)); +} + +async function loadAllAccounts() { + const tokens = Array.from(accountMap.keys()); + if (tokens.length === 0) { + showToast('暂无可用账号', 'error'); + return; + } + startBatchLoad(tokens); +} + +async function clearSelectedAccounts() { + if (selectedTokens.size === 0) { + showToast('请选择要清空的账号', 'error'); + return; + } + if (isBatchDeleting) { + showToast('正在清理中,请稍候', 'info'); + return; + } + if (isBatchLoading) { + showToast('正在加载中,请稍候', 'info'); + return; + } + const ok = await confirmAction(`确定要清空选中的 ${selectedTokens.size} 个账号在线资产吗?`, { okText: '清空' }); + if (!ok) return; + startBatchDelete(Array.from(selectedTokens)); +} + +async function startBatchDelete(tokens) { + if (!tokens || tokens.length === 0) return; + isBatchDeleting = true; + isDeletePaused = false; + currentBatchAction = 'delete'; + lastBatchAction = 'delete'; + deleteFailed.clear(); + deleteTotal = tokens.length; + deleteProcessed = 0; + batchQueue = tokens.slice(); + showToast('正在批量清理在线资产,请稍候...', 'info'); + updateDeleteButton(); + refreshBatchUI(); + try { + const res = await fetch('/v1/admin/cache/online/clear/async', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ tokens }) + }); + const data = await res.json(); + if (!res.ok || data.status !== 'success') { + throw new Error(data.detail || '请求失败'); + } + + currentBatchTaskId = data.task_id; + BatchSSE.close(batchEventSource); + batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, { + onMessage: (msg) => { + if (msg.type === 'snapshot' || msg.type === 'progress') { + if (typeof msg.total === 'number') deleteTotal = msg.total; + if (typeof msg.processed === 'number') deleteProcessed = msg.processed; + updateBatchProgress(); + } else if (msg.type === 'done') { + if (typeof msg.total === 'number') deleteTotal = msg.total; + deleteProcessed = deleteTotal; + updateBatchProgress(); + const result = msg.result; + deleteFailed.clear(); + if (result && result.results) { + Object.entries(result.results).forEach(([token, res]) => { + if (res.status !== 'success') { + deleteFailed.set(token, res.error || '清理失败'); + } + }); + } + finishBatchDelete(); + if (msg.warning) { + showToast(`清理完成\n⚠️ ${msg.warning}`, 'warning'); + } + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } else if (msg.type === 'cancelled') { + stopBatchDelete({ silent: true }); + showToast('已终止清理', 'info'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } else if (msg.type === 'error') { + stopBatchDelete({ silent: true }); + showToast('清理失败: ' + (msg.error || '未知错误'), 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } + }, + onError: () => { + stopBatchDelete({ silent: true }); + showToast('连接中断', 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } + }); + } catch (e) { + stopBatchDelete({ silent: true }); + showToast(e.message || '请求失败', 'error'); + } +} + +function finishBatchDelete() { + isBatchDeleting = false; + isDeletePaused = false; + currentBatchAction = null; + updateDeleteButton(); + refreshBatchUI(); + showToast('批量清理完成', 'success'); + loadStats(); +} + +async function clearOnlineCache(targetToken = '', skipConfirm = false) { + const tokenToClear = targetToken || (currentScope === 'all' ? '' : currentToken); + if (!tokenToClear) { + showToast('请选择要清空的账号', 'error'); + return; + } + const meta = accountMap.get(tokenToClear); + const label = meta ? meta.token_masked : tokenToClear; + if (!skipConfirm) { + const ok = await confirmAction(`确定要清空账号 ${label} 的在线资产吗?`, { okText: '清空' }); + if (!ok) return; + } + + showToast('正在清理在线资产,请稍候...', 'info'); + + try { + const res = await fetch('/v1/admin/cache/online/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ token: tokenToClear }) + }); + + const data = await res.json(); + if (data.status === 'success') { + showToast(`清理完成 (成功: ${data.result.success}, 失败: ${data.result.failed})`, 'success'); + } else { + showToast('清理失败', 'error'); + } + } catch (e) { + showToast('请求超时或失败', 'error'); + } +} + +window.onload = init; diff --git a/app/static/admin/js/config.js b/app/static/admin/js/config.js new file mode 100644 index 0000000000000000000000000000000000000000..c1c9726637b0be3ad9a9220e5f2e00ffa6981633 --- /dev/null +++ b/app/static/admin/js/config.js @@ -0,0 +1,619 @@ +let apiKey = ''; +let currentConfig = {}; +const byId = (id) => document.getElementById(id); +const NUMERIC_FIELDS = new Set([ + 'timeout', + 'max_retry', + 'retry_backoff_base', + 'retry_backoff_factor', + 'retry_backoff_max', + 'retry_budget', + 'refresh_interval_hours', + 'super_refresh_interval_hours', + 'fail_threshold', + 'limit_mb', + 'save_delay_ms', + 'usage_flush_interval_sec', + 'upload_concurrent', + 'upload_timeout', + 'download_concurrent', + 'download_timeout', + 'list_concurrent', + 'list_timeout', + 'list_batch_size', + 'delete_concurrent', + 'delete_timeout', + 'delete_batch_size', + 'reload_interval_sec', + 'stream_timeout', + 'final_timeout', + 'blocked_grace_seconds', + 'final_min_bytes', + 'medium_min_bytes', + 'blocked_parallel_attempts', + 'concurrent', + 'batch_size' +]); + +const LOCALE_MAP = { + "app": { + "label": "应用设置", + "api_key": { title: "API 密钥", desc: "调用 Grok2API 服务的 Token(可选,支持多个,逗号分隔或数组)。" }, + "app_key": { title: "后台密码", desc: "登录 Grok2API 管理后台的密码(必填)。" }, + "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 记忆功能。" }, + "stream": { title: "流式响应", desc: "是否默认启用流式输出。" }, + "thinking": { title: "思维链", desc: "是否默认启用思维链输出。" }, + "dynamic_statsig": { title: "动态指纹", desc: "是否默认启用动态生成 Statsig 指纹。" }, + "filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" } + }, + + + "proxy": { + "label": "代理配置", + "base_proxy_url": { title: "基础代理 URL", desc: "代理请求到 Grok 官网的基础服务地址。" }, + "asset_proxy_url": { title: "资源代理 URL", desc: "代理请求到 Grok 官网的静态资源(图片/视频)地址。" }, + "enabled": { title: "启用 CF 自动刷新", desc: "启用后将通过 FlareSolverr 自动获取 cf_clearance。" }, + "flaresolverr_url": { title: "FlareSolverr 地址", desc: "FlareSolverr 服务的 HTTP 地址(如 http://flaresolverr:8191)。" }, + "refresh_interval": { title: "刷新间隔(秒)", desc: "自动刷新 cf_clearance 的时间间隔,建议不低于 300 秒。" }, + "timeout": { title: "挑战超时(秒)", desc: "等待 FlareSolverr 解决 CF 挑战的最大时间。" }, + "cf_clearance": { title: "CF Clearance", desc: "Cloudflare Clearance Cookie,用于绕过反爬虫验证。启用自动刷新时由系统自动管理。" }, + "browser": { title: "浏览器指纹", desc: "curl_cffi 浏览器指纹标识(如 chrome136)。启用自动刷新时由系统自动管理。" }, + "user_agent": { title: "User-Agent", desc: "HTTP 请求的 User-Agent 字符串。启用自动刷新时由系统自动管理。" } + }, + + + "retry": { + "label": "重试策略", + "max_retry": { title: "最大重试次数", desc: "请求 Grok 服务失败时的最大重试次数。" }, + "retry_status_codes": { title: "重试状态码", desc: "触发重试的 HTTP 状态码列表。" }, + "retry_backoff_base": { title: "退避基数", desc: "重试退避的基础延迟(秒)。" }, + "retry_backoff_factor": { title: "退避倍率", desc: "重试退避的指数放大系数。" }, + "retry_backoff_max": { title: "退避上限", desc: "单次重试等待的最大延迟(秒)。" }, + "retry_budget": { title: "退避预算", desc: "单次请求的最大重试总耗时(秒)。" } + }, + + + "chat": { + "label": "对话配置", + "concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" }, + "timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" }, + "stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" } + }, + + + "video": { + "label": "视频配置", + "concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" }, + "timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" }, + "stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" } + }, + + + "image": { + "label": "图像配置", + "timeout": { title: "请求超时", desc: "WebSocket 请求超时时间(秒)。" }, + "stream_timeout": { title: "流空闲超时", desc: "WebSocket 流式空闲超时时间(秒)。" }, + "final_timeout": { title: "最终图超时", desc: "收到中等图后等待最终图的超时秒数。" }, + "blocked_grace_seconds": { title: "审查宽限秒数", desc: "收到中等图后,判定疑似被审查的宽限秒数(默认 10 秒,可自定义)。" }, + "nsfw": { title: "NSFW 模式", desc: "WebSocket 请求是否启用 NSFW。" }, + "medium_min_bytes": { title: "中等图最小字节", desc: "判定中等质量图的最小字节数。" }, + "final_min_bytes": { title: "最终图最小字节", desc: "判定最终图的最小字节数(通常 JPG > 100KB)。" }, + "blocked_parallel_enabled": { title: "启用并行补偿", desc: "疑似审查/拦截时,是否启用并行补偿生成。" }, + "blocked_parallel_attempts": { title: "拦截补偿并发次数", desc: "疑似审查/拦截导致无最终图时,自动并行补偿生成次数。" } + }, + + + "imagine_fast": { + "label": "Imagine Fast 配置", + "n": { title: "生成数量", desc: "仅用于 grok-imagine-1.0-fast 的服务端统一生成数量(1-10)。" }, + "size": { title: "图片尺寸", desc: "仅用于 grok-imagine-1.0-fast 的服务端统一尺寸。" }, + "response_format": { title: "响应格式", desc: "仅用于 grok-imagine-1.0-fast 的服务端统一返回格式。" } + }, + + + "asset": { + "label": "资产配置", + "upload_concurrent": { title: "上传并发", desc: "上传接口的最大并发数。推荐 30。" }, + "upload_timeout": { title: "上传超时", desc: "上传接口超时时间(秒)。推荐 60。" }, + "download_concurrent": { title: "下载并发", desc: "下载接口的最大并发数。推荐 30。" }, + "download_timeout": { title: "下载超时", desc: "下载接口超时时间(秒)。推荐 60。" }, + "list_concurrent": { title: "查询并发", desc: "资产查询接口的最大并发数。推荐 10。" }, + "list_timeout": { title: "查询超时", desc: "资产查询接口超时时间(秒)。推荐 60。" }, + "list_batch_size": { title: "查询批次大小", desc: "单次查询可处理的 Token 数量。推荐 10。" }, + "delete_concurrent": { title: "删除并发", desc: "资产删除接口的最大并发数。推荐 10。" }, + "delete_timeout": { title: "删除超时", desc: "资产删除接口超时时间(秒)。推荐 60。" }, + "delete_batch_size": { title: "删除批次大小", desc: "单次删除可处理的 Token 数量。推荐 10。" } + }, + + + "voice": { + "label": "语音配置", + "timeout": { title: "请求超时", desc: "Voice 请求超时时间(秒)。" } + }, + + + "token": { + "label": "Token 池管理", + "auto_refresh": { title: "自动刷新", desc: "是否开启 Token 自动刷新机制。" }, + "refresh_interval_hours": { title: "刷新间隔", desc: "普通 Token 刷新的时间间隔(小时)。" }, + "super_refresh_interval_hours": { title: "Super 刷新间隔", desc: "Super Token 刷新的时间间隔(小时)。" }, + "fail_threshold": { title: "失败阈值", desc: "单个 Token 连续失败多少次后被标记为不可用。" }, + "save_delay_ms": { title: "保存延迟", desc: "Token 变更合并写入的延迟(毫秒)。" }, + "usage_flush_interval_sec": { title: "用量落库间隔", desc: "用量类字段写入数据库的最小间隔(秒)。" }, + "reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" } + }, + + + "cache": { + "label": "缓存管理", + "enable_auto_clean": { title: "自动清理", desc: "是否启用缓存自动清理,开启后按上限自动回收。" }, + "limit_mb": { title: "清理阈值", desc: "缓存大小阈值(MB),超过阈值会触发清理。" } + }, + + + "nsfw": { + "label": "NSFW 配置", + "concurrent": { title: "并发上限", desc: "批量开启 NSFW 模式时的并发请求上限。推荐 10。" }, + "batch_size": { title: "批次大小", desc: "批量开启 NSFW 模式的单批处理数量。推荐 50。" }, + "timeout": { title: "请求超时", desc: "NSFW 开启相关请求的超时时间(秒)。推荐 60。" } + }, + + + "usage": { + "label": "Usage 配置", + "concurrent": { title: "并发上限", desc: "批量刷新用量时的并发请求上限。推荐 10。" }, + "batch_size": { title: "批次大小", desc: "批量刷新用量的单批处理数量。推荐 50。" }, + "timeout": { title: "请求超时", desc: "用量查询接口的超时时间(秒)。推荐 60。" } + } +}; + +// 配置部分说明(可选) +const SECTION_DESCRIPTIONS = { + "proxy": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。" +}; + +// CF 自动刷新联动禁用字段(全部在 proxy section 内) +const CF_MANAGED_PROXY_KEYS = ['cf_clearance', 'browser', 'user_agent']; +const CF_REFRESH_SUB_KEYS = ['flaresolverr_url', 'refresh_interval', 'timeout']; + +const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index])); + +function getText(section, key) { + if (LOCALE_MAP[section] && LOCALE_MAP[section][key]) { + return LOCALE_MAP[section][key]; + } + return { + title: key.replace(/_/g, ' '), + desc: '暂无说明,请参考配置文档。' + }; +} + +function getSectionLabel(section) { + return (LOCALE_MAP[section] && LOCALE_MAP[section].label) || `${section} 设置`; +} + +function sortByOrder(keys, orderMap) { + if (!orderMap) return keys; + return keys.sort((a, b) => { + const ia = orderMap.get(a); + const ib = orderMap.get(b); + if (ia !== undefined && ib !== undefined) return ia - ib; + if (ia !== undefined) return -1; + if (ib !== undefined) return 1; + return 0; + }); +} + +function setInputMeta(input, section, key) { + input.dataset.section = section; + input.dataset.key = key; +} + +function createOption(value, text, selectedValue) { + const option = document.createElement('option'); + option.value = value; + option.text = text; + if (selectedValue !== undefined && selectedValue === value) option.selected = true; + return option; +} + +function buildBooleanInput(section, key, val) { + const label = document.createElement('label'); + label.className = 'relative inline-flex items-center cursor-pointer'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = val; + input.className = 'sr-only peer'; + setInputMeta(input, section, key); + + const slider = document.createElement('div'); + slider.className = "w-9 h-5 bg-[var(--accents-2)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-black"; + + label.appendChild(input); + label.appendChild(slider); + + return { input, node: label }; +} + +function buildSelectInput(section, key, val, options) { + const input = document.createElement('select'); + input.className = 'geist-input h-[34px]'; + setInputMeta(input, section, key); + options.forEach(opt => { + input.appendChild(createOption(opt.val, opt.text, val)); + }); + return { input, node: input }; +} + +function buildJsonInput(section, key, val) { + const input = document.createElement('textarea'); + input.className = 'geist-input font-mono text-xs'; + input.rows = 4; + input.value = JSON.stringify(val, null, 2); + setInputMeta(input, section, key); + input.dataset.type = 'json'; + return { input, node: input }; +} + +function buildTextInput(section, key, val) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'geist-input'; + input.value = val; + setInputMeta(input, section, key); + return { input, node: input }; +} + +function buildSecretInput(section, key, val) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'geist-input flex-1 h-[34px]'; + input.value = val; + setInputMeta(input, section, key); + + const wrapper = document.createElement('div'); + wrapper.className = 'flex items-center gap-2'; + + const genBtn = document.createElement('button'); + genBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; + genBtn.type = 'button'; + genBtn.title = '生成'; + genBtn.innerHTML = ``; + genBtn.onclick = () => { + input.value = randomKey(16); + }; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; + copyBtn.type = 'button'; + copyBtn.innerHTML = ``; + 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; + loadData(); +} + +async function loadData() { + try { + const res = await fetch('/v1/admin/config', { + headers: buildAuthHeaders(apiKey) + }); + if (res.ok) { + currentConfig = await res.json(); + renderConfig(currentConfig); + } else if (res.status === 401) { + logout(); + } + } catch (e) { + showToast('连接失败', 'error'); + } +} + +function renderConfig(data) { + const container = byId('config-container'); + if (!container) return; + container.replaceChildren(); + + const fragment = document.createDocumentFragment(); + const sections = sortByOrder(Object.keys(data), SECTION_ORDER); + + sections.forEach(section => { + const items = data[section]; + const localeSection = LOCALE_MAP[section]; + const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null; + + const allKeys = sortByOrder(Object.keys(items), keyOrder); + const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies')); + + if (visibleKeys.length > 0) { + const card = document.createElement('div'); + card.className = 'config-section'; + + const header = document.createElement('div'); + header.innerHTML = `
${getSectionLabel(section)}
`; + + // 添加部分说明(如果有) + if (SECTION_DESCRIPTIONS[section]) { + const descP = document.createElement('p'); + descP.className = 'text-[var(--accents-4)] text-sm mt-1 mb-4'; + descP.textContent = SECTION_DESCRIPTIONS[section]; + header.appendChild(descP); + } + + card.appendChild(header); + + const grid = document.createElement('div'); + grid.className = 'config-grid'; + + visibleKeys.forEach(key => { + const fieldCard = buildFieldCard(section, key, items[key]); + grid.appendChild(fieldCard); + }); + + card.appendChild(grid); + if (grid.children.length > 0) { + fragment.appendChild(card); + } + } + }); + + container.appendChild(fragment); + + // 初始化 CF 自动刷新联动状态 + const cfEnabled = data.proxy && data.proxy.enabled; + applyCfRefreshState(cfEnabled); +} + +function applyCfRefreshState(enabled) { + // 设置字段禁用状态的辅助函数 + function setFieldDisabled(section, key, disabled) { + const input = document.querySelector( + `input[data-section="${section}"][data-key="${key}"],` + + `textarea[data-section="${section}"][data-key="${key}"],` + + `select[data-section="${section}"][data-key="${key}"]` + ); + if (!input) return; + input.disabled = disabled; + // 找到最近的 .config-field 父元素设置样式 + const field = input.closest('.config-field'); + if (field) { + field.style.opacity = disabled ? '0.45' : ''; + field.style.pointerEvents = disabled ? 'none' : ''; + } + } + + // enabled=true → 灰掉 cf_clearance/browser/user_agent + CF_MANAGED_PROXY_KEYS.forEach(k => setFieldDisabled('proxy', k, !!enabled)); + // enabled=false → 灰掉 flaresolverr_url/refresh_interval/timeout + CF_REFRESH_SUB_KEYS.forEach(k => setFieldDisabled('proxy', k, !enabled)); +} + +function buildFieldCard(section, key, val) { + const text = getText(section, key); + + const fieldCard = document.createElement('div'); + fieldCard.className = 'config-field'; + + // Title + const titleEl = document.createElement('div'); + titleEl.className = 'config-field-title'; + titleEl.textContent = text.title; + fieldCard.appendChild(titleEl); + + // Description (Muted) - 只在有描述时显示 + if (text.desc) { + const descEl = document.createElement('p'); + descEl.className = 'config-field-desc'; + descEl.textContent = text.desc; + fieldCard.appendChild(descEl); + } + + // Input Wrapper + const inputWrapper = document.createElement('div'); + inputWrapper.className = 'config-field-input'; + + // Input Logic + let built; + if (typeof val === 'boolean') { + built = buildBooleanInput(section, key, val); + } + else if (key === 'image_format') { + built = buildSelectInput(section, key, val, [ + { val: 'url', text: 'URL' }, + { val: 'base64', text: 'Base64' } + ]); + } + else if (key === 'video_format') { + built = buildSelectInput(section, key, val, [ + { val: 'html', text: 'HTML' }, + { val: 'url', text: 'URL' } + ]); + } + else if (section === 'imagine_fast' && key === 'size') { + built = buildSelectInput(section, key, val, [ + { val: '1024x1024', text: '1024x1024 (1:1)' }, + { val: '1280x720', text: '1280x720 (16:9)' }, + { val: '720x1280', text: '720x1280 (9:16)' }, + { val: '1792x1024', text: '1792x1024 (3:2)' }, + { val: '1024x1792', text: '1024x1792 (2:3)' } + ]); + } + else if (section === 'imagine_fast' && key === 'response_format') { + built = buildSelectInput(section, key, val, [ + { val: 'url', text: 'URL' }, + { val: 'b64_json', text: 'B64 JSON' }, + { val: 'base64', text: 'Base64' } + ]); + } + else if (Array.isArray(val) || typeof val === 'object') { + built = buildJsonInput(section, key, val); + } + else { + if (key === 'api_key' || key === 'app_key' || key === 'public_key') { + built = buildSecretInput(section, key, val); + } else { + built = buildTextInput(section, key, val); + } + } + + if (built) { + inputWrapper.appendChild(built.node); + } + fieldCard.appendChild(inputWrapper); + + // proxy.enabled (CF 自动刷新) 联动(toggle 本身始终可交互) + if (section === 'proxy' && key === 'enabled' && built && built.input) { + fieldCard.style.pointerEvents = 'auto'; + fieldCard.style.opacity = ''; + built.input.addEventListener('change', () => { + applyCfRefreshState(built.input.checked); + }); + } + + if (section === 'app' && key === 'public_enabled') { + fieldCard.classList.add('has-action'); + const link = document.createElement('a'); + link.href = '/login'; + link.className = 'config-field-action flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; + link.title = '功能玩法'; + link.setAttribute('aria-label', '功能玩法'); + link.innerHTML = ``; + link.style.display = val ? 'inline-flex' : 'none'; + fieldCard.appendChild(link); + if (built && built.input) { + built.input.addEventListener('change', () => { + link.style.display = built.input.checked ? 'inline-flex' : 'none'; + }); + } + } + + return fieldCard; +} + +async function saveConfig() { + const btn = byId('save-btn'); + const originalText = btn.innerText; + btn.disabled = true; + btn.innerText = '保存中...'; + + try { + const newConfig = typeof structuredClone === 'function' + ? structuredClone(currentConfig) + : JSON.parse(JSON.stringify(currentConfig)); + const inputs = document.querySelectorAll('input[data-section], textarea[data-section], select[data-section]'); + + inputs.forEach(input => { + const s = input.dataset.section; + const k = input.dataset.key; + let val = input.value; + + if (input.type === 'checkbox') { + val = input.checked; + } else if (input.dataset.type === 'json') { + try { val = JSON.parse(val); } catch (e) { throw new Error(`无效的 JSON: ${getText(s, k).title}`); } + } else if (k === 'app_key' && val.trim() === '') { + throw new Error('app_key 不能为空(后台密码)'); + } else if (NUMERIC_FIELDS.has(k)) { + if (val.trim() !== '' && !Number.isNaN(Number(val))) { + val = Number(val); + } + } + + if (!newConfig[s]) newConfig[s] = {}; + newConfig[s][k] = val; + }); + + if (newConfig.proxy && newConfig.proxy.enabled) { + const url = String(newConfig.proxy.flaresolverr_url || '').trim(); + if (!url) { + showToast('启用自动刷新时必须填写 FlareSolverr 地址', 'error'); + btn.disabled = false; + btn.innerText = originalText; + return; + } + } + + const res = await fetch('/v1/admin/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify(newConfig) + }); + + if (res.ok) { + btn.innerText = '成功'; + showToast('配置已保存', 'success'); + setTimeout(() => { + btn.innerText = originalText; + btn.style.backgroundColor = ''; + }, 2000); + } else { + showToast('保存失败', 'error'); + } + } catch (e) { + showToast('错误: ' + e.message, 'error'); + } finally { + if (btn.innerText === '保存中...') { + btn.disabled = false; + btn.innerText = originalText; + } else { + btn.disabled = false; + } + } +} + +async function copyToClipboard(text, btn) { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + + btn.innerHTML = ``; + btn.style.backgroundColor = '#10b981'; + btn.style.borderColor = '#10b981'; + + setTimeout(() => { + btn.innerHTML = ``; + btn.style.backgroundColor = ''; + btn.style.borderColor = ''; + }, 2000); + } catch (err) { + console.error('Failed to copy', err); + } +} + +window.onload = init; diff --git a/app/static/admin/js/login.js b/app/static/admin/js/login.js new file mode 100644 index 0000000000000000000000000000000000000000..48dfe9567d633453d3bb147ed5b372c4407306fe --- /dev/null +++ b/app/static/admin/js/login.js @@ -0,0 +1,53 @@ +const apiKeyInput = document.getElementById('api-key-input'); +const publicKeyInput = document.getElementById('public-key-input'); +if (apiKeyInput) { + apiKeyInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') login(); + }); +} +if (publicKeyInput) { + publicKeyInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') login(); + }); +} + +async function requestLogin(key) { + const res = await fetch('/v1/admin/verify', { + method: 'GET', + headers: { 'Authorization': `Bearer ${key}` } + }); + return res.ok; +} + +async function login() { + const input = (apiKeyInput ? apiKeyInput.value : '').trim(); + const publicKey = (publicKeyInput ? publicKeyInput.value : '').trim(); + if (!input) return; + + try { + const ok = await requestLogin(input); + if (ok) { + await storeAppKey(input); + if (publicKey) { + await storePublicKey(publicKey); + } + window.location.href = '/admin/token'; + } else { + showToast('密钥无效', 'error'); + } + } catch (e) { + showToast('连接失败', 'error'); + } +} + +// Auto-redirect checks +(async () => { + const existingKey = await getStoredAppKey(); + if (!existingKey) return; + try { + const ok = await requestLogin(existingKey); + if (ok) window.location.href = '/admin/token'; + } catch (e) { + return; + } +})(); diff --git a/app/static/admin/js/token.js b/app/static/admin/js/token.js new file mode 100644 index 0000000000000000000000000000000000000000..8c41e78b05a607aa0bd58672f8e8bcc9453b734b --- /dev/null +++ b/app/static/admin/js/token.js @@ -0,0 +1,1125 @@ +let apiKey = ''; +let allTokens = {}; +let flatTokens = []; +let isBatchProcessing = false; +let isBatchPaused = false; +let batchQueue = []; +let batchTotal = 0; +let batchProcessed = 0; +let currentBatchAction = null; +let currentFilter = 'all'; +let currentBatchTaskId = null; +let batchEventSource = null; +let currentPage = 1; +let pageSize = 50; + +const byId = (id) => document.getElementById(id); +const qsa = (selector) => document.querySelectorAll(selector); +const DEFAULT_QUOTA_BASIC = 80; +const DEFAULT_QUOTA_SUPER = 140; + +function getDefaultQuotaForPool(pool) { + return pool === 'ssoSuper' ? DEFAULT_QUOTA_SUPER : DEFAULT_QUOTA_BASIC; +} + +function setText(id, text) { + const el = byId(id); + if (el) el.innerText = text; +} + +function openModal(id) { + const modal = byId(id); + if (!modal) return null; + modal.classList.remove('hidden'); + requestAnimationFrame(() => { + modal.classList.add('is-open'); + }); + return modal; +} + +function closeModal(id, onClose) { + const modal = byId(id); + if (!modal) return; + modal.classList.remove('is-open'); + setTimeout(() => { + modal.classList.add('hidden'); + if (onClose) onClose(); + }, 200); +} + +function downloadTextFile(content, filename) { + const blob = new Blob([content], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); +} + +async function readJsonResponse(res) { + const text = await res.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`响应不是有效 JSON (HTTP ${res.status})`); + } +} + +function getSelectedTokens() { + return flatTokens.filter(t => t._selected); +} + +function countSelected(tokens) { + let count = 0; + for (const t of tokens) { + if (t._selected) count++; + } + return count; +} + +function setSelectedForTokens(tokens, selected) { + tokens.forEach(t => { + t._selected = selected; + }); +} + +function syncVisibleSelectionUI(selected) { + qsa('#token-table-body input[type="checkbox"]').forEach(input => { + input.checked = selected; + }); + qsa('#token-table-body tr').forEach(row => { + row.classList.toggle('row-selected', selected); + }); +} + +function getPaginationData() { + const filteredTokens = getFilteredTokens(); + const totalCount = filteredTokens.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (currentPage > totalPages) currentPage = totalPages; + const startIndex = (currentPage - 1) * pageSize; + const visibleTokens = filteredTokens.slice(startIndex, startIndex + pageSize); + return { filteredTokens, totalCount, totalPages, visibleTokens }; +} + +async function init() { + apiKey = await ensureAdminKey(); + if (apiKey === null) return; + setupEditPoolDefaults(); + setupConfirmDialog(); + loadData(); +} + +async function loadData() { + try { + const res = await fetch('/v1/admin/tokens', { + headers: buildAuthHeaders(apiKey) + }); + if (res.ok) { + const data = await res.json(); + allTokens = data; + processTokens(data); + updateStats(data); + renderTable(); + } else if (res.status === 401) { + logout(); + } else { + throw new Error(`HTTP ${res.status}`); + } + } catch (e) { + showToast('加载失败: ' + e.message, 'error'); + } +} + +// Convert pool dict to flattened array +function processTokens(data) { + flatTokens = []; + Object.keys(data).forEach(pool => { + const tokens = data[pool]; + if (Array.isArray(tokens)) { + tokens.forEach(t => { + // Normalize + const tObj = typeof t === 'string' + ? { token: t, status: 'active', quota: 0, note: '', use_count: 0, tags: [] } + : { + token: t.token, + status: t.status || 'active', + quota: t.quota || 0, + note: t.note || '', + fail_count: t.fail_count || 0, + use_count: t.use_count || 0, + tags: t.tags || [], + created_at: t.created_at, + last_used_at: t.last_used_at, + last_fail_at: t.last_fail_at, + last_fail_reason: t.last_fail_reason, + last_sync_at: t.last_sync_at, + last_asset_clear_at: t.last_asset_clear_at + }; + flatTokens.push({ ...tObj, pool: pool, _selected: false }); + }); + } + }); +} + +function updateStats(data) { + // Logic same as before, simplified reuse if possible, but let's re-run on flatTokens + let totalTokens = flatTokens.length; + let activeTokens = 0; + let coolingTokens = 0; + let invalidTokens = 0; + let nsfwTokens = 0; + let noNsfwTokens = 0; + let chatQuota = 0; + let totalCalls = 0; + + flatTokens.forEach(t => { + if (t.status === 'active') { + activeTokens++; + chatQuota += t.quota; + } else if (t.status === 'cooling') { + coolingTokens++; + } else { + invalidTokens++; + } + if (t.tags && t.tags.includes('nsfw')) { + nsfwTokens++; + } else { + noNsfwTokens++; + } + totalCalls += Number(t.use_count || 0); + }); + + const imageQuota = Math.floor(chatQuota / 2); + + setText('stat-total', totalTokens.toLocaleString()); + setText('stat-active', activeTokens.toLocaleString()); + setText('stat-cooling', coolingTokens.toLocaleString()); + setText('stat-invalid', invalidTokens.toLocaleString()); + + setText('stat-chat-quota', chatQuota.toLocaleString()); + setText('stat-image-quota', imageQuota.toLocaleString()); + setText('stat-total-calls', totalCalls.toLocaleString()); + + updateTabCounts({ + all: totalTokens, + active: activeTokens, + cooling: coolingTokens, + expired: invalidTokens, + nsfw: nsfwTokens, + 'no-nsfw': noNsfwTokens + }); +} + +function renderTable() { + const tbody = byId('token-table-body'); + const loading = byId('loading'); + const emptyState = byId('empty-state'); + + if (loading) loading.classList.add('hidden'); + + // 获取筛选后的列表 + const { totalCount, totalPages, visibleTokens } = getPaginationData(); + const indexByRef = new Map(flatTokens.map((t, i) => [t, i])); + + updatePaginationControls(totalCount, totalPages); + + if (visibleTokens.length === 0) { + tbody.replaceChildren(); + if (emptyState) { + emptyState.textContent = currentFilter === 'all' + ? '暂无 Token,请点击右上角导入或添加。' + : '当前筛选无结果,请切换筛选条件。'; + } + emptyState.classList.remove('hidden'); + updateSelectionState(); + return; + } + emptyState.classList.add('hidden'); + + const fragment = document.createDocumentFragment(); + visibleTokens.forEach((item) => { + // 获取原始索引用于操作 + const originalIndex = indexByRef.get(item); + const tr = document.createElement('tr'); + tr.dataset.index = originalIndex; + if (item._selected) tr.classList.add('row-selected'); + + // Checkbox (Center) + const tdCheck = document.createElement('td'); + tdCheck.className = 'text-center'; + tdCheck.innerHTML = ``; + + // Token (Left) + const tdToken = document.createElement('td'); + tdToken.className = 'text-left'; + const tokenShort = item.token.length > 24 + ? item.token.substring(0, 8) + '...' + item.token.substring(item.token.length - 16) + : item.token; + tdToken.innerHTML = ` +
+ ${tokenShort} + +
+ `; + + // Type (Center) + const tdType = document.createElement('td'); + tdType.className = 'text-center'; + tdType.innerHTML = `${escapeHtml(item.pool)}`; + + // Status (Center) - 显示状态和 nsfw 标签 + const tdStatus = document.createElement('td'); + let statusClass = 'badge-gray'; + if (item.status === 'active') statusClass = 'badge-green'; + else if (item.status === 'cooling') statusClass = 'badge-orange'; + else statusClass = 'badge-red'; + tdStatus.className = 'text-center'; + let statusHtml = `${item.status}`; + if (item.tags && item.tags.includes('nsfw')) { + statusHtml += ` nsfw`; + } + tdStatus.innerHTML = statusHtml; + + // Quota (Center) + const tdQuota = document.createElement('td'); + tdQuota.className = 'text-center font-mono text-xs'; + tdQuota.innerText = item.quota; + + // Note (Left) + const tdNote = document.createElement('td'); + tdNote.className = 'text-left text-gray-500 text-xs truncate max-w-[150px]'; + tdNote.innerText = item.note || '-'; + + // Actions (Center) + const tdActions = document.createElement('td'); + tdActions.className = 'text-center'; + tdActions.innerHTML = ` +
+ + + +
+ `; + + tr.appendChild(tdCheck); + tr.appendChild(tdToken); + tr.appendChild(tdType); + tr.appendChild(tdStatus); + tr.appendChild(tdQuota); + tr.appendChild(tdNote); + tr.appendChild(tdActions); + + fragment.appendChild(tr); + }); + + tbody.replaceChildren(fragment); + updateSelectionState(); +} + +// Selection Logic +function toggleSelectAll() { + const checkbox = byId('select-all'); + const checked = !!(checkbox && checkbox.checked); + // 只选择当前页可见的 Token + setSelectedForTokens(getVisibleTokens(), checked); + syncVisibleSelectionUI(checked); + updateSelectionState(); +} + +function selectAllFiltered() { + const filtered = getFilteredTokens(); + if (filtered.length === 0) return; + setSelectedForTokens(filtered, true); + syncVisibleSelectionUI(true); + updateSelectionState(); +} + +function selectVisibleAll() { + const visible = getVisibleTokens(); + if (visible.length === 0) return; + setSelectedForTokens(visible, true); + syncVisibleSelectionUI(true); + updateSelectionState(); +} + +function clearAllSelection() { + if (flatTokens.length === 0) return; + setSelectedForTokens(flatTokens, false); + syncVisibleSelectionUI(false); + updateSelectionState(); +} + +function toggleSelect(index) { + flatTokens[index]._selected = !flatTokens[index]._selected; + const row = document.querySelector(`#token-table-body tr[data-index="${index}"]`); + if (row) row.classList.toggle('row-selected', flatTokens[index]._selected); + updateSelectionState(); +} + +function updateSelectionState() { + const selectedCount = countSelected(flatTokens); + const visible = getVisibleTokens(); + const visibleSelected = countSelected(visible); + const selectAll = byId('select-all'); + if (selectAll) { + const hasVisible = visible.length > 0; + selectAll.disabled = !hasVisible; + selectAll.checked = hasVisible && visibleSelected === visible.length; + selectAll.indeterminate = visibleSelected > 0 && visibleSelected < visible.length; + } + const selectedCountEl = byId('selected-count'); + if (selectedCountEl) selectedCountEl.innerText = selectedCount; + setActionButtonsState(selectedCount); +} + +// Actions +function addToken() { + openEditModal(-1); +} + +// Batch export (Selected only) +function batchExport() { + const selected = getSelectedTokens(); + if (selected.length === 0) return showToast("未选择 Token", 'error'); + const content = selected.map(t => t.token).join('\n') + '\n'; + downloadTextFile(content, `tokens_export_selected_${new Date().toISOString().slice(0, 10)}.txt`); +} + + +// Modal Logic +let currentEditIndex = -1; +function openEditModal(index) { + const modal = byId('edit-modal'); + if (!modal) return; + + currentEditIndex = index; + + if (index >= 0) { + // Edit existing + const item = flatTokens[index]; + byId('edit-token-display').value = item.token; + byId('edit-original-token').value = item.token; + byId('edit-original-pool').value = item.pool; + byId('edit-pool').value = item.pool; + byId('edit-quota').value = item.quota; + byId('edit-note').value = item.note; + document.querySelector('#edit-modal h3').innerText = '编辑 Token'; + } else { + // New Token + const tokenInput = byId('edit-token-display'); + tokenInput.value = ''; + tokenInput.disabled = false; + tokenInput.placeholder = 'sk-...'; + tokenInput.classList.remove('bg-gray-50', 'text-gray-500'); + + byId('edit-original-token').value = ''; + byId('edit-original-pool').value = ''; + byId('edit-pool').value = 'ssoBasic'; + byId('edit-quota').value = getDefaultQuotaForPool('ssoBasic'); + byId('edit-note').value = ''; + document.querySelector('#edit-modal h3').innerText = '添加 Token'; + } + + openModal('edit-modal'); +} + +function setupEditPoolDefaults() { + const poolSelect = byId('edit-pool'); + const quotaInput = byId('edit-quota'); + if (!poolSelect || !quotaInput) return; + poolSelect.addEventListener('change', () => { + if (currentEditIndex >= 0) return; + quotaInput.value = getDefaultQuotaForPool(poolSelect.value); + }); +} + +function closeEditModal() { + closeModal('edit-modal', () => { + // reset styles for token input + const input = byId('edit-token-display'); + if (input) { + input.disabled = true; + input.classList.add('bg-gray-50', 'text-gray-500'); + } + }); +} + +async function saveEdit() { + // Collect data + let token; + const newPool = byId('edit-pool').value.trim(); + const newQuota = parseInt(byId('edit-quota').value) || 0; + const newNote = byId('edit-note').value.trim().slice(0, 50); + + if (currentEditIndex >= 0) { + // Updating existing + const item = flatTokens[currentEditIndex]; + token = item.token; + + // Update flatTokens first to reflect UI + item.pool = newPool || 'ssoBasic'; + item.quota = newQuota; + item.note = newNote; + } else { + // Creating new + token = byId('edit-token-display').value.trim(); + if (!token) return showToast('Token 不能为空', 'error'); + + // Check if exists + if (flatTokens.some(t => t.token === token)) { + return showToast('Token 已存在', 'error'); + } + + flatTokens.push({ + token: token, + pool: newPool || 'ssoBasic', + quota: newQuota, + note: newNote, + status: 'active', // default + use_count: 0, + _selected: false + }); + } + + await syncToServer(); + closeEditModal(); + // Reload to ensure consistent state/grouping + // Or simpler: just re-render but syncToServer does the hard work + loadData(); +} + +async function deleteToken(index) { + const ok = await confirmAction('确定要删除此 Token 吗?', { okText: '删除' }); + if (!ok) return; + flatTokens.splice(index, 1); + syncToServer().then(loadData); +} + +function batchDelete() { + startBatchDelete(); +} + +// Reconstruct object structure and save +async function syncToServer() { + const newTokens = {}; + flatTokens.forEach(t => { + if (!newTokens[t.pool]) newTokens[t.pool] = []; + const payload = { + token: t.token, + status: t.status, + quota: t.quota, + note: t.note, + fail_count: t.fail_count, + use_count: t.use_count || 0, + tags: Array.isArray(t.tags) ? t.tags : [] + }; + if (typeof t.created_at === 'number') payload.created_at = t.created_at; + if (typeof t.last_used_at === 'number') payload.last_used_at = t.last_used_at; + if (typeof t.last_fail_at === 'number') payload.last_fail_at = t.last_fail_at; + if (typeof t.last_sync_at === 'number') payload.last_sync_at = t.last_sync_at; + if (typeof t.last_asset_clear_at === 'number') payload.last_asset_clear_at = t.last_asset_clear_at; + if (typeof t.last_fail_reason === 'string' && t.last_fail_reason) payload.last_fail_reason = t.last_fail_reason; + newTokens[t.pool].push(payload); + }); + + try { + const res = await fetch('/v1/admin/tokens', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify(newTokens) + }); + if (!res.ok) showToast('保存失败', 'error'); + } catch (e) { + showToast('保存错误: ' + e.message, 'error'); + } +} + +// Import Logic +function openImportModal() { + openModal('import-modal'); +} + +function closeImportModal() { + closeModal('import-modal', () => { + const input = byId('import-text'); + if (input) input.value = ''; + }); +} + +async function submitImport() { + const pool = byId('import-pool').value.trim() || 'ssoBasic'; + const text = byId('import-text').value; + const lines = text.split('\n'); + const defaultQuota = getDefaultQuotaForPool(pool); + + lines.forEach(line => { + const t = line.trim(); + if (t && !flatTokens.some(ft => ft.token === t)) { + flatTokens.push({ + token: t, + pool: pool, + status: 'active', + quota: defaultQuota, + note: '', + tags: [], + fail_count: 0, + use_count: 0, + _selected: false + }); + } + }); + + await syncToServer(); + closeImportModal(); + loadData(); +} + +// Export Logic +function exportTokens() { + if (flatTokens.length === 0) return showToast("列表为空", 'error'); + const content = flatTokens.map(t => t.token).join('\n') + '\n'; + downloadTextFile(content, `tokens_export_${new Date().toISOString().slice(0, 10)}.txt`); +} + +async function copyToClipboard(text, btn) { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + const originalHtml = btn.innerHTML; + btn.innerHTML = ``; + btn.classList.remove('text-gray-400'); + btn.classList.add('text-green-500'); + setTimeout(() => { + btn.innerHTML = originalHtml; + btn.classList.add('text-gray-400'); + btn.classList.remove('text-green-500'); + }, 2000); + } catch (err) { + console.error('Copy failed', err); + } +} + +async function refreshStatus(token) { + try { + const btn = event.currentTarget; // Get button element if triggered by click + if (btn) { + btn.innerHTML = ``; + } + + const res = await fetch('/v1/admin/tokens/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ token: token }) + }); + + const data = await res.json(); + + if (res.ok && data.status === 'success') { + const isSuccess = data.results && data.results[token]; + loadData(); + + if (isSuccess) { + showToast('刷新成功', 'success'); + } else { + showToast('刷新失败', 'error'); + } + } else { + showToast('刷新失败', 'error'); + } + } catch (e) { + console.error(e); + showToast('请求错误', 'error'); + } +} + + +async function startBatchRefresh() { + if (isBatchProcessing) { + showToast('当前有任务进行中', 'info'); + return; + } + + const selected = getSelectedTokens(); + if (selected.length === 0) return showToast("未选择 Token", 'error'); + + // Init state + isBatchProcessing = true; + isBatchPaused = false; + currentBatchAction = 'refresh'; + batchQueue = selected.map(t => t.token); + batchTotal = batchQueue.length; + batchProcessed = 0; + + updateBatchProgress(); + setActionButtonsState(); + + try { + const res = await fetch('/v1/admin/tokens/refresh/async', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ tokens: batchQueue }) + }); + const data = await res.json(); + if (!res.ok || data.status !== 'success') { + throw new Error(data.detail || '请求失败'); + } + + currentBatchTaskId = data.task_id; + BatchSSE.close(batchEventSource); + batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, { + onMessage: (msg) => { + if (msg.type === 'snapshot' || msg.type === 'progress') { + if (typeof msg.total === 'number') batchTotal = msg.total; + if (typeof msg.processed === 'number') batchProcessed = msg.processed; + updateBatchProgress(); + } else if (msg.type === 'done') { + if (typeof msg.total === 'number') batchTotal = msg.total; + batchProcessed = batchTotal; + updateBatchProgress(); + finishBatchProcess(false, { silent: true }); + if (msg.warning) { + showToast(`刷新完成\n⚠️ ${msg.warning}`, 'warning'); + } else { + showToast('刷新完成', 'success'); + } + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } else if (msg.type === 'cancelled') { + finishBatchProcess(true, { silent: true }); + showToast('已终止刷新', 'info'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } else if (msg.type === 'error') { + finishBatchProcess(true, { silent: true }); + showToast('刷新失败: ' + (msg.error || '未知错误'), 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } + }, + onError: () => { + finishBatchProcess(true, { silent: true }); + showToast('连接中断', 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + } + }); + } catch (e) { + finishBatchProcess(true, { silent: true }); + showToast(e.message || '请求失败', 'error'); + currentBatchTaskId = null; + } +} + +function toggleBatchPause() { + if (!isBatchProcessing) return; + showToast('当前任务不支持暂停', 'info'); +} + +function stopBatchRefresh() { + if (!isBatchProcessing) return; + if (currentBatchTaskId) { + BatchSSE.cancel(currentBatchTaskId, apiKey); + BatchSSE.close(batchEventSource); + batchEventSource = null; + currentBatchTaskId = null; + } + finishBatchProcess(true); +} + +function finishBatchProcess(aborted = false, options = {}) { + const action = currentBatchAction; + isBatchProcessing = false; + isBatchPaused = false; + batchQueue = []; + currentBatchAction = null; + + updateBatchProgress(); + setActionButtonsState(); + updateSelectionState(); + loadData(); // Final data refresh + + if (options.silent) return; + if (aborted) { + if (action === 'delete') { + showToast('已终止删除', 'info'); + } else if (action === 'nsfw') { + showToast('已终止 NSFW', 'info'); + } else { + showToast('已终止刷新', 'info'); + } + } else { + if (action === 'delete') { + showToast('删除完成', 'success'); + } else if (action === 'nsfw') { + showToast('NSFW 开启完成', 'success'); + } else { + showToast('刷新完成', 'success'); + } + } +} + +async function batchUpdate() { + startBatchRefresh(); +} + +function updateBatchProgress() { + const container = byId('batch-progress'); + const text = byId('batch-progress-text'); + const pauseBtn = byId('btn-pause-action'); + const stopBtn = byId('btn-stop-action'); + if (!container || !text) return; + if (!isBatchProcessing) { + container.classList.add('hidden'); + if (pauseBtn) pauseBtn.classList.add('hidden'); + if (stopBtn) stopBtn.classList.add('hidden'); + return; + } + const pct = batchTotal ? Math.floor((batchProcessed / batchTotal) * 100) : 0; + text.textContent = `${pct}%`; + container.classList.remove('hidden'); + if (pauseBtn) { + pauseBtn.classList.add('hidden'); + } + if (stopBtn) stopBtn.classList.remove('hidden'); +} + +function setActionButtonsState(selectedCount = null) { + let count = selectedCount; + if (count === null) { + count = countSelected(flatTokens); + } + const disabled = isBatchProcessing; + const exportBtn = byId('btn-batch-export'); + const updateBtn = byId('btn-batch-update'); + const nsfwBtn = byId('btn-batch-nsfw'); + const deleteBtn = byId('btn-batch-delete'); + if (exportBtn) exportBtn.disabled = disabled || count === 0; + if (updateBtn) updateBtn.disabled = disabled || count === 0; + if (nsfwBtn) nsfwBtn.disabled = disabled || count === 0; + if (deleteBtn) deleteBtn.disabled = disabled || count === 0; +} + +async function startBatchDelete() { + if (isBatchProcessing) { + showToast('当前有任务进行中', 'info'); + return; + } + const selected = getSelectedTokens(); + if (selected.length === 0) return showToast("未选择 Token", 'error'); + const ok = await confirmAction(`确定要删除选中的 ${selected.length} 个 Token 吗?`, { okText: '删除' }); + if (!ok) return; + + isBatchProcessing = true; + isBatchPaused = false; + currentBatchAction = 'delete'; + batchQueue = selected.map(t => t.token); + batchTotal = batchQueue.length; + batchProcessed = 0; + + updateBatchProgress(); + setActionButtonsState(); + + try { + const toRemove = new Set(batchQueue); + flatTokens = flatTokens.filter(t => !toRemove.has(t.token)); + await syncToServer(); + batchProcessed = batchTotal; + updateBatchProgress(); + finishBatchProcess(false, { silent: true }); + showToast('删除完成', 'success'); + } catch (e) { + finishBatchProcess(true, { silent: true }); + showToast('删除失败', 'error'); + } +} + +let confirmResolver = null; + +function setupConfirmDialog() { + const dialog = byId('confirm-dialog'); + if (!dialog) return; + const okBtn = byId('confirm-ok'); + const cancelBtn = byId('confirm-cancel'); + dialog.addEventListener('click', (event) => { + if (event.target === dialog) { + closeConfirm(false); + } + }); + if (okBtn) okBtn.addEventListener('click', () => closeConfirm(true)); + if (cancelBtn) cancelBtn.addEventListener('click', () => closeConfirm(false)); +} + +function confirmAction(message, options = {}) { + const dialog = byId('confirm-dialog'); + if (!dialog) { + return Promise.resolve(false); + } + const messageEl = byId('confirm-message'); + const okBtn = byId('confirm-ok'); + const cancelBtn = byId('confirm-cancel'); + if (messageEl) messageEl.textContent = message; + if (okBtn) okBtn.textContent = options.okText || '确定'; + if (cancelBtn) cancelBtn.textContent = options.cancelText || '取消'; + return new Promise(resolve => { + confirmResolver = resolve; + dialog.classList.remove('hidden'); + requestAnimationFrame(() => { + dialog.classList.add('is-open'); + }); + }); +} + +function closeConfirm(ok) { + const dialog = byId('confirm-dialog'); + if (!dialog) return; + dialog.classList.remove('is-open'); + setTimeout(() => { + dialog.classList.add('hidden'); + if (confirmResolver) { + confirmResolver(ok); + confirmResolver = null; + } + }, 200); +} + +function escapeHtml(text) { + if (!text) return ''; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// ========== Tab 筛选功能 ========== + +function filterByStatus(status) { + currentFilter = status; + currentPage = 1; + + // 更新 Tab 样式和 ARIA + document.querySelectorAll('.tab-item').forEach(tab => { + const isActive = tab.dataset.filter === status; + tab.classList.toggle('active', isActive); + tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + + renderTable(); +} + +function getFilteredTokens() { + if (currentFilter === 'all') return flatTokens; + + return flatTokens.filter(t => { + if (currentFilter === 'active') return t.status === 'active'; + if (currentFilter === 'cooling') return t.status === 'cooling'; + if (currentFilter === 'expired') return t.status !== 'active' && t.status !== 'cooling'; + if (currentFilter === 'nsfw') return t.tags && t.tags.includes('nsfw'); + if (currentFilter === 'no-nsfw') return !t.tags || !t.tags.includes('nsfw'); + return true; + }); +} + +function updateTabCounts(counts) { + const safeCounts = counts || { + all: flatTokens.length, + active: flatTokens.filter(t => t.status === 'active').length, + cooling: flatTokens.filter(t => t.status === 'cooling').length, + expired: flatTokens.filter(t => t.status !== 'active' && t.status !== 'cooling').length, + nsfw: flatTokens.filter(t => t.tags && t.tags.includes('nsfw')).length, + 'no-nsfw': flatTokens.filter(t => !t.tags || !t.tags.includes('nsfw')).length + }; + + Object.entries(safeCounts).forEach(([key, count]) => { + const el = byId(`tab-count-${key}`); + if (el) el.textContent = count; + }); +} + +function getVisibleTokens() { + return getPaginationData().visibleTokens; +} + +function updatePaginationControls(totalCount, totalPages) { + const info = byId('pagination-info'); + const prevBtn = byId('page-prev'); + const nextBtn = byId('page-next'); + const sizeSelect = byId('page-size'); + + if (sizeSelect && String(sizeSelect.value) !== String(pageSize)) { + sizeSelect.value = String(pageSize); + } + + if (info) { + info.textContent = `第 ${totalCount === 0 ? 0 : currentPage} / ${totalPages} 页 · 共 ${totalCount} 条`; + } + if (prevBtn) prevBtn.disabled = totalCount === 0 || currentPage <= 1; + if (nextBtn) nextBtn.disabled = totalCount === 0 || currentPage >= totalPages; +} + +function goPrevPage() { + if (currentPage <= 1) return; + currentPage -= 1; + renderTable(); +} + +function goNextPage() { + const totalCount = getFilteredTokens().length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (currentPage >= totalPages) return; + currentPage += 1; + renderTable(); +} + +function changePageSize() { + const sizeSelect = byId('page-size'); + const value = sizeSelect ? parseInt(sizeSelect.value, 10) : 0; + if (!value || value === pageSize) return; + pageSize = value; + currentPage = 1; + renderTable(); +} + +// ========== NSFW 批量开启 ========== + +async function batchEnableNSFW() { + if (isBatchProcessing) { + showToast('当前有任务进行中', 'info'); + return; + } + + const selected = getSelectedTokens(); + const targetCount = selected.length; + if (targetCount === 0) { + showToast('未选择 Token', 'error'); + return; + } + const msg = `是否为选中的 ${targetCount} 个 Token 开启 NSFW 模式?`; + + const ok = await confirmAction(msg, { okText: '开启 NSFW' }); + if (!ok) return; + + // 禁用按钮 + const btn = byId('btn-batch-nsfw'); + if (btn) btn.disabled = true; + + isBatchProcessing = true; + currentBatchAction = 'nsfw'; + batchTotal = targetCount; + batchProcessed = 0; + updateBatchProgress(); + setActionButtonsState(); + + try { + const tokens = selected.length > 0 ? selected.map(t => t.token) : null; + const res = await fetch('/v1/admin/tokens/nsfw/enable/async', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(apiKey) + }, + body: JSON.stringify({ tokens }) + }); + + const data = await readJsonResponse(res); + if (!res.ok) { + const detail = data && (data.detail || data.message); + throw new Error(detail || `HTTP ${res.status}`); + } + if (!data) { + throw new Error(`空响应 (HTTP ${res.status})`); + } + if (data.status !== 'success') { + throw new Error(data.detail || '请求失败'); + } + + currentBatchTaskId = data.task_id; + BatchSSE.close(batchEventSource); + batchEventSource = BatchSSE.open(currentBatchTaskId, apiKey, { + onMessage: (msg) => { + if (msg.type === 'snapshot' || msg.type === 'progress') { + if (typeof msg.total === 'number') batchTotal = msg.total; + if (typeof msg.processed === 'number') batchProcessed = msg.processed; + updateBatchProgress(); + } else if (msg.type === 'done') { + if (typeof msg.total === 'number') batchTotal = msg.total; + batchProcessed = batchTotal; + updateBatchProgress(); + finishBatchProcess(false, { silent: true }); + const summary = msg.result && msg.result.summary ? msg.result.summary : null; + const okCount = summary ? summary.ok : 0; + const failCount = summary ? summary.fail : 0; + let text = `NSFW 开启完成:成功 ${okCount},失败 ${failCount}`; + if (msg.warning) text += `\n⚠️ ${msg.warning}`; + showToast(text, failCount > 0 || msg.warning ? 'warning' : 'success'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + if (btn) btn.disabled = false; + setActionButtonsState(); + } else if (msg.type === 'cancelled') { + finishBatchProcess(true, { silent: true }); + showToast('已终止 NSFW', 'info'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + if (btn) btn.disabled = false; + setActionButtonsState(); + } else if (msg.type === 'error') { + finishBatchProcess(true, { silent: true }); + showToast('开启失败: ' + (msg.error || '未知错误'), 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + if (btn) btn.disabled = false; + setActionButtonsState(); + } + }, + onError: () => { + finishBatchProcess(true, { silent: true }); + showToast('连接中断', 'error'); + currentBatchTaskId = null; + BatchSSE.close(batchEventSource); + batchEventSource = null; + if (btn) btn.disabled = false; + setActionButtonsState(); + } + }); + } catch (e) { + finishBatchProcess(true, { silent: true }); + showToast('请求错误: ' + e.message, 'error'); + if (btn) btn.disabled = false; + setActionButtonsState(); + } +} + + + +window.onload = init; diff --git a/app/static/admin/pages/cache.html b/app/static/admin/pages/cache.html new file mode 100644 index 0000000000000000000000000000000000000000..b59052863577f54fff1c2d62fbd18951c3d72a91 --- /dev/null +++ b/app/static/admin/pages/cache.html @@ -0,0 +1,208 @@ + + + + + + + Grok2API - 缓存管理 + + + + + + + + + + +
+ +
+
+
+
+

缓存管理

+

管理本地资源与在线资产缓存。

+
+
+ +
+ +
+
+
+
+
本地图片
+
+ 0 + 个文件 +
+
+
+
0 MB
+ +
+
+
+ +
+
+
+
本地视频
+
+ 0 + 个文件 +
+
+
+
0 MB
+ +
+
+
+ +
+
+
+
在线资产
+
+ 0 + 个文件 +
+
连接正常
+
+
+
+
+
+ + + +
+ + + + + + + + + + + + +
+ + Token类型资产数上次清空时间操作
+ +
+ +
+ +
+ + +
+
+ 已选择 + 0 + +
+ +
+ + +
+ +
+ + +
+
请确认
+
+
+ + +
+
+
+ + +
+
失败详情
+
+
+ + +
+
+
+ + + + + + + + + + + diff --git a/app/static/admin/pages/config.html b/app/static/admin/pages/config.html new file mode 100644 index 0000000000000000000000000000000000000000..ee550b39cb8411575fb2306b1f8a00b9e86bb722 --- /dev/null +++ b/app/static/admin/pages/config.html @@ -0,0 +1,56 @@ + + + + + + + Grok2API - 配置管理 + + + + + + + + + + + +
+ +
+
+
+
+

配置管理

+

管理 API 密钥及系统参数设置。

+
+ +
+ +
+ +
+
加载中...
+
+ +
+
+ + + + + + + + + + diff --git a/app/static/admin/pages/login.html b/app/static/admin/pages/login.html new file mode 100644 index 0000000000000000000000000000000000000000..1eaaf1c028a4328b5bf7c8833eaae02845b31f8c --- /dev/null +++ b/app/static/admin/pages/login.html @@ -0,0 +1,64 @@ + + + + + + + Korg - 登录 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/admin/pages/token.html b/app/static/admin/pages/token.html new file mode 100644 index 0000000000000000000000000000000000000000..be5d0ba305a468ed1f360c7dd163ea81833312d1 --- /dev/null +++ b/app/static/admin/pages/token.html @@ -0,0 +1,306 @@ + + + + + + + Grok2API - Token 管理 + + + + + + + + + + + + +
+
+ + +
+ +
+
+
+

Token 列表

+

管理 Grok2API 的 Token 服务号池。

+
+
+ + +
+
+ +
+ + +
+ +
+
-
+
Token 总数
+
+
+
-
+
Token 正常
+
+
+
-
+
Token 限流
+
+
+
-
+
Token 失效
+
+ + +
+
-
+
Chat 剩余
+
+
+
-
+
Image 剩余
+
+
+
无法统计
+
Video 剩余
+
+
+
-
+
总调用次数
+
+
+ + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + +
Token类型状态额度备注操作
+
加载中...
+ +
+ + +
+
第 0 / 0 页 · 共 0 条
+
+ + + + + + +
+
+ + + +
+
+ + + +
+
+ 已选择 + 0 + +
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + diff --git a/app/static/common/css/common.css b/app/static/common/css/common.css new file mode 100644 index 0000000000000000000000000000000000000000..def441851675b2d68e7c222f05c9854c7f168781 --- /dev/null +++ b/app/static/common/css/common.css @@ -0,0 +1,408 @@ +:root { + --bg: #fafafa; + --fg: #000; + --accents-1: #eaeaea; + --accents-2: #999; + --accents-3: #888; + --accents-4: #666; + --accents-5: #444; + --accents-6: #333; + --accents-7: #111; + --border: #eaeaea; + --success: #0070f3; + --error: #e00; + --warning: #f5a623; + --radius: 6px; +} + +body { + font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, sans-serif; + background-color: var(--bg); + color: var(--fg); + -webkit-font-smoothing: antialiased; +} + +#app-header { + height: 56px; +} + +.font-mono { + font-family: 'Geist Mono', monospace; +} + +.table-empty { + color: var(--accents-4); + font-size: 12px; + font-weight: 400; + padding: 48px 0; + text-align: center; +} + +td.table-empty { + height: auto; +} + +.fade-in { + animation: fadeIn 0.5s ease-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in-centered { + animation: fadeInCentered 0.5s ease-out forwards; +} + +@keyframes fadeInCentered { + from { + opacity: 0; + transform: translate(-50%, 10px); + } + + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +.geist-input { + width: 100%; + font-size: 0.8125rem; + border-radius: 0.5rem; + padding: 0.35rem 0.5rem; + outline: none; + border: 1px solid transparent; + transition: all 0.2s; + background: #fff; + border: 1px solid #e6e6e6; + color: var(--accents-7); +} + +.geist-input:focus { + border-color: #bdbdbd; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.03); +} + +.geist-input:disabled { + background: #f5f5f5; + color: var(--accents-4); + cursor: not-allowed; +} + +.geist-input::placeholder { + color: var(--accents-3); +} + +.geist-button { + height: 32px; + font-size: 0.875rem; + font-weight: 600; + background-color: #000; + color: #fff; + border-radius: 0.5rem; + padding: 0 1rem; + transition: opacity 0.2s; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.geist-button:hover { + opacity: 0.9; +} + +.geist-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.geist-button-outline { + height: 32px; + font-size: 0.875rem; + font-weight: 500; + background-color: transparent; + color: var(--fg); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0 1rem; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.geist-button-outline:hover { + border-color: #000; +} + +.geist-button-outline:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.geist-button-danger { + height: 32px; + font-size: 0.875rem; + font-weight: 500; + background-color: #e00; + color: #fff; + border-radius: 0.5rem; + padding: 0 1rem; + transition: opacity 0.2s; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.geist-button-danger:hover { + opacity: 0.9; +} + +.geist-button-danger:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.nav-link { + color: var(--accents-4); + transition: color 0.15s; +} + +.nav-link:hover { + color: var(--fg); +} + +.nav-link.active { + color: var(--fg); + font-weight: 500; +} + +.nav-group { + position: relative; +} + +.nav-group-trigger { + background: transparent; + border: 0; + padding: 0; + font: inherit; + cursor: pointer; +} + +.nav-group-menu { + position: absolute; + top: 100%; + left: -8px; + margin-top: 8px; + min-width: 140px; + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + opacity: 0; + visibility: hidden; + transform: translateY(-6px); + transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + z-index: 20; +} + +.nav-group:hover .nav-group-menu, +.nav-group:focus-within .nav-group-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.nav-group-menu .nav-link { + padding: 6px 10px; + border-radius: 6px; + display: flex; + align-items: center; + color: var(--accents-5); +} + +.nav-group-menu .nav-link:hover { + background: #f5f5f5; + color: var(--fg); +} + +.nav-group-menu .nav-link.active { + background: #f0f0f0; + color: var(--fg); + font-weight: 500; +} + +.nav-badge { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + color: var(--accents-5); + background: #f7f7f7; + border: 1px solid var(--border); + white-space: nowrap; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.nav-action-btn { + height: 24px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: #fff; + font-size: 11px; + font-weight: 500; + color: var(--accents-5); + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + transition: all 0.2s; +} + +.nav-action-btn:hover { + border-color: #000; + color: #000; +} + +.nav-action-btn.storage-ready { + background: #ecfdf3; + border-color: #a7f3d0; + color: #047857; +} + +.brand-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; +} + +.github-icon { + width: 14px; + height: 14px; + fill: currentColor; +} + +.app-footer { + position: fixed; + right: 16px; + left: auto; + bottom: 12px; + color: var(--accents-4); + font-size: 12px; + background: transparent; + text-align: right; + z-index: 10; +} + +.app-footer a { + color: inherit; + transition: color 0.15s; +} + +.app-footer a:hover { + color: var(--fg); +} + +.stat-card { + background: #fff; + border: 1px solid transparent; + border-radius: 10px; + padding: 14px 18px; + box-shadow: none; + transition: all 0.2s; +} + +.stat-card:hover { + border-color: #000; + box-shadow: none; +} + +.stat-value { + font-size: 20px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.stat-label { + font-size: 12px; + color: var(--accents-4); + margin-top: 2px; +} + +@media (max-width: 720px) { + #app-header nav > div { + padding-left: 12px; + padding-right: 12px; + gap: 8px; + } + + #app-header nav > div > div:first-child { + gap: 6px; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + #app-header nav > div > div:first-child::-webkit-scrollbar { + display: none; + } + + #app-header .nav-link { + white-space: nowrap; + font-size: 12px; + } + + #app-header .brand-link span { + display: none; + } + + #app-header nav > div > div:first-child > .h-4 { + display: none; + } + + .nav-actions { + flex-shrink: 0; + gap: 6px; + } + + .nav-action-btn { + font-size: 10px; + padding: 0 6px; + height: 22px; + } + + .app-footer { + right: 12px; + left: 12px; + text-align: center; + font-size: 11px; + } +} diff --git a/app/static/common/css/login.css b/app/static/common/css/login.css new file mode 100644 index 0000000000000000000000000000000000000000..119a13a16b694ad6cedaa4ef7e6b03d28d8ca478 --- /dev/null +++ b/app/static/common/css/login.css @@ -0,0 +1,99 @@ +/* Login page styles */ + +.login-body { + min-height: 100vh; + background: var(--bg); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.login-bg { + position: fixed; + inset: 0; + background: + radial-gradient(600px 280px at 20% 20%, rgba(0, 0, 0, 0.06), transparent 60%), + radial-gradient(500px 260px at 80% 80%, rgba(0, 0, 0, 0.04), transparent 55%), + linear-gradient(180deg, #fafafa, #f6f6f6); + z-index: 0; +} + +.login-shell { + position: relative; + z-index: 1; + width: min(420px, 92vw); + padding: 24px; +} + +.login-card { + background: #fff; + border: 1px solid transparent; + border-radius: 14px; + padding: 22px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.login-card:hover { + border-color: #000; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.12); +} + +.login-brand { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accents-4); + font-weight: 600; +} + +.login-brand-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; + transition: color 0.15s ease; +} + +.login-brand-link:hover { + color: var(--fg); +} + +.login-github-icon { + width: 14px; + height: 14px; + fill: currentColor; +} + +.login-title { + margin-top: 6px; + font-size: 18px; + font-weight: 600; + color: var(--accents-7); +} + +.login-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--accents-4); +} + +.login-form { + margin-top: 16px; + display: grid; + gap: 10px; +} + +.login-input { + height: 32px; + font-size: 12px; +} + +.login-button { + height: 32px; + font-size: 12px; + justify-content: center; +} diff --git a/app/static/common/css/toast.css b/app/static/common/css/toast.css new file mode 100644 index 0000000000000000000000000000000000000000..c9d689be639a3c00ff8445945cb17d2f0506d29b --- /dev/null +++ b/app/static/common/css/toast.css @@ -0,0 +1,134 @@ +/* Toast Notification */ +.toast-container { + position: fixed; + top: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + display: flex; + flex-direction: column; + gap: 12px; + pointer-events: none; + align-items: center; +} + +.toast { + background: #fff; + border: 0.5px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + min-width: 300px; + max-width: 400px; + animation: toastIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; + pointer-events: auto; +} + +.toast.out { + animation: toastOut 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.toast-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.toast-success .toast-icon { + background: #ecfdf5; + color: #059669; +} + +.toast-error .toast-icon { + background: #fef2f2; + color: #dc2626; +} + +.toast-content { + flex: 1; + font-size: 12px; + font-weight: 500; +} + +.notice-dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 24px; +} + +.notice-dialog { + background: #fff; + border: 1px solid var(--border); + border-radius: 12px; + width: min(520px, 92vw); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18); + padding: 20px 22px 16px; +} + +.notice-dialog-title { + font-size: 14px; + font-weight: 600; + color: #111; + margin-bottom: 8px; +} + +.notice-dialog-content { + font-size: 13px; + line-height: 1.6; + color: #333; +} + +.notice-dialog-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.notice-dialog-confirm { + border: 1px solid var(--border); + background: #111; + color: #fff; + border-radius: 8px; + padding: 8px 14px; + font-size: 12px; + cursor: pointer; +} + +.notice-dialog-confirm:hover { + opacity: 0.9; +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toastOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + + to { + opacity: 0; + transform: translateY(10px) scale(0.95); + } +} diff --git a/app/static/common/html/footer.html b/app/static/common/html/footer.html new file mode 100644 index 0000000000000000000000000000000000000000..1026540b3e7e4be6162fd4e5f1344fe515194266 --- /dev/null +++ b/app/static/common/html/footer.html @@ -0,0 +1,8 @@ + diff --git a/app/static/common/html/header.html b/app/static/common/html/header.html new file mode 100644 index 0000000000000000000000000000000000000000..2330a996c8c35fedf0225ab3d38ce9ff38e52bbe --- /dev/null +++ b/app/static/common/html/header.html @@ -0,0 +1,28 @@ + diff --git a/app/static/common/html/public-header.html b/app/static/common/html/public-header.html new file mode 100644 index 0000000000000000000000000000000000000000..dc59deb95ee545f96a46e4a540f853bc7adbd4c9 --- /dev/null +++ b/app/static/common/html/public-header.html @@ -0,0 +1,27 @@ + diff --git a/app/static/common/img/favicon/favicon.ico b/app/static/common/img/favicon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1e4505886c2bdb36c01dada0d7c58698df36e2b7 Binary files /dev/null and b/app/static/common/img/favicon/favicon.ico differ diff --git a/app/static/common/js/admin-auth.js b/app/static/common/js/admin-auth.js new file mode 100644 index 0000000000000000000000000000000000000000..4022c06c94905475cf733500c71d337f1f444fb5 --- /dev/null +++ b/app/static/common/js/admin-auth.js @@ -0,0 +1,280 @@ +const APP_KEY_STORAGE = 'grok2api_app_key'; +const PUBLIC_KEY_STORAGE = 'grok2api_public_key'; +const APP_KEY_ENC_PREFIX = 'enc:v1:'; +const APP_KEY_XOR_PREFIX = 'enc:xor:'; +const APP_KEY_SECRET = 'grok2api-admin-key'; +let cachedAdminKey = null; +let cachedPublicKey = null; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function toBase64(bytes) { + let binary = ''; + bytes.forEach(b => { binary += String.fromCharCode(b); }); + return btoa(binary); +} + +function fromBase64(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function xorCipher(bytes, keyBytes) { + const out = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + out[i] = bytes[i] ^ keyBytes[i % keyBytes.length]; + } + return out; +} + +function xorEncrypt(plain) { + const data = textEncoder.encode(plain); + const key = textEncoder.encode(APP_KEY_SECRET); + const cipher = xorCipher(data, key); + return `${APP_KEY_XOR_PREFIX}${toBase64(cipher)}`; +} + +function xorDecrypt(stored) { + if (!stored.startsWith(APP_KEY_XOR_PREFIX)) return stored; + const payload = stored.slice(APP_KEY_XOR_PREFIX.length); + const data = fromBase64(payload); + const key = textEncoder.encode(APP_KEY_SECRET); + const plain = xorCipher(data, key); + return textDecoder.decode(plain); +} + +async function deriveKey(salt) { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + textEncoder.encode(APP_KEY_SECRET), + 'PBKDF2', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +async function encryptAppKey(plain) { + if (!plain) return ''; + if (!crypto?.subtle) return xorEncrypt(plain); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(salt); + const cipher = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + textEncoder.encode(plain) + ); + return `${APP_KEY_ENC_PREFIX}${toBase64(salt)}:${toBase64(iv)}:${toBase64(new Uint8Array(cipher))}`; +} + +async function decryptAppKey(stored) { + if (!stored) return ''; + if (stored.startsWith(APP_KEY_XOR_PREFIX)) return xorDecrypt(stored); + if (!stored.startsWith(APP_KEY_ENC_PREFIX)) return stored; + if (!crypto?.subtle) return ''; + const parts = stored.split(':'); + if (parts.length !== 5) return ''; + const salt = fromBase64(parts[2]); + const iv = fromBase64(parts[3]); + const cipher = fromBase64(parts[4]); + const key = await deriveKey(salt); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + cipher + ); + return textDecoder.decode(plain); +} + +async function getStoredAppKey() { + const stored = localStorage.getItem(APP_KEY_STORAGE) || ''; + if (!stored) return ''; + try { + return await decryptAppKey(stored); + } catch (e) { + clearStoredAppKey(); + return ''; + } +} + +async function getStoredPublicKey() { + const stored = localStorage.getItem(PUBLIC_KEY_STORAGE) || ''; + if (!stored) return ''; + try { + return await decryptAppKey(stored); + } catch (e) { + clearStoredPublicKey(); + return ''; + } +} + +async function storeAppKey(appKey) { + if (!appKey) { + clearStoredAppKey(); + return; + } + const encrypted = await encryptAppKey(appKey); + localStorage.setItem(APP_KEY_STORAGE, encrypted || ''); +} + +async function storePublicKey(publicKey) { + if (!publicKey) { + clearStoredPublicKey(); + return; + } + const encrypted = await encryptAppKey(publicKey); + localStorage.setItem(PUBLIC_KEY_STORAGE, encrypted || ''); +} + +function clearStoredAppKey() { + localStorage.removeItem(APP_KEY_STORAGE); + cachedAdminKey = null; +} + +function clearStoredPublicKey() { + localStorage.removeItem(PUBLIC_KEY_STORAGE); + cachedPublicKey = null; +} + +async function verifyKey(url, key) { + const headers = key ? { 'Authorization': `Bearer ${key}` } : {}; + const res = await fetch(url, { method: 'GET', headers }); + return res.ok; +} + +async function ensureAdminKey() { + if (cachedAdminKey) return cachedAdminKey; + const appKey = await getStoredAppKey(); + if (!appKey) { + window.location.href = '/admin/login'; + return null; + } + try { + const ok = await verifyKey('/v1/admin/verify', appKey); + if (!ok) throw new Error('Unauthorized'); + cachedAdminKey = `Bearer ${appKey}`; + return cachedAdminKey; + } catch (e) { + clearStoredAppKey(); + window.location.href = '/admin/login'; + return null; + } +} + +async function hashPublicKey(key) { + if (!crypto?.subtle) return null; + const data = new TextEncoder().encode('grok2api-public:' + key); + const buf = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +async function ensurePublicKey() { + if (cachedPublicKey !== null) return cachedPublicKey; + + const key = await getStoredPublicKey(); + if (!key) { + try { + const ok = await verifyKey('/v1/public/verify', ''); + if (ok) { + cachedPublicKey = ''; + return cachedPublicKey; + } + } catch (e) { + // ignore + } + return null; + } + + try { + const ok = await verifyKey('/v1/public/verify', key); + if (!ok) throw new Error('Unauthorized'); + const hash = await hashPublicKey(key); + cachedPublicKey = hash ? `Bearer public-${hash}` : `Bearer ${key}`; + return cachedPublicKey; + } catch (e) { + clearStoredPublicKey(); + return null; + } +} + +function buildAuthHeaders(apiKey) { + return apiKey ? { 'Authorization': apiKey } : {}; +} + +function logout() { + clearStoredAppKey(); + clearStoredPublicKey(); + window.location.href = '/admin/login'; +} + +function publicLogout() { + clearStoredPublicKey(); + window.location.href = '/login'; +} + +async function fetchStorageType() { + const apiKey = await ensureAdminKey(); + if (apiKey === null) return null; + try { + const res = await fetch('/v1/admin/storage', { + headers: buildAuthHeaders(apiKey) + }); + if (!res.ok) return null; + const data = await res.json(); + return (data && data.type) ? String(data.type) : null; + } catch (e) { + return null; + } +} + +function formatStorageLabel(type) { + if (!type) return '-'; + const normalized = type.toLowerCase(); + const map = { + local: 'local', + mysql: 'mysql', + pgsql: 'pgsql', + postgres: 'pgsql', + postgresql: 'pgsql', + redis: 'redis' + }; + return map[normalized] || '-'; +} + +async function updateStorageModeButton() { + const btn = document.getElementById('storage-mode-btn'); + if (!btn) return; + btn.textContent = '...'; + btn.title = '存储模式'; + btn.classList.remove('storage-ready'); + const storageType = await fetchStorageType(); + const label = formatStorageLabel(storageType); + btn.textContent = label === '-' ? label : label.toUpperCase(); + btn.title = '存储模式'; + if (label !== '-') { + btn.classList.add('storage-ready'); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', updateStorageModeButton); +} else { + updateStorageModeButton(); +} diff --git a/app/static/common/js/batch-sse.js b/app/static/common/js/batch-sse.js new file mode 100644 index 0000000000000000000000000000000000000000..d1c2f0b08dd8f34c554bac46f7dfb5807e933495 --- /dev/null +++ b/app/static/common/js/batch-sse.js @@ -0,0 +1,55 @@ +(function (global) { + function normalizeApiKey(apiKey) { + if (!apiKey) return ''; + const trimmed = String(apiKey).trim(); + return trimmed.startsWith('Bearer ') ? trimmed.slice(7).trim() : trimmed; + } + + function openBatchStream(taskId, apiKey, handlers = {}) { + if (!taskId) return null; + // Query param expects raw key + const rawKey = normalizeApiKey(apiKey); + const url = `/v1/admin/batch/${taskId}/stream?app_key=${encodeURIComponent(rawKey || '')}`; + const es = new EventSource(url); + + es.onmessage = (e) => { + if (!e.data) return; + let msg; + try { + msg = JSON.parse(e.data); + } catch { + return; + } + if (handlers.onMessage) handlers.onMessage(msg); + }; + + es.onerror = () => { + if (handlers.onError) handlers.onError(); + }; + + return es; + } + + function closeBatchStream(es) { + if (es) es.close(); + } + + async function cancelBatchTask(taskId, apiKey) { + if (!taskId) return; + try { + const rawKey = normalizeApiKey(apiKey); + await fetch(`/v1/admin/batch/${taskId}/cancel`, { + method: 'POST', + headers: rawKey ? { Authorization: `Bearer ${rawKey}` } : undefined + }); + } catch { + // ignore + } + } + + global.BatchSSE = { + open: openBatchStream, + close: closeBatchStream, + cancel: cancelBatchTask + }; +})(window); diff --git a/app/static/common/js/draggable.js b/app/static/common/js/draggable.js new file mode 100644 index 0000000000000000000000000000000000000000..0443cc41d8219c6a81016f1f1811022f029e62b8 --- /dev/null +++ b/app/static/common/js/draggable.js @@ -0,0 +1,58 @@ + +// Draggable Batch Actions (Pointer Events for mouse + touch support) +const batchActions = document.getElementById('batch-actions'); +if (!batchActions) { + // No toolbar on this page +} else { +let isDragging = false; +let startX, startY, initialLeft, initialTop; + +// Critical for mobile: prevents browser from interpreting drag as scroll +batchActions.style.touchAction = 'none'; + +batchActions.addEventListener('pointerdown', (e) => { + // Prevent dragging if clicking buttons + if (e.target.tagName.toLowerCase() === 'button' || e.target.closest('button')) return; + + e.preventDefault(); // Prevent text selection and implicit browser behaviors + isDragging = true; + batchActions.setPointerCapture(e.pointerId); // Track pointer even if it leaves the element + startX = e.clientX; + startY = e.clientY; + + const rect = batchActions.getBoundingClientRect(); + + // Initialize top/left if not set (first time drag) + if (!batchActions.style.left || batchActions.style.left === '') { + batchActions.style.left = rect.left + 'px'; + batchActions.style.top = rect.top + 'px'; + // Remove transform to allow absolute positioning control + batchActions.style.transform = 'none'; + batchActions.style.bottom = 'auto'; + } + + initialLeft = parseFloat(batchActions.style.left); + initialTop = parseFloat(batchActions.style.top); + + // visual feedback + batchActions.classList.add('shadow-xl'); +}); + +document.addEventListener('pointermove', (e) => { + if (!isDragging) return; + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + batchActions.style.left = `${initialLeft + dx}px`; + batchActions.style.top = `${initialTop + dy}px`; +}); + +document.addEventListener('pointerup', (e) => { + if (isDragging) { + isDragging = false; + batchActions.releasePointerCapture(e.pointerId); + batchActions.classList.remove('shadow-xl'); + } +}); +} diff --git a/app/static/common/js/footer.js b/app/static/common/js/footer.js new file mode 100644 index 0000000000000000000000000000000000000000..9b0ba2398ac14f62dc80a0892bed08fe23ff1fb3 --- /dev/null +++ b/app/static/common/js/footer.js @@ -0,0 +1,17 @@ +async function loadAdminFooter() { + const container = document.getElementById('app-footer'); + if (!container) return; + try { + const res = await fetch('/static/common/html/footer.html?v=1.5.4'); + if (!res.ok) return; + container.innerHTML = await res.text(); + } catch (e) { + // Fail silently to avoid breaking page load + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadAdminFooter); +} else { + loadAdminFooter(); +} diff --git a/app/static/common/js/header.js b/app/static/common/js/header.js new file mode 100644 index 0000000000000000000000000000000000000000..b0b088c407b6e8f131ddf500a5dd041aed10fec6 --- /dev/null +++ b/app/static/common/js/header.js @@ -0,0 +1,35 @@ +async function loadAdminHeader() { + const container = document.getElementById('app-header'); + if (!container) return; + try { + const res = await fetch('/static/common/html/header.html?v=1.5.4'); + if (!res.ok) return; + container.innerHTML = await res.text(); + const path = window.location.pathname; + const links = container.querySelectorAll('a[data-nav]'); + links.forEach((link) => { + const target = link.getAttribute('data-nav') || ''; + if (target && path.startsWith(target)) { + link.classList.add('active'); + const group = link.closest('.nav-group'); + if (group) { + const trigger = group.querySelector('.nav-group-trigger'); + if (trigger) { + trigger.classList.add('active'); + } + } + } + }); + if (typeof updateStorageModeButton === 'function') { + updateStorageModeButton(); + } + } catch (e) { + // Fail silently to avoid breaking page load + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadAdminHeader); +} else { + loadAdminHeader(); +} diff --git a/app/static/common/js/public-header.js b/app/static/common/js/public-header.js new file mode 100644 index 0000000000000000000000000000000000000000..f086d29b64f4fbfbf932c582a72c61bb8989b4ac --- /dev/null +++ b/app/static/common/js/public-header.js @@ -0,0 +1,37 @@ +async function loadPublicHeader() { + const container = document.getElementById('app-header'); + if (!container) return; + try { + const res = await fetch('/static/common/html/public-header.html?v=1.5.4'); + 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) => { + const target = link.getAttribute('data-nav') || ''; + if (target && path.startsWith(target)) { + link.classList.add('active'); + } + }); + } catch (e) { + // Fail silently to avoid breaking page load + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadPublicHeader); +} else { + loadPublicHeader(); +} diff --git a/app/static/common/js/toast.js b/app/static/common/js/toast.js new file mode 100644 index 0000000000000000000000000000000000000000..c41e6e28317d31889a2661be40ee49af62375d01 --- /dev/null +++ b/app/static/common/js/toast.js @@ -0,0 +1,117 @@ +function showToast(message, type = 'success') { + // Ensure container exists + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast-container'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + const isSuccess = type === 'success'; + const iconClass = isSuccess ? 'text-green-600' : 'text-red-600'; + + const iconSvg = isSuccess + ? `` + : ``; + + toast.className = `toast ${isSuccess ? 'toast-success' : 'toast-error'}`; + + // Basic HTML escaping for message + const escapedMessage = message + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + toast.innerHTML = ` +
+ ${iconSvg} +
+
${escapedMessage}
+ `; + + container.appendChild(toast); + + // Remove after 3 seconds + setTimeout(() => { + toast.classList.add('out'); + toast.addEventListener('animationend', () => { + if (toast.parentElement) { + toast.parentElement.removeChild(toast); + } + }); + }, 3000); +} + +(function showRateLimitNoticeOnce() { + const noticeKey = 'grok2api_rate_limits_notice_v1'; + const noticeText = 'GROK 官方网页更新后未真实暴露 rate-limits 接口,导致无法准确计算 Token 剩余,请耐心等待官方接口上线,目前自动刷新后会更新为 8 次'; + const path = window.location.pathname || ''; + + if (!path.startsWith('/admin') || path.startsWith('/admin/login')) { + return; + } + + try { + if (localStorage.getItem(noticeKey)) { + return; + } + } catch (e) { + // If storage is blocked, keep showing dialog. + } + + const show = () => { + const backdrop = document.createElement('div'); + backdrop.className = 'notice-dialog-backdrop'; + + const dialog = document.createElement('div'); + dialog.className = 'notice-dialog'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + + const title = document.createElement('div'); + title.className = 'notice-dialog-title'; + title.textContent = '提示'; + + const content = document.createElement('div'); + content.className = 'notice-dialog-content'; + content.textContent = noticeText; + + const actions = document.createElement('div'); + actions.className = 'notice-dialog-actions'; + + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = 'notice-dialog-confirm'; + confirmBtn.textContent = '我知道了'; + + actions.appendChild(confirmBtn); + dialog.appendChild(title); + dialog.appendChild(content); + dialog.appendChild(actions); + backdrop.appendChild(dialog); + document.body.appendChild(backdrop); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + confirmBtn.addEventListener('click', () => { + try { + localStorage.setItem(noticeKey, '1'); + } catch (e) { + // ignore + } + document.body.style.overflow = prevOverflow; + backdrop.remove(); + }); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', show); + } else { + show(); + } +})(); diff --git a/app/static/public/.DS_Store b/app/static/public/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..51707bc4eafe75f6d920425142e71d07f06b518f Binary files /dev/null and b/app/static/public/.DS_Store differ diff --git a/app/static/public/css/chat.css b/app/static/public/css/chat.css new file mode 100644 index 0000000000000000000000000000000000000000..7b2346a788be2728158d41da366101d5f160e34f --- /dev/null +++ b/app/static/public/css/chat.css @@ -0,0 +1,1161 @@ +:root { + --chat-bg: #faf9f7; + --chat-surface: #ffffff; + --chat-ink: #111111; + --chat-muted: #f1f0ed; + --chat-border: #e7e5e4; + --chat-soft: rgba(17, 17, 17, 0.06); +} + +body { + background-color: var(--chat-bg); + background-image: radial-gradient(circle at 20% 10%, rgba(17, 17, 17, 0.04), transparent 50%), + radial-gradient(circle at 85% 0%, rgba(17, 17, 17, 0.03), transparent 50%); +} + +/* Hide scrollbars while keeping scroll behavior */ +* { + scrollbar-width: none; + -ms-overflow-style: none; +} + +*::-webkit-scrollbar { + width: 0; + height: 0; +} + +.chat-main { + padding-bottom: 16px; +} + +.chat-shell { + display: flex; + flex-direction: column; + gap: 20px; + min-height: calc(100vh - 56px - 64px); +} + +.chat-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.chat-title-text { + font-size: 24px; + font-weight: 600; + color: var(--chat-ink); +} + +.chat-subtitle { + font-size: 12px; + color: var(--accents-4); + margin-top: 4px; +} + +.status-text { + font-size: 12px; + color: var(--accents-4); + padding: 6px 10px; + border-radius: 999px; + background: var(--chat-muted); +} + +.status-text.connected { + color: #0f766e; + background: rgba(15, 118, 110, 0.12); +} + +.status-text.connecting { + color: #b45309; + background: rgba(217, 119, 6, 0.14); +} + +.status-text.error { + color: #b91c1c; + background: rgba(185, 28, 28, 0.12); +} + +.chat-thread { + display: flex; + flex-direction: column; + gap: 18px; + min-height: 320px; + padding-bottom: 12px; + flex: 1; +} + +.chat-empty { + text-align: center; + color: var(--accents-4); + font-size: 13px; + padding: 48px 0; +} + +.message-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.message-row.user { + align-items: flex-end; +} + +.message-row.assistant { + align-items: flex-start; + width: 100%; +} + +.message-actions { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accents-4); + font-size: 12px; + margin-left: 2px; +} + +.action-btn { + border: none; + background: transparent; + color: var(--accents-4); + font-size: 12px; + padding: 4px 6px; + border-radius: 999px; + cursor: pointer; + transition: color 0.2s ease, background 0.2s ease; +} + +.action-btn:hover { + color: var(--chat-ink); + background: rgba(17, 17, 17, 0.06); +} + +.message-bubble { + max-width: 100%; + font-size: 14px; + line-height: 1.6; + color: var(--chat-ink); + width: 100%; +} + +.message-row.user .message-bubble { + max-width: none; + width: fit-content; +} + +.message-row.assistant .message-content { + width: 100%; +} + +.message-row.user .message-bubble { + background: #efeeea; + padding: 6px 10px; + line-height: 1.45; + border-radius: 16px; + border: 1px solid var(--chat-border); +} + +.message-row.assistant .message-bubble { + background: transparent; + padding: 0; +} + +.message-content.rendered img { + max-width: 60%; + max-height: 320px; + border-radius: 12px; + margin-top: 12px; + border: 1px solid var(--chat-border); +} + +.message-content.rendered a { + color: inherit; + text-decoration: underline; +} + +.message-content.rendered .inline-code { + background: #f3f2ef; + border: 1px solid var(--chat-border); + border-radius: 6px; + padding: 2px 6px; + font-size: 12px; + font-family: 'Geist Mono', monospace; +} + +.message-content.rendered .code-block { + background: #f7f6f3; + border: 1px solid var(--chat-border); + border-radius: 12px; + padding: 12px 14px; + overflow-x: auto; + margin-top: 12px; + font-size: 12px; + font-family: 'Geist Mono', monospace; +} + +.message-content.rendered .code-block code { + font-family: 'Geist Mono', monospace; + white-space: pre-wrap; +} + +.message-content.rendered h1, +.message-content.rendered h2, +.message-content.rendered h3, +.message-content.rendered h4, +.message-content.rendered h5, +.message-content.rendered h6 { + font-weight: 600; + margin: 10px 0 6px; + line-height: 1.3; +} + +.message-content.rendered h1 { font-size: 20px; } +.message-content.rendered h2 { font-size: 18px; } +.message-content.rendered h3 { font-size: 16px; } +.message-content.rendered h4 { font-size: 15px; } +.message-content.rendered h5 { font-size: 14px; } +.message-content.rendered h6 { font-size: 13px; } + +.message-content.rendered p { + margin: 6px 0; +} + +.message-content.rendered blockquote { + margin: 8px 0; + padding: 6px 12px; + border-left: 3px solid var(--chat-border); + border-radius: 8px; + background: #f7f6f3; + color: var(--accents-5); +} + +.message-content.rendered blockquote p { + margin: 6px 0; +} + +.message-content.rendered hr { + border: none; + border-top: 1px solid var(--chat-border); + margin: 12px 0; +} + +.message-content.rendered ul, +.message-content.rendered ol { + margin: 6px 0 10px; + padding-left: 20px; +} + +.message-content.rendered ul.task-list { + list-style: none; + padding-left: 12px; +} + +.message-content.rendered .task-item { + display: flex; + align-items: center; + gap: 6px; +} + +.message-content.rendered .task-item input { + width: 14px; + height: 14px; + accent-color: #111; +} + +.message-content.rendered li { + margin: 4px 0; +} + +.message-content.rendered .table-wrap { + overflow-x: auto; + margin: 10px 0; +} + +.message-content.rendered table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.message-content.rendered th, +.message-content.rendered td { + border: 1px solid var(--chat-border); + padding: 6px 8px; + text-align: left; +} + +.message-content.rendered th { + background: #f5f4f1; + font-weight: 600; +} + +.think-block { + margin-top: 10px; + margin-bottom: 16px; + border: none; + padding: 0; + background: transparent; + width: 100%; +} + +.think-summary { + cursor: pointer; + font-size: 12px; + color: var(--accents-4); + list-style: none; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.think-summary::before { + content: ""; + width: 18px; + height: 18px; + border-radius: 50%; + background: conic-gradient(from 180deg, #f59e0b, #f97316, #22c55e, #0ea5e9, #f59e0b); + border: 1px solid #fff; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(17, 17, 17, 0.15); +} + +.think-summary::after { + content: ""; + width: 6px; + height: 6px; + border-right: 2px solid var(--accents-4); + border-bottom: 2px solid var(--accents-4); + transform: rotate(45deg); + margin-left: 2px; +} + +.think-block[open] .think-summary::after { + transform: rotate(225deg); +} + +.think-summary::-webkit-details-marker { + display: none; +} + +.think-content { + margin-top: 8px; + margin-left: 0; + padding-left: 0; + border-left: none; + font-size: 12px; + color: var(--accents-5); + line-height: 1.6; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 0.25s ease, opacity 0.25s ease; +} + +.think-block[open] .think-content { + max-height: 60vh; + opacity: 1; + overflow: auto; + padding-right: 4px; +} + +.img-grid { + display: grid; + gap: 2px; + margin: 12px 0; + grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); + width: 100%; + max-width: 100%; + justify-items: stretch; +} + +.img-grid > * { + display: block; + width: 100%; +} + +.message-content.rendered .img-grid img { + width: 100%; + height: auto; + display: block; + max-width: 100%; + max-height: none; + margin: 0; + border-radius: 12px; + border: 1px solid var(--chat-border); + background: transparent; +} + +.img-retry { + width: 100%; + min-height: 120px; + border: 1px dashed var(--chat-border); + border-radius: 12px; + background: #fafafa; + color: var(--accents-4); + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.img-retry:hover { + color: var(--chat-ink); + border-color: #111; +} + +.think-agents { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.think-agent { + border-radius: 12px; + width: 100%; +} + +.think-agent summary { + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--chat-ink); + list-style: none; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 999px; +} + +.think-agent summary::before { + content: ""; + width: 20px; + height: 20px; + border-radius: 50%; + background: conic-gradient(from 180deg, #f59e0b, #f97316, #22c55e, #0ea5e9, #f59e0b); + border: 1px solid #fff; + box-shadow: 0 2px 8px rgba(17, 17, 17, 0.15); +} + +.think-agent summary::after { + content: ""; + width: 6px; + height: 6px; + border-right: 2px solid var(--accents-4); + border-bottom: 2px solid var(--accents-4); + transform: rotate(45deg); + margin-left: 4px; +} + +.think-agent[open] summary::after { + transform: rotate(225deg); +} + +.think-agent summary::-webkit-details-marker { + display: none; +} + +@keyframes thinkGlow { + 0% { + box-shadow: 0 0 0 rgba(255, 255, 255, 0); + background: transparent; + } + 50% { + box-shadow: 0 0 12px rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.8); + } + 100% { + box-shadow: 0 0 0 rgba(255, 255, 255, 0); + background: transparent; + } +} + +.think-block[data-thinking="true"] .think-agent summary { + animation: thinkGlow 1.4s ease-in-out infinite; +} + +.think-agent-items { + margin-top: 10px; + margin-left: 0; + padding-left: 0; + border-left: none; + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.think-rollout-group { + border: 1px solid var(--chat-border); + border-radius: 14px; + padding: 8px 12px; + background: #fff; + width: 100%; +} + +.think-rollout-group summary { + cursor: pointer; + font-size: 12px; + color: var(--chat-ink); + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.think-rollout-group summary::-webkit-details-marker { + display: none; +} + +.think-rollout-group summary::after { + content: ""; + width: 6px; + height: 6px; + border-right: 2px solid var(--accents-4); + border-bottom: 2px solid var(--accents-4); + transform: rotate(45deg); + transition: transform 0.2s ease; +} + +.think-rollout-group[open] summary::after { + transform: rotate(225deg); +} + +.think-rollout-title { + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.think-rollout-title::before { + content: ""; + width: 18px; + height: 18px; + border-radius: 50%; + background: conic-gradient(from 180deg, #f59e0b, #f97316, #22c55e, #0ea5e9, #f59e0b); + border: 1px solid #fff; + box-shadow: 0 2px 8px rgba(17, 17, 17, 0.15); +} + +.think-rollout-body { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.think-item-row { + border-left: none; + padding-left: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.think-item-type { + font-size: 12px; + color: var(--accents-4); + display: inline-flex; + align-items: center; + gap: 8px; +} + +.think-item-type::before { + content: ""; + width: 14px; + height: 14px; + background-color: var(--accents-4); + mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nYmxhY2snPjxwYXRoIGQ9J005IDIxaDZ2LTFIOXYxem0zLTIwYTcgNyAwIDAwLTQgMTIuNzRWMTdhMSAxIDAgMDAxIDFoNmExIDEgMCAwMDEtMXYtMy4yNkE3IDcgMCAwMDEyIDF6Jy8+PC9zdmc+"); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nYmxhY2snPjxwYXRoIGQ9J005IDIxaDZ2LTFIOXYxem0zLTIwYTcgNyAwIDAwLTQgMTIuNzRWMTdhMSAxIDAgMDAxIDFoNmExIDEgMCAwMDEtMXYtMy4yNkE3IDcgMCAwMDEyIDF6Jy8+PC9zdmc+"); + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + +.think-item-type[data-type="websearch"]::before { + mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyMCAyMCcgZmlsbD0nYmxhY2snPjxwYXRoIGZpbGwtcnVsZT0nZXZlbm9kZCcgZD0nTTkgM2E2IDYgMCAxMDQuNDcyIDEwLjAxNmwzLjI1NiAzLjI1NmExIDEgMCAwMDEuNDE2LTEuNDE2bC0zLjI1Ni0zLjI1NkE2IDYgMCAwMDkgM3ptMCAyYTQgNCAwIDEwMCA4IDQgNCAwIDAwMC04eicgY2xpcC1ydWxlPSdldmVub2RkJy8+PC9zdmc+"); + -webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyMCAyMCcgZmlsbD0nYmxhY2snPjxwYXRoIGZpbGwtcnVsZT0nZXZlbm9kZCcgZD0nTTkgM2E2IDYgMCAxMDQuNDcyIDEwLjAxNmwzLjI1NiAzLjI1NmExIDEgMCAwMDEuNDE2LTEuNDE2bC0zLjI1Ni0zLjI1NkE2IDYgMCAwMDkgM3ptMCAyYTQgNCAwIDEwMCA4IDQgNCAwIDAwMC04eicgY2xpcC1ydWxlPSdldmVub2RkJy8+PC9zdmc+"); +} + +.think-item-type[data-type="agentthink"]::before { + mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nYmxhY2snPjxwYXRoIGQ9J00xMSAyaDJ2MmgtMnonLz48cGF0aCBkPSdNNyA2aDEwYTMgMyAwIDAxMyAzdjhhMyAzIDAgMDEtMyAzSDdhMyAzIDAgMDEtMy0zVjlhMyAzIDAgMDEzLTN6bTIgNWExLjUgMS41IDAgMTAwIDMgMS41IDEuNSAwIDAwMC0zem02IDBhMS41IDEuNSAwIDEwMCAzIDEuNSAxLjUgMCAwMDAtM3onLz48cGF0aCBkPSdNNCAxMGgydjRINHonLz48cGF0aCBkPSdNMTggMTBoMnY0aC0yeicvPjwvc3ZnPg=="); + -webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nYmxhY2snPjxwYXRoIGQ9J00xMSAyaDJ2MmgtMnonLz48cGF0aCBkPSdNNyA2aDEwYTMgMyAwIDAxMyAzdjhhMyAzIDAgMDEtMyAzSDdhMyAzIDAgMDEtMy0zVjlhMyAzIDAgMDEzLTN6bTIgNWExLjUgMS41IDAgMTAwIDMgMS41IDEuNSAwIDAwMC0zem02IDBhMS41IDEuNSAwIDEwMCAzIDEuNSAxLjUgMCAwMDAtM3onLz48cGF0aCBkPSdNNCAxMGgydjRINHonLz48cGF0aCBkPSdNMTggMTBoMnY0aC0yeicvPjwvc3ZnPg=="); +} + +.think-item-body { + font-size: 12px; + color: var(--accents-5); + line-height: 1.6; +} + +.composer-shell { + position: sticky; + bottom: 8px; + background: transparent; + backdrop-filter: none; + padding: 0; + border-radius: 0; + border: none; + box-shadow: none; + display: flex; + flex-direction: column; + gap: 10px; + margin-top: auto; +} + +.composer-input { + display: flex; + align-items: center; + gap: 10px; + background: var(--chat-surface); + border: 1px solid var(--chat-border); + border-radius: 999px; + padding: 10px 14px; + box-shadow: 0 18px 32px rgba(17, 17, 17, 0.08); +} + +.icon-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--chat-border); + background: #fff; + color: var(--chat-ink); + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, border 0.2s ease; +} + +.icon-btn:hover { + border-color: #111; +} + +.file-input { + display: none; +} + +.composer-textarea { + flex: 1; + min-height: 40px; + height: 40px; + max-height: 160px; + border: none; + outline: none; + resize: none; + font-size: 14px; + line-height: 1.4; + padding: 10px 0; + background: transparent; + color: var(--chat-ink); + overflow-y: auto; +} + +.composer-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.model-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--chat-border); + background: var(--chat-surface); + font-size: 12px; + height: 36px; +} + +.model-select { + border: none; + background: transparent; + font-size: 12px; + color: var(--chat-ink); + outline: none; + padding: 0; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +.send-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid #111; + background: #111; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s ease; +} + +.send-btn:disabled { + opacity: 0.5; +} + +.popover { + position: relative; +} + +.settings-panel { + position: absolute; + right: 0; + bottom: calc(100% + 12px); + min-width: 260px; + max-width: 320px; + background: #fff; + border: 1px solid var(--chat-border); + border-radius: 14px; + padding: 14px; + box-shadow: 0 20px 40px rgba(17, 17, 17, 0.12); + z-index: 20; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.settings-block { + display: flex; + flex-direction: column; + gap: 6px; +} + +.settings-block-full { + grid-column: span 2; +} + +.field-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accents-4); +} + +.range-row { + display: flex; + align-items: center; + gap: 10px; +} + +.range-value { + font-size: 12px; + color: var(--accents-5); + min-width: 40px; + text-align: right; +} + +.chat-input { + min-height: 80px; +} + +.file-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--chat-border); + background: #fff; + font-size: 12px; + color: var(--chat-ink); + max-width: 160px; + height: 28px; + flex-shrink: 0; +} + +.file-name { + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-remove { + border: none; + background: transparent; + font-size: 14px; + line-height: 1; + color: var(--accents-4); +} + +.meta-btn { + border: 1px solid var(--chat-border); + background: var(--chat-surface); + color: var(--chat-ink); + font-size: 12px; + padding: 6px 12px; + border-radius: 999px; + transition: border 0.2s ease; +} + +.meta-btn:hover { + border-color: #111; +} + +@media (max-width: 720px) { + .message-bubble { + max-width: 100%; + } + + .composer-input { + align-items: flex-start; + } + + .settings-panel { + right: auto; + left: 0; + width: 92vw; + } +} + +/* --- Extensions for multi-session UI --- */ +.chat-layout { + display: flex; + gap: 6px; + align-items: flex-start; +} + +.chat-sidebar { + width: 180px; + flex-shrink: 0; + border: none; + border-radius: 16px; + background: transparent; + display: flex; + flex-direction: column; + overflow: hidden; + height: calc(100vh - 56px - 64px); + position: sticky; + top: 56px; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + border-bottom: none; + min-height: 26px; +} + +.sidebar-title { + font-size: 12px; + font-weight: 600; + color: var(--chat-ink); +} + +.sidebar-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.sidebar-action-btn { + border: none; + background: transparent; + color: var(--chat-ink); + font-size: 10px; + padding: 0; + cursor: pointer; +} + +.sidebar-action-btn.icon-btn { + width: 24px; + height: 24px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.sidebar-action-btn.icon-btn:hover { + background: var(--chat-muted); +} + +.sidebar-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 6px; + border-radius: 10px; + cursor: pointer; +} + +.session-item:hover { + background: var(--chat-muted); +} + +.session-item.active { + background: var(--chat-soft); +} + +.session-title { + flex: 1; + min-width: 0; + font-size: 11px; + color: var(--chat-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1; +} + +.session-delete { + width: 22px; + height: 22px; + border: none; + background: transparent; + color: var(--accents-4); + border-radius: 6px; + cursor: pointer; + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 0; +} + +.session-item:hover .session-delete { + opacity: 1; +} + +.session-delete:hover { + color: #b91c1c; + background: rgba(185, 28, 28, 0.08); +} + +.session-rename-input { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--chat-ink); + border: 1px solid var(--chat-border); + border-radius: 6px; + padding: 2px 6px; + background: var(--chat-surface); + outline: none; +} + +.session-unread { + width: 8px; + height: 8px; + border-radius: 999px; + background: #22c55e; +} + +.sidebar-overlay { + display: none; +} + +.sidebar-toggle { + display: none; + width: 30px; + height: 30px; + border-radius: 8px; + border: 1px solid var(--chat-border); + background: var(--chat-surface); + color: var(--chat-ink); + align-items: center; + justify-content: center; + cursor: pointer; +} + +.sidebar-expand-btn { + display: none; + align-self: flex-start; + border: 1px solid var(--chat-border); + background: var(--chat-surface); + color: var(--chat-ink); + padding: 0; + width: 32px; + height: 32px; + border-radius: 999px; + cursor: pointer; + margin-top: 6px; + margin-left: 0; + align-items: center; + justify-content: center; + position: sticky; + top: 56px; + z-index: 5; +} + +.sidebar-expand-btn:hover { + border-color: #b8b8b8; + background: var(--chat-muted); +} + +@media (min-width: 1025px) { +.chat-layout.collapsed { + gap: 8px; +} + + .chat-layout.collapsed .chat-sidebar { + width: 0; + border: none; + padding: 0; + overflow: hidden; + } + + .chat-layout.collapsed .chat-shell { + padding-left: 0; + } + +.chat-layout.collapsed .sidebar-expand-btn { + display: inline-flex; + margin-left: 0; + margin-right: 8px; +} +} + +.chat-shell { + flex: 1; + min-width: 0; + border-left: 1px solid var(--chat-border); + padding-left: 16px; +} + +.model-chip { + cursor: pointer; + position: relative; +} + +.model-chip:hover { + border-color: #b8b8b8; +} + +.model-label { + font-size: 12px; + color: var(--chat-ink); +} + +.model-dropdown { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + min-width: 160px; + background: var(--chat-surface); + border: 1px solid var(--chat-border); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(15, 15, 15, 0.08); + padding: 6px; + z-index: 20; +} + +.model-option { + padding: 6px 8px; + border-radius: 8px; + font-size: 12px; + color: var(--chat-ink); + cursor: pointer; +} + +.model-option:hover, +.model-option.selected { + background: var(--chat-muted); +} + +.action-icon-btn { + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid var(--chat-border); + background: var(--chat-surface); + color: var(--chat-ink); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.action-icon-btn:hover { + border-color: #b8b8b8; +} + +.edit-msg-input { + width: 100%; + border: 1px solid var(--chat-border); + border-radius: 12px; + padding: 10px 12px; + font-size: 13px; + font-family: 'Geist Sans', sans-serif; +} + +.edit-msg-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} + +@media (max-width: 1024px) { + .chat-layout { + position: relative; + } + + .chat-sidebar { + position: fixed; + top: 56px; + left: 0; + height: calc(100% - 56px); + transform: translateX(-110%); + transition: transform 0.2s ease; + z-index: 50; + } + + .chat-sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(17, 17, 17, 0.18); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 40; + } + + .sidebar-overlay.open { + opacity: 1; + pointer-events: auto; + } + + .sidebar-toggle { + display: inline-flex; + } +} diff --git a/app/static/public/css/imagine.css b/app/static/public/css/imagine.css new file mode 100644 index 0000000000000000000000000000000000000000..19080cff752eac82d38838e9a6669667f9bb0517 --- /dev/null +++ b/app/static/public/css/imagine.css @@ -0,0 +1,851 @@ +.imagine-top-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + margin-bottom: 24px; + align-items: stretch; +} + +@media (max-width: 1024px) { + .imagine-top-grid { + grid-template-columns: 1fr; + } +} + +.imagine-card { + background: #fff; + border: none !important; + border-radius: 14px; + padding: 20px; + box-shadow: none !important; + display: flex; + flex-direction: column; + overflow: visible; +} + +.imagine-card.settings-card { + padding: 16px; +} + +.imagine-card-collapsible { + transition: all 0.3s ease; +} + +.card-title-row { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + margin-bottom: 12px; +} + +.status-header-row { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; + margin-bottom: 12px; +} + +.status-header-row .card-title { + margin-bottom: 0; +} + +.status-header-row .status-text { + flex: 1; + text-align: right; +} + +.status-header-row .collapse-icon { + flex-shrink: 0; +} + +.collapse-icon { + transition: transform 0.3s ease; + flex-shrink: 0; + color: var(--accents-4); +} + +.imagine-card-collapsible.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.card-title-row:hover .collapse-icon { + color: var(--accents-7); +} + +.card-content { + overflow: hidden; + transition: max-height 0.3s ease, opacity 0.2s ease; + max-height: 2000px; + opacity: 1; +} + +.imagine-card-collapsible.collapsed .card-content { + max-height: 0; + opacity: 0; + margin: 0; + padding: 0; +} + +.imagine-card-collapsible.collapsed .card-title-row { + margin-bottom: 0; +} + +.imagine-card-collapsible.collapsed .card-title { + margin-bottom: 0; +} + +.imagine-card-collapsible.collapsed .status-header-row { + margin-bottom: 0; +} + +.imagine-card-collapsible.collapsed { + padding: 12px 20px; +} + +.floating-actions { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + background: #fff; + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + cursor: move; + user-select: none; + white-space: nowrap; +} + +.floating-actions:active { + cursor: grabbing; +} + +.shadow-xl { + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2) !important; +} + +.toolbar-sep { + display: inline-block; + width: 1px; + height: 14px; + background: var(--border); + margin: 0 6px; +} + +.imagine-textarea { + height: auto; + min-height: 0; + resize: vertical; + line-height: 1.5; + flex: 1; +} + +.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: 6px; + height: 100%; +} + +.settings-block-prompt { + grid-column: 1; + grid-row: 1; +} + +.settings-block-duo { + grid-column: 2; + grid-row: 1; + height: 100%; + display: grid; + 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-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + align-items: stretch; + height: 100%; +} + +.settings-field { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; +} + +.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: 0; + line-height: 1; +} + +.folder-select-btn { + width: 100%; + justify-content: flex-start; + font-size: 12px; + height: 30px; + padding: 0 12px; + gap: 8px; + align-items: center; + white-space: normal; +} + +.folder-select-btn #folderPath { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 768px) { + .settings-grid { + grid-template-columns: 1fr; + grid-template-rows: auto; + } + + .settings-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-block-duo { + height: auto; + display: flex; + flex-direction: column; + } +} + + +.waterfall-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + min-height: 28px; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--accents-7); + margin: 0; +} + +.waterfall-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.batch-download-btn { + height: 28px; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + color: var(--fg); + flex-shrink: 0; +} + +.batch-download-btn svg { + width: 14px; + height: 14px; +} + +.selection-toolbar { + display: none; + align-items: center; + gap: 8px; +} + +.selection-toolbar.hidden { + display: none; +} + +.selection-toolbar:not(.hidden) { + display: flex; +} + +.selection-toolbar .geist-button-outline { + height: 28px; + padding: 0 10px; + font-size: 12px; +} + +.selected-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: var(--accents-2); + color: var(--accents-6); + font-size: 10px; + font-weight: 600; + line-height: 1; +} + +.field-label { + display: block; + font-size: 11px; + color: var(--accents-4); + margin-bottom: 6px; +} + +.status-text { + font-size: 11px; + color: var(--accents-4); +} + +.status-text.connected { + color: #059669; +} + +.status-text.connecting { + color: #d97706; +} + +.status-text.error { + color: #dc2626; +} + + +.meta-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.mode-item { + align-items: center; +} + +.mode-switch { + display: flex; + align-items: center; + gap: 6px; + padding: 0; + border: 1px solid var(--border); + border-radius: 999px; + background: #fff; + flex-wrap: wrap; +} + +.mode-btn { + border: 1px solid transparent; + background: transparent; + color: var(--accents-6); + font-size: 11px; + padding: 1px 8px; + border-radius: 999px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.mode-btn:hover { + background: var(--hover-bg); + border-color: var(--border); +} + +.mode-btn.active { + background: #000; + color: #fff; + border-color: #000; +} + +.meta-item { + padding: 12px; + border-radius: 10px; + background: #f7f7f8; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; +} + +.meta-label { + font-size: 10px; + color: var(--accents-4); +} + +.meta-value { + font-size: 12px; + font-weight: 600; + color: var(--accents-7); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 150px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.toggle input { + display: none; +} + +.toggle-track { + width: 38px; + height: 20px; + background: #e5e7eb; + border-radius: 999px; + position: relative; + transition: background 0.2s ease; +} + +.toggle-track::after { + content: ""; + width: 14px; + height: 14px; + background: #fff; + border-radius: 999px; + position: absolute; + top: 3px; + left: 3px; + box-shadow: none; + transition: transform 0.2s ease; +} + +.toggle input:checked + .toggle-track { + background: #111; +} + +.toggle input:checked + .toggle-track::after { + transform: translateX(18px); +} + +.toggle-label { + font-size: 12px; + color: var(--accents-4); +} + +.imagine-empty { + text-align: center; + padding: 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 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + width: 100%; +} + +@media (max-width: 1200px) { + .waterfall { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 768px) { + .waterfall { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .waterfall { + grid-template-columns: 1fr; + } +} + +.waterfall-item { + border-radius: 14px; + overflow: hidden; + border: none !important; + background: #fff; + box-shadow: none !important; + animation: riseIn 0.4s ease; + cursor: pointer; + transition: transform 0.2s ease; + display: flex; + flex-direction: column; + position: relative; +} + +.waterfall-item:hover { + transform: scale(1.02); +} + +.waterfall-item.selection-mode { + cursor: default; +} + +.waterfall-item.selection-mode:hover { + transform: none; +} + +.waterfall-item img { + width: 100%; + height: auto; + display: block; + border-radius: 14px 14px 0 0; +} + +.waterfall-item .image-checkbox { + position: absolute; + top: 8px; + left: 8px; + width: 16px; + height: 16px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid #ddd; + border-radius: 2px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + transition: all 0.2s; + backdrop-filter: blur(4px); + z-index: 2; +} + +.waterfall-item.selection-mode .image-checkbox { + display: flex; +} + +.waterfall-item .image-checkbox:hover { + border-color: #000; + background: rgba(255, 255, 255, 1); +} + +.waterfall-item.selected .image-checkbox { + background: #000; + border-color: #000; +} + +.waterfall-item.selected .image-checkbox::after { + content: ''; + width: 5px; + height: 8px; + border: solid #fff; + border-width: 0 1px 1px 0; + transform: rotate(45deg); + margin-top: -2px; +} + +.waterfall-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 10px 12px; + font-size: 11px; + color: var(--accents-4); + background: #fff; + border-radius: 0 0 14px 14px; +} + +.waterfall-meta .meta-right { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.image-status { + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 999px; + background: #f3f4f6; + color: var(--accents-5); + line-height: 1; +} + +.image-status.running { + background: #fef3c7; + color: #b45309; +} + +.image-status.done { + background: #d1fae5; + color: #047857; +} + +.image-status.error { + background: #fee2e2; + color: #b91c1c; +} + +.waterfall-meta span { + font-family: 'Geist Mono', ui-monospace, monospace; + color: var(--accents-5); +} + + +@keyframes riseIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 图片放大预览 */ +.image-lightbox { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 9999; + justify-content: center; + align-items: center; + cursor: zoom-out; + animation: fadeIn 0.2s ease; +} + +.image-lightbox.active { + display: flex; +} + +.image-lightbox img { + max-width: 90%; + max-height: 90%; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 20px; + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10000; + transition: all 0.2s; + backdrop-filter: blur(10px); +} + +.lightbox-nav:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); +} + +.lightbox-nav:active { + transform: translateY(-50%) scale(0.95); +} + +.lightbox-prev { + left: 30px; +} + +.lightbox-next { + right: 30px; +} + +.lightbox-nav:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.lightbox-nav:disabled:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-50%); +} + +@media (max-width: 768px) { + .lightbox-nav { + width: 40px; + height: 40px; + } + + .lightbox-prev { + left: 15px; + } + + .lightbox-next { + right: 15px; + } +} diff --git a/app/static/public/css/video.css b/app/static/public/css/video.css new file mode 100644 index 0000000000000000000000000000000000000000..a634b7a9769298cd9b5c30a4b17973bfc3e893e6 --- /dev/null +++ b/app/static/public/css/video.css @@ -0,0 +1,482 @@ +:root { + --video-surface: #ffffff; + --video-muted: #f2f4f7; + --video-bg: #f6f7fb; + --video-outline: #e6e6e6; + --video-ink: #0f172a; + --video-accent: #0f172a; + --video-glow: rgba(15, 23, 42, 0.08); +} + +body { + background-color: var(--video-bg); + background-image: none; +} + +.video-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.video-hero { + padding: 16px 20px; + border-radius: 12px; + border: none; + background: #fff; + box-shadow: none; +} + +.hero-kicker { + font-size: 11px; + color: var(--accents-4); + text-transform: uppercase; + letter-spacing: 0.18em; + margin-bottom: 6px; +} + +.hero-note { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 12px; + font-size: 11px; + color: var(--accents-5); +} + +.hero-note-item { + display: flex; + align-items: center; + gap: 8px; + background: #f5f5f5; + border: none; + padding: 6px 10px; + border-radius: 999px; +} + +.hero-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--video-accent); + display: inline-block; +} + +.video-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.video-top-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 24px; + align-items: stretch; + margin-bottom: 24px; +} + +@media (max-width: 1024px) { + .video-top-grid { + grid-template-columns: 1fr; + } +} + +.video-card { + background: var(--video-surface); + border: none; + border-radius: 12px; + padding: 20px; + box-shadow: none; + position: relative; + overflow: hidden; + --field-height: 32px; + --field-label: 11px; + --field-label-gap: 6px; + --field-block: calc(var(--field-height) + var(--field-label) + var(--field-label-gap)); + --field-row-gap: 12px; +} + +.video-card::after { + content: none; +} + +.video-card-glow::before { + content: none; +} + +.video-card-contrast { + background: #fff; + color: var(--video-ink); + border: none; +} + +.video-card-contrast .card-title, +.video-card-contrast .meta-label, +.video-card-contrast .meta-value, +.video-card-contrast .progress-meta, +.video-card-contrast .video-meta { + color: #e2e8f0; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--video-ink); + margin-bottom: 12px; +} + +.field-label { + display: block; + font-size: 11px; + color: var(--accents-4); + margin-bottom: 6px; + line-height: 1; +} + +.video-card input.geist-input, +.video-card select.geist-input { + height: var(--field-height); +} + +.settings-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-rows: var(--field-block) var(--field-block) auto; + column-gap: 16px; + row-gap: var(--field-row-gap); + align-items: start; +} + +.settings-block { + display: flex; + flex-direction: column; +} + +.prompt-block { + grid-column: 1; + grid-row: 1 / span 2; +} + +.ref-block { + grid-column: 1; + grid-row: 3; +} + +.ratio-block { + grid-column: 2; + grid-row: 1; +} + +.length-block { + grid-column: 3; + grid-row: 1; +} + +.resolution-block { + grid-column: 2; + grid-row: 2; +} + +.preset-block { + grid-column: 3; + grid-row: 2; +} + +.upload-block { + grid-column: 2; + grid-row: 3; +} + +.clear-block { + grid-column: 3; + grid-row: 3; +} + +.upload-block .geist-button-outline, +.clear-block .geist-button-outline { + height: 32px; + width: 100%; +} + +.ref-controls { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.ref-controls .geist-input { + min-width: 0; +} + +.ref-file-input { + display: none; +} + +.ref-meta { + margin-top: 6px; +} + +.ref-name { + font-size: 11px; + color: var(--accents-4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + display: inline-block; +} + +@media (max-width: 640px) { + .ref-controls { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .settings-grid { + grid-template-columns: 1fr; + grid-template-rows: none; + } + + .settings-block { + grid-column: auto; + grid-row: auto; + } +} + +.video-textarea { + min-height: calc(var(--field-block) * 2 + var(--field-row-gap) - var(--field-label) - var(--field-label-gap)); + height: calc(var(--field-block) * 2 + var(--field-row-gap) - var(--field-label) - var(--field-label-gap)); + resize: vertical; +} + +.status-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.status-text { + font-size: 11px; + color: var(--accents-4); +} + +.status-text.connected { + color: #059669; +} + +.status-text.connecting { + color: #d97706; +} + +.status-text.error { + color: #dc2626; +} + +.progress-wrap { + margin-bottom: 12px; +} + +.progress-bar { + width: 100%; + height: 8px; + border-radius: 999px; + background: #f0f0f0; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + width: 0%; + border-radius: 999px; + background: #111; + transition: width 0.3s ease; + position: absolute; + left: 0; + top: 0; +} + +.progress-bar.indeterminate .progress-fill { + width: 40%; + animation: progress-indeterminate 1.2s ease-in-out infinite; +} + +@keyframes progress-indeterminate { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(220%); + } +} + +.progress-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + font-size: 11px; + color: var(--accents-4); +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.meta-item { + padding: 10px 12px; + border-radius: 10px; + background: #f5f5f5; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.meta-label { + font-size: 10px; + color: var(--accents-4); +} + +.meta-value { + font-size: 12px; + font-weight: 600; + color: var(--accents-7); + text-align: right; + overflow-wrap: anywhere; +} + +.video-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.preview-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.video-empty { + text-align: center; + color: var(--accents-4); + font-size: 12px; + padding: 42px 12px; + background: #f5f5f5; + border-radius: 12px; + border: 1px dashed var(--video-outline); +} + +.video-stage { + min-height: 240px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--video-outline); + background: #111; + display: flex; + flex-direction: column; + gap: 12px; + align-items: stretch; +} + +.video-item { + background: #000; + border-radius: 10px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.video-item-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #e2e8f0; + font-size: 11px; +} + +.video-item-title { + font-size: 11px; + color: #e2e8f0; +} + +.video-item-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.video-item-actions .geist-button-outline { + height: 26px; + padding: 0 10px; + border-color: rgba(148, 163, 184, 0.35); + color: #e2e8f0; +} + +.video-item-body { + border-radius: 8px; + background: #000; + overflow: hidden; + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; +} + +.video-item video { + width: 100%; + border-radius: 8px; + background: #000; + max-height: 380px; +} + +.video-item-link { + display: none; + font-size: 11px; + color: rgba(226, 232, 240, 0.7); + word-break: break-all; +} + +.video-item-link.has-url { + display: block; +} + +.video-item-placeholder { + font-size: 12px; + color: rgba(226, 232, 240, 0.7); +} + +.video-item.is-pending .video-item-actions .video-open { + display: none; +} + +.video-stage.hidden, +.video-empty.hidden { + display: none; +} + +.preview-actions .geist-button-outline { + height: 30px; +} + +.video-card-contrast .geist-button-outline { + border-color: var(--video-outline); + color: var(--accents-7); +} diff --git a/app/static/public/css/voice.css b/app/static/public/css/voice.css new file mode 100644 index 0000000000000000000000000000000000000000..da511c14b06e705c753d8f3f6c80401a7fcf92d7 --- /dev/null +++ b/app/static/public/css/voice.css @@ -0,0 +1,251 @@ +.voice-grid { + display: grid; + grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.voice-primary, +.voice-side { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +@media (max-width: 1024px) { + .voice-grid { + grid-template-columns: 1fr; + } +} + +:root { + --voice-surface: #ffffff; + --voice-surface-muted: #f3f4f6; + --voice-bg: #f6f7f9; +} + +body { + background-color: var(--voice-bg); +} + +.voice-card { + background: var(--voice-surface); + border: none; + border-radius: 8px; + padding: 16px; + box-shadow: none; +} + +.voice-card + .voice-card { + margin-top: 16px; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--accents-7); + margin-bottom: 12px; +} + +.field-label { + display: block; + font-size: 11px; + color: var(--accents-4); + margin-bottom: 6px; +} + +.status-text { + font-size: 11px; + color: var(--accents-4); +} + +.status-text.connected { + color: #059669; +} + +.status-text.connecting { + color: #d97706; +} + +.status-text.error { + color: #dc2626; +} + +.meta-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + margin-top: 12px; +} + +.meta-item { + padding: 10px 12px; + border-radius: 8px; + background: var(--voice-surface-muted); + border: none; + box-shadow: none; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.meta-label { + font-size: 10px; + color: var(--accents-4); + margin: 0; +} + +.meta-value { + font-size: 12px; + font-weight: 500; + color: var(--accents-7); + margin: 0; + overflow-wrap: anywhere; + text-align: right; +} + +.visualizer { + display: flex; + align-items: flex-end; + width: 100%; + justify-content: space-between; + gap: 3px; + height: 44px; + margin-top: 14px; +} + +.visualizer .bar { + width: 4px; + height: 6px; + background: var(--accents-2); + border-radius: 999px; + transition: height 0.12s ease; + opacity: 0.8; + flex: 0 0 auto; +} + +.voice-log { + background: var(--voice-surface-muted); + color: var(--accents-6); + border: none; + border-radius: 8px; + margin-top: 8px; + padding: 12px; + font-size: 11px; + box-shadow: none; + min-height: 140px; + max-height: 240px; + overflow-y: auto; + font-family: 'Geist Mono', monospace; +} + +.voice-log p { + margin: 0 0 6px; +} + +.voice-log .log-error { + color: #dc2626; +} + +.voice-log .log-warn { + color: #d97706; +} + +.voice-hint { + font-size: 11px; + color: var(--accents-4); +} + +.range-value { + font-family: 'Geist Mono', monospace; + font-size: 11px; + color: var(--accents-6); +} + +.voice-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.voice-actions .geist-button, +.voice-actions .geist-button-outline { + height: 32px; +} + +#audioRoot audio { + width: 100%; + max-width: 100%; +} + +#audioRoot { + width: 100%; +} + +.voice-log-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; +} + +.voice-status-header { + flex-wrap: nowrap; +} + +.voice-status-header .card-title { + margin-bottom: 0; +} + +.range-input { + appearance: none; + width: 100%; + height: 6px; + border-radius: 999px; + background: transparent; + outline: none; + margin: 0; +} + +.range-input::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 999px; + background: #111; + cursor: pointer; + margin-top: -4px; +} + +.range-input::-webkit-slider-runnable-track { + height: 6px; + border-radius: 999px; + background: linear-gradient(90deg, #111 0%, #111 var(--range-progress, 50%), #e5e7eb var(--range-progress, 50%), #e5e7eb 100%); +} + +.range-input::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 999px; + background: #111; + border: none; + cursor: pointer; +} + +.range-input::-moz-range-track { + height: 6px; + border-radius: 999px; + background: #e5e7eb; +} + +.range-input::-moz-range-progress { + height: 6px; + border-radius: 999px; + background: #111; +} diff --git a/app/static/public/js/chat.js b/app/static/public/js/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..bdc5a0fed65ab2bce59e4fedfb5887390b95254c --- /dev/null +++ b/app/static/public/js/chat.js @@ -0,0 +1,1822 @@ +(() => { + const modelChip = document.getElementById('modelChip'); + const modelLabel = document.getElementById('modelLabel'); + const modelDropdown = document.getElementById('modelDropdown'); + let modelValue = 'grok-4.20-beta'; + let modelList = []; + const tempRange = document.getElementById('tempRange'); + const tempValue = document.getElementById('tempValue'); + const topPRange = document.getElementById('topPRange'); + const topPValue = document.getElementById('topPValue'); + const systemInput = document.getElementById('systemInput'); + const promptInput = document.getElementById('promptInput'); + const sendBtn = document.getElementById('sendBtn'); + const settingsToggle = document.getElementById('settingsToggle'); + const settingsPanel = document.getElementById('settingsPanel'); + const chatLog = document.getElementById('chatLog'); + const emptyState = document.getElementById('emptyState'); + const statusText = document.getElementById('statusText'); + const attachBtn = document.getElementById('attachBtn'); + const fileInput = document.getElementById('fileInput'); + const fileBadge = document.getElementById('fileBadge'); + const fileName = document.getElementById('fileName'); + const fileRemoveBtn = document.getElementById('fileRemoveBtn'); + const chatSidebar = document.getElementById('chatSidebar'); + const sidebarOverlay = document.getElementById('sidebarOverlay'); + const sidebarToggle = document.getElementById('sidebarToggle'); + const newChatBtn = document.getElementById('newChatBtn'); + const collapseSidebarBtn = document.getElementById('collapseSidebarBtn'); + const sidebarExpandBtn = document.getElementById('sidebarExpandBtn'); + const sessionListEl = document.getElementById('sessionList'); + + const STORAGE_KEY = 'grok2api_chat_sessions'; + const SIDEBAR_STATE_KEY = 'grok2api_chat_sidebar_collapsed'; + const MAX_CONTEXT_MESSAGES = 5; + + let messageHistory = []; + let isSending = false; + let abortController = null; + let attachment = null; + let activeStreamInfo = null; + const feedbackUrl = 'https://github.com/chenyme/grok2api/issues/new'; + const CHAT_COMPLETIONS_ENDPOINT = '/v1/public/chat/completions'; + + let sessionsData = null; + + function generateId() { + return crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); + } + + function loadSessions() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + sessionsData = JSON.parse(raw); + if (!sessionsData || !Array.isArray(sessionsData.sessions)) { + sessionsData = null; + } + } + } catch (e) { + sessionsData = null; + } + if (!sessionsData || !sessionsData.sessions.length) { + const id = generateId(); + sessionsData = { + activeId: id, + sessions: [{ + id, + title: '新会话', + createdAt: Date.now(), + updatedAt: Date.now(), + messages: [] + }] + }; + saveSessions(); + } + if (!sessionsData.activeId || !sessionsData.sessions.find(s => s.id === sessionsData.activeId)) { + sessionsData.activeId = sessionsData.sessions[0].id; + } + restoreActiveSession(); + renderSessionList(); + } + + function getMessageDisplay(msg) { + if (!msg) return ''; + if (typeof msg.content === 'string') return msg.content; + if (typeof msg.display === 'string' && msg.display.trim()) return msg.display; + if (Array.isArray(msg.content)) { + const textParts = []; + let hasFile = false; + for (const block of msg.content) { + if (!block) continue; + if (block.type === 'text' && block.text) { + textParts.push(block.text); + } + if (block.type === 'file') { + hasFile = true; + } + } + const name = msg.attachmentName || ''; + const fileLabel = hasFile ? (name ? `[文件] ${name}` : '[文件]') : ''; + if (textParts.length && fileLabel) return `${textParts.join('\n')}\n${fileLabel}`; + if (textParts.length) return textParts.join('\n'); + return fileLabel || '[复合内容]'; + } + return '[复合内容]'; + } + + function serializeMessage(msg) { + if (!msg || typeof msg !== 'object') return msg; + if (Array.isArray(msg.content)) { + return { + ...msg, + content: getMessageDisplay(msg) + }; + } + return msg; + } + + function serializeSessions() { + if (!sessionsData) return null; + return { + activeId: sessionsData.activeId, + sessions: sessionsData.sessions.map((session) => ({ + ...session, + messages: Array.isArray(session.messages) + ? session.messages.map(serializeMessage) + : [] + })) + }; + } + + function saveSessions() { + if (!sessionsData) return; + const snapshot = serializeSessions(); + if (!snapshot) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); + } catch (e) { + toast('本地存储空间不足,部分会话未保存', 'error'); + } + } + + function trimMessageHistory(maxCount = MAX_CONTEXT_MESSAGES) { + if (!maxCount || maxCount <= 0) return; + if (messageHistory.length <= maxCount) return; + messageHistory = messageHistory.slice(-maxCount); + const session = getActiveSession(); + if (session) { + session.messages = messageHistory.slice(); + session.updatedAt = Date.now(); + saveSessions(); + renderSessionList(); + } + if (chatLog) { + const rows = Array.from(chatLog.querySelectorAll('.message-row')); + const removeCount = rows.length - messageHistory.length; + if (removeCount > 0) { + rows.slice(0, removeCount).forEach((row) => row.remove()); + } + } + if (!messageHistory.length) { + showEmptyState(); + } + } + + function getActiveSession() { + if (!sessionsData) return null; + return sessionsData.sessions.find(s => s.id === sessionsData.activeId) || null; + } + + function restoreActiveSession() { + const session = getActiveSession(); + if (!session) return; + messageHistory = session.messages.slice(); + trimMessageHistory(); + if (chatLog) chatLog.innerHTML = ''; + if (!messageHistory.length) { + showEmptyState(); + return; + } + hideEmptyState(); + for (const msg of messageHistory) { + const displayContent = getMessageDisplay(msg); + const editable = !msg.hasAttachment && typeof msg.content === 'string'; + const entry = createMessage(msg.role, displayContent, true, { editable }); + if (entry && msg.role === 'assistant') { + updateMessage(entry, displayContent, true); + } + } + if (activeStreamInfo && activeStreamInfo.sessionId === session.id && activeStreamInfo.entry.row) { + chatLog.appendChild(activeStreamInfo.entry.row); + } + scrollToBottom(); + } + + function createSession() { + const id = generateId(); + const session = { + id, + title: '新会话', + createdAt: Date.now(), + updatedAt: Date.now(), + messages: [] + }; + sessionsData.sessions.unshift(session); + sessionsData.activeId = id; + messageHistory = []; + if (chatLog) chatLog.innerHTML = ''; + showEmptyState(); + saveSessions(); + renderSessionList(); + if (isMobileSidebar()) closeSidebar(); + } + + function deleteSession(id) { + const idx = sessionsData.sessions.findIndex(s => s.id === id); + if (idx === -1) return; + sessionsData.sessions.splice(idx, 1); + if (!sessionsData.sessions.length) { + createSession(); + return; + } + if (sessionsData.activeId === id) { + const newIdx = Math.min(idx, sessionsData.sessions.length - 1); + sessionsData.activeId = sessionsData.sessions[newIdx].id; + restoreActiveSession(); + } + saveSessions(); + renderSessionList(); + } + + function switchSession(id) { + if (sessionsData.activeId === id) return; + syncCurrentSession(); + syncSessionModel(); + sessionsData.activeId = id; + const target = getActiveSession(); + if (target) target.unread = false; + restoreActiveSession(); + restoreSessionModel(); + saveSessions(); + renderSessionList(); + if (isMobileSidebar()) closeSidebar(); + } + + function syncCurrentSession() { + const session = getActiveSession(); + if (!session) return; + session.messages = messageHistory.slice(); + session.updatedAt = Date.now(); + } + + function updateSessionTitle(session) { + if (!session) return; + if (session.title && session.title !== '新会话') return; + const firstUser = session.messages.find(m => m.role === 'user'); + if (!firstUser) return; + const text = getMessageDisplay(firstUser); + if (!text) return; + const title = text.replace(/\n/g, ' ').trim().slice(0, 20); + if (title) { + session.title = title; + } + } + + function renameSession(id, newTitle) { + const session = sessionsData.sessions.find(s => s.id === id); + if (!session) return; + const trimmed = (newTitle || '').trim(); + session.title = trimmed || '新会话'; + session.updatedAt = Date.now(); + saveSessions(); + renderSessionList(); + } + + function startRenameSession(sessionId, titleSpan) { + const session = sessionsData.sessions.find(s => s.id === sessionId); + if (!session) return; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'session-rename-input'; + input.value = session.title || ''; + input.maxLength = 40; + titleSpan.replaceWith(input); + input.focus(); + input.select(); + const commit = () => { + renameSession(sessionId, input.value); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { input.value = session.title || '新会话'; input.blur(); } + }); + } + + function syncSessionModel() { + const session = getActiveSession(); + if (!session) return; + session.model = modelValue || ''; + } + + function restoreSessionModel() { + const session = getActiveSession(); + if (!session || !session.model) return; + if (modelList.includes(session.model)) { + selectModel(session.model); + } + } + + function renderSessionList() { + if (!sessionListEl || !sessionsData) return; + sessionListEl.innerHTML = ''; + for (const session of sessionsData.sessions) { + const item = document.createElement('div'); + item.className = 'session-item' + (session.id === sessionsData.activeId ? ' active' : ''); + item.dataset.id = session.id; + + const titleSpan = document.createElement('span'); + titleSpan.className = 'session-title'; + titleSpan.textContent = session.title || '新会话'; + titleSpan.addEventListener('dblclick', (e) => { + e.stopPropagation(); + startRenameSession(session.id, titleSpan); + }); + item.appendChild(titleSpan); + + if (session.unread && session.id !== sessionsData.activeId) { + const dot = document.createElement('span'); + dot.className = 'session-unread'; + item.appendChild(dot); + } + + const delBtn = document.createElement('button'); + delBtn.className = 'session-delete'; + delBtn.type = 'button'; + delBtn.title = '删除'; + delBtn.textContent = '×'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + deleteSession(session.id); + }); + item.appendChild(delBtn); + + item.addEventListener('click', () => switchSession(session.id)); + sessionListEl.appendChild(item); + } + } + + function isMobileSidebar() { + return window.matchMedia('(max-width: 1024px)').matches; + } + + function setSidebarCollapsed(collapsed) { + const layout = chatSidebar ? chatSidebar.closest('.chat-layout') : null; + if (!layout) return; + layout.classList.toggle('collapsed', collapsed); + try { + localStorage.setItem(SIDEBAR_STATE_KEY, collapsed ? '1' : '0'); + } catch (e) { + // ignore storage failures + } + } + + function openSidebar() { + if (isMobileSidebar()) { + if (chatSidebar) chatSidebar.classList.add('open'); + if (sidebarOverlay) sidebarOverlay.classList.add('open'); + return; + } + setSidebarCollapsed(false); + } + + function closeSidebar() { + if (isMobileSidebar()) { + if (chatSidebar) chatSidebar.classList.remove('open'); + if (sidebarOverlay) sidebarOverlay.classList.remove('open'); + return; + } + setSidebarCollapsed(true); + } + + function toggleSidebar() { + if (isMobileSidebar()) { + if (chatSidebar && chatSidebar.classList.contains('open')) { + closeSidebar(); + } else { + openSidebar(); + } + return; + } + const layout = chatSidebar ? chatSidebar.closest('.chat-layout') : null; + if (!layout) return; + setSidebarCollapsed(!layout.classList.contains('collapsed')); + } + + 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 setSendingState(sending) { + isSending = sending; + if (sendBtn) sendBtn.disabled = sending; + } + + function updateRangeValues() { + if (tempValue && tempRange) { + tempValue.textContent = Number(tempRange.value).toFixed(2); + } + if (topPValue && topPRange) { + topPValue.textContent = Number(topPRange.value).toFixed(2); + } + } + + function scrollToBottom() { + const body = document.scrollingElement || document.documentElement; + if (!body) return; + const hasOwnScroll = chatLog && chatLog.scrollHeight > chatLog.clientHeight + 1; + if (hasOwnScroll) { + chatLog.scrollTop = chatLog.scrollHeight; + return; + } + body.scrollTop = body.scrollHeight; + } + + function hideEmptyState() { + if (emptyState) emptyState.classList.add('hidden'); + } + + function showEmptyState() { + if (emptyState) emptyState.classList.remove('hidden'); + } + + function setRenderedHTML(el, html) { + // html is pre-sanitized through renderMarkdown → escapeHtml pipeline; + // all user text is entity-escaped before any HTML construction. + el.innerHTML = html; + } + + function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function isSafeLinkUrl(url) { + const val = String(url || '').trim().toLowerCase(); + if (!val) return false; + return /^(https?:|mailto:|tel:|\/(?!\/)|\.\.?\/|#)/.test(val); + } + + function isSafeImageUrl(url) { + const val = String(url || '').trim().toLowerCase(); + if (!val) return false; + return /^(https?:|data:image\/(?:png|jpe?g|gif|webp|bmp|ico);base64,|\/(?!\/)|\.\.?\/)/.test(val); + } + + function renderBasicMarkdown(rawText) { + const text = (rawText || '').replace(/\\n/g, '\n'); + const escaped = escapeHtml(text); + const codeBlocks = []; + const fenced = escaped.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, (match, lang, code) => { + const safeLang = lang ? escapeHtml(lang) : ''; + const html = `
${code}
`; + const token = `@@CODEBLOCK_${codeBlocks.length}@@`; + codeBlocks.push(html); + return token; + }); + + const renderInline = (value) => { + const inlineCodes = []; + let output = value.replace(/`([^`]+)`/g, (match, code) => { + const token = `@@INLINE_${inlineCodes.length}@@`; + inlineCodes.push(`${code}`); + return token; + }); + + output = output + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/~~([^~]+)~~/g, '$1'); + + output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + const safeAlt = escapeHtml(alt || 'image'); + if (!isSafeImageUrl(url)) return safeAlt; + const safeUrl = escapeHtml(url || ''); + return `${safeAlt}`; + }); + + output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { + const safeLabel = escapeHtml(label || ''); + if (!isSafeLinkUrl(url)) return safeLabel; + const safeUrl = escapeHtml(url || ''); + return `${safeLabel}`; + }); + + output = output.replace(/(data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+)/g, (match, uri) => { + if (!isSafeImageUrl(uri)) return ''; + const safeUrl = escapeHtml(uri || ''); + return `image`; + }); + + inlineCodes.forEach((html, i) => { + output = output.replace(new RegExp(`@@INLINE_${i}@@`, 'g'), html); + }); + + return output; + }; + + const lines = fenced.split(/\r?\n/); + const htmlParts = []; + let inUl = false; + let inUlTask = false; + let inOl = false; + let inTable = false; + let paragraphLines = []; + + const closeLists = () => { + if (inUl) { + htmlParts.push(''); + inUl = false; + inUlTask = false; + } + if (inOl) { + htmlParts.push(''); + inOl = false; + } + }; + + const closeTable = () => { + if (inTable) { + htmlParts.push(''); + inTable = false; + } + }; + + const flushParagraph = () => { + if (!paragraphLines.length) return; + const joined = paragraphLines.join('
'); + htmlParts.push(`

${renderInline(joined)}

`); + paragraphLines = []; + }; + + const isTableSeparator = (line) => /^\s*\|?(?:\s*:?-+:?\s*\|)+\s*$/.test(line); + const splitTableRow = (line) => { + const trimmed = line.trim(); + const row = trimmed.replace(/^\|/, '').replace(/\|$/, ''); + return row.split('|').map(cell => cell.trim()); + }; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + flushParagraph(); + closeLists(); + closeTable(); + continue; + } + + const codeTokenMatch = trimmed.match(/^@@CODEBLOCK_(\d+)@@$/); + if (codeTokenMatch) { + flushParagraph(); + closeLists(); + closeTable(); + htmlParts.push(trimmed); + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + flushParagraph(); + closeLists(); + closeTable(); + const level = headingMatch[1].length; + htmlParts.push(`${renderInline(headingMatch[2])}`); + continue; + } + + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + flushParagraph(); + closeLists(); + closeTable(); + htmlParts.push('
'); + continue; + } + + if (/^\s*>/.test(line)) { + flushParagraph(); + closeLists(); + closeTable(); + const quoteLines = []; + let j = i; + for (; j < lines.length; j += 1) { + const currentLine = lines[j]; + if (!/^\s*>/.test(currentLine)) break; + quoteLines.push(currentLine.replace(/^\s*>\s?/, '')); + } + i = j - 1; + const quoteText = quoteLines.join('\n'); + htmlParts.push(`
${renderBasicMarkdown(quoteText)}
`); + continue; + } + + if (trimmed.includes('|')) { + const nextLine = lines[i + 1] || ''; + if (!inTable && isTableSeparator(nextLine.trim())) { + flushParagraph(); + closeLists(); + const headers = splitTableRow(trimmed); + htmlParts.push('
'); + headers.forEach(cell => htmlParts.push(``)); + htmlParts.push(''); + inTable = true; + i += 1; + continue; + } + if (inTable && !isTableSeparator(trimmed)) { + const cells = splitTableRow(trimmed); + htmlParts.push(''); + cells.forEach(cell => htmlParts.push(``)); + htmlParts.push(''); + continue; + } + } + + const taskMatch = trimmed.match(/^[-*+•·]\s+\[([ xX])\]\s+(.*)$/); + if (taskMatch) { + flushParagraph(); + if (inUl && !inUlTask) { + closeLists(); + } + if (!inUl) { + closeLists(); + closeTable(); + htmlParts.push('
    '); + inUl = true; + inUlTask = true; + } + const checked = taskMatch[1].toLowerCase() === 'x'; + htmlParts.push(`
  • ${renderInline(taskMatch[2])}
  • `); + continue; + } + + const ulMatch = trimmed.match(/^[-*+•·]\s+(.*)$/); + if (ulMatch) { + flushParagraph(); + if (!inUl) { + closeLists(); + closeTable(); + htmlParts.push('
      '); + inUl = true; + inUlTask = false; + } + htmlParts.push(`
    • ${renderInline(ulMatch[1])}
    • `); + continue; + } + + const olMatch = trimmed.match(/^\d+[.)、]\s+(.*)$/); + if (olMatch) { + flushParagraph(); + if (!inOl) { + closeLists(); + closeTable(); + htmlParts.push('
        '); + inOl = true; + } + htmlParts.push(`
      1. ${renderInline(olMatch[1])}
      2. `); + continue; + } + + paragraphLines.push(trimmed); + } + + flushParagraph(); + closeLists(); + closeTable(); + + let output = htmlParts.join(''); + codeBlocks.forEach((html, index) => { + output = output.replace(`@@CODEBLOCK_${index}@@`, html); + }); + return output; + } + + function parseThinkSections(raw) { + const parts = []; + let cursor = 0; + while (cursor < raw.length) { + const start = raw.indexOf('', cursor); + if (start === -1) { + parts.push({ type: 'text', value: raw.slice(cursor) }); + break; + } + if (start > cursor) { + parts.push({ type: 'text', value: raw.slice(cursor, start) }); + } + const thinkStart = start + 7; + const end = raw.indexOf('', thinkStart); + if (end === -1) { + parts.push({ type: 'think', value: raw.slice(thinkStart), open: true }); + cursor = raw.length; + } else { + parts.push({ type: 'think', value: raw.slice(thinkStart, end), open: false }); + cursor = end + 8; + } + } + return parts; + } + + function parseRolloutBlocks(text) { + const lines = (text || '').split(/\r?\n/); + const blocks = []; + let current = null; + for (const line of lines) { + const match = line.match(/^\s*\[([^\]]+)\]\[([^\]]+)\]\s*(.*)$/); + if (match) { + if (current) blocks.push(current); + current = { id: match[1], type: match[2], lines: [] }; + if (match[3]) current.lines.push(match[3]); + continue; + } + if (current) { + current.lines.push(line); + } + } + if (current) blocks.push(current); + return blocks; + } + + function parseAgentSections(text) { + const lines = (text || '').split(/\r?\n/); + const sections = []; + let current = { title: null, lines: [] }; + let hasAgentHeading = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + current.lines.push(line); + continue; + } + const agentMatch = trimmed.match(/^(Grok\s+Leader|(?:Grok\s+)?Agent\s*\d+)$/i); + if (agentMatch) { + hasAgentHeading = true; + if (current.lines.length) { + sections.push(current); + } + current = { title: agentMatch[1], lines: [] }; + continue; + } + current.lines.push(line); + } + if (current.lines.length) { + sections.push(current); + } + if (!hasAgentHeading) { + return [{ title: null, lines }]; + } + return sections; + } + + const toolTypeMap = { + websearch: { icon: '', label: '\u7F51\u9875\u641C\u7D22' }, + searchimage: { icon: '', label: '\u56FE\u7247\u641C\u7D22' }, + agentthink: { icon: '', label: '\u601D\u8003\u63A8\u7406' } + }; + const defaultToolType = { icon: '', label: '\u5DE5\u5177' }; + + function getToolMeta(typeStr) { + const key = String(typeStr || '').trim().toLowerCase().replace(/\s+/g, ''); + return toolTypeMap[key] || defaultToolType; + } + + function renderThinkContent(text, openAll) { + const sections = parseAgentSections(text); + if (!sections.length) { + return renderBasicMarkdown(text); + } + const renderGroups = (blocks, openAllGroups) => { + const groups = []; + const map = new Map(); + for (const block of blocks) { + const key = block.id; + let group = map.get(key); + if (!group) { + group = { id: key, items: [] }; + map.set(key, group); + groups.push(group); + } + group.items.push(block); + } + return groups.map((group) => { + const items = group.items.map((item) => { + const body = renderBasicMarkdown(item.lines.join('\n').trim()); + const typeKey = String(item.type || '').trim().toLowerCase().replace(/\s+/g, ''); + const typeAttr = escapeHtml(typeKey); + const meta = getToolMeta(item.type); + const iconHtml = meta.icon ? `${meta.icon}` : ''; + const typeLabel = `${iconHtml}${escapeHtml(meta.label)}`; + return `
        ${typeLabel}
        ${body || '\uFF08\u7A7A\uFF09'}
        `; + }).join(''); + const title = escapeHtml(group.id); + const openAttr = openAllGroups ? ' open' : ''; + return `
        ${title}
        ${items}
        `; + }).join(''); + }; + + const agentBlocks = sections.map((section, idx) => { + const blocks = parseRolloutBlocks(section.lines.join('\n')); + const inner = blocks.length + ? renderGroups(blocks, openAll) + : `
        ${renderBasicMarkdown(section.lines.join('\n').trim())}
        `; + if (!section.title) { + return `
        ${inner}
        `; + } + const title = escapeHtml(section.title); + const openAttr = openAll ? ' open' : (idx === 0 ? ' open' : ''); + return `
        ${title}
        ${inner}
        `; + }); + return `
        ${agentBlocks.join('')}
        `; + } + + function renderMarkdown(text) { + const raw = text || ''; + const parts = parseThinkSections(raw); + return parts.map((part) => { + if (part.type === 'think') { + const body = renderThinkContent(part.value.trim(), part.open); + const openAttr = part.open ? ' open' : ''; + return `
        思考
        ${body || '(空)'}
        `; + } + return renderBasicMarkdown(part.value); + }).join(''); + } + + function deleteMessageByRow(row) { + if (!row || !chatLog) return; + if (activeStreamInfo && activeStreamInfo.entry.row === row) return; + const rows = chatLog.querySelectorAll('.message-row'); + const idx = Array.from(rows).indexOf(row); + if (idx === -1 || idx >= messageHistory.length) return; + messageHistory.splice(idx, 1); + row.remove(); + const session = getActiveSession(); + if (session) { + session.messages = messageHistory.slice(); + session.updatedAt = Date.now(); + saveSessions(); + } + if (!messageHistory.length) { + showEmptyState(); + } + } + + function editMessageByRow(row) { + if (isSending || !row || !chatLog) return; + const rows = chatLog.querySelectorAll('.message-row'); + const idx = Array.from(rows).indexOf(row); + if (idx === -1 || idx >= messageHistory.length) return; + const msg = messageHistory[idx]; + if (msg && (msg.hasAttachment || typeof msg.content !== 'string')) { + toast('附件消息暂不支持编辑', 'error'); + return; + } + const currentText = typeof msg.content === 'string' ? msg.content : ''; + + const contentNode = row.querySelector('.message-content'); + if (!contentNode) return; + + const textarea = document.createElement('textarea'); + textarea.className = 'edit-msg-input'; + textarea.value = currentText; + textarea.rows = Math.max(3, currentText.split('\n').length); + + const btnWrap = document.createElement('div'); + btnWrap.className = 'edit-msg-actions'; + const saveBtn = createActionButton('保存', '保存编辑', () => commit()); + const cancelBtn = createActionButton('取消', '取消编辑', () => cancel()); + btnWrap.appendChild(saveBtn); + btnWrap.appendChild(cancelBtn); + + const savedChildren = Array.from(contentNode.childNodes).map(n => n.cloneNode(true)); + const originalClass = contentNode.className; + contentNode.className = 'message-content'; + contentNode.innerHTML = ''; + contentNode.appendChild(textarea); + contentNode.appendChild(btnWrap); + row.classList.add('editing'); + textarea.focus(); + + const actionsEl = row.querySelector('.message-actions'); + if (actionsEl) actionsEl.classList.add('hidden'); + + function finish() { + row.classList.remove('editing'); + if (actionsEl) actionsEl.classList.remove('hidden'); + } + + function commit() { + const newText = textarea.value.trim(); + if (!newText) { + toast('内容不能为空', 'error'); + return; + } + msg.content = newText; + contentNode.className = originalClass; + contentNode.textContent = ''; + if (msg.role === 'assistant') { + contentNode.classList.add('rendered'); + setRenderedHTML(contentNode, renderMarkdown(newText)); + } else { + contentNode.textContent = newText; + } + finish(); + const session = getActiveSession(); + if (session) { + session.messages = messageHistory.slice(); + session.updatedAt = Date.now(); + saveSessions(); + } + } + + function cancel() { + contentNode.className = originalClass; + contentNode.textContent = ''; + savedChildren.forEach(n => contentNode.appendChild(n)); + finish(); + } + } + + function regenerateFromRow(row) { + if (isSending || !row || !chatLog) return; + const rows = chatLog.querySelectorAll('.message-row'); + const idx = Array.from(rows).indexOf(row); + if (idx === -1 || idx >= messageHistory.length) return; + if (messageHistory[idx].role !== 'user') return; + + // 丢弃该用户消息之后的所有消息和 DOM + const allRows = Array.from(rows); + for (let i = allRows.length - 1; i > idx; i--) { + allRows[i].remove(); + } + messageHistory.splice(idx + 1); + + const session = getActiveSession(); + if (session) { + session.messages = messageHistory.slice(); + session.updatedAt = Date.now(); + saveSessions(); + } + + // 从该位置重新发送 + const sendSessionId = sessionsData.activeId; + const assistantEntry = createMessage('assistant', ''); + setSendingState(true); + setStatus('connecting', '发送中'); + + abortController = new AbortController(); + const payload = buildPayload(); + + (async () => { + let headers = { 'Content-Type': 'application/json' }; + try { + const authHeader = await ensurePublicKey(); + headers = { ...headers, ...buildAuthHeaders(authHeader) }; + } catch (e) {} + + try { + const res = await fetch(CHAT_COMPLETIONS_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: abortController.signal + }); + if (!res.ok) throw new Error(`请求失败: ${res.status}`); + await handleStream(res, assistantEntry, sendSessionId); + setStatus('connected', '完成'); + } catch (e) { + if (e && e.name === 'AbortError') { + updateMessage(assistantEntry, assistantEntry.raw || '已停止', true); + setStatus('error', '已停止'); + if (!assistantEntry.committed) { + assistantEntry.committed = true; + commitToSession(sendSessionId, assistantEntry.raw || ''); + } + } else { + updateMessage(assistantEntry, `请求失败: ${e.message || e}`, true); + setStatus('error', '失败'); + toast('请求失败,请检查服务状态', 'error'); + } + } finally { + setSendingState(false); + abortController = null; + scrollToBottom(); + } + })(); + } + + function createMessage(role, content, skipScroll, options) { + if (!chatLog) return null; + hideEmptyState(); + const row = document.createElement('div'); + row.className = `message-row ${role === 'user' ? 'user' : 'assistant'}`; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + const contentNode = document.createElement('div'); + contentNode.className = 'message-content'; + contentNode.textContent = content || ''; + bubble.appendChild(contentNode); + row.appendChild(bubble); + + chatLog.appendChild(row); + if (!skipScroll) scrollToBottom(); + const entry = { + row, + contentNode, + role, + raw: content || '', + committed: false, + startedAt: Date.now(), + firstTokenAt: null, + hasThink: false, + thinkElapsed: null, + thinkAutoCollapsed: false + }; + if (role === 'user') { + const editable = options && options.editable === false ? false : true; + const actions = document.createElement('div'); + actions.className = 'message-actions'; + if (editable) { + actions.appendChild(createActionButton('编辑', '编辑消息内容', () => editMessageByRow(row))); + } + actions.appendChild(createActionButton('重新生成', '从此处重新生成回复', () => regenerateFromRow(row))); + row.appendChild(actions); + } + return entry; + } + + function applyImageGrid(root) { + if (!root) return; + const isIgnorable = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return !node.textContent.trim(); + } + return node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR'; + }; + + const isImageLink = (node) => { + if (!node || node.nodeType !== Node.ELEMENT_NODE || node.tagName !== 'A') return false; + const children = Array.from(node.childNodes); + if (!children.length) return false; + return children.every((child) => { + if (child.nodeType === Node.TEXT_NODE) { + return !child.textContent.trim(); + } + return child.nodeType === Node.ELEMENT_NODE && child.tagName === 'IMG'; + }); + }; + + const extractImageItems = (node) => { + if (!node || node.nodeType !== Node.ELEMENT_NODE) return null; + if (node.classList && node.classList.contains('img-grid')) return null; + if (node.tagName === 'IMG') { + return { items: [node], removeNode: null }; + } + if (isImageLink(node)) { + return { items: [node], removeNode: null }; + } + if (node.tagName === 'P') { + const items = []; + const children = Array.from(node.childNodes); + if (!children.length) return null; + for (const child of children) { + if (child.nodeType === Node.TEXT_NODE) { + if (!child.textContent.trim()) continue; + return null; + } + if (child.nodeType === Node.ELEMENT_NODE) { + if (child.tagName === 'IMG' || isImageLink(child)) { + items.push(child); + continue; + } + if (child.tagName === 'BR') continue; + return null; + } + return null; + } + if (!items.length) return null; + return { items, removeNode: node }; + } + return null; + }; + + const wrapImagesInContainer = (container) => { + const children = Array.from(container.childNodes); + let group = []; + let groupStart = null; + let removeNodes = []; + + const flush = () => { + if (group.length < 2) { + group = []; + groupStart = null; + removeNodes = []; + return; + } + const wrapper = document.createElement('div'); + wrapper.className = 'img-grid'; + const cols = Math.min(4, group.length); + wrapper.style.setProperty('--cols', String(cols)); + if (groupStart) { + container.insertBefore(wrapper, groupStart); + } else { + container.appendChild(wrapper); + } + group.forEach((img) => wrapper.appendChild(img)); + removeNodes.forEach((n) => n.parentNode && n.parentNode.removeChild(n)); + group = []; + groupStart = null; + removeNodes = []; + }; + + children.forEach((node) => { + if (group.length && isIgnorable(node)) { + removeNodes.push(node); + return; + } + const extracted = extractImageItems(node); + if (extracted && extracted.items.length) { + if (!groupStart) groupStart = node; + group.push(...extracted.items); + if (extracted.removeNode) { + removeNodes.push(extracted.removeNode); + } + return; + } + flush(); + }); + flush(); + }; + + const containers = [root, ...root.querySelectorAll('.think-content, .think-item-body, .think-rollout-body, .think-agent-items')]; + containers.forEach((container) => { + if (!container || container.closest('.img-grid')) return; + if (!container.querySelector || !container.querySelector('img')) return; + wrapImagesInContainer(container); + }); + } + + function updateMessage(entry, content, finalize = false) { + if (!entry) return; + entry.raw = content || ''; + if (!entry.contentNode) return; + if (!entry.hasThink && entry.raw.includes('')) { + entry.hasThink = true; + } + let savedThinkStates = null; + if (entry.hasThink && entry.thinkAutoCollapsed) { + const blocks = entry.contentNode.querySelectorAll('.think-block[data-think="true"]'); + if (blocks.length) { + savedThinkStates = Array.from(blocks).map(b => b.hasAttribute('open')); + } + } + if (finalize) { + entry.contentNode.classList.add('rendered'); + setRenderedHTML(entry.contentNode, renderMarkdown(entry.raw)); + } else { + if (entry.role === 'assistant') { + setRenderedHTML(entry.contentNode, renderMarkdown(entry.raw)); + } else { + entry.contentNode.textContent = entry.raw; + } + } + if (entry.hasThink) { + updateThinkSummary(entry, finalize ? (entry.thinkElapsed ?? 0) : entry.thinkElapsed); + const thinkBlocks = entry.contentNode.querySelectorAll('.think-block[data-think="true"]'); + thinkBlocks.forEach((block, i) => { + if (savedThinkStates && i < savedThinkStates.length) { + if (savedThinkStates[i]) { + block.setAttribute('open', ''); + } else { + block.removeAttribute('open'); + } + } else if (entry.thinkElapsed === null || entry.thinkElapsed === undefined) { + block.setAttribute('open', ''); + } else if (!entry.thinkAutoCollapsed) { + block.removeAttribute('open'); + entry.thinkAutoCollapsed = true; + } + }); + } + if (entry.role === 'assistant') { + applyImageGrid(entry.contentNode); + const thinkNodes = entry.contentNode.querySelectorAll('.think-content'); + thinkNodes.forEach((node) => { + node.scrollTop = node.scrollHeight; + }); + enhanceBrokenImages(entry.contentNode); + if (finalize && entry.row && !entry.row.querySelector('.message-actions')) { + attachAssistantActions(entry); + } + } + scrollToBottom(); + } + + function enhanceBrokenImages(root) { + if (!root) return; + const images = root.querySelectorAll('img'); + images.forEach((img) => { + if (img.dataset.retryBound) return; + img.dataset.retryBound = '1'; + img.addEventListener('error', () => { + if (img.dataset.failed) return; + img.dataset.failed = '1'; + const wrapper = document.createElement('button'); + wrapper.type = 'button'; + wrapper.className = 'img-retry'; + wrapper.textContent = '点击重试'; + wrapper.addEventListener('click', () => { + wrapper.classList.add('loading'); + const original = img.getAttribute('src') || ''; + const cacheBust = original.includes('?') ? '&' : '?'; + img.dataset.failed = ''; + img.src = `${original}${cacheBust}t=${Date.now()}`; + }); + img.replaceWith(wrapper); + }); + img.addEventListener('load', () => { + if (img.dataset.failed) { + img.dataset.failed = ''; + } + }); + }); + } + + function updateThinkSummary(entry, elapsedSec) { + if (!entry || !entry.contentNode) return; + const summaries = entry.contentNode.querySelectorAll('.think-summary'); + if (!summaries.length) return; + const text = typeof elapsedSec === 'number' ? (elapsedSec > 0 ? `思考 ${elapsedSec} 秒` : '已思考') : '思考中'; + summaries.forEach((node) => { + node.textContent = text; + const block = node.closest('.think-block'); + if (!block) return; + if (typeof elapsedSec === 'number') { + block.removeAttribute('data-thinking'); + } else { + block.setAttribute('data-thinking', 'true'); + } + }); + } + + function buildMessages() { + return buildMessagesFrom(messageHistory); + } + + function buildMessagesFrom(history) { + const payload = []; + const systemPrompt = systemInput ? systemInput.value.trim() : ''; + if (systemPrompt) { + payload.push({ role: 'system', content: systemPrompt }); + } + for (const msg of history) { + payload.push({ role: msg.role, content: msg.content }); + } + return payload; + } + + function buildPayload() { + const payload = { + model: modelValue || 'grok-3', + messages: buildMessages(), + stream: true, + temperature: Number(tempRange ? tempRange.value : 0.8), + top_p: Number(topPRange ? topPRange.value : 0.95) + }; + return payload; + } + + function buildPayloadFrom(history) { + const payload = { + model: modelValue || 'grok-3', + messages: buildMessagesFrom(history), + stream: true, + temperature: Number(tempRange ? tempRange.value : 0.8), + top_p: Number(topPRange ? topPRange.value : 0.95) + }; + return payload; + } + + function selectModel(value) { + modelValue = value; + if (modelLabel) modelLabel.textContent = value; + renderModelDropdown(); + } + + function renderModelDropdown() { + if (!modelDropdown) return; + modelDropdown.innerHTML = ''; + for (const id of modelList) { + const opt = document.createElement('div'); + opt.className = 'model-option' + (id === modelValue ? ' selected' : ''); + opt.dataset.value = id; + opt.textContent = id; + modelDropdown.appendChild(opt); + } + } + + function toggleModelDropdown(show) { + if (!modelDropdown || !modelChip) return; + if (typeof show === 'boolean') { + modelDropdown.classList.toggle('hidden', !show); + modelChip.classList.toggle('open', show); + return; + } + const visible = !modelDropdown.classList.contains('hidden'); + modelDropdown.classList.toggle('hidden', visible); + modelChip.classList.toggle('open', !visible); + } + + async function loadModels() { + if (!modelDropdown) return; + const fallback = ['grok-4.1-fast', 'grok-4', 'grok-3', 'grok-3-mini', 'grok-3-thinking', 'grok-4.20-beta', 'grok-imagine-1.0-fast']; + const preferred = 'grok-4.20-beta'; + try { + const res = await fetch('/v1/models', { cache: 'no-store' }); + if (!res.ok) throw new Error('models fetch failed'); + const data = await res.json(); + const items = Array.isArray(data && data.data) ? data.data : []; + const ids = items + .map(item => item && item.id) + .filter(Boolean) + .filter((id) => { + const name = String(id); + if (name.startsWith('grok-imagine')) { + return name === 'grok-imagine-1.0-fast'; + } + return !name.includes('video'); + }); + modelList = ids.length ? ids : fallback; + } catch (e) { + modelList = fallback; + } + if (modelList.includes(preferred)) { + modelValue = preferred; + } else { + modelValue = modelList[modelList.length - 1] || preferred; + } + if (modelLabel) modelLabel.textContent = modelValue; + renderModelDropdown(); + restoreSessionModel(); + } + + function showAttachmentBadge() { + if (!fileBadge || !fileName) return; + if (attachment) { + fileName.textContent = attachment.name; + fileBadge.classList.remove('hidden'); + } else { + fileBadge.classList.add('hidden'); + fileName.textContent = ''; + } + } + + function clearAttachment() { + attachment = null; + if (fileInput) fileInput.value = ''; + showAttachmentBadge(); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error('文件读取失败')); + reader.readAsDataURL(file); + }); + } + + async function handleFileSelect(file) { + if (!file) return; + try { + const dataUrl = await readFileAsDataUrl(file); + attachment = { + name: file.name || 'file', + data: dataUrl + }; + showAttachmentBadge(); + } catch (e) { + toast('文件读取失败', 'error'); + } + } + + function createActionButton(label, title, onClick) { + const btn = document.createElement('button'); + btn.className = 'action-btn'; + btn.type = 'button'; + btn.textContent = label; + if (title) btn.title = title; + if (onClick) btn.addEventListener('click', onClick); + return btn; + } + + function attachAssistantActions(entry) { + if (!entry || !entry.row) return; + const actions = document.createElement('div'); + actions.className = 'message-actions'; + + const retryBtn = createActionButton('重试', '重试上一条回答', () => retryLast()); + const editBtn = createActionButton('编辑', '编辑回答内容', () => editMessageByRow(entry.row)); + const copyBtn = createActionButton('复制', '复制回答内容', () => copyToClipboard(entry.raw || '')); + const feedbackBtn = createActionButton('反馈', '反馈到 Grok2API', () => { + window.open(feedbackUrl, '_blank', 'noopener'); + }); + + actions.appendChild(retryBtn); + actions.appendChild(editBtn); + actions.appendChild(copyBtn); + actions.appendChild(feedbackBtn); + entry.row.appendChild(actions); + } + + async function copyToClipboard(text) { + if (!text) { + toast('暂无内容可复制', 'error'); + return; + } + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + const temp = document.createElement('textarea'); + temp.value = text; + temp.style.position = 'fixed'; + temp.style.opacity = '0'; + document.body.appendChild(temp); + temp.select(); + document.execCommand('copy'); + document.body.removeChild(temp); + } + toast('已复制', 'success'); + } catch (e) { + toast('复制失败', 'error'); + } + } + + async function retryLast() { + if (isSending) return; + if (!messageHistory.length) return; + let lastUserIndex = -1; + for (let i = messageHistory.length - 1; i >= 0; i -= 1) { + if (messageHistory[i].role === 'user') { + lastUserIndex = i; + break; + } + } + if (lastUserIndex === -1) { + toast('没有可重试的对话', 'error'); + return; + } + const historySlice = messageHistory.slice(0, lastUserIndex + 1); + const retrySessionId = sessionsData.activeId; + const assistantEntry = createMessage('assistant', ''); + setSendingState(true); + setStatus('connecting', '发送中'); + + abortController = new AbortController(); + const payload = buildPayloadFrom(historySlice); + + let headers = { 'Content-Type': 'application/json' }; + try { + const authHeader = await ensurePublicKey(); + headers = { ...headers, ...buildAuthHeaders(authHeader) }; + } catch (e) { + // ignore auth helper failures + } + + try { + const res = await fetch(CHAT_COMPLETIONS_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: abortController.signal + }); + + if (!res.ok) { + throw new Error(`请求失败: ${res.status}`); + } + + await handleStream(res, assistantEntry, retrySessionId); + setStatus('connected', '完成'); + } catch (e) { + updateMessage(assistantEntry, `请求失败: ${e.message || e}`, true); + setStatus('error', '失败'); + toast('请求失败,请检查服务状态', 'error'); + } finally { + setSendingState(false); + abortController = null; + scrollToBottom(); + } + } + + async function sendMessage() { + if (isSending) return; + const prompt = promptInput ? promptInput.value.trim() : ''; + if (!prompt && !attachment) { + toast('请输入内容', 'error'); + return; + } + + let displayText = prompt || ''; + if (attachment) { + const label = `[文件] ${attachment.name}`; + displayText = displayText ? `${displayText}\n${label}` : label; + } + + createMessage('user', displayText, false, { editable: !attachment }); + + let content = prompt; + if (attachment) { + const blocks = []; + if (prompt) { + blocks.push({ type: 'text', text: prompt }); + } + blocks.push({ type: 'file', file: { file_data: attachment.data } }); + content = blocks; + } + + messageHistory.push({ + role: 'user', + content, + display: displayText, + hasAttachment: !!attachment, + attachmentName: attachment ? attachment.name : '' + }); + trimMessageHistory(); + if (promptInput) promptInput.value = ''; + clearAttachment(); + syncCurrentSession(); + syncSessionModel(); + updateSessionTitle(getActiveSession()); + saveSessions(); + renderSessionList(); + + const sendSessionId = sessionsData.activeId; + const assistantEntry = createMessage('assistant', ''); + setSendingState(true); + setStatus('connecting', '发送中'); + + abortController = new AbortController(); + const payload = buildPayload(); + + let headers = { 'Content-Type': 'application/json' }; + try { + const authHeader = await ensurePublicKey(); + headers = { ...headers, ...buildAuthHeaders(authHeader) }; + } catch (e) { + // ignore auth helper failures + } + + try { + const res = await fetch(CHAT_COMPLETIONS_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: abortController.signal + }); + + if (!res.ok) { + throw new Error(`请求失败: ${res.status}`); + } + + await handleStream(res, assistantEntry, sendSessionId); + setStatus('connected', '完成'); + } catch (e) { + if (e && e.name === 'AbortError') { + updateMessage(assistantEntry, assistantEntry.raw || '已停止', true); + if (assistantEntry.hasThink) { + const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000)); + updateThinkSummary(assistantEntry, elapsed); + } + setStatus('error', '已停止'); + if (!assistantEntry.committed) { + assistantEntry.committed = true; + commitToSession(sendSessionId, assistantEntry.raw || ''); + } + } else { + updateMessage(assistantEntry, `请求失败: ${e.message || e}`, true); + setStatus('error', '失败'); + toast('请求失败,请检查服务状态', 'error'); + } + } finally { + setSendingState(false); + abortController = null; + scrollToBottom(); + } + } + + function commitToSession(sessionId, assistantText) { + const session = sessionsData.sessions.find(s => s.id === sessionId); + if (!session) return; + session.messages.push({ role: 'assistant', content: assistantText }); + if (session.messages.length > MAX_CONTEXT_MESSAGES) { + session.messages = session.messages.slice(-MAX_CONTEXT_MESSAGES); + } + session.updatedAt = Date.now(); + updateSessionTitle(session); + if (sessionsData.activeId === sessionId) { + messageHistory = session.messages.slice(); + trimMessageHistory(); + } else { + session.unread = true; + } + saveSessions(); + renderSessionList(); + } + + async function handleStream(res, assistantEntry, targetSessionId) { + activeStreamInfo = { sessionId: targetSessionId, entry: assistantEntry }; + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let assistantText = ''; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + for (const part of parts) { + const lines = part.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; + const payload = trimmed.slice(5).trim(); + if (!payload) continue; + if (payload === '[DONE]') { + updateMessage(assistantEntry, assistantText, true); + if (assistantEntry.hasThink) { + const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000)); + updateThinkSummary(assistantEntry, elapsed); + } + assistantEntry.committed = true; + commitToSession(targetSessionId, assistantText); + return; + } + try { + const json = JSON.parse(payload); + const delta = json && json.choices && json.choices[0] && json.choices[0].delta + ? json.choices[0].delta.content + : ''; + if (delta) { + assistantText += delta; + if (!assistantEntry.firstTokenAt) { + assistantEntry.firstTokenAt = Date.now(); + } + if (!assistantEntry.hasThink && assistantText.includes('')) { + assistantEntry.hasThink = true; + assistantEntry.thinkStartAt = Date.now(); + assistantEntry.thinkElapsed = null; + updateThinkSummary(assistantEntry, null); + } + if (assistantEntry.hasThink && assistantEntry.thinkStartAt && assistantEntry.thinkElapsed === null) { + if (assistantText.includes('')) { + assistantEntry.thinkElapsed = Math.max(1, Math.round((Date.now() - assistantEntry.thinkStartAt) / 1000)); + updateThinkSummary(assistantEntry, assistantEntry.thinkElapsed); + } + } + if (sessionsData.activeId === targetSessionId) { + updateMessage(assistantEntry, assistantText, false); + } + } + } catch (e) { + // ignore parse errors + } + } + } + } + updateMessage(assistantEntry, assistantText, true); + if (assistantEntry.hasThink) { + const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000)); + updateThinkSummary(assistantEntry, elapsed); + } + assistantEntry.committed = true; + commitToSession(targetSessionId, assistantText); + } finally { + activeStreamInfo = null; + } + } + + function toggleSettings(show) { + if (!settingsPanel) return; + if (typeof show === 'boolean') { + settingsPanel.classList.toggle('hidden', !show); + return; + } + settingsPanel.classList.toggle('hidden'); + } + + function restoreSidebarState() { + try { + const raw = localStorage.getItem(SIDEBAR_STATE_KEY); + setSidebarCollapsed(raw === '1'); + } catch (e) {} + } + + function bindEvents() { + if (tempRange) tempRange.addEventListener('input', updateRangeValues); + if (topPRange) topPRange.addEventListener('input', updateRangeValues); + if (modelChip) { + modelChip.addEventListener('click', (event) => { + if (event.target.closest('.model-dropdown')) return; + event.stopPropagation(); + toggleModelDropdown(); + }); + } + if (modelDropdown) { + modelDropdown.addEventListener('click', (event) => { + const opt = event.target.closest('.model-option'); + if (!opt) return; + event.stopPropagation(); + selectModel(opt.dataset.value); + toggleModelDropdown(false); + }); + } + if (sendBtn) sendBtn.addEventListener('click', sendMessage); + if (settingsToggle) { + settingsToggle.addEventListener('click', (event) => { + event.stopPropagation(); + toggleSettings(); + }); + } + document.addEventListener('click', (event) => { + if (settingsPanel && !settingsPanel.classList.contains('hidden')) { + if (!settingsPanel.contains(event.target) && !(settingsToggle && settingsToggle.contains(event.target))) { + toggleSettings(false); + } + } + if (modelDropdown && !modelDropdown.classList.contains('hidden')) { + if (!(modelChip && modelChip.contains(event.target))) { + toggleModelDropdown(false); + } + } + }); + if (promptInput) { + let composing = false; + promptInput.addEventListener('compositionstart', () => { + composing = true; + }); + promptInput.addEventListener('compositionend', () => { + composing = false; + }); + promptInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + if (composing || event.isComposing) return; + event.preventDefault(); + sendMessage(); + } + }); + } + if (attachBtn && fileInput) { + attachBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files[0]) { + handleFileSelect(fileInput.files[0]); + } + }); + } + if (fileRemoveBtn) { + fileRemoveBtn.addEventListener('click', clearAttachment); + } + if (newChatBtn) { + newChatBtn.addEventListener('click', createSession); + } + if (collapseSidebarBtn) { + collapseSidebarBtn.addEventListener('click', toggleSidebar); + } + if (sidebarExpandBtn) { + sidebarExpandBtn.addEventListener('click', openSidebar); + } + if (sidebarToggle) { + sidebarToggle.addEventListener('click', toggleSidebar); + } + if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', closeSidebar); + } + } + + updateRangeValues(); + loadModels(); + bindEvents(); + restoreSidebarState(); + + (async () => { + try { + const authResult = await ensurePublicKey(); + if (authResult === null) { + window.location.href = '/login'; + return; + } + } catch (e) { + window.location.href = '/login'; + return; + } + loadSessions(); + })(); +})(); diff --git a/app/static/public/js/imagine.js b/app/static/public/js/imagine.js new file mode 100644 index 0000000000000000000000000000000000000000..1ddf28771b88718b908210928670c3721e5c440b --- /dev/null +++ b/app/static/public/js/imagine.js @@ -0,0 +1,1304 @@ +(() => { + const startBtn = document.getElementById('startBtn'); + const stopBtn = document.getElementById('stopBtn'); + const clearBtn = document.getElementById('clearBtn'); + const promptInput = document.getElementById('promptInput'); + const ratioSelect = document.getElementById('ratioSelect'); + const concurrentSelect = document.getElementById('concurrentSelect'); + const autoScrollToggle = document.getElementById('autoScrollToggle'); + const autoDownloadToggle = document.getElementById('autoDownloadToggle'); + const 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'); + const countValue = document.getElementById('countValue'); + const activeValue = document.getElementById('activeValue'); + const latencyValue = document.getElementById('latencyValue'); + const modeButtons = document.querySelectorAll('.mode-btn'); + const waterfall = document.getElementById('waterfall'); + const emptyState = document.getElementById('emptyState'); + const lightbox = document.getElementById('lightbox'); + const lightboxImg = document.getElementById('lightboxImg'); + const closeLightbox = document.getElementById('closeLightbox'); + + let wsConnections = []; + let sseConnections = []; + let imageCount = 0; + let totalLatency = 0; + let latencyCount = 0; + let lastRunId = ''; + let isRunning = false; + let connectionMode = 'ws'; + let modePreference = 'auto'; + const MODE_STORAGE_KEY = 'imagine_mode'; + let pendingFallbackTimer = null; + let currentTaskIds = []; + let directoryHandle = null; + 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') { + showToast(message, type); + } + } + + function setStatus(state, text) { + if (!statusText) return; + statusText.textContent = text || '未连接'; + statusText.classList.remove('connected', 'connecting', 'error'); + if (state) { + statusText.classList.add(state); + } + } + + function setButtons(connected) { + if (!startBtn || !stopBtn) return; + if (connected) { + startBtn.classList.add('hidden'); + stopBtn.classList.remove('hidden'); + } else { + startBtn.classList.remove('hidden'); + stopBtn.classList.add('hidden'); + startBtn.disabled = false; + } + } + + function updateCount(value) { + if (countValue) { + countValue.textContent = String(value); + } + } + + function updateActive() { + if (!activeValue) return; + if (connectionMode === 'sse') { + const active = sseConnections.filter(es => es && es.readyState === EventSource.OPEN).length; + activeValue.textContent = String(active); + return; + } + const active = wsConnections.filter(ws => ws && ws.readyState === WebSocket.OPEN).length; + activeValue.textContent = String(active); + } + + function setModePreference(mode, persist = true) { + if (!['auto', 'ws', 'sse'].includes(mode)) return; + modePreference = mode; + modeButtons.forEach(btn => { + if (btn.dataset.mode === mode) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + if (persist) { + try { + localStorage.setItem(MODE_STORAGE_KEY, mode); + } catch (e) { + // ignore + } + } + updateModeValue(); + } + + 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) { + totalLatency += value; + latencyCount += 1; + const avg = Math.round(totalLatency / latencyCount); + if (latencyValue) { + latencyValue.textContent = `${avg} ms`; + } + } else { + if (latencyValue) { + latencyValue.textContent = '-'; + } + } + } + + function updateError(value) {} + + function setImageStatus(item, state, label) { + if (!item) return; + const statusEl = item.querySelector('.image-status'); + if (!statusEl) return; + statusEl.textContent = label; + statusEl.classList.remove('running', 'done', 'error'); + if (state) { + statusEl.classList.add(state); + } + } + + 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'; + if (base64.startsWith('/9j/')) return 'image/jpeg'; + if (base64.startsWith('R0lGOD')) return 'image/gif'; + 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; + const header = parts[0]; + const b64 = parts.slice(1).join(','); + const match = header.match(/data:(.*?);base64/); + const mime = match ? match[1] : 'application/octet-stream'; + try { + const byteString = atob(b64); + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + return new Blob([ab], { type: mime }); + } catch (e) { + return null; + } + } + + 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, nsfw: nsfwEnabled }) + }); + 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 createImagineTasks(prompt, ratio, concurrent, authHeader, nsfwEnabled) { + const tasks = []; + for (let i = 0; i < concurrent; i++) { + const taskId = await createImagineTask(prompt, ratio, authHeader, nsfwEnabled); + if (!taskId) { + throw new Error('Missing task id'); + } + tasks.push(taskId); + } + return tasks; + } + + async function stopImagineTasks(taskIds, authHeader) { + if (!taskIds || taskIds.length === 0) return; + try { + await fetch('/v1/public/imagine/stop', { + method: 'POST', + headers: { + ...buildAuthHeaders(authHeader), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ task_ids: taskIds }) + }); + } catch (e) { + // ignore + } + } + + async function saveToFileSystem(base64, filename) { + try { + if (!directoryHandle) { + return false; + } + + const mime = inferMime(base64); + const ext = mime === 'image/png' ? 'png' : 'jpg'; + const finalFilename = filename.endsWith(`.${ext}`) ? filename : `${filename}.${ext}`; + + const fileHandle = await directoryHandle.getFileHandle(finalFilename, { create: true }); + const writable = await fileHandle.createWritable(); + + // Convert base64 to blob + const byteString = atob(base64); + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + const blob = new Blob([ab], { type: mime }); + + await writable.write(blob); + await writable.close(); + return true; + } catch (e) { + console.error('File System API save failed:', e); + return false; + } + } + + function downloadImage(base64, filename) { + const mime = inferMime(base64); + const dataUrl = `data:${mime};base64,${base64}`; + const link = document.createElement('a'); + link.href = dataUrl; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + function appendImage(base64, meta) { + if (!waterfall) return; + if (autoFilterToggle && autoFilterToggle.checked) { + const bytes = estimateBase64Bytes(base64 || ''); + const minBytes = getFinalMinBytes(); + if (bytes !== null && bytes < minBytes) { + return; + } + } + if (emptyState) { + emptyState.style.display = 'none'; + } + + const 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 = meta && meta.sequence ? `image-${meta.sequence}` : 'image'; + const mime = inferMime(base64); + const dataUrl = `data:${mime};base64,${base64}`; + img.src = dataUrl; + + const metaBar = document.createElement('div'); + metaBar.className = 'waterfall-meta'; + const left = document.createElement('div'); + left.textContent = meta && meta.sequence ? `#${meta.sequence}` : '#'; + const rightWrap = document.createElement('div'); + rightWrap.className = 'meta-right'; + const status = document.createElement('span'); + status.className = 'image-status done'; + status.textContent = '完成'; + const right = document.createElement('span'); + if (meta && meta.elapsed_ms) { + right.textContent = `${meta.elapsed_ms}ms`; + } else { + right.textContent = ''; + } + + rightWrap.appendChild(status); + rightWrap.appendChild(right); + metaBar.appendChild(left); + metaBar.appendChild(rightWrap); + + 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 (autoScrollToggle && autoScrollToggle.checked) { + if (reverseInsertToggle && reverseInsertToggle.checked) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + } + } + + if (autoDownloadToggle && autoDownloadToggle.checked) { + const timestamp = Date.now(); + const seq = meta && meta.sequence ? meta.sequence : 'unknown'; + const ext = mime === 'image/png' ? 'png' : 'jpg'; + const filename = `imagine_${timestamp}_${seq}.${ext}`; + + if (useFileSystemAPI && directoryHandle) { + saveToFileSystem(base64, filename).catch(() => { + downloadImage(base64, filename); + }); + } else { + downloadImage(base64, filename); + } + } + } + + function 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 rightWrap = document.createElement('div'); + rightWrap.className = 'meta-right'; + const status = document.createElement('span'); + status.className = `image-status ${isFinal ? 'done' : 'running'}`; + status.textContent = isFinal ? '完成' : '生成中'; + const right = document.createElement('span'); + right.textContent = ''; + if (meta && meta.elapsed_ms) { + right.textContent = `${meta.elapsed_ms}ms`; + } + + rightWrap.appendChild(status); + rightWrap.appendChild(right); + metaBar.appendChild(left); + metaBar.appendChild(rightWrap); + + 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 .meta-right span:last-child'); + if (right && meta && meta.elapsed_ms) { + right.textContent = `${meta.elapsed_ms}ms`; + } + } + + setImageStatus(item, isFinal ? 'done' : 'running', isFinal ? '完成' : '生成中'); + 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 { + data = JSON.parse(raw); + } catch (e) { + return; + } + if (!data || typeof data !== 'object') return; + + 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); + updateError(''); + appendImage(data.b64_json, data); + } else if (data.type === 'status') { + if (data.status === 'running') { + setStatus('connected', '生成中'); + lastRunId = data.run_id || ''; + } else if (data.status === 'stopped') { + if (data.run_id && lastRunId && data.run_id !== lastRunId) { + return; + } + setStatus('', '已停止'); + } + } else if (data.type === 'error' || data.error) { + const message = data.message || (data.error && data.error.message) || '生成失败'; + const errorImageId = data.image_id || data.imageId; + if (errorImageId && streamImageMap.has(errorImageId)) { + setImageStatus(streamImageMap.get(errorImageId), 'error', '失败'); + } + updateError(message); + toast(message, 'error'); + } + } + + function stopAllConnections() { + wsConnections.forEach(ws => { + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'stop' })); + } catch (e) { + // ignore + } + } + try { + ws.close(1000, 'client stop'); + } catch (e) { + // ignore + } + }); + wsConnections = []; + + sseConnections.forEach(es => { + try { + es.close(); + } catch (e) { + // ignore + } + }); + sseConnections = []; + updateActive(); + updateModeValue(); + } + + function normalizeAuthHeader(authHeader) { + if (!authHeader) return ''; + if (authHeader.startsWith('Bearer ')) { + return authHeader.slice(7).trim(); + } + return authHeader; + } + + function buildSseUrl(taskId, index, rawPublicKey) { + const httpProtocol = window.location.protocol === 'https:' ? 'https' : 'http'; + const base = `${httpProtocol}://${window.location.host}/v1/public/imagine/sse`; + const params = new URLSearchParams(); + params.set('task_id', taskId); + params.set('t', String(Date.now())); + if (typeof index === 'number') { + params.set('conn', String(index)); + } + if (rawPublicKey) { + params.set('public_key', rawPublicKey); + } + return `${base}?${params.toString()}`; + } + + function startSSE(taskIds, rawPublicKey) { + connectionMode = 'sse'; + stopAllConnections(); + updateModeValue(); + + setStatus('connected', '生成中 (SSE)'); + setButtons(true); + toast(`已启动 ${taskIds.length} 个并发任务 (SSE)`, 'success'); + + for (let i = 0; i < taskIds.length; i++) { + const url = buildSseUrl(taskIds[i], i, rawPublicKey); + const es = new EventSource(url); + + es.onopen = () => { + updateActive(); + }; + + es.onmessage = (event) => { + handleMessage(event.data); + }; + + es.onerror = () => { + updateActive(); + const remaining = sseConnections.filter(e => e && e.readyState === EventSource.OPEN).length; + if (remaining === 0) { + setStatus('error', '连接错误'); + setButtons(false); + isRunning = false; + startBtn.disabled = false; + updateModeValue(); + } + }; + + sseConnections.push(es); + } + } + + async function startConnection() { + const prompt = promptInput ? promptInput.value.trim() : ''; + if (!prompt) { + toast('请输入提示词', 'error'); + return; + } + + const authHeader = await ensurePublicKey(); + if (authHeader === null) { + toast('请先配置 Public Key', 'error'); + window.location.href = '/login'; + return; + } + const rawPublicKey = normalizeAuthHeader(authHeader); + + 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'); + return; + } + + isRunning = true; + setStatus('connecting', '连接中'); + startBtn.disabled = true; + + if (pendingFallbackTimer) { + clearTimeout(pendingFallbackTimer); + pendingFallbackTimer = null; + } + + let taskIds = []; + try { + taskIds = await createImagineTasks(prompt, ratio, concurrent, authHeader, nsfwEnabled); + } catch (e) { + setStatus('error', '创建任务失败'); + startBtn.disabled = false; + isRunning = false; + return; + } + currentTaskIds = taskIds; + + if (modePreference === 'sse') { + startSSE(taskIds, rawPublicKey); + return; + } + + connectionMode = 'ws'; + stopAllConnections(); + updateModeValue(); + + let opened = 0; + let fallbackDone = false; + let fallbackTimer = null; + if (modePreference === 'auto') { + fallbackTimer = setTimeout(() => { + if (!fallbackDone && opened === 0) { + fallbackDone = true; + startSSE(taskIds, rawPublicKey); + } + }, 1500); + } + pendingFallbackTimer = fallbackTimer; + + wsConnections = []; + + for (let i = 0; i < taskIds.length; i++) { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const params = new URLSearchParams({ task_id: taskIds[i] }); + if (rawPublicKey) { + params.set('public_key', rawPublicKey); + } + const wsUrl = `${protocol}://${window.location.host}/v1/public/imagine/ws?${params.toString()}`; + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + opened += 1; + updateActive(); + if (i === 0) { + setStatus('connected', '生成中'); + setButtons(true); + toast(`已启动 ${concurrent} 个并发任务`, 'success'); + } + sendStart(prompt, ws); + }; + + ws.onmessage = (event) => { + handleMessage(event.data); + }; + + ws.onclose = () => { + updateActive(); + if (connectionMode !== 'ws') { + return; + } + const remaining = wsConnections.filter(w => w && w.readyState === WebSocket.OPEN).length; + if (remaining === 0 && !fallbackDone) { + setStatus('', '未连接'); + setButtons(false); + isRunning = false; + updateModeValue(); + } + }; + + ws.onerror = () => { + updateActive(); + if (modePreference === 'auto' && opened === 0 && !fallbackDone) { + fallbackDone = true; + if (fallbackTimer) { + clearTimeout(fallbackTimer); + } + startSSE(taskIds, rawPublicKey); + return; + } + if (i === 0 && wsConnections.filter(w => w && w.readyState === WebSocket.OPEN).length === 0) { + setStatus('error', '连接错误'); + startBtn.disabled = false; + isRunning = false; + updateModeValue(); + } + }; + + wsConnections.push(ws); + } + } + + function sendStart(promptOverride, targetWs) { + const ws = targetWs || wsConnections[0]; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const prompt = promptOverride || (promptInput ? promptInput.value.trim() : ''); + const ratio = ratioSelect ? ratioSelect.value : '2:3'; + const nsfwEnabled = nsfwSelect ? nsfwSelect.value === 'true' : true; + const payload = { + type: 'start', + prompt, + aspect_ratio: ratio, + nsfw: nsfwEnabled + }; + ws.send(JSON.stringify(payload)); + updateError(''); + } + + async function stopConnection() { + if (pendingFallbackTimer) { + clearTimeout(pendingFallbackTimer); + pendingFallbackTimer = null; + } + + const authHeader = await ensurePublicKey(); + if (authHeader !== null && currentTaskIds.length > 0) { + await stopImagineTasks(currentTaskIds, authHeader); + } + + stopAllConnections(); + currentTaskIds = []; + isRunning = false; + updateActive(); + updateModeValue(); + setButtons(false); + setStatus('', '未连接'); + } + + function clearImages() { + if (waterfall) { + waterfall.innerHTML = ''; + } + streamImageMap.clear(); + streamSequence = 0; + imageCount = 0; + totalLatency = 0; + latencyCount = 0; + updateCount(imageCount); + updateLatency(''); + updateError(''); + if (emptyState) { + emptyState.style.display = 'block'; + } + } + + if (startBtn) { + startBtn.addEventListener('click', () => startConnection()); + } + + if (stopBtn) { + stopBtn.addEventListener('click', () => { + stopConnection(); + }); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => clearImages()); + } + + if (promptInput) { + promptInput.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + startConnection(); + } + }); + } + + loadFilterDefaults(); + + if (ratioSelect) { + ratioSelect.addEventListener('change', () => { + if (isRunning) { + if (connectionMode === 'sse') { + stopConnection().then(() => { + setTimeout(() => startConnection(), 50); + }); + return; + } + wsConnections.forEach(ws => { + if (ws && ws.readyState === WebSocket.OPEN) { + sendStart(null, ws); + } + }); + } + }); + } + + if (modeButtons.length > 0) { + const saved = (() => { + try { + return localStorage.getItem(MODE_STORAGE_KEY); + } catch (e) { + return null; + } + })(); + if (saved) { + setModePreference(saved, false); + } else { + setModePreference('auto', false); + } + + modeButtons.forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode; + if (!mode) return; + setModePreference(mode); + if (isRunning) { + stopConnection().then(() => { + setTimeout(() => startConnection(), 50); + }); + } + }); + }); + } + + // File System API support check + if ('showDirectoryPicker' in window) { + if (selectFolderBtn) { + selectFolderBtn.disabled = false; + selectFolderBtn.addEventListener('click', async () => { + try { + directoryHandle = await window.showDirectoryPicker({ + mode: 'readwrite' + }); + useFileSystemAPI = true; + if (folderPath) { + folderPath.textContent = directoryHandle.name; + selectFolderBtn.style.color = '#059669'; + } + toast('已选择文件夹: ' + directoryHandle.name, 'success'); + } catch (e) { + if (e.name !== 'AbortError') { + toast('选择文件夹失败', 'error'); + } + } + }); + } + } + + // Enable/disable folder selection based on auto-download + if (autoDownloadToggle && selectFolderBtn) { + autoDownloadToggle.addEventListener('change', () => { + if (autoDownloadToggle.checked && 'showDirectoryPicker' in window) { + selectFolderBtn.disabled = false; + } else { + selectFolderBtn.disabled = true; + } + }); + } + + // Collapsible cards - 点击"连接状态"标题控制所有卡片 + const statusToggle = document.getElementById('statusToggle'); + + if (statusToggle) { + statusToggle.addEventListener('click', (e) => { + e.stopPropagation(); + const cards = document.querySelectorAll('.imagine-card-collapsible'); + const allCollapsed = Array.from(cards).every(card => card.classList.contains('collapsed')); + + cards.forEach(card => { + if (allCollapsed) { + card.classList.remove('collapsed'); + } else { + card.classList.add('collapsed'); + } + }); + }); + } + + // Batch download functionality + const batchDownloadBtn = document.getElementById('batchDownloadBtn'); + const selectionToolbar = document.getElementById('selectionToolbar'); + const toggleSelectAllBtn = document.getElementById('toggleSelectAllBtn'); + const downloadSelectedBtn = document.getElementById('downloadSelectedBtn'); + + function enterSelectionMode() { + isSelectionMode = true; + selectedImages.clear(); + selectionToolbar.classList.remove('hidden'); + + const items = document.querySelectorAll('.waterfall-item'); + items.forEach(item => { + item.classList.add('selection-mode'); + }); + + updateSelectedCount(); + } + + function exitSelectionMode() { + isSelectionMode = false; + selectedImages.clear(); + selectionToolbar.classList.add('hidden'); + + const items = document.querySelectorAll('.waterfall-item'); + items.forEach(item => { + item.classList.remove('selection-mode', 'selected'); + }); + } + + function toggleSelectionMode() { + if (isSelectionMode) { + exitSelectionMode(); + } else { + enterSelectionMode(); + } + } + + function toggleImageSelection(item) { + if (!isSelectionMode) return; + + if (item.classList.contains('selected')) { + item.classList.remove('selected'); + selectedImages.delete(item); + } else { + item.classList.add('selected'); + selectedImages.add(item); + } + + updateSelectedCount(); + } + + function updateSelectedCount() { + const countSpan = document.getElementById('selectedCount'); + if (countSpan) { + countSpan.textContent = selectedImages.size; + } + if (downloadSelectedBtn) { + downloadSelectedBtn.disabled = selectedImages.size === 0; + } + + // Update toggle select all button text + if (toggleSelectAllBtn) { + const items = document.querySelectorAll('.waterfall-item'); + const allSelected = items.length > 0 && selectedImages.size === items.length; + toggleSelectAllBtn.textContent = allSelected ? '取消全选' : '全选'; + } + } + + function toggleSelectAll() { + const items = document.querySelectorAll('.waterfall-item'); + const allSelected = items.length > 0 && selectedImages.size === items.length; + + if (allSelected) { + // Deselect all + items.forEach(item => { + item.classList.remove('selected'); + }); + selectedImages.clear(); + } else { + // Select all + items.forEach(item => { + item.classList.add('selected'); + selectedImages.add(item); + }); + } + + updateSelectedCount(); + } + + async function downloadSelectedImages() { + if (selectedImages.size === 0) { + toast('请先选择要下载的图片', 'warning'); + return; + } + + if (typeof JSZip === 'undefined') { + toast('JSZip 库加载失败,请刷新页面重试', 'error'); + return; + } + + toast(`正在打包 ${selectedImages.size} 张图片...`, 'info'); + downloadSelectedBtn.disabled = true; + downloadSelectedBtn.textContent = '打包中...'; + + const zip = new JSZip(); + const imgFolder = zip.folder('images'); + let processed = 0; + + try { + for (const item of selectedImages) { + const url = item.dataset.imageUrl; + const prompt = item.dataset.prompt || 'image'; + + try { + let blob = null; + if (url && url.startsWith('data:')) { + blob = dataUrlToBlob(url); + } else if (url) { + const response = await fetch(url); + blob = await response.blob(); + } + if (!blob) { + throw new Error('empty blob'); + } + const filename = `${prompt.substring(0, 30).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')}_${processed + 1}.png`; + imgFolder.file(filename, blob); + processed++; + + // Update progress + downloadSelectedBtn.innerHTML = `打包中... (${processed}/${selectedImages.size})`; + } catch (error) { + console.error('Failed to fetch image:', error); + } + } + + if (processed === 0) { + toast('没有成功获取任何图片', 'error'); + return; + } + + // Generate zip file + downloadSelectedBtn.textContent = '生成压缩包...'; + const content = await zip.generateAsync({ type: 'blob' }); + + // Download zip + const link = document.createElement('a'); + link.href = URL.createObjectURL(content); + link.download = `imagine_${new Date().toISOString().slice(0, 10)}_${Date.now()}.zip`; + link.click(); + URL.revokeObjectURL(link.href); + + toast(`成功打包 ${processed} 张图片`, 'success'); + exitSelectionMode(); + } catch (error) { + console.error('Download failed:', error); + toast('打包失败,请重试', 'error'); + } finally { + downloadSelectedBtn.disabled = false; + downloadSelectedBtn.innerHTML = `下载 ${selectedImages.size}`; + } + } + + if (batchDownloadBtn) { + batchDownloadBtn.addEventListener('click', toggleSelectionMode); + } + + if (toggleSelectAllBtn) { + toggleSelectAllBtn.addEventListener('click', toggleSelectAll); + } + + if (downloadSelectedBtn) { + downloadSelectedBtn.addEventListener('click', downloadSelectedImages); + } + + + // Handle image/checkbox clicks in waterfall + if (waterfall) { + waterfall.addEventListener('click', (e) => { + const item = e.target.closest('.waterfall-item'); + if (!item) return; + + if (isSelectionMode) { + // In selection mode, clicking anywhere on the item toggles selection + toggleImageSelection(item); + } else { + // In normal mode, only clicking the image opens lightbox + if (e.target.closest('.waterfall-item img')) { + const img = e.target.closest('.waterfall-item img'); + const images = getAllImages(); + const index = images.indexOf(img); + + if (index !== -1) { + updateLightbox(index); + lightbox.classList.add('active'); + } + } + } + }); + } + + // Lightbox for image preview with navigation + const lightboxPrev = document.getElementById('lightboxPrev'); + const lightboxNext = document.getElementById('lightboxNext'); + let currentImageIndex = -1; + + function getAllImages() { + return Array.from(document.querySelectorAll('.waterfall-item img')); + } + + function updateLightbox(index) { + const images = getAllImages(); + if (index < 0 || index >= images.length) return; + + currentImageIndex = index; + lightboxImg.src = images[index].src; + + // Update navigation buttons state + if (lightboxPrev) lightboxPrev.disabled = (index === 0); + if (lightboxNext) lightboxNext.disabled = (index === images.length - 1); + } + + function showPrevImage() { + if (currentImageIndex > 0) { + updateLightbox(currentImageIndex - 1); + } + } + + function showNextImage() { + const images = getAllImages(); + if (currentImageIndex < images.length - 1) { + updateLightbox(currentImageIndex + 1); + } + } + + if (lightbox && closeLightbox) { + closeLightbox.addEventListener('click', (e) => { + e.stopPropagation(); + lightbox.classList.remove('active'); + currentImageIndex = -1; + }); + + lightbox.addEventListener('click', () => { + lightbox.classList.remove('active'); + currentImageIndex = -1; + }); + + // Prevent closing when clicking on the image + if (lightboxImg) { + lightboxImg.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + // Navigation buttons + if (lightboxPrev) { + lightboxPrev.addEventListener('click', (e) => { + e.stopPropagation(); + showPrevImage(); + }); + } + + if (lightboxNext) { + lightboxNext.addEventListener('click', (e) => { + e.stopPropagation(); + showNextImage(); + }); + } + + // Keyboard navigation + document.addEventListener('keydown', (e) => { + if (!lightbox.classList.contains('active')) return; + + if (e.key === 'Escape') { + lightbox.classList.remove('active'); + currentImageIndex = -1; + } else if (e.key === 'ArrowLeft') { + showPrevImage(); + } else if (e.key === 'ArrowRight') { + showNextImage(); + } + }); + } + + // Make floating actions draggable + const floatingActions = document.getElementById('floatingActions'); + if (floatingActions) { + let isDragging = false; + let startX, startY, initialLeft, initialTop; + + floatingActions.style.touchAction = 'none'; + + floatingActions.addEventListener('pointerdown', (e) => { + if (e.target.tagName.toLowerCase() === 'button' || e.target.closest('button')) return; + + e.preventDefault(); + isDragging = true; + floatingActions.setPointerCapture(e.pointerId); + startX = e.clientX; + startY = e.clientY; + + const rect = floatingActions.getBoundingClientRect(); + + if (!floatingActions.style.left || floatingActions.style.left === '') { + floatingActions.style.left = rect.left + 'px'; + floatingActions.style.top = rect.top + 'px'; + floatingActions.style.transform = 'none'; + floatingActions.style.bottom = 'auto'; + } + + initialLeft = parseFloat(floatingActions.style.left); + initialTop = parseFloat(floatingActions.style.top); + + floatingActions.classList.add('shadow-xl'); + }); + + document.addEventListener('pointermove', (e) => { + if (!isDragging) return; + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + floatingActions.style.left = `${initialLeft + dx}px`; + floatingActions.style.top = `${initialTop + dy}px`; + }); + + document.addEventListener('pointerup', (e) => { + if (isDragging) { + isDragging = false; + floatingActions.releasePointerCapture(e.pointerId); + floatingActions.classList.remove('shadow-xl'); + } + }); + } +})(); diff --git a/app/static/public/js/login.js b/app/static/public/js/login.js new file mode 100644 index 0000000000000000000000000000000000000000..fdd0588387d522e33730a5c39712b65a2b2d328b --- /dev/null +++ b/app/static/public/js/login.js @@ -0,0 +1,51 @@ +const publicKeyInput = document.getElementById('public-key-input'); +if (publicKeyInput) { + publicKeyInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') login(); + }); +} + +async function requestPublicLogin(key) { + const headers = key ? { 'Authorization': `Bearer ${key}` } : {}; + const res = await fetch('/v1/public/verify', { + method: 'GET', + headers + }); + return res.ok; +} + +async function login() { + const input = (publicKeyInput ? publicKeyInput.value : '').trim(); + try { + const ok = await requestPublicLogin(input); + if (ok) { + await storePublicKey(input); + window.location.href = '/chat'; + } else { + showToast('密钥无效', 'error'); + } + } catch (e) { + showToast('连接失败', 'error'); + } +} + +(async () => { + try { + const stored = await getStoredPublicKey(); + if (stored) { + const ok = await requestPublicLogin(stored); + if (ok) { + window.location.href = '/chat'; + return; + } + clearStoredPublicKey(); + } + + const ok = await requestPublicLogin(''); + if (ok) { + window.location.href = '/chat'; + } + } catch (e) { + return; + } +})(); diff --git a/app/static/public/js/video.js b/app/static/public/js/video.js new file mode 100644 index 0000000000000000000000000000000000000000..076483ce47ac823ec87cc000925634461bb92848 --- /dev/null +++ b/app/static/public/js/video.js @@ -0,0 +1,640 @@ +(() => { + const startBtn = document.getElementById('startBtn'); + const stopBtn = document.getElementById('stopBtn'); + const clearBtn = document.getElementById('clearBtn'); + const promptInput = document.getElementById('promptInput'); + const imageUrlInput = document.getElementById('imageUrlInput'); + const imageFileInput = document.getElementById('imageFileInput'); + const imageFileName = document.getElementById('imageFileName'); + const clearImageFileBtn = document.getElementById('clearImageFileBtn'); + const selectImageFileBtn = document.getElementById('selectImageFileBtn'); + const ratioSelect = document.getElementById('ratioSelect'); + const lengthSelect = document.getElementById('lengthSelect'); + const resolutionSelect = document.getElementById('resolutionSelect'); + const presetSelect = document.getElementById('presetSelect'); + const statusText = document.getElementById('statusText'); + const progressBar = document.getElementById('progressBar'); + const progressFill = document.getElementById('progressFill'); + const progressText = document.getElementById('progressText'); + const durationValue = document.getElementById('durationValue'); + const aspectValue = document.getElementById('aspectValue'); + const lengthValue = document.getElementById('lengthValue'); + const resolutionValue = document.getElementById('resolutionValue'); + const presetValue = document.getElementById('presetValue'); + const videoEmpty = document.getElementById('videoEmpty'); + const videoStage = document.getElementById('videoStage'); + + let currentSource = null; + let currentTaskId = ''; + let isRunning = false; + let progressBuffer = ''; + let contentBuffer = ''; + let collectingContent = false; + let startAt = 0; + let fileDataUrl = ''; + let elapsedTimer = null; + let lastProgress = 0; + let currentPreviewItem = null; + let previewCount = 0; + const DEFAULT_REASONING_EFFORT = 'low'; + + function toast(message, type) { + if (typeof showToast === 'function') { + showToast(message, type); + } + } + + function setStatus(state, text) { + if (!statusText) return; + statusText.textContent = text; + statusText.classList.remove('connected', 'connecting', 'error'); + if (state) { + statusText.classList.add(state); + } + } + + function setButtons(running) { + if (!startBtn || !stopBtn) return; + if (running) { + startBtn.classList.add('hidden'); + stopBtn.classList.remove('hidden'); + } else { + startBtn.classList.remove('hidden'); + stopBtn.classList.add('hidden'); + startBtn.disabled = false; + } + } + + function updateProgress(value) { + const safe = Math.max(0, Math.min(100, Number(value) || 0)); + lastProgress = safe; + if (progressFill) { + progressFill.style.width = `${safe}%`; + } + if (progressText) { + progressText.textContent = `${safe}%`; + } + } + + function updateMeta() { + if (aspectValue && ratioSelect) { + aspectValue.textContent = ratioSelect.value; + } + if (lengthValue && lengthSelect) { + lengthValue.textContent = `${lengthSelect.value}s`; + } + if (resolutionValue && resolutionSelect) { + resolutionValue.textContent = resolutionSelect.value; + } + if (presetValue && presetSelect) { + presetValue.textContent = presetSelect.value; + } + } + + function resetOutput(keepPreview) { + progressBuffer = ''; + contentBuffer = ''; + collectingContent = false; + lastProgress = 0; + currentPreviewItem = null; + updateProgress(0); + setIndeterminate(false); + if (!keepPreview) { + if (videoStage) { + videoStage.innerHTML = ''; + videoStage.classList.add('hidden'); + } + if (videoEmpty) { + videoEmpty.classList.remove('hidden'); + } + previewCount = 0; + } + if (durationValue) { + durationValue.textContent = '耗时 -'; + } + } + + function initPreviewSlot() { + if (!videoStage) return; + previewCount += 1; + currentPreviewItem = document.createElement('div'); + currentPreviewItem.className = 'video-item'; + currentPreviewItem.dataset.index = String(previewCount); + currentPreviewItem.classList.add('is-pending'); + + const header = document.createElement('div'); + header.className = 'video-item-bar'; + + const title = document.createElement('div'); + title.className = 'video-item-title'; + title.textContent = `视频 ${previewCount}`; + + const actions = document.createElement('div'); + actions.className = 'video-item-actions'; + + const openBtn = document.createElement('a'); + openBtn.className = 'geist-button-outline text-xs px-3 video-open hidden'; + openBtn.target = '_blank'; + openBtn.rel = 'noopener'; + openBtn.textContent = '打开'; + + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'geist-button-outline text-xs px-3 video-download'; + downloadBtn.type = 'button'; + downloadBtn.textContent = '下载'; + downloadBtn.disabled = true; + + actions.appendChild(openBtn); + actions.appendChild(downloadBtn); + header.appendChild(title); + header.appendChild(actions); + + const body = document.createElement('div'); + body.className = 'video-item-body'; + body.innerHTML = '
        生成中…
        '; + + const link = document.createElement('div'); + link.className = 'video-item-link'; + + currentPreviewItem.appendChild(header); + currentPreviewItem.appendChild(body); + currentPreviewItem.appendChild(link); + videoStage.appendChild(currentPreviewItem); + videoStage.classList.remove('hidden'); + if (videoEmpty) { + videoEmpty.classList.add('hidden'); + } + } + + function ensurePreviewSlot() { + if (!currentPreviewItem) { + initPreviewSlot(); + } + return currentPreviewItem; + } + + function updateItemLinks(item, url) { + if (!item) return; + const openBtn = item.querySelector('.video-open'); + const downloadBtn = item.querySelector('.video-download'); + const link = item.querySelector('.video-item-link'); + const safeUrl = url || ''; + item.dataset.url = safeUrl; + if (link) { + link.textContent = safeUrl; + link.classList.toggle('has-url', Boolean(safeUrl)); + } + if (openBtn) { + if (safeUrl) { + openBtn.href = safeUrl; + openBtn.classList.remove('hidden'); + } else { + openBtn.classList.add('hidden'); + openBtn.removeAttribute('href'); + } + } + if (downloadBtn) { + downloadBtn.dataset.url = safeUrl; + downloadBtn.disabled = !safeUrl; + } + if (safeUrl) { + item.classList.remove('is-pending'); + } + } + + function setIndeterminate(active) { + if (!progressBar) return; + if (active) { + progressBar.classList.add('indeterminate'); + } else { + progressBar.classList.remove('indeterminate'); + } + } + + function startElapsedTimer() { + stopElapsedTimer(); + if (!durationValue) return; + elapsedTimer = setInterval(() => { + if (!startAt) return; + const seconds = Math.max(0, Math.round((Date.now() - startAt) / 1000)); + durationValue.textContent = `耗时 ${seconds}s`; + }, 1000); + } + + function stopElapsedTimer() { + if (elapsedTimer) { + clearInterval(elapsedTimer); + elapsedTimer = null; + } + } + + function clearFileSelection() { + fileDataUrl = ''; + if (imageFileInput) { + imageFileInput.value = ''; + } + if (imageFileName) { + imageFileName.textContent = '未选择文件'; + } + } + + function normalizeAuthHeader(authHeader) { + if (!authHeader) return ''; + if (authHeader.startsWith('Bearer ')) { + return authHeader.slice(7).trim(); + } + return authHeader; + } + + function buildSseUrl(taskId, rawPublicKey) { + const httpProtocol = window.location.protocol === 'https:' ? 'https' : 'http'; + const base = `${httpProtocol}://${window.location.host}/v1/public/video/sse`; + const params = new URLSearchParams(); + params.set('task_id', taskId); + params.set('t', String(Date.now())); + if (rawPublicKey) { + params.set('public_key', rawPublicKey); + } + return `${base}?${params.toString()}`; + } + + async function createVideoTask(authHeader) { + const prompt = promptInput ? promptInput.value.trim() : ''; + const rawUrl = imageUrlInput ? imageUrlInput.value.trim() : ''; + if (fileDataUrl && rawUrl) { + toast('参考图只能选择其一:URL/Base64 或 本地上传', 'error'); + throw new Error('invalid_reference'); + } + const imageUrl = fileDataUrl || rawUrl; + const res = await fetch('/v1/public/video/start', { + method: 'POST', + headers: { + ...buildAuthHeaders(authHeader), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + prompt, + image_url: imageUrl || null, + reasoning_effort: DEFAULT_REASONING_EFFORT, + aspect_ratio: ratioSelect ? ratioSelect.value : '3:2', + video_length: lengthSelect ? parseInt(lengthSelect.value, 10) : 6, + resolution_name: resolutionSelect ? resolutionSelect.value : '480p', + preset: presetSelect ? presetSelect.value : 'normal' + }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || 'Failed to create task'); + } + const data = await res.json(); + return data && data.task_id ? String(data.task_id) : ''; + } + + async function stopVideoTask(taskId, authHeader) { + if (!taskId) return; + try { + await fetch('/v1/public/video/stop', { + method: 'POST', + headers: { + ...buildAuthHeaders(authHeader), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ task_ids: [taskId] }) + }); + } catch (e) { + // ignore + } + } + + function extractVideoInfo(buffer) { + if (!buffer) return null; + if (buffer.includes('/gi); + if (matches && matches.length) { + return { html: matches[matches.length - 1] }; + } + } + const mdMatches = buffer.match(/\[video\]\(([^)]+)\)/g); + if (mdMatches && mdMatches.length) { + const last = mdMatches[mdMatches.length - 1]; + const urlMatch = last.match(/\[video\]\(([^)]+)\)/); + if (urlMatch) { + return { url: urlMatch[1] }; + } + } + const urlMatches = buffer.match(/https?:\/\/[^\s<)]+/g); + if (urlMatches && urlMatches.length) { + return { url: urlMatches[urlMatches.length - 1] }; + } + return null; + } + + function renderVideoFromHtml(html) { + const container = ensurePreviewSlot(); + if (!container) return; + const body = container.querySelector('.video-item-body'); + if (!body) return; + body.innerHTML = html; + const videoEl = body.querySelector('video'); + let videoUrl = ''; + if (videoEl) { + videoEl.controls = true; + videoEl.preload = 'metadata'; + const source = videoEl.querySelector('source'); + if (source && source.getAttribute('src')) { + videoUrl = source.getAttribute('src'); + } else if (videoEl.getAttribute('src')) { + videoUrl = videoEl.getAttribute('src'); + } + } + updateItemLinks(container, videoUrl); + } + + function renderVideoFromUrl(url) { + const container = ensurePreviewSlot(); + if (!container) return; + const safeUrl = url || ''; + const body = container.querySelector('.video-item-body'); + if (!body) return; + body.innerHTML = `\n \n `; + updateItemLinks(container, safeUrl); + } + + function handleDelta(text) { + if (!text) return; + if (text.includes('') || text.includes('')) { + return; + } + if (text.includes('超分辨率')) { + setStatus('connecting', '超分辨率中'); + setIndeterminate(true); + if (progressText) { + progressText.textContent = '超分辨率中'; + } + return; + } + + if (!collectingContent) { + const maybeVideo = text.includes(' { + setStatus('connected', '生成中'); + }; + + es.onmessage = (event) => { + if (!event || !event.data) return; + if (event.data === '[DONE]') { + finishRun(); + return; + } + let payload = null; + try { + payload = JSON.parse(event.data); + } catch (e) { + return; + } + if (payload && payload.error) { + toast(payload.error, 'error'); + setStatus('error', '生成失败'); + finishRun(true); + return; + } + const choice = payload.choices && payload.choices[0]; + const delta = choice && choice.delta ? choice.delta : null; + if (delta && delta.content) { + handleDelta(delta.content); + } + if (choice && choice.finish_reason === 'stop') { + finishRun(); + } + }; + + es.onerror = () => { + if (!isRunning) return; + setStatus('error', '连接错误'); + finishRun(true); + }; + } + + async function stopConnection() { + const authHeader = await ensurePublicKey(); + if (authHeader !== null) { + await stopVideoTask(currentTaskId, authHeader); + } + closeSource(); + isRunning = false; + currentTaskId = ''; + stopElapsedTimer(); + setButtons(false); + setStatus('', '未连接'); + } + + function finishRun(hasError) { + if (!isRunning) return; + closeSource(); + isRunning = false; + setButtons(false); + stopElapsedTimer(); + if (!hasError) { + setStatus('connected', '完成'); + setIndeterminate(false); + updateProgress(100); + } + if (durationValue && startAt) { + const seconds = Math.max(0, Math.round((Date.now() - startAt) / 1000)); + durationValue.textContent = `耗时 ${seconds}s`; + } + } + + if (startBtn) { + startBtn.addEventListener('click', () => startConnection()); + } + + if (stopBtn) { + stopBtn.addEventListener('click', () => stopConnection()); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => resetOutput()); + } + + if (videoStage) { + videoStage.addEventListener('click', async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (!target.classList.contains('video-download')) return; + event.preventDefault(); + const item = target.closest('.video-item'); + if (!item) return; + const url = item.dataset.url || target.dataset.url || ''; + const index = item.dataset.index || ''; + if (!url) return; + try { + const response = await fetch(url, { mode: 'cors' }); + if (!response.ok) { + throw new Error('download_failed'); + } + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = blobUrl; + anchor.download = index ? `grok_video_${index}.mp4` : 'grok_video.mp4'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(blobUrl); + } catch (e) { + toast('下载失败,请检查视频链接是否可访问', 'error'); + } + }); + } + + if (imageFileInput) { + imageFileInput.addEventListener('change', () => { + const file = imageFileInput.files && imageFileInput.files[0]; + if (!file) { + clearFileSelection(); + return; + } + if (imageUrlInput && imageUrlInput.value.trim()) { + imageUrlInput.value = ''; + } + if (imageFileName) { + imageFileName.textContent = file.name; + } + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + fileDataUrl = reader.result; + } else { + fileDataUrl = ''; + toast('文件读取失败', 'error'); + } + }; + reader.onerror = () => { + fileDataUrl = ''; + toast('文件读取失败', 'error'); + }; + reader.readAsDataURL(file); + }); + } + + if (selectImageFileBtn && imageFileInput) { + selectImageFileBtn.addEventListener('click', () => { + imageFileInput.click(); + }); + } + + if (clearImageFileBtn) { + clearImageFileBtn.addEventListener('click', () => { + clearFileSelection(); + }); + } + + if (imageUrlInput) { + imageUrlInput.addEventListener('input', () => { + if (imageUrlInput.value.trim() && fileDataUrl) { + clearFileSelection(); + } + }); + } + + if (promptInput) { + promptInput.addEventListener('keydown', (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + startConnection(); + } + }); + } + + updateMeta(); +})(); diff --git a/app/static/public/js/voice.js b/app/static/public/js/voice.js new file mode 100644 index 0000000000000000000000000000000000000000..3da0a2a13518fd990ba15c3c30b360fbfd033472 --- /dev/null +++ b/app/static/public/js/voice.js @@ -0,0 +1,303 @@ +(() => { + let Room; + let createLocalTracks; + let RoomEvent; + let Track; + let room = null; + let visualizerTimer = null; + + const startBtn = document.getElementById('startBtn'); + const stopBtn = document.getElementById('stopBtn'); + const statusText = document.getElementById('statusText'); + const logContainer = document.getElementById('log'); + const voiceSelect = document.getElementById('voiceSelect'); + const personalitySelect = document.getElementById('personalitySelect'); + const speedRange = document.getElementById('speedRange'); + const speedValue = document.getElementById('speedValue'); + const statusVoice = document.getElementById('statusVoice'); + const statusPersonality = document.getElementById('statusPersonality'); + const statusSpeed = document.getElementById('statusSpeed'); + const audioRoot = document.getElementById('audioRoot'); + const copyLogBtn = document.getElementById('copyLogBtn'); + const clearLogBtn = document.getElementById('clearLogBtn'); + const visualizer = document.getElementById('visualizer'); + + function log(message, level = 'info') { + if (!logContainer) { + return; + } + const p = document.createElement('p'); + const time = new Date().toLocaleTimeString(); + p.textContent = `[${time}] ${message}`; + if (level === 'error') { + p.classList.add('log-error'); + } else if (level === 'warn') { + p.classList.add('log-warn'); + } + logContainer.prepend(p); + if (typeof console !== 'undefined') { + console.log(message); + } + } + + function toast(message, type) { + if (typeof showToast === 'function') { + showToast(message, type); + } else { + log(message, type === 'error' ? 'error' : 'info'); + } + } + + function setStatus(state, text) { + if (!statusText) { + return; + } + statusText.textContent = text; + statusText.classList.remove('connected', 'connecting', 'error'); + if (state) { + statusText.classList.add(state); + } + } + + function setButtons(connected) { + if (!startBtn || !stopBtn) { + return; + } + if (connected) { + startBtn.classList.add('hidden'); + stopBtn.classList.remove('hidden'); + } else { + startBtn.classList.remove('hidden'); + stopBtn.classList.add('hidden'); + startBtn.disabled = false; + } + } + + function updateMeta() { + if (statusVoice) { + statusVoice.textContent = voiceSelect.value; + } + if (statusPersonality) { + statusPersonality.textContent = personalitySelect.value; + } + if (statusSpeed) { + statusSpeed.textContent = `${speedRange.value}x`; + } + } + + function initLiveKit() { + const lk = window.LiveKitClient || window.LivekitClient; + if (!lk) { + return false; + } + Room = lk.Room; + createLocalTracks = lk.createLocalTracks; + RoomEvent = lk.RoomEvent; + Track = lk.Track; + return true; + } + + function ensureLiveKit() { + if (Room) { + return true; + } + if (!initLiveKit()) { + log('错误: LiveKit SDK 未能正确加载,请刷新页面重试', 'error'); + toast('LiveKit SDK 加载失败', 'error'); + return false; + } + return true; + } + + function ensureMicSupport() { + const hasMediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices; + const hasGetUserMedia = hasMediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'; + if (hasGetUserMedia) { + return true; + } + const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname); + const secureHint = window.isSecureContext || isLocalhost + ? '请使用最新版浏览器并允许麦克风权限' + : '请使用 HTTPS 或在本机 localhost 访问'; + throw new Error(`当前环境不支持麦克风权限,${secureHint}`); + } + + async function startSession() { + if (!ensureLiveKit()) { + return; + } + + try { + const authHeader = await ensurePublicKey(); + if (authHeader === null) { + toast('请先配置 Public Key', 'error'); + window.location.href = '/login'; + return; + } + + startBtn.disabled = true; + updateMeta(); + setStatus('connecting', '正在连接'); + log('正在获取 Token...'); + + const params = new URLSearchParams({ + voice: voiceSelect.value, + personality: personalitySelect.value, + speed: speedRange.value + }); + + const headers = buildAuthHeaders(authHeader); + + const response = await fetch(`/v1/public/voice/token?${params.toString()}`, { + headers + }); + + if (!response.ok) { + throw new Error(`获取 Token 失败: ${response.status}`); + } + + const { token, url } = await response.json(); + log(`获取 Token 成功 (${voiceSelect.value}, ${personalitySelect.value}, ${speedRange.value}x)`); + + room = new Room({ + adaptiveStream: true, + dynacast: true + }); + + room.on(RoomEvent.ParticipantConnected, (p) => log(`参与者已连接: ${p.identity}`)); + room.on(RoomEvent.ParticipantDisconnected, (p) => log(`参与者已断开: ${p.identity}`)); + room.on(RoomEvent.TrackSubscribed, (track) => { + log(`订阅音轨: ${track.kind}`); + if (track.kind === Track.Kind.Audio) { + const element = track.attach(); + if (audioRoot) { + audioRoot.appendChild(element); + } else { + document.body.appendChild(element); + } + } + }); + + room.on(RoomEvent.Disconnected, () => { + log('已断开连接'); + resetUI(); + }); + + await room.connect(url, token); + log('已连接到 LiveKit 服务器'); + + setStatus('connected', '通话中'); + setButtons(true); + + log('正在开启麦克风...'); + ensureMicSupport(); + const tracks = await createLocalTracks({ audio: true, video: false }); + for (const track of tracks) { + await room.localParticipant.publishTrack(track); + } + log('语音已开启'); + toast('语音连接成功', 'success'); + } catch (err) { + const message = err && err.message ? err.message : '连接失败'; + log(`错误: ${message}`, 'error'); + toast(message, 'error'); + setStatus('error', '连接错误'); + startBtn.disabled = false; + } + } + + async function stopSession() { + if (room) { + await room.disconnect(); + } + resetUI(); + } + + function resetUI() { + setStatus('', '未连接'); + setButtons(false); + if (audioRoot) { + audioRoot.innerHTML = ''; + } + } + + function clearLog() { + if (logContainer) { + logContainer.innerHTML = ''; + } + } + + async function copyLog() { + if (!logContainer) { + return; + } + const lines = Array.from(logContainer.querySelectorAll('p')) + .map((p) => p.textContent) + .join('\n'); + try { + await navigator.clipboard.writeText(lines); + toast('日志已复制', 'success'); + } catch (err) { + toast('复制失败,请手动选择', 'error'); + } + } + + speedRange.addEventListener('input', (e) => { + speedValue.textContent = Number(e.target.value).toFixed(1); + const min = Number(speedRange.min || 0); + const max = Number(speedRange.max || 100); + const val = Number(speedRange.value || 0); + const pct = ((val - min) / (max - min)) * 100; + speedRange.style.setProperty('--range-progress', `${pct}%`); + updateMeta(); + }); + + voiceSelect.addEventListener('change', updateMeta); + personalitySelect.addEventListener('change', updateMeta); + + startBtn.addEventListener('click', startSession); + stopBtn.addEventListener('click', stopSession); + if (copyLogBtn) { + copyLogBtn.addEventListener('click', copyLog); + } + if (clearLogBtn) { + clearLogBtn.addEventListener('click', clearLog); + } + + speedValue.textContent = Number(speedRange.value).toFixed(1); + { + const min = Number(speedRange.min || 0); + const max = Number(speedRange.max || 100); + const val = Number(speedRange.value || 0); + const pct = ((val - min) / (max - min)) * 100; + speedRange.style.setProperty('--range-progress', `${pct}%`); + } + function buildVisualizerBars() { + if (!visualizer) return; + visualizer.innerHTML = ''; + const targetCount = Math.max(36, Math.floor(visualizer.offsetWidth / 7)); + for (let i = 0; i < targetCount; i += 1) { + const bar = document.createElement('div'); + bar.className = 'bar'; + visualizer.appendChild(bar); + } + } + + window.addEventListener('resize', buildVisualizerBars); + buildVisualizerBars(); + updateMeta(); + setStatus('', '未连接'); + + if (!visualizerTimer) { + visualizerTimer = setInterval(() => { + const bars = document.querySelectorAll('.visualizer .bar'); + bars.forEach((bar) => { + if (statusText && statusText.classList.contains('connected')) { + bar.style.height = `${Math.random() * 32 + 6}px`; + } else { + bar.style.height = '6px'; + } + }); + }, 150); + } +})(); diff --git a/app/static/public/pages/chat.html b/app/static/public/pages/chat.html new file mode 100644 index 0000000000000000000000000000000000000000..a01c7aa42d6ab5c95a0e75c4dc1e412f1d08cfd0 --- /dev/null +++ b/app/static/public/pages/chat.html @@ -0,0 +1,157 @@ + + + + + + + Grok2API - Chat 聊天 + + + + + + + + + + +
        +
        + +
        +
        + + + +
        +
        +
        + +
        +

        Chat 聊天

        +

        通过 chat 接口与 Grok 进行会话聊天

        +
        +
        + 就绪 +
        + +
        +
        输入一条消息开始对话。
        +
        + +
        +
        + + + + +
        +
        + + + + + + grok-4.20-beta + + + + +
        + + +
        +
        +
        +
        +
        +
        + + + + + + + + + + + diff --git a/app/static/public/pages/imagine.html b/app/static/public/pages/imagine.html new file mode 100644 index 0000000000000000000000000000000000000000..df8d9d99e02995bc81c5cc5f2ed94d3fe11e274f --- /dev/null +++ b/app/static/public/pages/imagine.html @@ -0,0 +1,248 @@ + + + + + + + Grok2API - Imagine 瀑布流 + + + + + + + + + + +
        +
        + +
        +
        +
        +

        Imagine 瀑布流

        +

        通过 WebSocket 持续生成图片,实时展示 base64 瀑布流。

        +
        + +
        + +
        +
        +
        +
        生成设置
        +
        +
        +
        + + +
        +
        +
        +
        +
        +
        自动跟随
        +
        滚动到最新
        +
        + +
        +
        +
        +
        自动保存
        +
        自动保存图片
        +
        + +
        +
        +
        +
        +
        + + +
        +
        + + +
        +
        +
        +
        +
        +
        +
        自动过滤
        +
        过滤不达标图片
        +
        + +
        +
        +
        +
        +
        + + +
        +
        + + +
        +
        +
        +
        +
        +
        +
        反向新增
        +
        最新显示在上方
        +
        + +
        +
        +
        +
        +
        + +
        +
        +
        连接状态
        + 未连接 + + + +
        +
        +
        +
        +
        连接模式
        +
        + + + +
        +
        +
        +
        图片数量
        +
        0
        +
        +
        +
        活跃任务
        +
        0
        +
        +
        +
        平均耗时
        +
        -
        +
        +
        +
        +
        +
        + +
        +
        +
        瀑布流
        +
        +
        +
        +
        +
        等待连接中
        +
        启动任务后,生成的图片会自动汇聚到这里
        +
        +
        +
        +
        + + + + +
        + + + + + + + +
        + + + + + + + + + + + + diff --git a/app/static/public/pages/login.html b/app/static/public/pages/login.html new file mode 100644 index 0000000000000000000000000000000000000000..d259ed130d2c74f1b348e7132801efe4b1de643e --- /dev/null +++ b/app/static/public/pages/login.html @@ -0,0 +1,68 @@ + + + + + + + Korg - Public + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/public/pages/video.html b/app/static/public/pages/video.html new file mode 100644 index 0000000000000000000000000000000000000000..ec154ba423d49f3b6e336ad0c21475b022563c27 --- /dev/null +++ b/app/static/public/pages/video.html @@ -0,0 +1,168 @@ + + + + + + + Grok2API - Video 视频生成 + + + + + + + + + + +
        +
        + +
        +
        +
        +
        +

        Video 视频生成

        +

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

        +
        +
        + + +
        +
        + +
        + +
        +
        +
        生成设置
        +
        +
        + + +
        +
        + +
        + +
        +
        + 未选择文件 +
        +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + + +
        +
        + + +
        +
        +
        + +
        +
        +
        运行状态
        + 未连接 +
        +
        +
        +
        +
        +
        + 0% + 耗时 - +
        +
        +
        +
        +
        比例
        +
        -
        +
        +
        +
        时长
        +
        -
        +
        +
        +
        分辨率
        +
        -
        +
        +
        +
        预设
        +
        -
        +
        +
        +
        +
        + +
        +
        +
        视频预览
        +
        + +
        +
        +
        等待生成视频
        + +
        +
        +
        + + + + + + + + + + + diff --git a/app/static/public/pages/voice.html b/app/static/public/pages/voice.html new file mode 100644 index 0000000000000000000000000000000000000000..40f1990f83f3c9f79b6f112b494566fe033edecd --- /dev/null +++ b/app/static/public/pages/voice.html @@ -0,0 +1,157 @@ + + + + + + + Grok2API - LiveKit 陪聊 + + + + + + + + + + + +
        +
        + +
        +
        +
        +
        +

        LiveKit 陪聊

        +

        LiveKit 语音会话,连接 Grok Voice。

        +
        +
        + + +
        +
        + +
        + +
        +
        +
        +
        连接设置
        +
        +
        + + +
        + +
        + + +
        + +
        +
        + + 1.0x +
        + +
        +
        +
        提示:Voice / Personality 会在建立连接前发送到服务端。
        +
        + +
        +
        +
        会话日志
        +
        + + +
        +
        +
        +
        +
        + + +
        +
        +
        + + + + + + + + + + + diff --git a/config.defaults.toml b/config.defaults.toml new file mode 100644 index 0000000000000000000000000000000000000000..2619d6f300a57a2cde617adb865a00a8e18d9101 --- /dev/null +++ b/config.defaults.toml @@ -0,0 +1,189 @@ +# ==================== 应用设置 ==================== +[app] +# 应用访问地址(用于生成文件链接) +app_url = "" +# 后台管理密码 +app_key = "grok2api" +# API 调用密钥(可选,支持列表) +api_key = "" +# 是否启用 public 功能玩法 +public_enabled = false +# Public 调用密钥(可选) +public_key = "" +# 生成图片的格式(url 或 base64) +image_format = "url" +# 生成视频的格式(html 或 url) +video_format = "html" +# 是否启用临时对话模式 +temporary = true +# 是否禁用 Grok 记忆功能 +disable_memory = true +# 是否默认启用流式响应 +stream = true +# 是否默认启用思维链输出 +thinking = true +# 是否动态生成 Statsig 指纹 +dynamic_statsig = true +# 过滤的特殊标签列表 +filter_tags = ["xaiartifact","xai:tool_usage_card","grok:render"] + + +# ==================== 代理配置 ==================== +[proxy] +# 基础代理地址(代理到 Grok 官网) +base_proxy_url = "" +# 资源代理地址(代理静态资源如图片/视频) +asset_proxy_url = "" +# 是否启用 CF 自动刷新 +enabled = false +# FlareSolverr 服务地址(通过环境变量 FLARESOLVERR_URL 自动设置) +flaresolverr_url = "" +# 刷新间隔(秒) +refresh_interval = 3600 +# CF 挑战等待超时(秒) +timeout = 60 +# Cloudflare Clearance Cookie +cf_clearance = "" +# curl_cffi 浏览器指纹 +browser = "chrome136" +# User-Agent 字符串 +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" + + +# ==================== 重试策略 ==================== +[retry] +# 最大重试次数 +max_retry = 3 +# 触发重试的 HTTP 状态码 +retry_status_codes = [401,429,403] +# 触发重建 session 的 HTTP 状态码(用于轮换代理) +# 退避基础延迟(秒) +retry_backoff_base = 0.5 +# 退避倍率 +retry_backoff_factor = 2.0 +# 单次重试最大延迟(秒) +retry_backoff_max = 20.0 +# 总重试预算时间(秒) +retry_budget = 60.0 + + +# ==================== Token 池管理 ==================== +[token] +# 是否启用 Token 自动刷新 +auto_refresh = true +# 普通 Token 刷新间隔(小时) +refresh_interval_hours = 8 +# Super Token 刷新间隔(小时) +super_refresh_interval_hours = 2 +# Token 连续失败阈值 +fail_threshold = 5 +# Token 变更保存延迟(毫秒) +save_delay_ms = 500 +# 使用量写入最小间隔(秒) +usage_flush_interval_sec = 5 +# 多 worker 状态同步间隔(秒) +reload_interval_sec = 30 + +# ==================== 缓存管理 ==================== +[cache] +# 是否启用自动清理 +enable_auto_clean = true +# 缓存大小上限(MB) +limit_mb = 512 + +# ==================== 对话配置 ==================== +[chat] +# Reverse 接口并发上限 +concurrent = 50 +# Reverse 接口超时时间(秒) +timeout = 60 +# 流式空闲超时时间(秒) +stream_timeout = 60 + +# ==================== 图像配置 ==================== +[image] +# WebSocket 请求超时时间(秒) +timeout = 60 +# WebSocket 流式空闲超时时间(秒) +stream_timeout = 60 +# 中等图后等待最终图的超时秒数 +final_timeout = 15 +# blocked / 无最终图时,WebSocket 请求重试次数 +# 判定疑似被审查时的宽限秒数(默认 10 秒,可自定义) +blocked_grace_seconds = 10 +# 是否启用 NSFW +nsfw = true +# 判定为中等质量图的最小字节数 +medium_min_bytes = 30000 +# 判定为最终图的最小字节数 +final_min_bytes = 100000 +# 遇到疑似审查/拦截时的并行补偿生成次数 +blocked_parallel_attempts = 5 +# 是否启用并行补偿(启用时优先使用不同 token) +blocked_parallel_enabled = true + + +# ==================== SuperImage 配置 ==================== +[imagine_fast] +# 仅对 grok-imagine-1.0-fast 生效,由服务端统一控制,不使用客户端 image_config +n = 1 +# 图片尺寸:1280x720 / 720x1280 / 1792x1024 / 1024x1792 / 1024x1024 +size = "1024x1024" +# 响应格式:url / b64_json / base64 +response_format = "url" + + +# ==================== 视频配置 ==================== +[video] +# Reverse 接口并发上限 +concurrent = 100 +# Reverse 接口超时时间(秒) +timeout = 60 +# 流式空闲超时时间(秒) +stream_timeout = 60 + +# ==================== 语音配置 ==================== +[voice] +# Voice 请求超时时间(秒) +timeout = 60 + +# ==================== 资产配置 ==================== +[asset] +# 上传并发数 +upload_concurrent = 100 +# 上传超时时间(秒) +upload_timeout = 60 +# 下载并发数 +download_concurrent = 100 +# 下载超时时间(秒) +download_timeout = 60 +# 资产查询并发数 +list_concurrent = 100 +# 资产查询超时时间(秒) +list_timeout = 60 +# 资产查询批次大小(Token 维度) +list_batch_size = 50 +# 资产删除并发数 +delete_concurrent = 100 +# 资产删除超时时间(秒) +delete_timeout = 60 +# 资产删除批次大小(Token 维度) +delete_batch_size = 50 + +# ==================== NSFW ==================== +[nsfw] +# NSFW 批量开启并发上限 +concurrent = 60 +# NSFW 批量开启批次大小 +batch_size = 30 +# NSFW 请求超时时间(秒) +timeout = 60 + +# ==================== 用量配置 ==================== +[usage] +# Usage 批量开启并发上限 +concurrent = 100 +# Usage 批量开启批次大小 +batch_size = 50 +# Usage 请求超时时间(秒) +timeout = 60 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..491bc0b0796feee99e9f2344a9f90198ddd35179 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + grok2api: + container_name: grok2api + image: ghcr.io/chenyme/grok2api:latest + ports: + - "8000:8000" + environment: + TZ: Asia/Shanghai + LOG_LEVEL: INFO + SERVER_PORT: 8000 + SERVER_WORKERS: 1 + SERVER_STORAGE_TYPE: local + # 启用 CF 自动刷新: 取消以下三行注释,并取消底部 flaresolverr 服务的注释 + # FLARESOLVERR_URL: http://flaresolverr:8191 + # CF_REFRESH_INTERVAL: "600" + # CF_TIMEOUT: "60" + + # SERVER_STORAGE_TYPE: (local, redis, mysql, pgsql) default: local + # SERVER_STORAGE_URL: (local mode is empty) default: empty + # Redis: redis://localhost:6379/0 or redis://:password@localhost:6379/0 + # MySQL: mysql+aiomysql://user:pass@localhost/db + # PgSQL: postgresql+asyncpg://user:pass@localhost/db + volumes: + - ./data:/app/data + - ./logs:/app/logs + restart: unless-stopped + + # 如果出口 IP 不干净,可取消以下注释使用 Warp 作为落地代理 + # 启用后将 proxy.base_proxy_url 设为 socks5://warp:1080 + # warp: + # container_name: warp + # image: caomingjun/warp:latest + # restart: unless-stopped + # ports: + # - "127.0.0.1:1080:1080" + # environment: + # - WARP_SLEEP=2 + # cap_add: + # - NET_ADMIN + + # 启用 CF 自动刷新时取消以下注释 + # flaresolverr: + # container_name: flaresolverr + # image: ghcr.io/flaresolverr/flaresolverr:latest + # ports: + # - "127.0.0.1:8191:8191" + # environment: + # TZ: Asia/Shanghai + # LOG_LEVEL: info + # restart: unless-stopped diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fb419960ae348a09bb7a3970753f7ab3bf042577 --- /dev/null +++ b/main.py @@ -0,0 +1,190 @@ +""" +Grok2API 应用入口 + +FastAPI 应用初始化和路由注册 +""" + +from contextlib import asynccontextmanager +import os +import platform +import sys +from pathlib import Path + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent +APP_DIR = BASE_DIR / "app" + +# Ensure the project root is on sys.path (helps when Vercel sets a different CWD) +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +env_file = BASE_DIR / ".env" +if env_file.exists(): + load_dotenv(env_file) + +from fastapi import FastAPI # noqa: E402 +from fastapi.middleware.cors import CORSMiddleware # noqa: E402 +from fastapi import Depends # noqa: E402 + +from app.core.auth import verify_api_key # noqa: E402 +from app.core.config import get_config # noqa: E402 +from app.core.logger import logger, setup_logging # noqa: E402 +from app.core.exceptions import register_exception_handlers # noqa: E402 +from app.core.response_middleware import ResponseLoggerMiddleware # noqa: E402 +from app.api.v1.chat import router as chat_router # noqa: E402 +from app.api.v1.image import router as image_router # noqa: E402 +from app.api.v1.files import router as files_router # noqa: E402 +from app.api.v1.models import router as models_router # noqa: E402 +from app.api.v1.response import router as responses_router # noqa: E402 +from app.services.token import get_scheduler # noqa: E402 +from app.api.v1.admin_api import router as admin_router +from app.api.v1.public_api import router as public_router +from app.api.pages import router as pages_router +from fastapi.staticfiles import StaticFiles + +# 初始化日志 +setup_logging( + level=os.getenv("LOG_LEVEL", "INFO"), json_console=False, file_logging=True +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 1. 注册服务默认配置 + from app.core.config import config, register_defaults + from app.services.grok.defaults import get_grok_defaults + + register_defaults(get_grok_defaults()) + + # 2. 加载配置 + await config.load() + + # 3. 启动服务显示 + logger.info("Starting Grok2API...") + logger.info(f"Platform: {platform.system()} {platform.release()}") + logger.info(f"Python: {sys.version.split()[0]}") + + # 4. 启动 Token 刷新调度器 + refresh_enabled = get_config("token.auto_refresh", True) + if refresh_enabled: + basic_interval = get_config("token.refresh_interval_hours", 8) + super_interval = get_config("token.super_refresh_interval_hours", 2) + interval = min(basic_interval, super_interval) + scheduler = get_scheduler(interval) + scheduler.start() + + # 5. 启动 cf_clearance 自动刷新 + # 环境变量 FLARESOLVERR_URL 会作为初始值写入配置(兼容旧部署方式) + _flaresolverr_env = os.getenv("FLARESOLVERR_URL", "") + if _flaresolverr_env and not get_config("proxy.flaresolverr_url"): + await config.update({ + "proxy": { + "enabled": True, + "flaresolverr_url": _flaresolverr_env, + "refresh_interval": int(os.getenv("CF_REFRESH_INTERVAL", "600")), + "timeout": int(os.getenv("CF_TIMEOUT", "60")), + } + }) + + from app.services.cf_refresh import start as cf_refresh_start + cf_refresh_start() + + logger.info("Application startup complete.") + yield + + # 关闭 + logger.info("Shutting down Grok2API...") + + from app.services.cf_refresh import stop as cf_refresh_stop + cf_refresh_stop() + + from app.core.storage import StorageFactory + + if StorageFactory._instance: + await StorageFactory._instance.close() + + if refresh_enabled: + scheduler = get_scheduler() + scheduler.stop() + + +def create_app() -> FastAPI: + """创建 FastAPI 应用""" + app = FastAPI( + title="Grok2API", + lifespan=lifespan, + ) + + # CORS 配置 + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 请求日志和 ID 中间件 + app.add_middleware(ResponseLoggerMiddleware) + + # 注册异常处理器 + register_exception_handlers(app) + + # 注册路由 + app.include_router( + chat_router, prefix="/v1", dependencies=[Depends(verify_api_key)] + ) + app.include_router( + image_router, prefix="/v1", dependencies=[Depends(verify_api_key)] + ) + app.include_router( + models_router, prefix="/v1", dependencies=[Depends(verify_api_key)] + ) + app.include_router( + responses_router, prefix="/v1", dependencies=[Depends(verify_api_key)] + ) + app.include_router(files_router, prefix="/v1/files") + + # 静态文件服务 + static_dir = APP_DIR / "static" + if static_dir.exists(): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + # 注册管理与公共路由 + app.include_router(admin_router, prefix="/v1/admin") + app.include_router(public_router, prefix="/v1/public") + app.include_router(pages_router) + + return app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + + host = os.getenv("SERVER_HOST", "0.0.0.0") + port = int(os.getenv("SERVER_PORT", "8000")) + workers = int(os.getenv("SERVER_WORKERS", "1")) + + # 平台检查 + is_windows = platform.system() == "Windows" + + # 自动降级 + if is_windows and workers > 1: + logger.warning( + f"Windows platform detected. Multiple workers ({workers}) is not supported. " + "Using single worker instead." + ) + workers = 1 + + uvicorn.run( + "main:app", + host=host, + port=port, + workers=workers, + log_level=os.getenv("LOG_LEVEL", "INFO").lower(), + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..ae73268453c907416b51e942bce943e47db79c6f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "grok2api" +version = "1.5.4" +description = "Grok2API rebuilt with FastAPI, fully aligned with the latest web call format. Supports streaming and non-streaming chat, image generation/editing, deep thinking, token pool concurrency, and automatic load balancing." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "aiofiles>=25.1.0", + "aiohttp>=3.13.3", + "aiohttp-socks>=0.11.0", + "aiomysql>=0.2.0", + "asyncpg>=0.31.0", + "cryptography>=46.0.5", + "curl-cffi>=0.13.0", + "fastapi>=0.119.0", + "greenlet>=3.3.1", + "livekit>=1.0.25", + "loguru>=0.7.3", + "orjson>=3.11.4", + "python-dotenv>=1.0.0", + "python-multipart>=0.0.21", + "redis>=6.4.0", + "sqlalchemy>=2.0.46", + "uvicorn>=0.37.0", + "websockets>=16.0", +] + +[dependency-groups] +dev = [ + "ruff>=0.15.0", +] diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..fafc9e44109a1ed447333d3f9264271130451ded --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +/app/scripts/init_storage.sh + +exec "$@" diff --git a/scripts/init_storage.sh b/scripts/init_storage.sh new file mode 100644 index 0000000000000000000000000000000000000000..5231c2eddc90e1fa7ebe4fe120b6f7465baaedd3 --- /dev/null +++ b/scripts/init_storage.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +DATA_DIR="${DATA_DIR:-$ROOT_DIR/data}" +LOG_DIR="${LOG_DIR:-$ROOT_DIR/logs}" +TMP_DIR="${TMP_DIR:-$DATA_DIR/tmp}" +DEFAULT_CONFIG="$ROOT_DIR/config.defaults.toml" + +mkdir -p "$DATA_DIR" "$LOG_DIR" "$TMP_DIR" + +if [ ! -f "$DATA_DIR/config.toml" ]; then + cp "$DEFAULT_CONFIG" "$DATA_DIR/config.toml" +fi + +if [ ! -f "$DATA_DIR/token.json" ]; then + echo "{}" > "$DATA_DIR/token.json" +fi + +chmod 600 "$DATA_DIR/config.toml" "$DATA_DIR/token.json" || true diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..fd85b5b8ef8b7fd06ed3792169fb12873150da1c --- /dev/null +++ b/uv.lock @@ -0,0 +1,1188 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiohttp-socks" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" }, +] + +[[package]] +name = "aiomysql" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymysql" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e0/302aeffe8d90853556f47f3106b89c16cc2ec2a4d269bdfd82e3f4ae12cc/aiomysql-0.3.2.tar.gz", hash = "sha256:72d15ef5cfc34c03468eb41e1b90adb9fd9347b0b589114bd23ead569a02ac1a", size = 108311, upload-time = "2025-10-22T00:15:21.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/af/aae0153c3e28712adaf462328f6c7a3c196a1c1c27b491de4377dd3e6b52/aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2", size = 71834, upload-time = "2025-10-22T00:15:15.905Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + +[[package]] +name = "grok2api" +version = "1.5.4" +source = { virtual = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiohttp-socks" }, + { name = "aiomysql" }, + { name = "asyncpg" }, + { name = "cryptography" }, + { name = "curl-cffi" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "livekit" }, + { name = "loguru" }, + { name = "orjson" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "uvicorn" }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=25.1.0" }, + { name = "aiohttp", specifier = ">=3.13.3" }, + { name = "aiohttp-socks", specifier = ">=0.11.0" }, + { name = "aiomysql", specifier = ">=0.2.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "cryptography", specifier = ">=46.0.5" }, + { name = "curl-cffi", specifier = ">=0.13.0" }, + { name = "fastapi", specifier = ">=0.119.0" }, + { name = "greenlet", specifier = ">=3.3.1" }, + { name = "livekit", specifier = ">=1.0.25" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "orjson", specifier = ">=3.11.4" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.21" }, + { name = "redis", specifier = ">=6.4.0" }, + { name = "sqlalchemy", specifier = ">=2.0.46" }, + { name = "uvicorn", specifier = ">=0.37.0" }, + { name = "websockets", specifier = ">=16.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.0" }] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "livekit" +version = "1.0.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/80/b6dbac61e7c10edee295dba3d0431b8a8504cd6db5286709b03d66a66d0a/livekit-1.0.25.tar.gz", hash = "sha256:a28ef3a1f420616facfb36a92cd590e5510b9cfc5b8b9bd425587a159edfd57b", size = 316847, upload-time = "2026-01-30T17:07:25.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/bc/ad8d6e340c02db57cc624c025cc6d838703fa6a8a211b643beaf5c5f789d/livekit-1.0.25-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:1ec9e16e30cc9831c447cdf05fcffc74453ae40a33ed7e2ef2178f98a3bb2de4", size = 11059941, upload-time = "2026-01-30T17:07:13.644Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fc/dfbbab4f3168f2e0ed4c255b9f387f37ff70a29d8026ca7b411e368a565a/livekit-1.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6237866efbbf033a3d174f15edc891cb793e9fbafe9432d1fd5bb62080704fbb", size = 9807946, upload-time = "2026-01-30T17:07:15.986Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b6/1ec8e007631ba8da62d963547f2d7237e1696106e9ddb69b3cd1bc20318b/livekit-1.0.25-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5a93e854744074fa37eb02f77b4dcdbafcefddc3763f28ec5b267059ddb0c65e", size = 15175694, upload-time = "2026-01-30T17:07:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e7/1963f93804697de51c34677405e26960ca5934fe865632422eb712bca2e7/livekit-1.0.25-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:abe498700566e90c89dbbf73731f850f4b5f7d2185bac88be71a34657b51fd3a", size = 12636407, upload-time = "2026-01-30T17:07:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3a/d5427fe78f2c5395d3da4b895978c693a2652f9480e75abf93d8ab315a94/livekit-1.0.25-py3-none-win_amd64.whl", hash = "sha256:6ef59cca92db6f207d40b4499b8d2338f8c7278e8cae9e24d8208d66a7d2b8b0", size = 11727716, upload-time = "2026-01-30T17:07:23.007Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" }, + { url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" }, + { url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" }, + { url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" }, + { url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" }, + { url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" }, + { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" }, + { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" }, + { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" }, + { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" }, + { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" }, + { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pymysql" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "python-socks" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/07/cfdd6a846ac859e513b4e68bb6c669a90a74d89d8d405516fba7fc9c6f0c/python_socks-2.8.0.tar.gz", hash = "sha256:340f82778b20a290bdd538ee47492978d603dff7826aaf2ce362d21ad9ee6f1b", size = 273130, upload-time = "2025-12-09T12:17:05.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/10/e2b575faa32d1d32e5e6041fc64794fa9f09526852a06b25353b66f52cae/python_socks-2.8.0-py3-none-any.whl", hash = "sha256:57c24b416569ccea493a101d38b0c82ed54be603aa50b6afbe64c46e4a4e4315", size = 55075, upload-time = "2025-12-09T12:17:03.269Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20251210" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +]
${renderInline(cell)}
${renderInline(cell)}