gmn2a / admin.html
longxing's picture
修正功能开关的说明并修复派生标志不重算的问题
840a690
Raw History Blame Contribute Delete
48.4 kB
<!DOCTYPE html>
<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>