Download admin.html from longxing/gmn2a: direct link, hf CLI and curl.
- Browser
- Download file 48.4 kB
-
https://huggingface.co/spaces/longxing/gmn2a/resolve/main/admin.html
- Command line
-
hf download hf://spaces/longxing/gmn2a/admin.html
-
curl -L -o admin.html https://huggingface.co/spaces/longxing/gmn2a/resolve/main/admin.html
48.4 kB
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Gemi2Api Server 管理面板</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #0f0f0f; | |
| color: #e0e0e0; | |
| min-height: 100vh; | |
| } | |
| .header { | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| padding: 16px 30px; | |
| border-bottom: 1px solid #333; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| position: relative; | |
| } | |
| .header-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .header-left h1 { | |
| font-size: 22px; | |
| color: #4fc3f7; | |
| font-weight: 600; | |
| } | |
| .header-right { | |
| position: absolute; | |
| right: 30px; | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .github-link { | |
| display: flex; | |
| align-items: center; | |
| opacity: 0.7; | |
| transition: opacity 0.2s; | |
| } | |
| .github-link:hover { | |
| opacity: 1; | |
| } | |
| .header .status { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .status-dot { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background: #4caf50; | |
| animation: pulse 2s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| } | |
| .grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 20px; | |
| } | |
| .card { | |
| background: #1a1a1a; | |
| border-radius: 12px; | |
| padding: 20px; | |
| border: 1px solid #333; | |
| } | |
| .card h3 { | |
| color: #4fc3f7; | |
| margin-bottom: 15px; | |
| font-size: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .stat-value { | |
| font-size: 32px; | |
| font-weight: bold; | |
| color: #fff; | |
| } | |
| .stat-label { | |
| color: #888; | |
| font-size: 14px; | |
| margin-top: 5px; | |
| } | |
| .config-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 12px 0; | |
| border-bottom: 1px solid #333; | |
| } | |
| .config-item:last-child { border-bottom: none; } | |
| .config-label { color: #aaa; } | |
| .config-value { color: #fff; font-family: monospace; } | |
| .toggle { | |
| position: relative; | |
| width: 50px; | |
| height: 26px; | |
| } | |
| .toggle input { opacity: 0; width: 0; height: 0; } | |
| .toggle .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: #333; | |
| border-radius: 26px; | |
| transition: 0.3s; | |
| } | |
| .toggle .slider:before { | |
| content: ""; | |
| position: absolute; | |
| height: 20px; | |
| width: 20px; | |
| left: 3px; | |
| bottom: 3px; | |
| background: #fff; | |
| border-radius: 50%; | |
| transition: 0.3s; | |
| } | |
| .toggle input:checked + .slider { background: #4caf50; } | |
| .toggle input:checked + .slider:before { transform: translateX(24px); } | |
| .btn { | |
| padding: 10px 20px; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| transition: all 0.2s; | |
| } | |
| .btn-primary { | |
| background: #4fc3f7; | |
| color: #000; | |
| } | |
| .btn-primary:hover { background: #29b6f6; } | |
| .btn-danger { | |
| background: #f44336; | |
| color: #fff; | |
| } | |
| .btn-danger:hover { background: #d32f2f; } | |
| .btn-success { | |
| background: #4caf50; | |
| color: #fff; | |
| } | |
| .btn-success:hover { background: #388e3c; } | |
| .input-group { | |
| display: flex; | |
| gap: 10px; | |
| margin-top: 10px; | |
| } | |
| .input-group input, .input-group select { | |
| flex: 1; | |
| padding: 10px; | |
| background: #2a2a2a; | |
| border: 1px solid #444; | |
| border-radius: 6px; | |
| color: #fff; | |
| font-size: 14px; | |
| } | |
| .input-group input:focus, .input-group select:focus { | |
| outline: none; | |
| border-color: #4fc3f7; | |
| } | |
| .model-list { | |
| max-height: 300px; | |
| overflow-y: auto; | |
| } | |
| .model-item { | |
| padding: 10px; | |
| background: #2a2a2a; | |
| border-radius: 6px; | |
| margin-bottom: 8px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .model-name { font-family: monospace; color: #4fc3f7; } | |
| .log-container { | |
| max-height: 400px; | |
| overflow-y: auto; | |
| font-family: monospace; | |
| font-size: 13px; | |
| background: #0a0a0a; | |
| padding: 15px; | |
| border-radius: 8px; | |
| } | |
| .log-entry { | |
| padding: 8px 0; | |
| border-bottom: 1px solid #222; | |
| display: flex; | |
| gap: 15px; | |
| } | |
| .log-time { color: #666; min-width: 150px; } | |
| .log-method { color: #4caf50; min-width: 50px; } | |
| .log-path { color: #fff; } | |
| .log-status { min-width: 40px; } | |
| .log-status.ok { color: #4caf50; } | |
| .log-status.error { color: #f44336; } | |
| .chat-area { | |
| display: flex; | |
| flex-direction: column; | |
| height: 400px; | |
| } | |
| .chat-messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 15px; | |
| background: #0a0a0a; | |
| border-radius: 8px; | |
| margin-bottom: 10px; | |
| } | |
| .chat-msg { | |
| margin-bottom: 15px; | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| max-width: 80%; | |
| } | |
| .chat-msg.user { | |
| background: #1a3a5c; | |
| margin-left: auto; | |
| } | |
| .chat-msg.assistant { | |
| background: #2a2a2a; | |
| } | |
| .think-block { | |
| margin-bottom: 10px; | |
| border-left: 3px solid #555; | |
| padding-left: 10px; | |
| } | |
| .think-block > summary { | |
| cursor: pointer; | |
| color: #9e9e9e; | |
| font-size: 13px; | |
| user-select: none; | |
| } | |
| .think-body { | |
| margin-top: 6px; | |
| color: #9e9e9e; | |
| font-size: 13px; | |
| line-height: 1.6; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .answer-body { | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .chat-input { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .chat-input input { | |
| flex: 1; | |
| padding: 12px; | |
| background: #2a2a2a; | |
| border: 1px solid #444; | |
| border-radius: 8px; | |
| color: #fff; | |
| font-size: 14px; | |
| } | |
| .chat-input input:focus { outline: none; border-color: #4fc3f7; } | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(0,0,0,0.8); | |
| z-index: 1000; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .modal.active { display: flex; } | |
| .modal-content { | |
| background: #1a1a1a; | |
| padding: 30px; | |
| border-radius: 12px; | |
| max-width: 500px; | |
| width: 90%; | |
| border: 1px solid #333; | |
| } | |
| .modal h3 { margin-bottom: 20px; color: #4fc3f7; } | |
| .form-group { | |
| margin-bottom: 15px; | |
| } | |
| .form-group label { | |
| display: block; | |
| margin-bottom: 5px; | |
| color: #aaa; | |
| } | |
| .form-group input { | |
| width: 100%; | |
| padding: 10px; | |
| background: #2a2a2a; | |
| border: 1px solid #444; | |
| border-radius: 6px; | |
| color: #fff; | |
| } | |
| .form-group input:focus { outline: none; border-color: #4fc3f7; } | |
| .modal-buttons { | |
| display: flex; | |
| gap: 10px; | |
| justify-content: flex-end; | |
| margin-top: 20px; | |
| } | |
| .section-title { | |
| font-size: 18px; | |
| color: #fff; | |
| margin-bottom: 15px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px solid #333; | |
| } | |
| .warning { | |
| background: #3a2a1a; | |
| border: 1px solid #ff9800; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin-bottom: 20px; | |
| color: #ffb74d; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="header-left"> | |
| <svg viewBox="0 0 1024 1024" width="32" height="32" style="vertical-align: middle; margin-right: 10px;"> | |
| <path d="M960 512.896A477.248 477.248 0 0 0 512.896 960h-1.792A477.184 477.184 0 0 0 64 512.896v-1.792A477.184 477.184 0 0 0 511.104 64h1.792A477.248 477.248 0 0 0 960 511.104z" fill="#448AFF"></path> | |
| </svg> | |
| <h1>Gemi2Api Server</h1> | |
| </div> | |
| <div class="header-right"> | |
| <div class="status"> | |
| <div class="status-dot" id="statusDot"></div> | |
| <span id="statusText">运行中</span> | |
| </div> | |
| <a href="https://github.com/zhiyu1998/Gemi2Api-Server" target="_blank" rel="noopener noreferrer" class="github-link" title="GitHub"> | |
| <svg viewBox="0 0 1024 1024" width="28" height="28"> | |
| <path d="M64 512c0 195.2 124.8 361.6 300.8 422.4 22.4 6.4 19.2-9.6 19.2-22.4v-76.8c-134.4 16-140.8-73.6-150.4-89.6-19.2-32-60.8-38.4-48-54.4 32-16 64 3.2 99.2 57.6 25.6 38.4 76.8 32 105.6 25.6 6.4-22.4 19.2-44.8 35.2-60.8-144-22.4-201.6-108.8-201.6-211.2 0-48 16-96 48-131.2-22.4-60.8 0-115.2 3.2-121.6 57.6-6.4 118.4 41.6 124.8 44.8 32-9.6 70.4-12.8 112-12.8 41.6 0 80 6.4 112 12.8 12.8-9.6 67.2-48 121.6-44.8 3.2 6.4 25.6 57.6 6.4 118.4 32 38.4 48 83.2 48 131.2 0 102.4-57.6 188.8-201.6 214.4 22.4 22.4 38.4 54.4 38.4 92.8v112c0 9.6 0 19.2 16 19.2C832 876.8 960 710.4 960 512c0-246.4-201.6-448-448-448S64 265.6 64 512z" fill="#e0e0e0"></path> | |
| </svg> | |
| </a> | |
| </div> | |
| </div> | |
| <!-- 登录页面 --> | |
| <div class="container" id="loginPage" style="display: none;"> | |
| <div style="max-width: 400px; margin: 80px auto;"> | |
| <div class="card" style="text-align: center;"> | |
| <div style="margin-bottom: 20px;"> | |
| <svg viewBox="0 0 1024 1024" width="64" height="64" style="margin-bottom: 10px;"> | |
| <path d="M960 512.896A477.248 477.248 0 0 0 512.896 960h-1.792A477.184 477.184 0 0 0 64 512.896v-1.792A477.184 477.184 0 0 0 511.104 64h1.792A477.248 477.248 0 0 0 960 511.104z" fill="#448AFF"></path> | |
| </svg> | |
| <h2 style="color: #4fc3f7; margin-bottom: 5px;">Gemi2Api Server</h2> | |
| <p style="color: #888; font-size: 14px;">管理面板登录</p> | |
| </div> | |
| <div class="form-group" style="text-align: left;"> | |
| <label>API_KEY</label> | |
| <input type="password" id="loginApiKey" placeholder="输入 API_KEY" style="width: 100%; padding: 12px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; color: #fff; font-size: 14px;" onkeypress="if(event.key==='Enter')adminLogin()"> | |
| </div> | |
| <button class="btn btn-primary" style="width: 100%; padding: 12px; font-size: 16px; margin-top: 10px;" onclick="adminLogin()">登录</button> | |
| <div id="loginMessage" style="margin-top: 15px; display: none; padding: 10px; border-radius: 6px; font-size: 14px;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="container" id="mainPage"> | |
| <!-- 状态卡片 --> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h3>📊 服务状态</h3> | |
| <div class="stat-value" id="uptime">--</div> | |
| <div class="stat-label">运行时间</div> | |
| </div> | |
| <div class="card"> | |
| <h3>📈 请求统计</h3> | |
| <div class="stat-value" id="totalRequests">0</div> | |
| <div class="stat-label">总请求数</div> | |
| </div> | |
| <div class="card"> | |
| <h3>⏱️ 平均响应</h3> | |
| <div class="stat-value" id="avgResponseTime">--ms</div> | |
| <div class="stat-label">响应时间</div> | |
| </div> | |
| <div class="card"> | |
| <h3>🎯 错误率</h3> | |
| <div class="stat-value" id="errorRate">0%</div> | |
| <div class="stat-label">请求错误率</div> | |
| </div> | |
| </div> | |
| <!-- 配置管理 --> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h3>⚙️ 服务配置</h3> | |
| <div class="config-item"> | |
| <span class="config-label">监听地址</span> | |
| <span class="config-value" id="currentHost">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">监听端口</span> | |
| <span class="config-value" id="currentPort">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">API_KEY</span> | |
| <span class="config-value" id="apiKeyStatus">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">Cookie 状态</span> | |
| <span class="config-value" id="cookieStatus">--</span> | |
| </div> | |
| <button class="btn btn-primary" onclick="showEditConfig()">修改配置</button> | |
| </div> | |
| <div class="card"> | |
| <h3>🍪 Gemini Cookie 配置</h3> | |
| <div class="warning"> | |
| 💡 登录 <a href="https://gemini.google.com/" target="_blank" rel="noopener noreferrer" style="color: #4fc3f7;">gemini.google.com</a>,F12 → Application → Cookies 复制以下两个值。 | |
| </div> | |
| <div class="form-group"> | |
| <label>一键粘贴(从浏览器复制的整段 Cookie,自动提取)</label> | |
| <textarea id="cookieRaw" rows="3" placeholder="粘贴整段 Cookie,例如:__Secure-1PSID=xxx; __Secure-1PSIDTS=yyy; ..." style="width: 100%; padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #fff; font-family: monospace; font-size: 12px; resize: vertical;" oninput="parseRawCookie()"></textarea> | |
| <button class="btn btn-primary" style="margin-top: 8px;" onclick="parseRawCookie(true)">🔍 提取 Cookie</button> | |
| </div> | |
| <div class="form-group"> | |
| <label>__Secure-1PSID</label> | |
| <input type="text" id="cookie1psid" placeholder="粘贴 __Secure-1PSID 的值" style="font-family: monospace; font-size: 12px;"> | |
| </div> | |
| <div class="form-group"> | |
| <label>__Secure-1PSIDTS</label> | |
| <input type="text" id="cookie1psidts" placeholder="粘贴 __Secure-1PSIDTS 的值" style="font-family: monospace; font-size: 12px;"> | |
| </div> | |
| <div style="display: flex; gap: 10px; margin-top: 10px;"> | |
| <button class="btn btn-success" onclick="saveCookiesAndReinit()">💾 保存并重新连接 Gemini</button> | |
| </div> | |
| <div id="cookieMessage" style="margin-top: 10px; display: none; padding: 10px; border-radius: 6px;"></div> | |
| </div> | |
| </div> | |
| <!-- 会话与 __Secure-1PSIDTS 轮换状态 --> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h3>🔄 会话与 __Secure-1PSIDTS 轮换</h3> | |
| <div class="warning"> | |
| 💡 只要进程在运行,<span style="font-family: monospace;">__Secure-1PSIDTS</span> 就会由后台任务自动轮换,通常无需再手动更新 Cookie。这里是实时状态。 | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">会话状态</span> | |
| <span class="config-value" id="sessionStatus">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">凭据来源</span> | |
| <span class="config-value" id="sessionSource">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">后台轮换任务</span> | |
| <span class="config-value" id="rotationTask">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">累计轮换次数</span> | |
| <span class="config-value" id="rotationCount">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">最近换值</span> | |
| <span class="config-value" id="rotationAge">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">生效中 1PSIDTS</span> | |
| <span class="config-value" id="live1psidts" style="font-family: monospace; font-size: 12px;">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">缓存 1PSIDTS</span> | |
| <span class="config-value" id="cached1psidts" style="font-family: monospace; font-size: 12px;">--</span> | |
| </div> | |
| <div class="config-item" id="sessionErrorRow" style="display: none;"> | |
| <span class="config-label">最近错误</span> | |
| <span class="config-value" id="sessionError" style="color: #f44336; font-size: 12px;"></span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">重建 / 重拉</span> | |
| <span class="config-value" id="sessionRecovery">--</span> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">最近一次回复</span> | |
| <span class="config-value" id="lastResponse">--</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h3>🔧 功能开关</h3> | |
| <div class="warning"> | |
| 💡 开关<b>点一下立即生效</b>,不需要重启。但它同时会写容器内的 <code>.env</code>, | |
| 而 Hugging Face Space 的文件系统是临时的,<b>重新构建后会回到默认值</b>—— | |
| 想永久生效就到 Space 的 <b>Settings → Variables and secrets</b> 里设同名变量 | |
| (如 <code>ENABLE_THINKING=true</code>)。 | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">思考模式</span> | |
| <label class="toggle"> | |
| <input type="checkbox" id="toggleThinking" onchange="toggleFeature('thinking')"> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">临时对话</span> | |
| <label class="toggle"> | |
| <input type="checkbox" id="toggleTemporary" onchange="toggleFeature('temporary')"> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| <div class="config-item"> | |
| <span class="config-label">自动删除对话</span> | |
| <label class="toggle"> | |
| <input type="checkbox" id="toggleAutoDelete" onchange="toggleFeature('autoDelete')"> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 模型列表 --> | |
| <div class="card" style="margin-bottom: 20px;"> | |
| <h3>📋 可用模型</h3> | |
| <div class="model-list" id="modelList"> | |
| <div style="color: #666;">加载中...</div> | |
| </div> | |
| </div> | |
| <!-- 日志和测试 --> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h3>📝 最近日志</h3> | |
| <div class="log-container" id="logContainer"> | |
| <div style="color: #666;">暂无日志</div> | |
| </div> | |
| <button class="btn btn-primary" style="margin-top: 10px;" onclick="refreshLogs()">刷新</button> | |
| </div> | |
| <div class="card"> | |
| <h3>💬 快速测试</h3> | |
| <div class="chat-area"> | |
| <div class="chat-messages" id="chatMessages"> | |
| <div class="chat-msg assistant">你好!我是 Gemini,有什么可以帮你的?</div> | |
| </div> | |
| <div class="chat-input"> | |
| <select id="chatModel" style="background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 6px; padding: 8px 12px; font-size: 14px; min-width: 180px;"> | |
| <option value="">加载模型中...</option> | |
| </select> | |
| <input type="text" id="chatInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter')sendTestMessage()"> | |
| <button class="btn btn-primary" onclick="sendTestMessage()">发送</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 配置编辑弹窗 --> | |
| <div class="modal" id="configModal"> | |
| <div class="modal-content"> | |
| <h3>修改服务配置</h3> | |
| <div class="warning"> | |
| ⚠️ 修改监听地址或端口后需要重启服务才能生效。 | |
| </div> | |
| <div class="form-group"> | |
| <label>监听地址</label> | |
| <input type="text" id="editHost" placeholder="0.0.0.0"> | |
| </div> | |
| <div class="form-group"> | |
| <label>监听端口</label> | |
| <input type="number" id="editPort" placeholder="8000"> | |
| </div> | |
| <div class="form-group"> | |
| <label>API_KEY(留空表示禁用鉴权)</label> | |
| <input type="text" id="editApiKey" placeholder="可选"> | |
| </div> | |
| <div class="modal-buttons"> | |
| <button class="btn" style="background: #444; color: #fff;" onclick="closeModal()">取消</button> | |
| <button class="btn btn-primary" onclick="saveConfigAndRestart()">💾 保存并重启服务</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // API 基础路径 | |
| const API_BASE = '/admin/api'; | |
| // 会话管理 | |
| let adminToken = localStorage.getItem('adminToken') || ''; | |
| // 带 token 的 fetch 封装 | |
| function authFetch(url, options = {}) { | |
| if (!options.headers) options.headers = {}; | |
| if (adminToken) options.headers['X-Admin-Token'] = adminToken; | |
| return fetch(url, options); | |
| } | |
| // 检查登录状态 | |
| async function checkAuth() { | |
| if (!adminToken) { | |
| showLogin(); | |
| return false; | |
| } | |
| try { | |
| const res = await fetch(`${API_BASE}/check?token=${adminToken}`); | |
| if (res.ok) return true; | |
| } catch (e) {} | |
| // token 无效,清除并显示登录 | |
| adminToken = ''; | |
| localStorage.removeItem('adminToken'); | |
| showLogin(); | |
| return false; | |
| } | |
| function showLogin() { | |
| document.getElementById('loginPage').style.display = 'block'; | |
| document.getElementById('mainPage').style.display = 'none'; | |
| } | |
| function showMain() { | |
| document.getElementById('loginPage').style.display = 'none'; | |
| document.getElementById('mainPage').style.display = 'block'; | |
| } | |
| // 登录 | |
| async function adminLogin() { | |
| const apiKey = document.getElementById('loginApiKey').value.trim(); | |
| const msgEl = document.getElementById('loginMessage'); | |
| if (!apiKey) { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#3a1a1a'; | |
| msgEl.style.color = '#f44336'; | |
| msgEl.textContent = '请输入 API_KEY'; | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`${API_BASE}/login`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ api_key: apiKey }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| adminToken = data.token; | |
| localStorage.setItem('adminToken', adminToken); | |
| showMain(); | |
| initDashboard(); | |
| } else { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#3a1a1a'; | |
| msgEl.style.color = '#f44336'; | |
| msgEl.textContent = '❌ ' + (data.detail || '登录失败'); | |
| } | |
| } catch (e) { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#3a1a1a'; | |
| msgEl.style.color = '#f44336'; | |
| msgEl.textContent = '❌ 网络错误: ' + e.message; | |
| } | |
| } | |
| // 退出登录 | |
| function adminLogout() { | |
| adminToken = ''; | |
| localStorage.removeItem('adminToken'); | |
| showLogin(); | |
| } | |
| // 定时刷新状态 | |
| let refreshInterval = null; | |
| // 初始化仪表盘 | |
| async function initDashboard() { | |
| fetchStatus(); | |
| fetchModels(); | |
| fetchLogs(); | |
| if (refreshInterval) clearInterval(refreshInterval); | |
| refreshInterval = setInterval(() => { | |
| fetchStatus(); | |
| fetchLogs(); | |
| }, 10000); | |
| } | |
| // 启动 | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| const valid = await checkAuth(); | |
| if (valid) { | |
| showMain(); | |
| initDashboard(); | |
| } | |
| }); | |
| // 秒数 -> "多久以前" | |
| function formatAge(seconds) { | |
| if (seconds === null || seconds === undefined) return '--'; | |
| if (seconds < 60) return seconds + ' 秒前'; | |
| const minutes = Math.floor(seconds / 60); | |
| if (minutes < 60) return minutes + ' 分钟前'; | |
| const hours = Math.floor(minutes / 60); | |
| if (hours < 24) return hours + ' 小时 ' + (minutes % 60) + ' 分钟前'; | |
| return Math.floor(hours / 24) + ' 天 ' + (hours % 24) + ' 小时前'; | |
| } | |
| // 获取服务状态 | |
| async function fetchStatus() { | |
| try { | |
| const res = await authFetch(`${API_BASE}/status`); | |
| const data = await res.json(); | |
| document.getElementById('uptime').textContent = data.uptime || '--'; | |
| document.getElementById('totalRequests').textContent = data.total_requests || 0; | |
| document.getElementById('avgResponseTime').textContent = (data.avg_response_time || 0) + 'ms'; | |
| document.getElementById('errorRate').textContent = (data.error_rate || 0).toFixed(1) + '%'; | |
| document.getElementById('currentHost').textContent = data.host || '--'; | |
| document.getElementById('currentPort').textContent = data.port || '--'; | |
| document.getElementById('apiKeyStatus').textContent = data.api_key_enabled ? '已启用' : '未设置'; | |
| document.getElementById('cookieStatus').textContent = data.cookie_valid ? '有效' : '无效'; | |
| // 显示脱敏的 Cookie 值 | |
| document.getElementById('cookie1psid').placeholder = data.secure_1psid_masked || '粘贴 __Secure-1PSID 的值'; | |
| document.getElementById('cookie1psidts').placeholder = data.secure_1psidts_masked || '粘贴 __Secure-1PSIDTS 的值'; | |
| // 会话与 __Secure-1PSIDTS 轮换状态 | |
| const session = data.session || {}; | |
| const accountStatus = session.account_status || 'unknown'; | |
| const initialized = !!session.initialized; | |
| const sessionStatusEl = document.getElementById('sessionStatus'); | |
| sessionStatusEl.textContent = initialized ? ('已连接 · ' + accountStatus) : '未连接'; | |
| sessionStatusEl.style.color = initialized && accountStatus === 'AVAILABLE' ? '#4caf50' : '#ff9800'; | |
| document.getElementById('sessionSource').textContent = session.credential_source || '--'; | |
| const taskAlive = !!session.rotation_task_alive; | |
| const supervisorAlive = !!session.supervisor_alive; | |
| const rotationTaskEl = document.getElementById('rotationTask'); | |
| rotationTaskEl.textContent = taskAlive | |
| ? '运行中' | |
| : (supervisorAlive ? '等待重新拉起' : '已停止'); | |
| rotationTaskEl.style.color = taskAlive ? '#4caf50' : '#ff9800'; | |
| document.getElementById('rotationCount').textContent = (session.rotation_count || 0) + ' 次'; | |
| document.getElementById('rotationAge').textContent = formatAge(session.last_1psidts_change_age_sec); | |
| document.getElementById('live1psidts').textContent = session.live_1psidts_masked || '--'; | |
| document.getElementById('cached1psidts').textContent = session.cached_1psidts_masked || '--'; | |
| document.getElementById('sessionRecovery').textContent = | |
| '重建 ' + (session.recycle_count || 0) + ' 次 / 重拉 ' + (session.rearm_count || 0) + ' 次'; | |
| // 最近一次回复的诊断:用来判断"内容不全"是服务端没发全还是客户端没显示全 | |
| const last = data.last_response || {}; | |
| const lastEl = document.getElementById('lastResponse'); | |
| if (last.chars === undefined) { | |
| lastEl.textContent = '--'; | |
| lastEl.style.color = ''; | |
| } else { | |
| const parts = [last.mode === 'stream' ? '流式' : '非流式', last.chars + ' 字符']; | |
| if (last.truncated) parts.push('疑似被截断'); | |
| if (last.error) parts.push('有错误'); | |
| lastEl.textContent = parts.join(' · '); | |
| lastEl.style.color = last.truncated || last.error ? '#ff9800' : '#4caf50'; | |
| } | |
| const sessionErrorRow = document.getElementById('sessionErrorRow'); | |
| if (session.last_error) { | |
| sessionErrorRow.style.display = ''; | |
| document.getElementById('sessionError').textContent = session.last_error; | |
| } else { | |
| sessionErrorRow.style.display = 'none'; | |
| } | |
| // 功能开关状态 | |
| document.getElementById('toggleThinking').checked = data.thinking_enabled || false; | |
| document.getElementById('toggleTemporary').checked = data.temporary_chat || false; | |
| document.getElementById('toggleAutoDelete').checked = data.auto_delete_chat || false; | |
| // 状态指示 | |
| document.getElementById('statusDot').style.background = data.running ? '#4caf50' : '#f44336'; | |
| document.getElementById('statusText').textContent = data.running ? '运行中' : '已停止'; | |
| } catch (e) { | |
| console.error('获取状态失败:', e); | |
| document.getElementById('statusDot').style.background = '#f44336'; | |
| document.getElementById('statusText').textContent = '连接失败'; | |
| } | |
| } | |
| // 获取模型列表(来自当前账号的真实探测结果) | |
| async function fetchModels() { | |
| try { | |
| const res = await fetch('/v1/models'); | |
| const data = await res.json(); | |
| const list = document.getElementById('modelList'); | |
| const select = document.getElementById('chatModel'); | |
| const models = (data.data || []).filter(m => m.id !== 'unspecified'); | |
| if (models.length > 0) { | |
| const sourceNote = data.source === 'account' | |
| ? '' | |
| : '<div style="color: #ff9800; margin-bottom: 8px;">⚠️ 客户端尚未就绪,以下为兜底的静态模型列表(可能已过期)</div>'; | |
| list.innerHTML = sourceNote + models.map(m => ` | |
| <div class="model-item"> | |
| <span class="model-name">${escapeHtml(m.display_name || m.id)}</span> | |
| <span style="color: #888;">${escapeHtml(m.id)}</span> | |
| </div> | |
| `).join(''); | |
| select.innerHTML = models.map(m => { | |
| const label = m.display_name ? `${m.display_name} (${m.id})` : m.id; | |
| return `<option value="${escapeHtml(m.id)}">${escapeHtml(label)}</option>`; | |
| }).join(''); | |
| if (select.options.length > 0) select.selectedIndex = 0; | |
| } else { | |
| list.innerHTML = '<div style="color: #666;">暂无可用模型</div>'; | |
| select.innerHTML = '<option value="">无可用模型</option>'; | |
| } | |
| } catch (e) { | |
| console.error('获取模型列表失败:', e); | |
| } | |
| } | |
| // 获取日志 | |
| async function fetchLogs() { | |
| try { | |
| const res = await authFetch(`${API_BASE}/logs`); | |
| const data = await res.json(); | |
| const container = document.getElementById('logContainer'); | |
| if (data.logs && data.logs.length > 0) { | |
| container.innerHTML = data.logs.map(log => ` | |
| <div class="log-entry"> | |
| <span class="log-time">${escapeHtml(String(log.time))}</span> | |
| <span class="log-method">${escapeHtml(log.method)}</span> | |
| <span class="log-path">${escapeHtml(log.path)}</span> | |
| <span class="log-status ${log.status < 400 ? 'ok' : 'error'}">${Number(log.status)}</span> | |
| </div> | |
| `).join(''); | |
| } else { | |
| container.innerHTML = '<div style="color: #666;">暂无日志</div>'; | |
| } | |
| } catch (e) { | |
| console.error('获取日志失败:', e); | |
| } | |
| } | |
| // 刷新日志 | |
| function refreshLogs() { | |
| fetchLogs(); | |
| } | |
| // 切换功能开关 | |
| async function toggleFeature(feature) { | |
| const checkbox = document.getElementById(`toggle${feature.charAt(0).toUpperCase() + feature.slice(1)}`); | |
| try { | |
| await authFetch(`${API_BASE}/config`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ feature, enabled: checkbox.checked }) | |
| }); | |
| } catch (e) { | |
| console.error('切换功能失败:', e); | |
| checkbox.checked = !checkbox.checked; | |
| } | |
| } | |
| // 显示配置编辑弹窗 | |
| function showEditConfig() { | |
| fetchStatus().then(() => { | |
| document.getElementById('editHost').value = document.getElementById('currentHost').textContent; | |
| document.getElementById('editPort').value = document.getElementById('currentPort').textContent; | |
| document.getElementById('configModal').classList.add('active'); | |
| }); | |
| } | |
| // 关闭弹窗 | |
| function closeModal() { | |
| document.getElementById('configModal').classList.remove('active'); | |
| } | |
| // 保存配置并重启服务 | |
| async function saveConfigAndRestart() { | |
| if (!confirm('确定要保存配置并重启服务吗?')) return; | |
| const config = { | |
| host: document.getElementById('editHost').value, | |
| port: parseInt(document.getElementById('editPort').value), | |
| api_key: document.getElementById('editApiKey').value | |
| }; | |
| try { | |
| const res = await authFetch(`${API_BASE}/config-save-restart`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(config) | |
| }); | |
| if (res.ok) { | |
| alert('配置已保存,服务正在重启...'); | |
| closeModal(); | |
| setTimeout(fetchStatus, 3000); | |
| } else { | |
| alert('保存失败'); | |
| } | |
| } catch (e) { | |
| console.error('保存配置失败:', e); | |
| alert('保存失败: ' + e.message); | |
| } | |
| } | |
| // 保存 Gemini Cookie 并重新连接 | |
| async function saveCookiesAndReinit() { | |
| const psid = document.getElementById('cookie1psid').value.trim(); | |
| const psidts = document.getElementById('cookie1psidts').value.trim(); | |
| const msgEl = document.getElementById('cookieMessage'); | |
| if (!psid || !psidts) { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#3a1a1a'; | |
| msgEl.style.color = '#f44336'; | |
| msgEl.textContent = '请填写 __Secure-1PSID 和 __Secure-1PSIDTS'; | |
| return; | |
| } | |
| try { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#1a2a3a'; | |
| msgEl.style.color = '#4fc3f7'; | |
| msgEl.textContent = '⏳ 正在保存 Cookie 并重新连接 Gemini...'; | |
| const res = await authFetch(`${API_BASE}/cookies-save-reinit`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ secure_1psid: psid, secure_1psidts: psidts }) | |
| }); | |
| const data = await res.json(); | |
| if (res.ok && data.success) { | |
| msgEl.style.background = '#1a3a1a'; | |
| msgEl.style.color = '#4caf50'; | |
| msgEl.textContent = '✅ ' + data.message; | |
| document.getElementById('cookie1psid').value = ''; | |
| document.getElementById('cookie1psidts').value = ''; | |
| document.getElementById('cookieRaw').value = ''; | |
| } else { | |
| msgEl.style.background = '#3a2a1a'; | |
| msgEl.style.color = '#ff9800'; | |
| msgEl.textContent = '⚠️ ' + (data.message || '操作失败'); | |
| } | |
| fetchStatus(); | |
| } catch (e) { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#3a1a1a'; | |
| msgEl.style.color = '#f44336'; | |
| msgEl.textContent = '❌ 网络错误: ' + e.message; | |
| } | |
| } | |
| // 从整段 Cookie 字符串中提取 __Secure-1PSID 和 __Secure-1PSIDTS | |
| function parseRawCookie(showMessage = false) { | |
| const raw = document.getElementById('cookieRaw').value.trim(); | |
| const msgEl = document.getElementById('cookieMessage'); | |
| if (!raw) { | |
| if (showMessage) { | |
| msgEl.style.display = 'block'; | |
| msgEl.style.background = '#3a2a1a'; | |
| msgEl.style.color = '#ffb74d'; | |
| msgEl.textContent = '⚠️ 请先粘贴 Cookie 字符串'; | |
| } | |
| return; | |
| } | |
| // 解析 key=value; key=value; 格式 | |
| const cookies = {}; | |
| // 匹配 key=value 对,支持空格和分号分隔 | |
| const regex = /([\w\-]+)=([^;]+)/g; | |
| let match; | |
| while ((match = regex.exec(raw)) !== null) { | |
| cookies[match[1].trim()] = match[2].trim(); | |
| } | |
| const psid = cookies['__Secure-1PSID'] || ''; | |
| const psidts = cookies['__Secure-1PSIDTS'] || ''; | |
| // 填入输入框 | |
| document.getElementById('cookie1psid').value = psid; | |
| document.getElementById('cookie1psidts').value = psidts; | |
| if (showMessage) { | |
| msgEl.style.display = 'block'; | |
| if (psid && psidts) { | |
| msgEl.style.background = '#1a3a1a'; | |
| msgEl.style.color = '#4caf50'; | |
| msgEl.textContent = `✅ 成功提取!共识别 ${Object.keys(cookies).length} 个 Cookie,已填入 __Secure-1PSID (${psid.substring(0, 20)}...) 和 __Secure-1PSIDTS (${psidts.substring(0, 20)}...)`; | |
| } else if (psid || psidts) { | |
| msgEl.style.background = '#3a2a1a'; | |
| msgEl.style.color = '#ffb74d'; | |
| const missing = !psid ? '__Secure-1PSID' : '__Secure-1PSIDTS'; | |
| msgEl.textContent = `⚠️ 只找到 ${psid ? '__Secure-1PSID' : '__Secure-1PSIDTS'},缺少 ${missing},请手动补充`; | |
| } else { | |
| msgEl.style.background = '#3a1a1a'; | |
| msgEl.style.color = '#f44336'; | |
| msgEl.textContent = `❌ 未找到 __Secure-1PSID 或 __Secure-1PSIDTS(共识别 ${Object.keys(cookies).length} 个 Cookie)`; | |
| } | |
| } | |
| } | |
| // 发送测试消息(真流式,思考过程单独折叠展示) | |
| async function sendTestMessage() { | |
| const input = document.getElementById('chatInput'); | |
| const message = input.value.trim(); | |
| if (!message) return; | |
| const chatMessages = document.getElementById('chatMessages'); | |
| chatMessages.innerHTML += `<div class="chat-msg user">${escapeHtml(message)}</div>`; | |
| input.value = ''; | |
| const bubbleId = 'reply-' + Date.now(); | |
| chatMessages.innerHTML += `<div class="chat-msg assistant" id="${bubbleId}">思考中...</div>`; | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| const bubble = document.getElementById(bubbleId); | |
| let thinking = ''; | |
| let answer = ''; | |
| let received = false; | |
| const render = () => { | |
| let html = ''; | |
| if (thinking) { | |
| html += `<details class="think-block"><summary>思考过程(${thinking.length} 字)</summary>` + | |
| `<div class="think-body">${escapeHtml(thinking)}</div></details>`; | |
| } | |
| html += `<div class="answer-body">${escapeHtml(answer.trimStart()) || (received ? '' : '思考中...')}</div>`; | |
| bubble.innerHTML = html; | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| }; | |
| try { | |
| const model = document.getElementById('chatModel').value || 'gemini-3-flash'; | |
| const res = await fetch('/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${adminToken}` | |
| }, | |
| body: JSON.stringify({ | |
| model: model, | |
| messages: [{ role: 'user', content: message }], | |
| stream: true | |
| }) | |
| }); | |
| if (!res.ok || !res.body) { | |
| let errMsg; | |
| try { | |
| const errBody = await res.json(); | |
| errMsg = errBody.error?.message || errBody.detail || JSON.stringify(errBody); | |
| } catch { errMsg = res.statusText || `HTTP ${res.status}`; } | |
| bubble.innerHTML = `<span style="color: #f44336;">请求失败 (${res.status}): ${escapeHtml(errMsg)}</span>`; | |
| return; | |
| } | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| received = true; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| const payload = line.slice(6).trim(); | |
| if (!payload || payload === '[DONE]') continue; | |
| let chunk; | |
| try { chunk = JSON.parse(payload); } catch { continue; } | |
| const delta = chunk.choices?.[0]?.delta; | |
| if (!delta) continue; | |
| if (delta.reasoning_content) { | |
| // 后端把思考同时放进 content 与 reasoning_content,这里只取一份 | |
| thinking += delta.reasoning_content; | |
| } else if (delta.content) { | |
| // 去掉 <think> 包裹标签本身,正文保留 | |
| answer += delta.content.replace(/<\/?think>/g, ''); | |
| } | |
| render(); | |
| } | |
| } | |
| render(); | |
| } catch (e) { | |
| bubble.innerHTML = '<span style="color: #f44336;">错误: </span>' + escapeHtml(e.message); | |
| } | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| // HTML 转义 | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| </script> | |
| </body> | |
| </html> |