953 lines
36 KiB
JavaScript
953 lines
36 KiB
JavaScript
let apiKey = '';
|
|
let currentConfig = {};
|
|
let modelRoutingMeta = { models: [], pools: ['ssoBasic', 'ssoSuper'] };
|
|
let modelRoutingAssignments = {};
|
|
let modelRoutingDragId = '';
|
|
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',
|
|
'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 响应中的特殊标签。" },
|
|
"app_log_enabled": { title: "应用运行日志", desc: "控制是否将服务运行日志写入 logs/app_YYYY-MM-DD.log,用于排查启动、接口错误和系统异常。" },
|
|
"chat_capture_enabled": { title: "对话抓包日志", desc: "控制是否记录对话上游原始响应到抓包文件,用于排查图片来源、卡片附件和搜索结果关联问题。" },
|
|
"chat_capture_file": { title: "对话抓包文件", desc: "对话抓包日志的写入路径,建议保留在 logs 目录下统一管理。" }
|
|
},
|
|
|
|
|
|
"proxy": {
|
|
"label": "代理配置",
|
|
"base_proxy_url": { title: "基础代理 URL", desc: "代理请求到 Grok 官网的基础服务地址。" },
|
|
"asset_proxy_url": { title: "资源代理 URL", desc: "代理请求到 Grok 官网的静态资源(图片/视频)地址。" },
|
|
"enabled": { title: "启用 CF 自动刷新", desc: "启用后将通过 FlareSolverr 自动获取 cf_clearance。" },
|
|
"flaresolverr_url": { title: "FlareSolverr 地址", desc: "FlareSolverr 服务的 HTTP 地址(如 http://flaresolverr:8191)。" },
|
|
"refresh_interval": { title: "刷新间隔(秒)", desc: "自动刷新 cf_clearance 的时间间隔,建议不低于 300 秒。" },
|
|
"timeout": { title: "挑战超时(秒)", desc: "等待 FlareSolverr 解决 CF 挑战的最大时间。" },
|
|
"cf_clearance": { title: "CF Clearance", desc: "Cloudflare Clearance Cookie,用于绕过反爬虫验证。启用自动刷新时由系统自动管理。" },
|
|
"browser": { title: "浏览器指纹", desc: "curl_cffi 浏览器指纹标识(如 chrome136)。启用自动刷新时由系统自动管理。" },
|
|
"user_agent": { title: "User-Agent", desc: "HTTP 请求的 User-Agent 字符串。启用自动刷新时由系统自动管理。" }
|
|
},
|
|
|
|
|
|
"retry": {
|
|
"label": "重试策略",
|
|
"max_retry": { title: "最大重试次数", desc: "请求 Grok 服务失败时的最大重试次数。" },
|
|
"retry_status_codes": { title: "重试状态码", desc: "触发重试的 HTTP 状态码列表。" },
|
|
"retry_backoff_base": { title: "退避基数", desc: "重试退避的基础延迟(秒)。" },
|
|
"retry_backoff_factor": { title: "退避倍率", desc: "重试退避的指数放大系数。" },
|
|
"retry_backoff_max": { title: "退避上限", desc: "单次重试等待的最大延迟(秒)。" },
|
|
"retry_budget": { title: "退避预算", desc: "单次请求的最大重试总耗时(秒)。" }
|
|
},
|
|
|
|
|
|
"chat": {
|
|
"label": "对话配置",
|
|
"concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" },
|
|
"timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" },
|
|
"stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" }
|
|
},
|
|
|
|
|
|
"video": {
|
|
"label": "视频配置",
|
|
"concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" },
|
|
"timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" },
|
|
"stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" }
|
|
},
|
|
|
|
|
|
"image": {
|
|
"label": "图像配置",
|
|
"timeout": { title: "请求超时", desc: "WebSocket 请求超时时间(秒)。" },
|
|
"stream_timeout": { title: "流空闲超时", desc: "WebSocket 流式空闲超时时间(秒)。" },
|
|
"final_timeout": { title: "最终图超时", desc: "收到中等图后等待最终图的超时秒数。" },
|
|
"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 请求超时时间(秒)。" },
|
|
"livekit_url": { title: "LiveKit 地址", desc: "优先使用的 LiveKit 信令地址,留空则使用上游返回值。" },
|
|
"livekit_urls": { title: "LiveKit 地址列表", desc: "可配置多个信令地址,连接时会按顺序回退。" },
|
|
"signal_proxy_enabled": { title: "Signal 代理", desc: "开启后返回同域 WebSocket 代理地址,移动端可优先尝试。" },
|
|
"signal_proxy_url": { title: "代理地址", desc: "同域 signal 代理的自定义地址,留空将自动按当前域名生成。" }
|
|
},
|
|
|
|
|
|
"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 变更合并写入的延迟(毫秒)。" },
|
|
"reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" }
|
|
},
|
|
|
|
|
|
"model_routing": {
|
|
"label": "模型池路由",
|
|
"model_pools": { title: "模型到 Token 池映射", desc: "为每个模型手动指定使用哪个 Token 池。值支持字符串或字符串数组,例如 {\"grok-4.20-expert\":\"ssoSuper\",\"grok-4.20-auto\":[\"ssoBasic\",\"ssoSuper\"]}。" }
|
|
},
|
|
|
|
|
|
"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。",
|
|
"model_routing": "这里可以手动指定每个模型优先走哪个 Token 池。未配置的模型仍会按系统默认路由。"
|
|
};
|
|
|
|
// CF 自动刷新联动禁用字段(全部在 proxy section 内)
|
|
const CF_MANAGED_PROXY_KEYS = ['cf_clearance', 'browser', 'user_agent'];
|
|
const CF_REFRESH_SUB_KEYS = ['flaresolverr_url', 'refresh_interval', 'timeout'];
|
|
|
|
const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index]));
|
|
const HIDDEN_CONFIG_KEYS = new Map([
|
|
['chat', new Set(['capture_enabled', 'capture_file'])],
|
|
]);
|
|
|
|
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;
|
|
await loadModelRoutingMeta();
|
|
loadData();
|
|
}
|
|
|
|
async function loadModelRoutingMeta() {
|
|
try {
|
|
const res = await fetch('/v1/admin/model-routing/meta', {
|
|
headers: buildAuthHeaders(apiKey)
|
|
});
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (Array.isArray(data.models)) {
|
|
modelRoutingMeta.models = data.models;
|
|
}
|
|
if (Array.isArray(data.pools) && data.pools.length) {
|
|
modelRoutingMeta.pools = data.pools;
|
|
}
|
|
} catch (e) {
|
|
console.warn('模型池路由元数据加载失败', e);
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
try {
|
|
const res = await fetch('/v1/admin/config', {
|
|
headers: buildAuthHeaders(apiKey)
|
|
});
|
|
if (res.ok) {
|
|
currentConfig = await res.json();
|
|
renderConfig(currentConfig);
|
|
} else if (res.status === 401) {
|
|
logout();
|
|
}
|
|
} catch (e) {
|
|
showToast('连接失败', 'error');
|
|
}
|
|
}
|
|
|
|
function renderConfig(data) {
|
|
const container = byId('config-container');
|
|
if (!container) return;
|
|
container.replaceChildren();
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
const sections = sortByOrder(Object.keys(data), SECTION_ORDER);
|
|
|
|
sections.forEach(section => {
|
|
const items = data[section];
|
|
const localeSection = LOCALE_MAP[section];
|
|
const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null;
|
|
|
|
const allKeys = sortByOrder(Object.keys(items), keyOrder);
|
|
const hiddenKeys = HIDDEN_CONFIG_KEYS.get(section) || new Set();
|
|
const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies') && !hiddenKeys.has(key));
|
|
|
|
if (visibleKeys.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 === 'proxy') {
|
|
const actionRow = document.createElement('div');
|
|
actionRow.className = 'config-section-actions';
|
|
|
|
const refreshBtn = document.createElement('button');
|
|
refreshBtn.type = 'button';
|
|
refreshBtn.id = 'cf-refresh-btn';
|
|
refreshBtn.className = 'geist-button-outline gap-2';
|
|
refreshBtn.innerHTML = `
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 2v6h-6"></path>
|
|
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
|
<path d="M3 22v-6h6"></path>
|
|
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
|
</svg>
|
|
手动刷新 CF
|
|
`;
|
|
refreshBtn.addEventListener('click', manualRefreshCfClearance);
|
|
actionRow.appendChild(refreshBtn);
|
|
header.appendChild(actionRow);
|
|
}
|
|
|
|
// 添加部分说明(如果有)
|
|
if (SECTION_DESCRIPTIONS[section]) {
|
|
const descP = document.createElement('p');
|
|
descP.className = 'text-[var(--accents-4)] text-sm mt-1 mb-4';
|
|
descP.textContent = SECTION_DESCRIPTIONS[section];
|
|
header.appendChild(descP);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'config-grid';
|
|
|
|
visibleKeys.forEach(key => {
|
|
const fieldCard = buildFieldCard(section, key, items[key]);
|
|
grid.appendChild(fieldCard);
|
|
});
|
|
|
|
card.appendChild(grid);
|
|
if (section === 'app') {
|
|
const footerActions = document.createElement('div');
|
|
footerActions.className = 'config-footer-actions';
|
|
const clearLogsBtn = document.createElement('button');
|
|
clearLogsBtn.type = 'button';
|
|
clearLogsBtn.id = 'clear-logs-btn';
|
|
clearLogsBtn.className = 'geist-button-danger gap-2';
|
|
clearLogsBtn.innerHTML = `
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M3 6h18"></path>
|
|
<path d="M8 6V4h8v2"></path>
|
|
<path d="M19 6l-1 14H6L5 6"></path>
|
|
<path d="M10 11v6"></path>
|
|
<path d="M14 11v6"></path>
|
|
</svg>
|
|
清空日志文件夹
|
|
`;
|
|
clearLogsBtn.addEventListener('click', clearLogsDirectory);
|
|
card.appendChild(footerActions);
|
|
footerActions.appendChild(clearLogsBtn);
|
|
}
|
|
if (grid.children.length > 0) {
|
|
fragment.appendChild(card);
|
|
}
|
|
}
|
|
});
|
|
|
|
container.appendChild(fragment);
|
|
|
|
// 初始化 CF 自动刷新联动状态
|
|
const cfEnabled = data.proxy && data.proxy.enabled;
|
|
applyCfRefreshState(cfEnabled);
|
|
}
|
|
|
|
async function manualRefreshCfClearance() {
|
|
const btn = byId('cf-refresh-btn');
|
|
if (!btn) return;
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = `
|
|
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
|
</svg>
|
|
刷新中...
|
|
`;
|
|
try {
|
|
const res = await fetch('/v1/admin/config/cf-refresh', {
|
|
method: 'POST',
|
|
headers: buildAuthHeaders(apiKey)
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.detail || data.message || `HTTP ${res.status}`);
|
|
}
|
|
showToast(data.message || 'CF Clearance 已刷新', 'success');
|
|
await loadData();
|
|
} catch (e) {
|
|
showToast(`刷新失败: ${e.message || e}`, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
const size = Number(bytes || 0);
|
|
if (!size) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let value = size;
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
async function clearLogsDirectory() {
|
|
const btn = byId('clear-logs-btn');
|
|
if (!btn) return;
|
|
const confirmed = window.confirm('将清空 logs 文件夹中的全部日志文件,是否继续?');
|
|
if (!confirmed) return;
|
|
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = `
|
|
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
|
</svg>
|
|
清理中...
|
|
`;
|
|
|
|
try {
|
|
const res = await fetch('/v1/admin/config/clear-logs', {
|
|
method: 'POST',
|
|
headers: buildAuthHeaders(apiKey)
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.detail || data.message || `HTTP ${res.status}`);
|
|
}
|
|
const info = data.data || {};
|
|
const filesText = Number(info.deleted_files || 0);
|
|
const dirsText = Number(info.deleted_dirs || 0);
|
|
const bytesText = formatBytes(info.released_bytes || 0);
|
|
showToast(`已清空日志:${filesText} 个文件,${dirsText} 个目录,释放 ${bytesText}`, 'success');
|
|
} catch (e) {
|
|
showToast(`清空失败: ${e.message || e}`, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
|
|
function normalizeModelRoutingAssignments(raw) {
|
|
const next = {};
|
|
const source = raw && typeof raw === 'object' ? raw : {};
|
|
Object.entries(source).forEach(([modelId, value]) => {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
next[modelId] = value.trim();
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const first = value.map(item => String(item).trim()).find(Boolean);
|
|
if (first) next[modelId] = first;
|
|
}
|
|
});
|
|
return next;
|
|
}
|
|
|
|
function buildModelRoutingInput(section, key, val) {
|
|
modelRoutingAssignments = normalizeModelRoutingAssignments(val);
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'routing-board';
|
|
wrapper.dataset.type = 'model-routing';
|
|
wrapper.dataset.section = section;
|
|
wrapper.dataset.key = key;
|
|
|
|
const leftPane = document.createElement('div');
|
|
leftPane.className = 'routing-pane routing-pane-models';
|
|
leftPane.innerHTML = `
|
|
<div class="routing-pane-header">
|
|
<div class="routing-pane-title">模型列表</div>
|
|
<div class="routing-pane-subtitle">拖到右侧池中即可指定路由,拖回这里表示按系统默认池处理。</div>
|
|
</div>
|
|
`;
|
|
|
|
const unassignedZone = document.createElement('div');
|
|
unassignedZone.className = 'routing-dropzone routing-dropzone-source';
|
|
unassignedZone.dataset.pool = '';
|
|
registerRoutingDropzone(unassignedZone, '');
|
|
|
|
const rightPane = document.createElement('div');
|
|
rightPane.className = 'routing-pane routing-pane-pools';
|
|
rightPane.innerHTML = `
|
|
<div class="routing-pane-header">
|
|
<div class="routing-pane-title">Token 池</div>
|
|
<div class="routing-pane-subtitle">模型放到哪个池,后端请求就优先走哪个池。</div>
|
|
</div>
|
|
`;
|
|
|
|
const poolGrid = document.createElement('div');
|
|
poolGrid.className = 'routing-pool-grid';
|
|
const pools = Array.isArray(modelRoutingMeta.pools) && modelRoutingMeta.pools.length
|
|
? modelRoutingMeta.pools
|
|
: ['ssoBasic', 'ssoSuper'];
|
|
|
|
pools.forEach(poolName => {
|
|
const card = document.createElement('div');
|
|
card.className = 'routing-pool-card';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'routing-pool-header';
|
|
header.innerHTML = `
|
|
<div class="routing-pool-title">${poolName}</div>
|
|
<div class="routing-pool-hint">拖拽模型到这里</div>
|
|
`;
|
|
|
|
const zone = document.createElement('div');
|
|
zone.className = 'routing-dropzone routing-dropzone-pool';
|
|
zone.dataset.pool = poolName;
|
|
registerRoutingDropzone(zone, poolName);
|
|
|
|
card.appendChild(header);
|
|
card.appendChild(zone);
|
|
poolGrid.appendChild(card);
|
|
});
|
|
|
|
leftPane.appendChild(unassignedZone);
|
|
rightPane.appendChild(poolGrid);
|
|
wrapper.appendChild(leftPane);
|
|
wrapper.appendChild(rightPane);
|
|
|
|
renderModelRoutingBoard(wrapper);
|
|
return { input: wrapper, node: wrapper };
|
|
}
|
|
|
|
function registerRoutingDropzone(zone, poolName) {
|
|
zone.addEventListener('dragover', (event) => {
|
|
event.preventDefault();
|
|
zone.classList.add('is-over');
|
|
});
|
|
zone.addEventListener('dragleave', () => {
|
|
zone.classList.remove('is-over');
|
|
});
|
|
zone.addEventListener('drop', (event) => {
|
|
event.preventDefault();
|
|
zone.classList.remove('is-over');
|
|
const modelId = event.dataTransfer.getData('text/plain') || modelRoutingDragId;
|
|
if (!modelId) return;
|
|
if (poolName) {
|
|
modelRoutingAssignments[modelId] = poolName;
|
|
} else {
|
|
delete modelRoutingAssignments[modelId];
|
|
}
|
|
const board = zone.closest('.routing-board');
|
|
if (board) renderModelRoutingBoard(board);
|
|
modelRoutingDragId = '';
|
|
});
|
|
}
|
|
|
|
function createModelChip(model) {
|
|
const chip = document.createElement('button');
|
|
chip.type = 'button';
|
|
chip.className = 'routing-chip';
|
|
chip.draggable = true;
|
|
chip.dataset.modelId = model.id;
|
|
chip.innerHTML = `
|
|
<span class="routing-chip-name">${model.display_name || model.id}</span>
|
|
<span class="routing-chip-id">${model.id}</span>
|
|
`;
|
|
|
|
chip.addEventListener('dragstart', (event) => {
|
|
modelRoutingDragId = model.id;
|
|
event.dataTransfer.setData('text/plain', model.id);
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
chip.classList.add('is-dragging');
|
|
});
|
|
chip.addEventListener('dragend', () => {
|
|
chip.classList.remove('is-dragging');
|
|
modelRoutingDragId = '';
|
|
document.querySelectorAll('.routing-dropzone.is-over').forEach((node) => {
|
|
node.classList.remove('is-over');
|
|
});
|
|
});
|
|
|
|
return chip;
|
|
}
|
|
|
|
function renderModelRoutingBoard(board) {
|
|
const models = Array.isArray(modelRoutingMeta.models) ? [...modelRoutingMeta.models] : [];
|
|
models.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
|
|
const sourceZone = board.querySelector('.routing-dropzone-source');
|
|
const poolZones = Array.from(board.querySelectorAll('.routing-dropzone-pool'));
|
|
if (!sourceZone) return;
|
|
|
|
sourceZone.replaceChildren();
|
|
poolZones.forEach(zone => zone.replaceChildren());
|
|
|
|
const assignedModelIds = new Set();
|
|
poolZones.forEach(zone => {
|
|
const poolName = zone.dataset.pool || '';
|
|
const assigned = models.filter(model => modelRoutingAssignments[model.id] === poolName);
|
|
assigned.forEach(model => {
|
|
assignedModelIds.add(model.id);
|
|
zone.appendChild(createModelChip(model));
|
|
});
|
|
if (!assigned.length) {
|
|
zone.appendChild(createRoutingEmptyState('拖拽模型到这里'));
|
|
}
|
|
});
|
|
|
|
const unassigned = models.filter(model => !assignedModelIds.has(model.id));
|
|
unassigned.forEach(model => {
|
|
sourceZone.appendChild(createModelChip(model));
|
|
});
|
|
if (!unassigned.length) {
|
|
sourceZone.appendChild(createRoutingEmptyState('所有模型都已分配到池'));
|
|
}
|
|
}
|
|
|
|
function createRoutingEmptyState(text) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'routing-empty';
|
|
empty.textContent = text;
|
|
return empty;
|
|
}
|
|
|
|
function applyCfRefreshState(enabled) {
|
|
// 设置字段禁用状态的辅助函数
|
|
function setFieldDisabled(section, key, disabled) {
|
|
const input = document.querySelector(
|
|
`input[data-section="${section}"][data-key="${key}"],` +
|
|
`textarea[data-section="${section}"][data-key="${key}"],` +
|
|
`select[data-section="${section}"][data-key="${key}"]`
|
|
);
|
|
if (!input) return;
|
|
input.disabled = disabled;
|
|
// 找到最近的 .config-field 父元素设置样式
|
|
const field = input.closest('.config-field');
|
|
if (field) {
|
|
field.style.opacity = disabled ? '0.45' : '';
|
|
field.style.pointerEvents = disabled ? 'none' : '';
|
|
}
|
|
}
|
|
|
|
// enabled=true → 灰掉 cf_clearance/browser/user_agent
|
|
CF_MANAGED_PROXY_KEYS.forEach(k => setFieldDisabled('proxy', k, !!enabled));
|
|
// enabled=false → 灰掉 flaresolverr_url/refresh_interval/timeout
|
|
CF_REFRESH_SUB_KEYS.forEach(k => setFieldDisabled('proxy', k, !enabled));
|
|
}
|
|
|
|
function buildFieldCard(section, key, val) {
|
|
const text = getText(section, key);
|
|
|
|
const fieldCard = document.createElement('div');
|
|
fieldCard.className = 'config-field';
|
|
|
|
// Title
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'config-field-title';
|
|
titleEl.textContent = text.title;
|
|
fieldCard.appendChild(titleEl);
|
|
|
|
// Description (Muted) - 只在有描述时显示
|
|
if (text.desc) {
|
|
const descEl = document.createElement('p');
|
|
descEl.className = 'config-field-desc';
|
|
descEl.textContent = text.desc;
|
|
fieldCard.appendChild(descEl);
|
|
}
|
|
|
|
// Input Wrapper
|
|
const inputWrapper = document.createElement('div');
|
|
inputWrapper.className = 'config-field-input';
|
|
|
|
// Input Logic
|
|
let built;
|
|
if (typeof val === 'boolean') {
|
|
built = buildBooleanInput(section, key, val);
|
|
}
|
|
else if (key === 'image_format') {
|
|
built = buildSelectInput(section, key, val, [
|
|
{ val: 'url', text: 'URL' },
|
|
{ val: 'base64', text: 'Base64' }
|
|
]);
|
|
}
|
|
else if (key === 'video_format') {
|
|
built = buildSelectInput(section, key, val, [
|
|
{ val: 'html', text: 'HTML' },
|
|
{ val: 'url', text: 'URL' }
|
|
]);
|
|
}
|
|
else if (section === 'imagine_fast' && key === 'size') {
|
|
built = buildSelectInput(section, key, val, [
|
|
{ val: '1024x1024', text: '1024x1024 (1:1)' },
|
|
{ val: '1280x720', text: '1280x720 (16:9)' },
|
|
{ val: '720x1280', text: '720x1280 (9:16)' },
|
|
{ val: '1792x1024', text: '1792x1024 (3:2)' },
|
|
{ val: '1024x1792', text: '1024x1792 (2:3)' }
|
|
]);
|
|
}
|
|
else if (section === 'imagine_fast' && key === 'response_format') {
|
|
built = buildSelectInput(section, key, val, [
|
|
{ val: 'url', text: 'URL' },
|
|
{ val: 'b64_json', text: 'B64 JSON' },
|
|
{ val: 'base64', text: 'Base64' }
|
|
]);
|
|
}
|
|
else if (Array.isArray(val) || typeof val === 'object') {
|
|
if (section === 'model_routing' && key === 'model_pools') {
|
|
built = buildModelRoutingInput(section, key, val);
|
|
} else {
|
|
built = buildJsonInput(section, key, val);
|
|
}
|
|
}
|
|
else {
|
|
if (key === 'api_key' || key === 'app_key' || key === 'public_key') {
|
|
built = buildSecretInput(section, key, val);
|
|
} else {
|
|
built = buildTextInput(section, key, val);
|
|
}
|
|
}
|
|
|
|
if (built) {
|
|
inputWrapper.appendChild(built.node);
|
|
}
|
|
fieldCard.appendChild(inputWrapper);
|
|
|
|
// proxy.enabled (CF 自动刷新) 联动(toggle 本身始终可交互)
|
|
if (section === 'proxy' && key === 'enabled' && built && built.input) {
|
|
fieldCard.style.pointerEvents = 'auto';
|
|
fieldCard.style.opacity = '';
|
|
built.input.addEventListener('change', () => {
|
|
applyCfRefreshState(built.input.checked);
|
|
});
|
|
}
|
|
|
|
if (section === 'app' && key === 'public_enabled') {
|
|
fieldCard.classList.add('has-action');
|
|
const link = document.createElement('a');
|
|
link.href = '/login';
|
|
link.className = 'config-field-action flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity';
|
|
link.title = '功能玩法';
|
|
link.setAttribute('aria-label', '功能玩法');
|
|
link.innerHTML = `<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], [data-type="model-routing"][data-section]');
|
|
|
|
inputs.forEach(input => {
|
|
const s = input.dataset.section;
|
|
const k = input.dataset.key;
|
|
if (input.dataset.type === 'model-routing') {
|
|
if (!newConfig[s]) newConfig[s] = {};
|
|
newConfig[s][k] = { ...modelRoutingAssignments };
|
|
return;
|
|
}
|
|
let val = input.value;
|
|
|
|
if (input.type === 'checkbox') {
|
|
val = input.checked;
|
|
} else if (input.dataset.type === 'json') {
|
|
try { val = JSON.parse(val); } catch (e) { throw new Error(`无效的 JSON: ${getText(s, k).title}`); }
|
|
} else if (k === 'app_key' && val.trim() === '') {
|
|
throw new Error('app_key 不能为空(后台密码)');
|
|
} else if (NUMERIC_FIELDS.has(k)) {
|
|
if (val.trim() !== '' && !Number.isNaN(Number(val))) {
|
|
val = Number(val);
|
|
}
|
|
}
|
|
|
|
if (!newConfig[s]) newConfig[s] = {};
|
|
newConfig[s][k] = val;
|
|
});
|
|
|
|
if (newConfig.proxy && newConfig.proxy.enabled) {
|
|
const url = String(newConfig.proxy.flaresolverr_url || '').trim();
|
|
if (!url) {
|
|
showToast('启用自动刷新时必须填写 FlareSolverr 地址', 'error');
|
|
btn.disabled = false;
|
|
btn.innerText = originalText;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const res = await fetch('/v1/admin/config', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...buildAuthHeaders(apiKey)
|
|
},
|
|
body: JSON.stringify(newConfig)
|
|
});
|
|
|
|
if (res.ok) {
|
|
btn.innerText = '成功';
|
|
showToast('配置已保存', 'success');
|
|
setTimeout(() => {
|
|
btn.innerText = originalText;
|
|
btn.style.backgroundColor = '';
|
|
}, 2000);
|
|
} else {
|
|
showToast('保存失败', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('错误: ' + e.message, 'error');
|
|
} finally {
|
|
if (btn.innerText === '保存中...') {
|
|
btn.disabled = false;
|
|
btn.innerText = originalText;
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(text, btn) {
|
|
if (!text) return;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
btn.innerHTML = `<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;
|