fix: 统一云端部署兼容并更新 Vercel 存储说明

This commit is contained in:
XianYuDaXian committed 2026-03-14 11:07:12 +08:00
1 parent 2cc6b8209c
commit f0f499bbca
8 files changed
+89 -28

No files matched your search

+2 -2
View File
@@ -28,10 +28,10 @@ COPY main.py ./
COPY scripts ./scripts
RUN mkdir -p /app/data /app/data/tmp /app/logs \
&& chmod +x /app/scripts/entrypoint.sh
&& chmod +x /app/scripts/entrypoint.sh /app/scripts/init_storage.sh
EXPOSE 8000
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+7 -5
View File
@@ -1,7 +1,9 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import RedirectResponse
from app.api.pages.asset_response import serve_embedded_asset
router = APIRouter()
STATIC_DIR = Path(__file__).resolve().parents[2] / "static"
@@ -14,19 +16,19 @@ async def admin_root():
@router.get("/admin/login", include_in_schema=False)
async def admin_login():
return FileResponse(STATIC_DIR / "admin/pages/login.html")
return serve_embedded_asset(STATIC_DIR, "admin/pages/login.html", "text/html; charset=utf-8")
@router.get("/admin/config", include_in_schema=False)
async def admin_config():
return FileResponse(STATIC_DIR / "admin/pages/config.html")
return serve_embedded_asset(STATIC_DIR, "admin/pages/config.html", "text/html; charset=utf-8")
@router.get("/admin/cache", include_in_schema=False)
async def admin_cache():
return FileResponse(STATIC_DIR / "admin/pages/cache.html")
return serve_embedded_asset(STATIC_DIR, "admin/pages/cache.html", "text/html; charset=utf-8")
@router.get("/admin/token", include_in_schema=False)
async def admin_token():
return FileResponse(STATIC_DIR / "admin/pages/token.html")
return serve_embedded_asset(STATIC_DIR, "admin/pages/token.html", "text/html; charset=utf-8")
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
from fastapi.responses import FileResponse, Response
from app.api.pages.embedded_assets import EMBEDDED_ASSETS
def serve_embedded_asset(static_dir: Path, relative_path: str, media_type: str) -> Response:
"""优先返回磁盘文件,不存在时回退到内嵌资源。"""
file_path = static_dir / relative_path
if file_path.exists():
return FileResponse(file_path, media_type=media_type)
content = EMBEDDED_ASSETS.get(relative_path)
if content is None:
raise RuntimeError(f"Embedded asset not found: {relative_path}")
return Response(content=content, media_type=media_type)
+17
View File
@@ -0,0 +1,17 @@
"""内嵌页面资源,用于 Serverless 环境回退。"""
EMBEDDED_ASSETS = {
"public/pages/login.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - Public</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script>\n tailwind.config = {\n theme: {\n extend: {\n fontFamily: { sans: ['Geist Sans', 'sans-serif'] },\n colors: {\n dark: '#000000',\n light: '#ffffff',\n subtle: '#666666',\n border: '#e5e5e5'\n }\n }\n }\n }\n </script>\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/common/css/login.css?v=0.3.0\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"login-body\">\n <div class=\"login-bg\" aria-hidden=\"true\"></div>\n\n <div class=\"login-shell\">\n <div class=\"login-card\">\n <div class=\"login-brand\">\n <a href=\"https://github.com/chenyme/grok2api\" target=\"_blank\" rel=\"noopener\" class=\"login-brand-link\">\n <svg class=\"login-github-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path\n d=\"M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.1.68-.22.68-.48 0-.24-.01-.86-.01-1.69-2.78.62-3.37-1.37-3.37-1.37-.45-1.17-1.11-1.48-1.11-1.48-.91-.64.07-.63.07-.63 1.01.07 1.54 1.06 1.54 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.04 1.03-2.76-.1-.26-.45-1.3.1-2.71 0 0 .84-.27 2.75 1.05A9.3 9.3 0 0 1 12 6.79c.85 0 1.7.12 2.5.36 1.9-1.32 2.74-1.05 2.74-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.64 1.03 2.76 0 3.94-2.34 4.81-4.58 5.06.36.32.68.95.68 1.92 0 1.38-.01 2.49-.01 2.83 0 .26.18.58.69.48A10.05 10.05 0 0 0 22 12.26C22 6.58 17.52 2 12 2Z\" />\n </svg>\n <span>Grok2API</span>\n </a>\n </div>\n <div class=\"login-title\">Public Access</div>\n <div class=\"login-subtitle\">请输入 Public Key(如未配置可直接进入)</div>\n\n <div class=\"login-form\">\n <input type=\"password\" id=\"public-key-input\" placeholder=\"Public Key\" class=\"geist-input login-input\">\n <button onclick=\"login()\" class=\"geist-button login-button\">进入</button>\n </div>\n\n <div class=\"mt-3 text-xs text-[var(--accents-4)]\">\n <a href=\"/admin/login\" class=\"hover:text-black\">进入管理后台</a>\n </div>\n </div>\n </div>\n\n <div id=\"app-footer\"></div>\n\n <script src=\"/static/common/js/admin-auth.js?v=0.3.0\"></script>\n <script src=\"/static/common/js/toast.js\"></script>\n <script src=\"/static/common/js/footer.js?v=0.3.0\"></script>\n <script src=\"/static/common/js/pwa.js?v=0.1.1\"></script>\n <script src=\"/static/public/js/login.js?v=0.3.0\"></script>\n</body>\n\n</html>\n",
"public/pages/imagine.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\"\n content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">\n <title>Grok2API - Imagine 瀑布流</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.8\" rel=\"stylesheet\">\n <link href=\"/static/public/css/imagine.css?v=0.5.1\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"public-page min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <main class=\"flex-1 container mx-auto max-w-5xl px-4 sm:px-6 py-5 sm:py-8 fade-in\">\n <div class=\"space-y-4 sm:space-y-6\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">Imagine 瀑布流</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">通过 WebSocket 持续生成图片,实时展示 base64 瀑布流。</p>\n </div>\n\n <div class=\"h-px bg-[var(--border)]\"></div>\n\n <div class=\"imagine-top-grid\">\n <div class=\"imagine-card imagine-card-collapsible settings-card\">\n <div class=\"card-content\" id=\"settingsContent\">\n <div class=\"card-title\">生成设置</div>\n <div class=\"settings-grid\">\n <div class=\"settings-block settings-block-prompt\">\n <div class=\"settings-prompt\">\n <label class=\"field-label\" for=\"promptInput\">提示词</label>\n <textarea id=\"promptInput\" class=\"geist-input imagine-textarea\"\n placeholder=\"描述你想要的画面,例如:未来城市霓虹雨夜,广角摄影\"></textarea>\n </div>\n </div>\n <div class=\"settings-block settings-block-duo\">\n <div class=\"toggle-row\">\n <div class=\"toggle-text\">\n <div class=\"toggle-title\">自动跟随</div>\n <div class=\"toggle-desc\">滚动到最新</div>\n </div>\n <label class=\"toggle\">\n <input id=\"autoScrollToggle\" type=\"checkbox\" checked>\n <span class=\"toggle-track\"></span>\n </label>\n </div>\n <div class=\"toggle-row\">\n <div class=\"toggle-text\">\n <div class=\"toggle-title\">自动保存</div>\n <div class=\"toggle-desc\">自动保存图片</div>\n </div>\n <label class=\"toggle\">\n <input id=\"autoDownloadToggle\" type=\"checkbox\">\n <span class=\"toggle-track\"></span>\n </label>\n </div>\n </div>\n <div class=\"settings-block settings-block-row2\">\n <div class=\"settings-row\">\n <div class=\"settings-field\">\n <label class=\"field-label\" for=\"ratioSelect\">宽高比</label>\n <select id=\"ratioSelect\" class=\"geist-input\">\n <option value=\"2:3\" selected>2:3 竖图</option>\n <option value=\"1:1\">1:1 方图</option>\n <option value=\"3:2\">3:2 横图</option>\n <option value=\"16:9\">16:9 宽屏</option>\n <option value=\"9:16\">9:16 竖屏</option>\n </select>\n </div>\n <div class=\"settings-field\">\n <label class=\"field-label\" for=\"concurrentSelect\">并发数量</label>\n <select id=\"concurrentSelect\" class=\"geist-input\">\n <option value=\"1\" selected>1 个任务</option>\n <option value=\"2\">2 个任务</option>\n <option value=\"3\">3 个任务</option>\n </select>\n </div>\n </div>\n Line truncated
"public/pages/voice.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - LiveKit 陪聊</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/public/css/voice.css?v=0.3.1\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n <script src=\"https://cdn.jsdelivr.net/npm/livekit-client@2.11.4/dist/livekit-client.umd.min.js\"></script>\n</head>\n\n<body class=\"public-page min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <main class=\"space-y-4 sm:space-y-6 flex-1 container mx-auto max-w-5xl px-4 sm:px-6 py-5 sm:py-8 fade-in\">\n <div class=\"space-y-4 sm:space-y-6\">\n <div class=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">LiveKit 陪聊</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">LiveKit 语音会话,连接 Grok Voice。</p>\n </div>\n <div class=\"voice-actions\">\n <button id=\"startBtn\" class=\"geist-button gap-2\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polygon points=\"5 3 19 12 5 21 5 3\" />\n </svg>\n 开始会话\n </button>\n <button id=\"stopBtn\" class=\"geist-button-outline gap-2 hidden\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" />\n </svg>\n 停止会话\n </button>\n </div>\n </div>\n\n <div class=\"h-px bg-[var(--border)] my-6\"></div>\n\n <div class=\"voice-grid\">\n <section class=\"voice-primary\">\n <div class=\"voice-card\">\n <div class=\"card-title\">连接设置</div>\n <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n <div>\n <label class=\"field-label\" for=\"voiceSelect\">声音 (Voice)</label>\n <select id=\"voiceSelect\" class=\"geist-input\">\n <option value=\"ara\" selected>Ara (Upbeat Female)</option>\n <option value=\"eve\">Eve (Soothing Female)</option>\n <option value=\"leo\">Leo (British Male)</option>\n <option value=\"rex\">Rex (Calm Male)</option>\n <option value=\"sal\">Sal (Smooth Male)</option>\n <option value=\"gork\">Gork (Lazy Male)</option>\n </select>\n </div>\n\n <div>\n <label class=\"field-label\" for=\"personalitySelect\">个性 (Personality)</label>\n <select id=\"personalitySelect\" class=\"geist-input\">\n <option value=\"assistant\" selected>Assistant (Default)</option>\n <option value=\"custom\">Custom</option>\n <option value=\"therapist\">Therapist</option>\n <option value=\"storyteller\">Storyteller</option>\n <option value=\"kids_story_time\">Kids Story Time</option>\n <option value=\"kids_trivia_game\">Kids Trivia Game</option>\n <option value=\"meditation\">Meditation</option>\n <option value=\"doc\">Grok \"Doc\"</option>\n <option value=\"unhinged\">Unhinged 18+</option>\n <option value=\"sexy\">Sexy 18+</option>\n <option value=\"motivation\">Motivation 18+</option>\n <option value=\"conspiracy\">Conspiracy</option>\n <optiLine truncated
"public/pages/video.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\"\n content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - Video 视频工作台</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/public/css/video.css?v=0.4.22\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"public-page min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <main class=\"flex-1 container mx-auto max-w-6xl px-4 sm:px-6 py-5 sm:py-8 fade-in\">\n <div class=\"space-y-4 sm:space-y-6\">\n <div class=\"video-header-row\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">Video 视频工作台</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">生成短视频,支持参考图、多种预设风格与视频拼接。</p>\n </div>\n </div>\n\n <div class=\"h-px bg-[var(--border)]\"></div>\n\n <div class=\"video-main-grid\">\n <div class=\"video-main-left\">\n <div class=\"video-card\">\n <div class=\"video-workbench-shell\">\n <div class=\"video-workbench-left\">\n <div id=\"refDropZone\" class=\"video-workbench-panel\">\n <div class=\"panel-title\">当前画面</div>\n <div class=\"upload-row\">\n <button id=\"selectImageFileBtn\" class=\"geist-button-outline\" type=\"button\">添加参考图</button>\n <span id=\"imageFileName\" class=\"file-name\">已添加 0/7 张</span>\n </div>\n <input id=\"imageUrlInput\" class=\"geist-input hidden\" placeholder=\"https://... 或 data:image/...\">\n <input id=\"imageFileInput\" class=\"ref-file-input\" type=\"file\" accept=\"image/*\" multiple>\n <div id=\"referenceStrip\" class=\"reference-strip\">\n <div class=\"reference-empty\">可上传 / 粘贴 / 拖拽参考图(最多 7 张)</div>\n </div>\n <div class=\"parent-quick-row\">\n <label class=\"field-label\" for=\"parentPostInput\">粘贴 parentPostId(全局记忆)</label>\n <div class=\"parent-quick-input-wrap\">\n <input id=\"parentPostInput\" class=\"geist-input\" placeholder=\"粘贴 parentPostId 或包含它的 URL\">\n <button id=\"applyParentBtn\" class=\"geist-button-outline text-xs px-3\" type=\"button\">使用</button>\n </div>\n </div>\n <div id=\"referencePreview\" class=\"preview-shell\">\n <div id=\"currentGallery\" class=\"current-gallery hidden\"></div>\n <div id=\"previewEmpty\" class=\"preview-empty\">请先添加参考图</div>\n <div id=\"dropHint\" class=\"drop-hint\">支持拖拽图片到此处,或按 Ctrl+V 粘贴图片</div>\n </div>\n <div class=\"meta-grid ref-meta-grid\">\n <div class=\"meta-item\">\n <div class=\"meta-label\">当前 parentPostId</div>\n <div id=\"currentParentId\" class=\"meta-value\">-</div>\n </div>\n <div class=\"meta-item\">\n <div class=\"meta-label\">当前模式</div>\n <div id=\"currentMode\" class=\"meta-value\">upload</div>\n </div>\n </div>\n <div class=\"field video-prompt-field\">\n <label class=\"field-label\" for=\"promptInput\">视频提示词</label>\n <div class=\"video-prompt-wrap\">\n <div id=\"promptRichInput\" class=\"prompt-rich-input\" contenteditable=\"true\" data-placehoLine truncated
"public/pages/chat.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - Chat 聊天</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"https://cdn.tailwindcss.com\"></script>\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css\" rel=\"stylesheet\">\n <link href=\"https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css\" rel=\"stylesheet\">\n <link href=\"/static/common/css/common.css?v=1.5.0\" rel=\"stylesheet\">\n <link href=\"/static/public/css/chat.css?v=0.1.34\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <main class=\"flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in chat-main\">\n <div class=\"chat-layout\">\n <aside class=\"chat-sidebar\" id=\"chatSidebar\">\n <div class=\"sidebar-header\">\n <span class=\"sidebar-title\">会话</span>\n <div class=\"sidebar-actions\">\n <button id=\"newChatBtn\" class=\"sidebar-action-btn icon-btn\" type=\"button\" title=\"新增\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"></line>\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>\n </svg>\n </button>\n <button id=\"collapseSidebarBtn\" class=\"sidebar-action-btn icon-btn\" type=\"button\" title=\"折叠\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polyline points=\"15 6 9 12 15 18\"></polyline>\n </svg>\n </button>\n </div>\n </div>\n <div class=\"sidebar-list\" id=\"sessionList\"></div>\n </aside>\n <div class=\"sidebar-overlay\" id=\"sidebarOverlay\"></div>\n <button id=\"sidebarExpandBtn\" class=\"sidebar-expand-btn icon-btn\" type=\"button\" title=\"展开\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polyline points=\"9 6 15 12 9 18\"></polyline>\n </svg>\n </button>\n <div class=\"chat-shell\">\n <div class=\"chat-title\">\n <div style=\"display:flex;align-items:center;gap:10px;\">\n <button id=\"sidebarToggle\" class=\"sidebar-toggle\" type=\"button\" title=\"会话列表\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"></line>\n <line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"></line>\n <line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"></line>\n </svg>\n </button>\n <div>\n <h2 class=\"chat-title-text\">Chat 聊天</h2>\n <p class=\"chat-subtitle\">通过 chat 接口与 Grok 进行会话聊天</p>\n </div>\n </div>\n <span id=\"statusText\" class=\"status-text\">就绪</span>\n </div>\n\n <div id=\"chatLog\" class=\"chat-thread\">\n <div id=\"emptyState\" class=\"chat-empty\">输入一条消息开始对话。</div>\n </div>\n\n <div class=\"composer-shell\">\n <input id=\"fileInput\" class=\"file-input\" type=\"file\" multiple />\n <div class=\"composer-meta\">\n <div id=\"fileBadge\" class=\"file-badge hidden\"></div>\n <div id=\"modelPicker\" class=\"model-chip model-picker\">\n <button id=\"modelPickerBtn\" class=\"model-picker-btn\" type=\"button\" aria-haspopup=\"listbox\" aria-expanded=\"false\">\n <spLine truncated
"public/pages/nsfw.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - NSFW 工作台</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n <link href=\"/static/public/css/nsfw.css?v=0.4.2\" rel=\"stylesheet\">\n</head>\n\n<body class=\"public-page min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <main class=\"flex-1 container mx-auto max-w-6xl px-4 sm:px-6 py-5 sm:py-8 fade-in\">\n <div class=\"space-y-4 sm:space-y-6\">\n <div class=\"page-head\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">NSFW 工作台</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">先生成 6 张候选图,再选中 1 张按 parentPostId 并行生成视频。</p>\n </div>\n <div class=\"status-stack\">\n <div id=\"imageStatusText\" class=\"status-chip\">候选图:未开始</div>\n <div id=\"videoStatusText\" class=\"status-chip\">视频:未开始</div>\n </div>\n </div>\n\n <div class=\"h-px bg-[var(--border)]\"></div>\n\n <div class=\"top-grid\">\n <div class=\"panel\">\n <div class=\"panel-title\">生成参数</div>\n <div class=\"form-grid\">\n <div class=\"field field-wide\">\n <label class=\"field-label\" for=\"imagePromptInput\">图片提示词</label>\n <textarea id=\"imagePromptInput\" class=\"geist-input form-textarea\" placeholder=\"描述你要生成的 NSFW 候选图\"></textarea>\n </div>\n <div class=\"field field-wide\">\n <label class=\"field-label\" for=\"videoPromptInput\">视频提示词(可选)</label>\n <textarea id=\"videoPromptInput\" class=\"geist-input form-textarea\" placeholder=\"留空会按 spicy 模式处理;填写会走 custom 模式\"></textarea>\n </div>\n <div class=\"field\">\n <label class=\"field-label\" for=\"ratioSelect\">画面比例</label>\n <select id=\"ratioSelect\" class=\"geist-input\">\n <option value=\"16:9\" selected>16:9</option>\n <option value=\"9:16\">9:16</option>\n <option value=\"3:2\">3:2</option>\n <option value=\"2:3\">2:3</option>\n <option value=\"1:1\">1:1</option>\n </select>\n </div>\n <div class=\"field\">\n <label class=\"field-label\" for=\"videoParallelSelect\">并行视频数</label>\n <select id=\"videoParallelSelect\" class=\"geist-input\">\n <option value=\"1\">1</option>\n <option value=\"2\">2</option>\n <option value=\"3\">3</option>\n <option value=\"4\" selected>4</option>\n </select>\n </div>\n <div class=\"field\">\n <label class=\"field-label\" for=\"resolutionSelect\">分辨率</label>\n <select id=\"resolutionSelect\" class=\"geist-input\">\n <option value=\"480p\" selected>480p</option>\n <option value=\"720p\">720p</option>\n </select>\n </div>\n <div class=\"field\">\n <label class=\"field-label\" for=\"videoLengthSelect\">视频时长</label>\n <select id=\"videoLengthSelect\" class=\"geist-input\">\n <option value=\"6\" selected>6 秒</option>\n <option value=\"10\">10 秒</option>\n <option value=\"15\">15 秒</option>\n </select>\n </div>\n <div class=\"field\">\n <label class=\"field-label\" for=\"nsfwSelect\">NSFW</label>\n <select id=\"nsfwSelect\" class=\"geist-input\">\n Line truncated
"public/pages/imagine_workbench.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - 图片编辑工作台</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <link rel=\"manifest\" href=\"/manifest.webmanifest\">\n <meta name=\"theme-color\" content=\"#fafafa\">\n <meta name=\"mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">\n <meta name=\"apple-mobile-web-app-title\" content=\"Grok2API\">\n <link rel=\"apple-touch-icon\" href=\"/static/public/pwa/apple-touch-icon.png\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n <link href=\"/static/public/css/imagine_workbench.css?v=0.2.4\" rel=\"stylesheet\">\n</head>\n\n<body class=\"public-page min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <main class=\"flex-1 container mx-auto max-w-6xl px-4 sm:px-6 py-5 sm:py-8 fade-in\">\n <div class=\"space-y-4 sm:space-y-6\">\n <div class=\"page-head\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">图片编辑工作台</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">\n 支持上传/粘贴/拖拽最多 5 张参考图进行编辑;编辑后沿用上一次输出的 parentPostId 继续链路。\n </p>\n </div>\n <div id=\"editStatusText\" class=\"status-chip\">未开始</div>\n </div>\n\n <div class=\"h-px bg-[var(--border)]\"></div>\n\n <div class=\"workbench-grid\">\n <section class=\"panel\">\n <div class=\"panel-title\">当前画面</div>\n\n <div class=\"upload-row\">\n <input id=\"seedImageInput\" class=\"hidden\" type=\"file\" accept=\"image/*\" multiple>\n <button id=\"selectSeedBtn\" type=\"button\" class=\"geist-button-outline\">添加参考图</button>\n <span id=\"seedFileName\" class=\"file-name\">已添加 0/5 张</span>\n </div>\n\n <div id=\"referenceStrip\" class=\"reference-strip\">\n <div class=\"reference-empty\">可上传 / 粘贴 / 拖拽参考图(最多 5 张)</div>\n </div>\n\n <div class=\"parent-quick-row\">\n <label class=\"field-label\" for=\"parentPostInput\">粘贴 parentPostId(全局记忆)</label>\n <div class=\"parent-quick-input-wrap\">\n <input id=\"parentPostInput\" class=\"geist-input\" placeholder=\"粘贴 parentPostId 或包含它的 URL\">\n <button id=\"applyParentBtn\" type=\"button\" class=\"geist-button-outline\">使用</button>\n </div>\n </div>\n\n <div id=\"previewShell\" class=\"preview-shell\">\n <div id=\"currentGallery\" class=\"current-gallery hidden\"></div>\n <div id=\"previewEmpty\" class=\"preview-empty\">请先添加参考图</div>\n <div id=\"dropHint\" class=\"drop-hint\">支持拖拽图片到此处,或按 Ctrl+V 粘贴图片</div>\n </div>\n\n <div class=\"meta-grid\">\n <div class=\"meta-item\">\n <div class=\"meta-label\">当前 parentPostId</div>\n <div id=\"currentParentId\" class=\"meta-value\">-</div>\n </div>\n <div class=\"meta-item\">\n <div class=\"meta-label\">当前模式</div>\n <div id=\"currentMode\" class=\"meta-value\">upload</div>\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\" for=\"editPromptInput\">编辑提示词</label>\n <div class=\"prompt-editor-shell\">\n <div id=\"editPromptRichInput\" class=\"prompt-rich-input\" contenteditable=\"true\" data-placeholder=\"例如:@Image 1 在左侧,@Image 2 在右侧,两人合照\"></div>\n <div id=\"editPromptMentionMenu\" class=\"prompt-mention-menu hidden\"></div>\n <textarea id=\"editPromptInput\" class=\"geist-input form-textarea prompt-textarea-proxy\" data-prompt-enhancer=\"off\" aria-hidden=\"true\" tabindex=\"-1\" placeholder=\"例如:@Image 1 在左侧,@Image 2 在右侧,两人合照\"></textarea>\n </div>\n </div>\n\n <div class=\"acLine truncated
"public/manifest.webmanifest": "{\n \"name\": \"Grok2API\",\n \"short_name\": \"Grok2API\",\n \"description\": \"Grok2API 功能工作台\",\n \"start_url\": \"/login\",\n \"scope\": \"/\",\n \"display\": \"standalone\",\n \"background_color\": \"#fafafa\",\n \"theme_color\": \"#fafafa\",\n \"icons\": [\n {\n \"src\": \"/static/public/pwa/icon-192.png\",\n \"sizes\": \"192x192\",\n \"type\": \"image/png\",\n \"purpose\": \"any\"\n },\n {\n \"src\": \"/static/public/pwa/icon-512.png\",\n \"sizes\": \"512x512\",\n \"type\": \"image/png\",\n \"purpose\": \"any maskable\"\n }\n ]\n}\n",
"public/sw.js": "const CACHE_PREFIX = \"grok2api-pwa-\";\nconst CACHE_NAME = `${CACHE_PREFIX}v2`;\n\nself.addEventListener(\"install\", (event) => {\n event.waitUntil(self.skipWaiting());\n});\n\nself.addEventListener(\"activate\", (event) => {\n event.waitUntil(\n (async () => {\n const keys = await caches.keys();\n await Promise.all(\n keys\n .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)\n .map((key) => caches.delete(key))\n );\n await self.clients.claim();\n })()\n );\n});\n\nself.addEventListener(\"fetch\", (event) => {\n const request = event.request;\n if (request.method !== \"GET\") return;\n\n const url = new URL(request.url);\n if (url.origin !== self.location.origin) return;\n\n const cacheablePathPrefixes = [\n \"/static/\",\n \"/login\",\n \"/imagine\",\n \"/video\",\n \"/voice\",\n \"/nsfw\",\n \"/imagine-workbench\",\n \"/manifest.webmanifest\"\n ];\n const isCacheable = cacheablePathPrefixes.some((prefix) => url.pathname.startsWith(prefix));\n if (!isCacheable) return;\n\n event.respondWith(\n (async () => {\n try {\n const response = await fetch(request);\n const cache = await caches.open(CACHE_NAME);\n cache.put(request, response.clone());\n return response;\n } catch (error) {\n const cached = await caches.match(request);\n if (cached) return cached;\n throw error;\n }\n })()\n );\n});\n",
"admin/pages/login.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - 登录</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script>\n tailwind.config = {\n theme: {\n extend: {\n fontFamily: { sans: ['Geist Sans', 'sans-serif'] },\n colors: {\n dark: '#000000',\n light: '#ffffff',\n subtle: '#666666',\n border: '#e5e5e5'\n }\n }\n }\n }\n </script>\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/common/css/login.css?v=0.3.0\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"login-body\">\n <div class=\"login-bg\" aria-hidden=\"true\"></div>\n\n <div class=\"login-shell\">\n <div class=\"login-card\">\n <div class=\"login-brand\">\n <a href=\"https://github.com/chenyme/grok2api\" target=\"_blank\" rel=\"noopener\" class=\"login-brand-link\">\n <svg class=\"login-github-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path\n d=\"M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.1.68-.22.68-.48 0-.24-.01-.86-.01-1.69-2.78.62-3.37-1.37-3.37-1.37-.45-1.17-1.11-1.48-1.11-1.48-.91-.64.07-.63.07-.63 1.01.07 1.54 1.06 1.54 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.04 1.03-2.76-.1-.26-.45-1.3.1-2.71 0 0 .84-.27 2.75 1.05A9.3 9.3 0 0 1 12 6.79c.85 0 1.7.12 2.5.36 1.9-1.32 2.74-1.05 2.74-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.64 1.03 2.76 0 3.94-2.34 4.81-4.58 5.06.36.32.68.95.68 1.92 0 1.38-.01 2.49-.01 2.83 0 .26.18.58.69.48A10.05 10.05 0 0 0 22 12.26C22 6.58 17.52 2 12 2Z\" />\n </svg>\n <span>Grok2API</span>\n </a>\n </div>\n <div class=\"login-title\">管理后台</div>\n <div class=\"login-subtitle\">请输入后台密码以继续</div>\n\n <div class=\"login-form\">\n <input type=\"password\" id=\"api-key-input\" placeholder=\"后台密码 (app_key)\" class=\"geist-input login-input\">\n <button onclick=\"login()\" class=\"geist-button login-button\">继续</button>\n </div>\n </div>\n </div>\n\n <div id=\"app-footer\"></div>\n\n <script src=\"/static/common/js/admin-auth.js?v=0.3.0\"></script>\n <script src=\"/static/common/js/toast.js\"></script>\n <script src=\"/static/common/js/footer.js?v=0.3.0\"></script>\n <script src=\"/static/admin/js/login.js?v=0.3.0\"></script>\n</body>\n\n</html>\n",
"admin/pages/config.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - 配置管理</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/admin/css/config.css?v=0.3.0\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n\n <div id=\"app-header\"></div>\n\n <main class=\"space-y-6 flex-1 container mx-auto max-w-5xl px-4 sm:px-6 py-6 sm:py-8 fade-in\">\n <div class=\"space-y-6\">\n <div class=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">配置管理</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">管理 API 密钥及系统参数设置。</p>\n </div>\n <button onclick=\"saveConfig()\" id=\"save-btn\" class=\"geist-button gap-2 w-full md:w-auto justify-center\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\" />\n <polyline points=\"17 21 17 13 7 13 7 21\" />\n <polyline points=\"7 3 7 8 15 8\" />\n </svg>\n 保存\n </button>\n </div>\n\n <div class=\"h-px bg-[var(--border)] my-6\"></div>\n\n <div id=\"config-container\" class=\"space-y-8\">\n <div class=\"text-center py-12 text-[var(--accents-4)]\">加载中...</div>\n </div>\n\n </div>\n </main>\n <div id=\"app-footer\"></div>\n\n <script src=\"/static/common/js/admin-auth.js?v=0.3.0\"></script>\n <script src=\"/static/common/js/header.js?v=0.3.1\"></script>\n <script src=\"/static/common/js/footer.js?v=0.3.0\"></script>\n <script src=\"/static/common/js/toast.js\"></script>\n <script src=\"/static/admin/js/config.js?v=0.3.0\"></script>\n</body>\n\n</html>\n",
"admin/pages/cache.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - 缓存管理</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/admin/css/cache.css?v=0.3.0\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n <div id=\"app-header\"></div>\n\n <main class=\"space-y-6 flex-1 container mx-auto max-w-5xl px-4 sm:px-6 py-6 sm:py-8 fade-in\">\n <div class=\"space-y-6\">\n <div class=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">缓存管理</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">管理本地资源与在线资产缓存。</p>\n </div>\n </div>\n\n <div class=\"h-px bg-[var(--border)] my-6\"></div>\n\n <div class=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n <div class=\"stat-card cache-card\" data-type=\"image\">\n <div class=\"flex items-start justify-between\">\n <div>\n <div class=\"cache-stat-label\">本地图片</div>\n <div class=\"cache-stat-value\">\n <span id=\"img-count\">0</span>\n <span class=\"text-xs text-[var(--accents-4)] ml-1\">个文件</span>\n </div>\n </div>\n <div class=\"text-right\">\n <div id=\"img-size\" class=\"text-xs font-mono text-[var(--accents-4)]\">0 MB</div>\n <button onclick=\"clearCache('image')\" class=\"cache-action-btn mt-3\" title=\"清空缓存\">\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n </svg>\n </button>\n </div>\n </div>\n </div>\n\n <div class=\"stat-card cache-card\" data-type=\"video\">\n <div class=\"flex items-start justify-between\">\n <div>\n <div class=\"cache-stat-label\">本地视频</div>\n <div class=\"cache-stat-value\">\n <span id=\"video-count\">0</span>\n <span class=\"text-xs text-[var(--accents-4)] ml-1\">个文件</span>\n </div>\n </div>\n <div class=\"text-right\">\n <div id=\"video-size\" class=\"text-xs font-mono text-[var(--accents-4)]\">0 MB</div>\n <button onclick=\"clearCache('video')\" class=\"cache-action-btn mt-3\" title=\"清空缓存\">\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n </svg>\n </button>\n </div>\n </div>\n </div>\n\n <div class=\"stat-card cache-card\" data-type=\"online\">\n <div class=\"flex items-start justify-between\">\n <div>\n <div class=\"cache-stat-label\">在线资产</div>\n <div class=\"cache-stat-value\">\n <span id=\"online-count\">0</span>\n <span class=\"text-xs text-[var(--accents-4)] ml-1\">个文件</span>\n </div>\n <div id=\"online-status\" class=\"text-xs text-green-600 mt-1\">连接正常</div>\n <div id=\"online-last-clear\" class=\"text-xs text-[var(--accents-4)] mt-1\"></div>\n </div>\n </div>\n </div>\n </div>\n\n <div id=\"local-cache-lists\" class=\"space-y-4 hidden\">\n <div id=\"local-image-list\" class=\"cache-list hidden\">\n <div class=\"rounded-lg overflow-hidden bg-white overflow-x-auto\">\n <table class=\"geist-table min-w-[600px]\">\n <thead>\n <tr>\n <th class=\"w-10 text-center\">\n <input id=\"local-image-select-all\" typeLine truncated
"admin/pages/token.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content\">\n <title>Grok2API - Token 管理</title>\n <link rel=\"icon\" href=\"/static/common/img/favicon/favicon.ico\">\n <script src=\"/static/vendor/tailwind/tailwindcss-cdn.js\"></script>\n <link href=\"/static/vendor/geist/geist-sans.css\" rel=\"stylesheet\">\n <link href=\"/static/vendor/geist/geist-mono.css\" rel=\"stylesheet\">\n <script src=\"/static/common/js/theme.js?v=0.1.0\"></script>\n <link href=\"/static/common/css/common.css?v=0.3.7\" rel=\"stylesheet\">\n <link href=\"/static/admin/css/token.css?v=0.3.0\" rel=\"stylesheet\">\n <link href=\"/static/common/css/toast.css\" rel=\"stylesheet\">\n</head>\n\n<body class=\"min-h-screen flex flex-col\" style=\"background-color: var(--bg);\">\n\n <!-- Toast Container -->\n <div id=\"toast-container\" class=\"toast-container\"></div>\n <div id=\"app-header\"></div>\n\n <!-- Main Content -->\n <main class=\"space-y-6 flex-1 container mx-auto max-w-5xl px-4 sm:px-6 py-6 sm:py-8 fade-in\">\n\n <div class=\"space-y-6\">\n <div class=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n <div>\n <h2 class=\"text-2xl font-semibold tracking-tight\">Token 列表</h2>\n <p class=\"text-[var(--accents-4)] mt-1 text-sm\">管理 Grok2API 的 Token 服务号池。</p>\n </div>\n <div class=\"flex w-full flex-wrap items-center gap-2 md:w-auto md:gap-3\">\n <button onclick=\"openImportModal()\" class=\"geist-button-outline gap-2\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path>\n <polyline points=\"17 8 12 3 7 8\"></polyline>\n <line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line>\n </svg>\n 导入\n </button>\n <button onclick=\"addToken()\" class=\"geist-button gap-2\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M12 5v14M5 12h14\" />\n </svg>\n 添加\n </button>\n </div>\n </div>\n\n <div class=\"h-px bg-[var(--border)] my-6\"></div>\n\n <!-- Statistics -->\n <div class=\"grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-6\" id=\"stats-container\">\n <!-- Row 1 -->\n <div class=\"stat-card\">\n <div class=\"stat-value\" id=\"stat-total\">-</div>\n <div class=\"stat-label\">Token 总数</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value text-green-600\" id=\"stat-active\">-</div>\n <div class=\"stat-label\">Token 正常</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value text-orange-500\" id=\"stat-cooling\">-</div>\n <div class=\"stat-label\">Token 限流</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value text-red-600\" id=\"stat-invalid\">-</div>\n <div class=\"stat-label\">Token 失效</div>\n </div>\n\n <!-- Row 2 -->\n <div class=\"stat-card\">\n <div class=\"stat-value\" id=\"stat-chat-quota\">-</div>\n <div class=\"stat-label\">Chat 剩余</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value text-blue-600\" id=\"stat-image-quota\">-</div>\n <div class=\"stat-label\">Image 剩余</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value text-gray-400\" id=\"stat-video-quota\">-</div>\n <div class=\"stat-label\">Video 剩余</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value\" id=\"stat-total-calls\">-</div>\n <div class=\"stat-label\">总调用次数</div>\n </div>\n </div>\n\n <!-- Tab Filter (with ARIA) -->\n <div class=\"flex items-center gap-4 mb-4 border-b border-[var(--border)] overflow-x-auto hide-scrollbar\"\n role=\"tablist\" aria-label=\"Token 状态筛选\" id=\"status-tabs\">\n <button class=\"tab-item active\" role=\"tab\" aria-selected=\"true\"\n data-filter=\"all\" onclick=\"filterByStatus('all')\">\n 全部 <span class=\"badge\" id=\"tab-count-all\">0</span>\n </button>\n <button class=\"tab-item\" role=\"tab\" aria-selected=\"false\"\n data-filter=\"active\" onclick=\"filterByStatus('active')\">\n 正常 <span class=\"badge badge-green\" id=\"tab-count-active\">0</span>\n </button>\n Line truncated
}
+11 -16
View File
@@ -1,8 +1,9 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import RedirectResponse
from app.api.pages.asset_response import serve_embedded_asset
from app.core.auth import is_public_enabled
router = APIRouter()
@@ -20,66 +21,60 @@ async def root():
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")
return serve_embedded_asset(STATIC_DIR, "public/pages/login.html", "text/html; charset=utf-8")
@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")
return serve_embedded_asset(STATIC_DIR, "public/pages/imagine.html", "text/html; charset=utf-8")
@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")
return serve_embedded_asset(STATIC_DIR, "public/pages/voice.html", "text/html; charset=utf-8")
@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")
return serve_embedded_asset(STATIC_DIR, "public/pages/video.html", "text/html; charset=utf-8")
@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")
return serve_embedded_asset(STATIC_DIR, "public/pages/chat.html", "text/html; charset=utf-8")
@router.get("/nsfw", include_in_schema=False)
async def public_nsfw():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(STATIC_DIR / "public/pages/nsfw.html")
return serve_embedded_asset(STATIC_DIR, "public/pages/nsfw.html", "text/html; charset=utf-8")
@router.get("/imagine-workbench", include_in_schema=False)
async def public_imagine_workbench():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(STATIC_DIR / "public/pages/imagine_workbench.html")
return serve_embedded_asset(STATIC_DIR, "public/pages/imagine_workbench.html", "text/html; charset=utf-8")
@router.get("/manifest.webmanifest", include_in_schema=False)
async def public_manifest():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(
STATIC_DIR / "public/manifest.webmanifest",
media_type="application/manifest+json",
)
return serve_embedded_asset(STATIC_DIR, "public/manifest.webmanifest", "application/manifest+json")
@router.get("/sw.js", include_in_schema=False)
async def public_service_worker():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(
STATIC_DIR / "public/sw.js",
media_type="application/javascript",
)
return serve_embedded_asset(STATIC_DIR, "public/sw.js", "application/javascript")
+11 -3
View File
@@ -79,6 +79,13 @@ def _env_flag(name: str, default: bool) -> bool:
return raw.strip().lower() in ("1", "true", "yes", "on", "y")
def _should_enqueue_logs() -> bool:
"""Serverless 环境禁用 multiprocessing 队列日志"""
if "VERCEL" in os.environ or "AWS_LAMBDA_FUNCTION_NAME" in os.environ:
return False
return _env_flag("LOG_ENQUEUE", True)
def _make_json_sink(output):
"""创建 JSON sink"""
@@ -106,6 +113,7 @@ def setup_logging(
"""设置日志配置"""
logger.remove()
file_logging = _env_flag("LOG_FILE_ENABLED", file_logging)
enqueue_logs = _should_enqueue_logs()
# 控制台输出
if json_console:
@@ -114,7 +122,7 @@ def setup_logging(
level=level,
format="{message}",
colorize=False,
enqueue=True,
enqueue=enqueue_logs,
)
else:
logger.add(
@@ -122,7 +130,7 @@ def setup_logging(
level=level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{file.name}:{line}</cyan> - <level>{message}</level>",
colorize=True,
enqueue=True,
enqueue=enqueue_logs,
)
# 文件输出
@@ -132,7 +140,7 @@ def setup_logging(
_file_json_sink,
level=level,
format="{message}",
enqueue=True,
enqueue=enqueue_logs,
)
else:
logger.warning("File logging disabled: no writable log directory.")
+23 -1
View File
@@ -216,9 +216,29 @@ Vercel 部署建议:
- 必设 `DATA_DIR=/tmp/data`
- 建议设 `LOG_FILE_ENABLED=false`
- 如需持久化数据,不要使用 `local`,改为外部 `redis/mysql/pgsql`
- 不建议使用 `local` 保存长期状态
- 推荐直接使用外部 `redis/mysql/pgsql` 保存配置、Token 和运行状态
- 如需前端工作台,建议同时设置 `PUBLIC_ENABLED=true` 和 `PUBLIC_KEY`
推荐环境变量:
```env
LOG_LEVEL=INFO
LOG_FILE_ENABLED=false
DATA_DIR=/tmp/data
SERVER_STORAGE_TYPE=redis
SERVER_STORAGE_URL=redis://:password@host:6379/0
PUBLIC_ENABLED=true
PUBLIC_KEY=your-own-long-random-key
```
如果你使用 PostgreSQL,也可以改成:
```env
SERVER_STORAGE_TYPE=pgsql
SERVER_STORAGE_URL=postgresql+asyncpg://user:password@host:5432/db
```
适用场景:
- 轻量 API 转发
@@ -228,7 +248,9 @@ Vercel 部署建议:
注意:
- Vercel 的本地磁盘是临时的
- 后台保存的配置如果依赖 `local`,在冷启动或实例切换后可能恢复默认值
- 大量视频/图片缓存不适合长期放在 `local`
- 如果你要长期使用管理后台、Public 页面和工作台,建议从一开始就配置远程存储
## Render 部署
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
set -eu
/app/scripts/init_storage.sh
sh /app/scripts/init_storage.sh
exec "$@"