| let apiKey = ''; |
| let currentConfig = {}; |
| const byId = (id) => document.getElementById(id); |
| const NUMERIC_FIELDS = new Set([ |
| 'timeout', |
| 'max_retry', |
| 'retry_backoff_base', |
| 'retry_backoff_factor', |
| 'retry_backoff_max', |
| 'retry_budget', |
| 'refresh_interval_hours', |
| 'super_refresh_interval_hours', |
| 'fail_threshold', |
| 'limit_mb', |
| 'save_delay_ms', |
| 'usage_flush_interval_sec', |
| 'upload_concurrent', |
| 'upload_timeout', |
| 'download_concurrent', |
| 'download_timeout', |
| 'list_concurrent', |
| 'list_timeout', |
| 'list_batch_size', |
| 'delete_concurrent', |
| 'delete_timeout', |
| 'delete_batch_size', |
| 'reload_interval_sec', |
| 'stream_timeout', |
| 'final_timeout', |
| 'final_min_bytes', |
| 'medium_min_bytes', |
| 'concurrent', |
| 'batch_size' |
| ]); |
|
|
| const LOCALE_MAP = { |
| "app": { |
| "label": "应用设置", |
| "api_key": { title: "API 密钥", desc: "调用 Grok2API 服务的 Token(可选)。" }, |
| "app_key": { title: "后台密码", desc: "登录 Grok2API 管理后台的密码(必填)。" }, |
| "public_enabled": { title: "启用功能玩法", desc: "是否启用功能玩法入口(关闭则功能玩法页面不可访问)。" }, |
| "public_key": { title: "Public 密码", desc: "功能玩法页面的访问密码(可选)。" }, |
| "app_url": { title: "应用地址", desc: "当前 Grok2API 服务的外部访问 URL,用于文件链接访问。" }, |
| "image_format": { title: "图片格式", desc: "默认生成的图片格式(url 或 base64)。" }, |
| "video_format": { title: "视频格式", desc: "默认生成的视频格式(html 或 url,url 为处理后的链接)。" }, |
| "temporary": { title: "临时对话", desc: "是否默认启用临时对话模式。" }, |
| "disable_memory": { title: "禁用记忆", desc: "是否默认禁用 Grok 记忆功能。" }, |
| "stream": { title: "流式响应", desc: "是否默认启用流式输出。" }, |
| "thinking": { title: "思维链", desc: "是否默认启用思维链输出。" }, |
| "dynamic_statsig": { title: "动态指纹", desc: "是否默认启用动态生成 Statsig 指纹。" }, |
| "filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" } |
| }, |
|
|
|
|
| "proxy": { |
| "label": "代理配置", |
| "base_proxy_url": { title: "基础代理 URL", desc: "代理请求到 Grok 官网的基础服务地址。" }, |
| "asset_proxy_url": { title: "资源代理 URL", desc: "代理请求到 Grok 官网的静态资源(图片/视频)地址。" }, |
| "cf_clearance": { title: "CF Clearance", desc: "Cloudflare Clearance Cookie,用于绕过反爬虫验证。" }, |
| "browser": { title: "浏览器指纹", desc: "curl_cffi 浏览器指纹标识(如 chrome136)。" }, |
| "user_agent": { title: "User-Agent", desc: "HTTP 请求的 User-Agent 字符串,需与浏览器指纹匹配。" } |
| }, |
|
|
|
|
| "retry": { |
| "label": "重试策略", |
| "max_retry": { title: "最大重试次数", desc: "请求 Grok 服务失败时的最大重试次数。" }, |
| "retry_status_codes": { title: "重试状态码", desc: "触发重试的 HTTP 状态码列表。" }, |
| "retry_backoff_base": { title: "退避基数", desc: "重试退避的基础延迟(秒)。" }, |
| "retry_backoff_factor": { title: "退避倍率", desc: "重试退避的指数放大系数。" }, |
| "retry_backoff_max": { title: "退避上限", desc: "单次重试等待的最大延迟(秒)。" }, |
| "retry_budget": { title: "退避预算", desc: "单次请求的最大重试总耗时(秒)。" } |
| }, |
|
|
|
|
| "chat": { |
| "label": "对话配置", |
| "concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" }, |
| "timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" }, |
| "stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" } |
| }, |
|
|
|
|
| "video": { |
| "label": "视频配置", |
| "concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" }, |
| "timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" }, |
| "stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" } |
| }, |
|
|
|
|
| "image": { |
| "label": "图像配置", |
| "timeout": { title: "请求超时", desc: "WebSocket 请求超时时间(秒)。" }, |
| "stream_timeout": { title: "流空闲超时", desc: "WebSocket 流式空闲超时时间(秒)。" }, |
| "final_timeout": { title: "最终图超时", desc: "收到中等图后等待最终图的超时秒数。" }, |
| "nsfw": { title: "NSFW 模式", desc: "WebSocket 请求是否启用 NSFW。" }, |
| "medium_min_bytes": { title: "中等图最小字节", desc: "判定中等质量图的最小字节数。" }, |
| "final_min_bytes": { title: "最终图最小字节", desc: "判定最终图的最小字节数(通常 JPG > 100KB)。" } |
| }, |
|
|
|
|
| "asset": { |
| "label": "资产配置", |
| "upload_concurrent": { title: "上传并发", desc: "上传接口的最大并发数。推荐 30。" }, |
| "upload_timeout": { title: "上传超时", desc: "上传接口超时时间(秒)。推荐 60。" }, |
| "download_concurrent": { title: "下载并发", desc: "下载接口的最大并发数。推荐 30。" }, |
| "download_timeout": { title: "下载超时", desc: "下载接口超时时间(秒)。推荐 60。" }, |
| "list_concurrent": { title: "查询并发", desc: "资产查询接口的最大并发数。推荐 10。" }, |
| "list_timeout": { title: "查询超时", desc: "资产查询接口超时时间(秒)。推荐 60。" }, |
| "list_batch_size": { title: "查询批次大小", desc: "单次查询可处理的 Token 数量。推荐 10。" }, |
| "delete_concurrent": { title: "删除并发", desc: "资产删除接口的最大并发数。推荐 10。" }, |
| "delete_timeout": { title: "删除超时", desc: "资产删除接口超时时间(秒)。推荐 60。" }, |
| "delete_batch_size": { title: "删除批次大小", desc: "单次删除可处理的 Token 数量。推荐 10。" } |
| }, |
|
|
|
|
| "voice": { |
| "label": "语音配置", |
| "timeout": { title: "请求超时", desc: "Voice 请求超时时间(秒)。" } |
| }, |
|
|
|
|
| "token": { |
| "label": "Token 池管理", |
| "auto_refresh": { title: "自动刷新", desc: "是否开启 Token 自动刷新机制。" }, |
| "refresh_interval_hours": { title: "刷新间隔", desc: "普通 Token 刷新的时间间隔(小时)。" }, |
| "super_refresh_interval_hours": { title: "Super 刷新间隔", desc: "Super Token 刷新的时间间隔(小时)。" }, |
| "fail_threshold": { title: "失败阈值", desc: "单个 Token 连续失败多少次后被标记为不可用。" }, |
| "save_delay_ms": { title: "保存延迟", desc: "Token 变更合并写入的延迟(毫秒)。" }, |
| "usage_flush_interval_sec": { title: "用量落库间隔", desc: "用量类字段写入数据库的最小间隔(秒)。" }, |
| "reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" } |
| }, |
|
|
|
|
| "cache": { |
| "label": "缓存管理", |
| "enable_auto_clean": { title: "自动清理", desc: "是否启用缓存自动清理,开启后按上限自动回收。" }, |
| "limit_mb": { title: "清理阈值", desc: "缓存大小阈值(MB),超过阈值会触发清理。" } |
| }, |
|
|
|
|
| "nsfw": { |
| "label": "NSFW 配置", |
| "concurrent": { title: "并发上限", desc: "批量开启 NSFW 模式时的并发请求上限。推荐 10。" }, |
| "batch_size": { title: "批次大小", desc: "批量开启 NSFW 模式的单批处理数量。推荐 50。" }, |
| "timeout": { title: "请求超时", desc: "NSFW 开启相关请求的超时时间(秒)。推荐 60。" } |
| }, |
|
|
|
|
| "usage": { |
| "label": "Usage 配置", |
| "concurrent": { title: "并发上限", desc: "批量刷新用量时的并发请求上限。推荐 10。" }, |
| "batch_size": { title: "批次大小", desc: "批量刷新用量的单批处理数量。推荐 50。" }, |
| "timeout": { title: "请求超时", desc: "用量查询接口的超时时间(秒)。推荐 60。" } |
| } |
| }; |
|
|
| |
| const SECTION_DESCRIPTIONS = { |
| "proxy": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。" |
| }; |
|
|
| const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index])); |
|
|
| function getText(section, key) { |
| if (LOCALE_MAP[section] && LOCALE_MAP[section][key]) { |
| return LOCALE_MAP[section][key]; |
| } |
| return { |
| title: key.replace(/_/g, ' '), |
| desc: '暂无说明,请参考配置文档。' |
| }; |
| } |
|
|
| function getSectionLabel(section) { |
| return (LOCALE_MAP[section] && LOCALE_MAP[section].label) || `${section} 设置`; |
| } |
|
|
| function sortByOrder(keys, orderMap) { |
| if (!orderMap) return keys; |
| return keys.sort((a, b) => { |
| const ia = orderMap.get(a); |
| const ib = orderMap.get(b); |
| if (ia !== undefined && ib !== undefined) return ia - ib; |
| if (ia !== undefined) return -1; |
| if (ib !== undefined) return 1; |
| return 0; |
| }); |
| } |
|
|
| function setInputMeta(input, section, key) { |
| input.dataset.section = section; |
| input.dataset.key = key; |
| } |
|
|
| function createOption(value, text, selectedValue) { |
| const option = document.createElement('option'); |
| option.value = value; |
| option.text = text; |
| if (selectedValue !== undefined && selectedValue === value) option.selected = true; |
| return option; |
| } |
|
|
| function buildBooleanInput(section, key, val) { |
| const label = document.createElement('label'); |
| label.className = 'relative inline-flex items-center cursor-pointer'; |
|
|
| const input = document.createElement('input'); |
| input.type = 'checkbox'; |
| input.checked = val; |
| input.className = 'sr-only peer'; |
| setInputMeta(input, section, key); |
|
|
| const slider = document.createElement('div'); |
| slider.className = "w-9 h-5 bg-[var(--accents-2)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-black"; |
|
|
| label.appendChild(input); |
| label.appendChild(slider); |
|
|
| return { input, node: label }; |
| } |
|
|
| function buildSelectInput(section, key, val, options) { |
| const input = document.createElement('select'); |
| input.className = 'geist-input h-[34px]'; |
| setInputMeta(input, section, key); |
| options.forEach(opt => { |
| input.appendChild(createOption(opt.val, opt.text, val)); |
| }); |
| return { input, node: input }; |
| } |
|
|
| function buildJsonInput(section, key, val) { |
| const input = document.createElement('textarea'); |
| input.className = 'geist-input font-mono text-xs'; |
| input.rows = 4; |
| input.value = JSON.stringify(val, null, 2); |
| setInputMeta(input, section, key); |
| input.dataset.type = 'json'; |
| return { input, node: input }; |
| } |
|
|
| function buildTextInput(section, key, val) { |
| const input = document.createElement('input'); |
| input.type = 'text'; |
| input.className = 'geist-input'; |
| input.value = val; |
| setInputMeta(input, section, key); |
| return { input, node: input }; |
| } |
|
|
| function buildSecretInput(section, key, val) { |
| const input = document.createElement('input'); |
| input.type = 'text'; |
| input.className = 'geist-input flex-1 h-[34px]'; |
| input.value = val; |
| setInputMeta(input, section, key); |
|
|
| const wrapper = document.createElement('div'); |
| wrapper.className = 'flex items-center gap-2'; |
|
|
| const genBtn = document.createElement('button'); |
| genBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; |
| genBtn.type = 'button'; |
| genBtn.title = '生成'; |
| genBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg>`; |
| genBtn.onclick = () => { |
| input.value = randomKey(16); |
| }; |
|
|
| const copyBtn = document.createElement('button'); |
| copyBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; |
| copyBtn.type = 'button'; |
| copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`; |
| copyBtn.onclick = () => copyToClipboard(input.value, copyBtn); |
|
|
| wrapper.appendChild(input); |
| wrapper.appendChild(genBtn); |
| wrapper.appendChild(copyBtn); |
|
|
| return { input, node: wrapper }; |
| } |
|
|
| function randomKey(len) { |
| const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; |
| const out = []; |
| if (window.crypto && window.crypto.getRandomValues) { |
| const buf = new Uint8Array(len); |
| window.crypto.getRandomValues(buf); |
| for (let i = 0; i < len; i++) { |
| out.push(chars[buf[i] % chars.length]); |
| } |
| return out.join(''); |
| } |
| for (let i = 0; i < len; i++) { |
| out.push(chars[Math.floor(Math.random() * chars.length)]); |
| } |
| return out.join(''); |
| } |
|
|
| async function init() { |
| apiKey = await ensureAdminKey(); |
| if (apiKey === null) return; |
| loadData(); |
| } |
|
|
| async function loadData() { |
| try { |
| const res = await fetch('/v1/admin/config', { |
| headers: buildAuthHeaders(apiKey) |
| }); |
| if (res.ok) { |
| currentConfig = await res.json(); |
| renderConfig(currentConfig); |
| } else if (res.status === 401) { |
| logout(); |
| } |
| } catch (e) { |
| showToast('连接失败', 'error'); |
| } |
| } |
|
|
| function renderConfig(data) { |
| const container = byId('config-container'); |
| if (!container) return; |
| container.replaceChildren(); |
|
|
| const fragment = document.createDocumentFragment(); |
| const sections = sortByOrder(Object.keys(data), SECTION_ORDER); |
|
|
| sections.forEach(section => { |
| const items = data[section]; |
| const localeSection = LOCALE_MAP[section]; |
| const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null; |
|
|
| const allKeys = sortByOrder(Object.keys(items), keyOrder); |
|
|
| if (allKeys.length > 0) { |
| const card = document.createElement('div'); |
| card.className = 'config-section'; |
|
|
| const header = document.createElement('div'); |
| header.innerHTML = `<div class="config-section-title">${getSectionLabel(section)}</div>`; |
| |
| |
| if (SECTION_DESCRIPTIONS[section]) { |
| const descP = document.createElement('p'); |
| descP.className = 'text-[var(--accents-4)] text-sm mt-1 mb-4'; |
| descP.textContent = SECTION_DESCRIPTIONS[section]; |
| header.appendChild(descP); |
| } |
| |
| card.appendChild(header); |
|
|
| const grid = document.createElement('div'); |
| grid.className = 'config-grid'; |
|
|
| allKeys.forEach(key => { |
| const fieldCard = buildFieldCard(section, key, items[key]); |
| grid.appendChild(fieldCard); |
| }); |
|
|
| card.appendChild(grid); |
| if (grid.children.length > 0) { |
| fragment.appendChild(card); |
| } |
| } |
| }); |
|
|
| container.appendChild(fragment); |
| } |
|
|
| function buildFieldCard(section, key, val) { |
| const text = getText(section, key); |
|
|
| const fieldCard = document.createElement('div'); |
| fieldCard.className = 'config-field'; |
|
|
| |
| const titleEl = document.createElement('div'); |
| titleEl.className = 'config-field-title'; |
| titleEl.textContent = text.title; |
| fieldCard.appendChild(titleEl); |
|
|
| |
| if (text.desc) { |
| const descEl = document.createElement('p'); |
| descEl.className = 'config-field-desc'; |
| descEl.textContent = text.desc; |
| fieldCard.appendChild(descEl); |
| } |
|
|
| |
| const inputWrapper = document.createElement('div'); |
| inputWrapper.className = 'config-field-input'; |
|
|
| |
| let built; |
| if (typeof val === 'boolean') { |
| built = buildBooleanInput(section, key, val); |
| } |
| else if (key === 'image_format') { |
| built = buildSelectInput(section, key, val, [ |
| { val: 'url', text: 'URL' }, |
| { val: 'base64', text: 'Base64' } |
| ]); |
| } |
| else if (key === 'video_format') { |
| built = buildSelectInput(section, key, val, [ |
| { val: 'html', text: 'HTML' }, |
| { val: 'url', text: 'URL' } |
| ]); |
| } |
| else if (Array.isArray(val) || typeof val === 'object') { |
| built = buildJsonInput(section, key, val); |
| } |
| else { |
| if (key === 'api_key' || key === 'app_key' || key === 'public_key') { |
| built = buildSecretInput(section, key, val); |
| } else { |
| built = buildTextInput(section, key, val); |
| } |
| } |
|
|
| if (built) { |
| inputWrapper.appendChild(built.node); |
| } |
| fieldCard.appendChild(inputWrapper); |
|
|
| if (section === 'app' && key === 'public_enabled') { |
| fieldCard.classList.add('has-action'); |
| const link = document.createElement('a'); |
| link.href = '/login'; |
| link.className = 'config-field-action flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity'; |
| link.title = '功能玩法'; |
| link.setAttribute('aria-label', '功能玩法'); |
| link.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>`; |
| link.style.display = val ? 'inline-flex' : 'none'; |
| fieldCard.appendChild(link); |
| if (built && built.input) { |
| built.input.addEventListener('change', () => { |
| link.style.display = built.input.checked ? 'inline-flex' : 'none'; |
| }); |
| } |
| } |
|
|
| return fieldCard; |
| } |
|
|
| async function saveConfig() { |
| const btn = byId('save-btn'); |
| const originalText = btn.innerText; |
| btn.disabled = true; |
| btn.innerText = '保存中...'; |
|
|
| try { |
| const newConfig = typeof structuredClone === 'function' |
| ? structuredClone(currentConfig) |
| : JSON.parse(JSON.stringify(currentConfig)); |
| const inputs = document.querySelectorAll('input[data-section], textarea[data-section], select[data-section]'); |
|
|
| inputs.forEach(input => { |
| const s = input.dataset.section; |
| const k = input.dataset.key; |
| let val = input.value; |
|
|
| if (input.type === 'checkbox') { |
| val = input.checked; |
| } else if (input.dataset.type === 'json') { |
| try { val = JSON.parse(val); } catch (e) { throw new Error(`无效的 JSON: ${getText(s, k).title}`); } |
| } else if (k === 'app_key' && val.trim() === '') { |
| throw new Error('app_key 不能为空(后台密码)'); |
| } else if (NUMERIC_FIELDS.has(k)) { |
| if (val.trim() !== '' && !Number.isNaN(Number(val))) { |
| val = Number(val); |
| } |
| } |
|
|
| if (!newConfig[s]) newConfig[s] = {}; |
| newConfig[s][k] = val; |
| }); |
|
|
| const res = await fetch('/v1/admin/config', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| ...buildAuthHeaders(apiKey) |
| }, |
| body: JSON.stringify(newConfig) |
| }); |
|
|
| if (res.ok) { |
| btn.innerText = '成功'; |
| showToast('配置已保存', 'success'); |
| setTimeout(() => { |
| btn.innerText = originalText; |
| btn.style.backgroundColor = ''; |
| }, 2000); |
| } else { |
| showToast('保存失败', 'error'); |
| } |
| } catch (e) { |
| showToast('错误: ' + e.message, 'error'); |
| } finally { |
| if (btn.innerText === '保存中...') { |
| btn.disabled = false; |
| btn.innerText = originalText; |
| } else { |
| btn.disabled = false; |
| } |
| } |
| } |
|
|
| async function copyToClipboard(text, btn) { |
| if (!text) return; |
| try { |
| await navigator.clipboard.writeText(text); |
|
|
| btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`; |
| btn.style.backgroundColor = '#10b981'; |
| btn.style.borderColor = '#10b981'; |
|
|
| setTimeout(() => { |
| btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`; |
| btn.style.backgroundColor = ''; |
| btn.style.borderColor = ''; |
| }, 2000); |
| } catch (err) { |
| console.error('Failed to copy', err); |
| } |
| } |
|
|
| window.onload = init; |
|
|