// ==UserScript== // @name CLI Proxy API 检测提供商是否可用 // @namespace cli-proxy-api-management // @version 6.1.0 // @description 上传 YAML 检测 OpenAI 兼容提供商,自动同步启用状态,支持断点续跑、日志筛选、CSV 导出、拖动与折叠 // @match http://127.0.0.1:8317/management.html* // @match http://localhost:8317/management.html* // @require https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js // @connect * // @grant GM_xmlhttpRequest // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ========================================================= * 配置 * ========================================================= */ const CONFIG = { panelId: 'tm-yaml-provider-checker', styleId: 'tm-yaml-provider-checker-style', settingsKey: 'tm-yaml-provider-checker-settings-v6', checkpointKey: 'tm-yaml-provider-checker-checkpoint-v6', // 单个 HTTP 请求超时时间 requestTimeout: 20000, // 每个 API Key 最多检测次数 requestAttempts: 2, // 同一个 API Key 重试前的等待时间 requestRetryDelay: 1200, // 不同 API Key 之间的等待时间 keyDelay: 250, // 不同提供商之间的等待时间 providerDelay: 500, // 点击页面开关后等待状态改变的最长时间 switchTimeout: 10000, // 页面开关状态轮询间隔 switchPollInterval: 100, // 开关状态改变后的额外等待时间 afterSwitchDelay: 700, /* * false: * GET /models 成功即可判定密钥可用。 * * true: * /models 成功后还需要发送最小聊天请求。 * 检测更严格,但可能产生少量费用。 */ requireChatTest: false, /* * 当 /models 返回 404、405 或 501 时, * 是否回退请求 /chat/completions。 */ fallbackToChat: true, /* * 402 余额不足是否算明确失败: * * false:归类为异常,保持开关状态。 * true:归类为失败,所有 Key 均失败时关闭。 */ paymentRequiredIsFailure: false }; const Result = Object.freeze({ OK: 'ok', FAILED: 'failed', ABNORMAL: 'abnormal', STOPPED: 'stopped' }); const LogType = Object.freeze({ SUCCESS: 'success', FAILED: 'failed', ABNORMAL: 'abnormal' }); let running = false; let stopRequested = false; let activeLogFilter = 'all'; /* * 文件内容及 API Key 仅保存在内存中, * 不会写入 localStorage。 */ let currentFile = null; let currentProviders = []; const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); /* ========================================================= * 通用工具 * ========================================================= */ function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function normalizeName(value) { return String(value ?? '') .trim() .replace(/\s+/g, ' ') .toLowerCase(); } function normalizeBaseUrl(value) { return String(value ?? '') .trim() .replace(/\/+$/, ''); } function joinUrl(baseUrl, path) { return ( normalizeBaseUrl(baseUrl) + '/' + String(path ?? '').replace(/^\/+/, '') ); } function safeJsonParse(text) { try { return JSON.parse(text); } catch { return null; } } function getResponseErrorMessage(text, fallback = '请求失败') { const body = safeJsonParse(text); if (body) { const message = body?.error?.message || body?.error?.msg || body?.message || body?.msg; if (message) { return String(message) .replace(/\s+/g, ' ') .slice(0, 240); } } const plainText = String(text || '') .replace(/\s+/g, ' ') .trim(); return plainText ? plainText.slice(0, 240) : fallback; } function maskKey(key) { const value = String(key || ''); if (value.length <= 8) { return '******'; } return `${value.slice(0, 4)}…${value.slice(-4)}`; } function getResultText(result) { return { success: '成功', failed: '失败', abnormal: '异常', all: '全部' }[result] || String(result || ''); } /* ========================================================= * 设置存储 * ========================================================= */ function loadSettings() { const defaults = { left: 20, top: 100, collapsed: false }; try { return { ...defaults, ...JSON.parse( localStorage.getItem(CONFIG.settingsKey) || '{}' ) }; } catch { return defaults; } } function saveSettings(partial) { const value = { ...loadSettings(), ...partial }; localStorage.setItem( CONFIG.settingsKey, JSON.stringify(value) ); return value; } /* ========================================================= * 断点 * ========================================================= */ function getFileIdentity(file) { return [ file?.name || '', file?.size || 0, file?.lastModified || 0 ].join('::'); } function loadCheckpoint() { try { return JSON.parse( localStorage.getItem(CONFIG.checkpointKey) || 'null' ); } catch { return null; } } function saveCheckpoint(data) { localStorage.setItem( CONFIG.checkpointKey, JSON.stringify({ ...data, updatedAt: Date.now() }) ); } function clearCheckpoint() { localStorage.removeItem(CONFIG.checkpointKey); } function getSuggestedStart(file, providerCount) { const checkpoint = loadCheckpoint(); if (!checkpoint) { return 1; } if (checkpoint.fileIdentity !== getFileIdentity(file)) { return 1; } const nextIndex = Number(checkpoint.nextIndex); if (!Number.isInteger(nextIndex)) { return 1; } /* * 允许总数 + 1,代表此文件上次已经全部完成。 */ return Math.min( Math.max(nextIndex, 1), Math.max(providerCount + 1, 1) ); } /* ========================================================= * YAML 解析 * ========================================================= */ function readFileAsText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject( new Error('无法读取 YAML 文件') ); reader.readAsText(file, 'UTF-8'); }); } function parseYamlProviders(text) { if (typeof jsyaml === 'undefined') { throw new Error( 'js-yaml 未加载,请检查脚本管理器的 @require 权限' ); } const root = jsyaml.load(text); if (!root || typeof root !== 'object') { throw new Error('YAML 根节点格式无效'); } const rawProviders = root['openai-compatibility']; if (!Array.isArray(rawProviders)) { throw new Error( 'YAML 中没有找到 openai-compatibility 数组' ); } const providers = []; rawProviders.forEach((raw, yamlIndex) => { if (!raw || typeof raw !== 'object') return; const name = String(raw.name || '').trim(); const baseUrl = normalizeBaseUrl(raw['base-url']); const rawKeyEntries = Array.isArray(raw['api-key-entries']) ? raw['api-key-entries'] : []; const apiKeys = rawKeyEntries .map(entry => { if (typeof entry === 'string') { return entry.trim(); } return String(entry?.['api-key'] || '').trim(); }) .filter(Boolean); const rawModels = Array.isArray(raw.models) ? raw.models : []; const models = rawModels .map(model => { if (typeof model === 'string') { return model.trim(); } /* * 实际请求优先使用 alias。 * alias 为空时使用模型 name。 */ const alias = String(model?.alias || '').trim(); const modelName = String(model?.name || '').trim(); return alias || modelName; }) .filter(Boolean); providers.push({ yamlIndex: yamlIndex + 1, name, baseUrl, apiKeys, models, yamlDisabled: raw.disabled === true, raw }); }); if (providers.length === 0) { throw new Error( 'openai-compatibility 中没有可识别的提供商' ); } return providers; } async function selectAndParseYaml() { return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = [ '.yaml', '.yml', 'application/yaml', 'text/yaml', 'text/x-yaml' ].join(','); input.style.display = 'none'; document.body.appendChild(input); let settled = false; const finish = () => { setTimeout(() => input.remove(), 0); }; input.addEventListener( 'change', async () => { if (settled) return; settled = true; try { const file = input.files?.[0]; if (!file) { resolve(null); finish(); return; } const text = await readFileAsText(file); const providers = parseYamlProviders(text); resolve({ file, providers }); } catch (error) { reject(error); } finally { finish(); } }, { once: true } ); /* * 用户取消选择文件时,部分浏览器不会触发 change。 */ window.addEventListener( 'focus', () => { setTimeout(() => { if (!settled && !input.files?.length) { settled = true; resolve(null); finish(); } }, 500); }, { once: true } ); input.click(); }); } /* ========================================================= * HTTP 请求 * ========================================================= */ function gmRequest(options) { return new Promise(resolve => { let completed = false; const finish = result => { if (completed) return; completed = true; resolve(result); }; try { GM_xmlhttpRequest({ method: options.method || 'GET', url: options.url, headers: options.headers || {}, data: options.data, timeout: options.timeout || CONFIG.requestTimeout, onload(response) { finish({ transport: 'load', status: Number(response.status || 0), statusText: response.statusText || '', text: response.responseText || '', finalUrl: response.finalUrl || options.url }); }, ontimeout() { finish({ transport: 'timeout', status: 0, statusText: '请求超时', text: '', finalUrl: options.url }); }, onerror(response) { finish({ transport: 'error', status: Number(response?.status || 0), statusText: response?.statusText || '网络连接失败', text: response?.responseText || '', finalUrl: options.url }); }, onabort() { finish({ transport: 'abort', status: 0, statusText: '请求已中止', text: '', finalUrl: options.url }); } }); } catch (error) { finish({ transport: 'exception', status: 0, statusText: error?.message || 'GM_xmlhttpRequest 调用失败', text: '', finalUrl: options.url }); } }); } function classifyHttpResponse(response) { if (response.transport === 'timeout') { return { result: Result.ABNORMAL, reason: '请求超时' }; } if ( response.transport === 'error' || response.transport === 'exception' || response.transport === 'abort' ) { return { result: Result.ABNORMAL, reason: response.statusText || '网络连接异常' }; } const status = response.status; if (status >= 200 && status < 300) { return { result: Result.OK, reason: `HTTP ${status}` }; } /* * 401 和 403 表示密钥无效或无权限, * 视为明确失败。 */ if (status === 401 || status === 403) { return { result: Result.FAILED, reason: `HTTP ${status}:` + getResponseErrorMessage( response.text, '密钥无效或无权限' ) }; } if (status === 402) { return { result: CONFIG.paymentRequiredIsFailure ? Result.FAILED : Result.ABNORMAL, reason: 'HTTP 402:' + getResponseErrorMessage( response.text, '余额不足或需要付费' ) }; } if (status === 429) { return { result: Result.ABNORMAL, reason: 'HTTP 429:' + getResponseErrorMessage( response.text, '请求过于频繁' ) }; } if (status >= 500) { return { result: Result.ABNORMAL, reason: `HTTP ${status}:` + getResponseErrorMessage( response.text, '服务端暂时异常' ) }; } /* * 404/405/501 对 /models 而言可能只是接口不支持, * 是否失败由上层决定是否回退聊天检测。 */ if ([404, 405, 501].includes(status)) { return { result: Result.ABNORMAL, unsupported: true, reason: `HTTP ${status}:` + getResponseErrorMessage( response.text, '当前接口可能不受支持' ) }; } /* * 400、422 等也可能是兼容性差异, * 不直接当成密钥确定失效。 */ return { result: Result.ABNORMAL, reason: `HTTP ${status || 0}:` + getResponseErrorMessage( response.text, response.statusText || '未知响应' ) }; } async function requestModels(provider, apiKey) { const url = joinUrl(provider.baseUrl, 'models'); const response = await gmRequest({ method: 'GET', url, headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` } }); const classification = classifyHttpResponse(response); if (classification.result !== Result.OK) { return { ...classification, endpoint: '/models', status: response.status }; } const body = safeJsonParse(response.text); if (!body) { return { result: Result.ABNORMAL, reason: '/models 返回的内容不是有效 JSON', endpoint: '/models', status: response.status }; } const modelIds = Array.isArray(body.data) ? body.data .map(item => String(item?.id || '').trim()) .filter(Boolean) : []; return { result: Result.OK, reason: modelIds.length > 0 ? `/models 成功,返回 ${modelIds.length} 个模型` : '/models 请求成功', endpoint: '/models', status: response.status, modelIds }; } function chooseChatModel(provider, modelIds = []) { if (provider.models.length > 0) { return provider.models[0]; } if (modelIds.length > 0) { return modelIds[0]; } return ''; } async function requestChat(provider, apiKey, model) { if (!model) { return { result: Result.ABNORMAL, reason: '没有可用于聊天检测的模型名称', endpoint: '/chat/completions' }; } const url = joinUrl( provider.baseUrl, 'chat/completions' ); const body = { model, messages: [ { role: 'user', content: 'Reply with OK.' } ], max_tokens: 1, temperature: 0, stream: false }; const response = await gmRequest({ method: 'POST', url, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, data: JSON.stringify(body) }); const classification = classifyHttpResponse(response); if (classification.result !== Result.OK) { return { ...classification, endpoint: '/chat/completions', status: response.status, model }; } const responseBody = safeJsonParse(response.text); if (!responseBody) { return { result: Result.ABNORMAL, reason: '/chat/completions 返回的内容不是有效 JSON', endpoint: '/chat/completions', status: response.status, model }; } /* * 一些兼容服务返回结构可能稍有不同。 * HTTP 成功且没有 error 字段,就认为调用成功。 */ if (responseBody.error) { return { result: Result.ABNORMAL, reason: getResponseErrorMessage( response.text, '聊天接口返回错误' ), endpoint: '/chat/completions', status: response.status, model }; } return { result: Result.OK, reason: `聊天请求成功,模型 ${model}`, endpoint: '/chat/completions', status: response.status, model }; } async function checkKeyOnce(provider, apiKey) { const modelsResult = await requestModels( provider, apiKey ); if (stopRequested) { return { result: Result.STOPPED, reason: '用户停止' }; } if (modelsResult.result === Result.OK) { if (!CONFIG.requireChatTest) { return modelsResult; } const model = chooseChatModel( provider, modelsResult.modelIds ); return requestChat(provider, apiKey, model); } if ( modelsResult.unsupported && CONFIG.fallbackToChat ) { const model = chooseChatModel(provider); return requestChat(provider, apiKey, model); } return modelsResult; } async function checkKey(provider, apiKey) { let lastResult = null; for ( let attempt = 1; attempt <= CONFIG.requestAttempts; attempt++ ) { if (stopRequested) { return { result: Result.STOPPED, reason: '用户停止' }; } lastResult = await checkKeyOnce( provider, apiKey ); if ( lastResult.result === Result.OK || lastResult.result === Result.FAILED || lastResult.result === Result.STOPPED ) { return { ...lastResult, attempts: attempt }; } if (attempt < CONFIG.requestAttempts) { await sleep(CONFIG.requestRetryDelay); } } return { ...(lastResult || { result: Result.ABNORMAL, reason: '没有获得检测结果' }), attempts: CONFIG.requestAttempts }; } async function checkProvider(provider) { if (!provider.name) { return { result: Result.ABNORMAL, reason: 'YAML 中缺少提供商 name', keyResults: [] }; } if (!provider.baseUrl) { return { result: Result.ABNORMAL, reason: 'YAML 中缺少 base-url', keyResults: [] }; } if (provider.apiKeys.length === 0) { return { result: Result.ABNORMAL, reason: 'YAML 中没有 API Key', keyResults: [] }; } const keyResults = []; for ( let keyIndex = 0; keyIndex < provider.apiKeys.length; keyIndex++ ) { if (stopRequested) { return { result: Result.STOPPED, reason: '用户停止', keyResults }; } const apiKey = provider.apiKeys[keyIndex]; const keyResult = await checkKey( provider, apiKey ); keyResults.push({ keyIndex: keyIndex + 1, maskedKey: maskKey(apiKey), ...keyResult }); /* * 任意一个 Key 可用,该提供商即判定成功, * 无须继续测试剩余 Key。 */ if (keyResult.result === Result.OK) { return { result: Result.OK, reason: `第 ${keyIndex + 1}/${provider.apiKeys.length} ` + `个密钥可用:${keyResult.reason}`, keyResults }; } if (keyResult.result === Result.STOPPED) { return { result: Result.STOPPED, reason: '用户停止', keyResults }; } if (keyIndex < provider.apiKeys.length - 1) { await sleep(CONFIG.keyDelay); } } const allFailed = keyResults.length === provider.apiKeys.length && keyResults.every( item => item.result === Result.FAILED ); if (allFailed) { return { result: Result.FAILED, reason: `${provider.apiKeys.length}/${provider.apiKeys.length} ` + '个密钥均明确无效', keyResults }; } const abnormalReasons = keyResults .filter(item => item.result === Result.ABNORMAL) .map(item => item.reason) .filter(Boolean); return { result: Result.ABNORMAL, reason: abnormalReasons[0] || '检测过程异常,无法确认是否可用', keyResults }; } /* ========================================================= * 页面开关同步 * ========================================================= */ function getProviderRows() { return [ ...document.querySelectorAll('tbody tr') ]; } function getRowProviderName(row) { const firstCell = row?.querySelector('td'); if (!firstCell) return ''; /* * 第一列通常有名称和掩码密钥。 * 优先查找名称类元素,失败时使用第一行文本。 */ const namedElement = firstCell.querySelector( '[class*="primaryName"]' ); if (namedElement?.textContent?.trim()) { return namedElement.textContent.trim(); } return String(firstCell.innerText || '') .split('\n') .map(item => item.trim()) .filter(Boolean)[0] || ''; } function findProviderRow(providerName) { const targetName = normalizeName(providerName); return getProviderRows().find(row => { return normalizeName( getRowProviderName(row) ) === targetName; }) || null; } function getRowSwitch(row) { return row?.querySelector( 'input[type="checkbox"][aria-label="启用"],' + 'input[type="checkbox"][aria-label="停用"]' ) || null; } function getSwitchEnabled(input) { if (!input) return null; /* * 当前页面表现: * checked=true、aria-label=停用:当前已启用; * checked=false、aria-label=启用:当前已停用。 */ if (input.getAttribute('aria-label') === '停用') { return true; } if (input.getAttribute('aria-label') === '启用') { return false; } return Boolean(input.checked); } async function waitForSwitchState( providerName, targetEnabled ) { const deadline = Date.now() + CONFIG.switchTimeout; while (Date.now() < deadline) { if (stopRequested) { return { ok: false, stopped: true, reason: '用户停止' }; } const currentRow = findProviderRow(providerName); const currentInput = getRowSwitch(currentRow); if ( currentInput && getSwitchEnabled(currentInput) === targetEnabled ) { return { ok: true, changed: true }; } await sleep(CONFIG.switchPollInterval); } return { ok: false, reason: `等待页面开关变为` + `${targetEnabled ? '开启' : '关闭'}超时` }; } async function setProviderEnabled( providerName, targetEnabled ) { const row = findProviderRow(providerName); if (!row) { return { ok: false, abnormal: true, action: '未修改', reason: '页面列表中未找到同名提供商' }; } const input = getRowSwitch(row); if (!input) { return { ok: false, abnormal: true, action: '未修改', reason: '页面提供商行中未找到开关' }; } const currentEnabled = getSwitchEnabled(input); if (currentEnabled === targetEnabled) { return { ok: true, changed: false, action: targetEnabled ? '已是开启' : '已是关闭', reason: '开关已经处于目标状态' }; } /* * 使用真实 click,让 React 事件和后端保存逻辑正常执行。 */ input.click(); const waitResult = await waitForSwitchState( providerName, targetEnabled ); if (!waitResult.ok) { return { ok: false, abnormal: true, stopped: waitResult.stopped, action: '修改失败', reason: waitResult.reason }; } await sleep(CONFIG.afterSwitchDelay); /* * 再次复核,避免短暂改变后被服务端回滚。 */ const finalRow = findProviderRow(providerName); const finalInput = getRowSwitch(finalRow); const finalEnabled = getSwitchEnabled(finalInput); if (finalEnabled !== targetEnabled) { return { ok: false, abnormal: true, action: '修改失败', reason: '开关状态被页面或服务端回滚' }; } return { ok: true, changed: true, action: targetEnabled ? '已开启' : '已关闭', reason: '页面开关状态已确认' }; } /* ========================================================= * 日志 * ========================================================= */ function getPanel() { return document.getElementById(CONFIG.panelId); } function addSystemLog(message) { const list = getPanel()?.querySelector( '.tm-log-list' ); if (!list) return; const item = document.createElement('div'); item.className = 'tm-log-item tm-system-log'; item.innerHTML = ` ${escapeHtml(new Date().toLocaleTimeString())} ${escapeHtml(message)} `; list.appendChild(item); list.scrollTop = list.scrollHeight; } function addResultLog({ index, name, result, action = '', detail = '' }) { const list = getPanel()?.querySelector( '.tm-log-list' ); if (!list) return; const item = document.createElement('div'); const time = new Date(); item.className = 'tm-log-item'; item.dataset.result = result; item.dataset.index = String(index ?? ''); item.dataset.name = String(name ?? ''); item.dataset.action = String(action ?? ''); item.dataset.detail = String(detail ?? ''); item.dataset.time = time.toISOString(); item.innerHTML = `
${escapeHtml(getResultText(result))} #${escapeHtml(index)} ${escapeHtml(name)}
${escapeHtml(detail)}
操作:${escapeHtml(action || '保持原状态')} · ${escapeHtml(time.toLocaleTimeString())}
`; list.appendChild(item); applyLogFilter(); updateLogFilterCounts(); list.scrollTop = list.scrollHeight; } function getLogCounts() { const items = [ ...(getPanel()?.querySelectorAll( '.tm-log-list ' + '.tm-log-item[data-result]' ) || []) ]; return { all: items.length, success: items.filter( item => item.dataset.result === 'success' ).length, failed: items.filter( item => item.dataset.result === 'failed' ).length, abnormal: items.filter( item => item.dataset.result === 'abnormal' ).length }; } function updateLogFilterCounts() { const panel = getPanel(); if (!panel) return; const counts = getLogCounts(); for (const [type, count] of Object.entries(counts)) { const target = panel.querySelector( `.tm-log-filter [data-count="${type}"]` ); if (target) { target.textContent = String(count); } } } function applyLogFilter() { const panel = getPanel(); if (!panel) return; const items = panel.querySelectorAll( '.tm-log-list .tm-log-item' ); items.forEach(item => { /* * 系统日志仅在“全部”筛选下显示。 */ if (!item.dataset.result) { item.classList.toggle( 'tm-filter-hidden', activeLogFilter !== 'all' ); return; } const visible = activeLogFilter === 'all' || item.dataset.result === activeLogFilter; item.classList.toggle( 'tm-filter-hidden', !visible ); }); panel.querySelectorAll( '.tm-log-filter button[data-filter]' ).forEach(button => { button.classList.toggle( 'active', button.dataset.filter === activeLogFilter ); }); } function setLogFilter(filter) { if ( !['all', 'success', 'failed', 'abnormal'] .includes(filter) ) { filter = 'all'; } activeLogFilter = filter; applyLogFilter(); } function clearLogs() { const list = getPanel()?.querySelector( '.tm-log-list' ); if (list) { list.innerHTML = ''; } updateLogFilterCounts(); } /* ========================================================= * CSV 导出 * ========================================================= */ function csvEscape(value) { return `"${String(value ?? '') .replace(/"/g, '""')}"`; } function formatExportTime(isoString) { if (!isoString) return ''; const date = new Date(isoString); if (Number.isNaN(date.getTime())) { return isoString; } return date.toLocaleString(); } function getExportLogItems() { const items = [ ...(getPanel()?.querySelectorAll( '.tm-log-list ' + '.tm-log-item[data-result]' ) || []) ]; if (activeLogFilter === 'all') { return items; } return items.filter( item => item.dataset.result === activeLogFilter ); } function makeExportFilename() { const now = new Date(); const pad = value => String(value).padStart(2, '0'); const timestamp = [ now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate()) ].join('') + '-' + [ pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds()) ].join(''); return ( `提供商检测结果-` + `${getResultText(activeLogFilter)}-` + `${timestamp}.csv` ); } function exportLogs() { const items = getExportLogItems(); if (items.length === 0) { alert( '当前筛选条件下没有可以导出的检测结果。' ); return; } const rows = [ [ 'YAML序号', '提供商名称', '检测结果', '开关操作', '详细信息', '检测时间' ] ]; items.forEach(item => { rows.push([ item.dataset.index || '', item.dataset.name || '', getResultText(item.dataset.result), item.dataset.action || '', item.dataset.detail || '', formatExportTime(item.dataset.time) ]); }); const csv = rows .map(row => row.map(csvEscape).join(',')) .join('\r\n'); /* * UTF-8 BOM 避免 Excel 打开时中文乱码。 */ const blob = new Blob( ['\uFEFF', csv], { type: 'text/csv;charset=utf-8' } ); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = makeExportFilename(); link.style.display = 'none'; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => { URL.revokeObjectURL(url); }, 1000); addSystemLog( `已导出当前筛选下的 ${items.length} 条结果` ); } /* ========================================================= * 状态与运行控制 * ========================================================= */ function setStatus(message, type = '') { const element = getPanel()?.querySelector( '.tm-status' ); if (!element) return; element.textContent = message; element.dataset.type = type; } function setProgress(current, total) { const element = getPanel()?.querySelector( '.tm-progress' ); if (!element) return; if (!total) { element.style.width = '0%'; return; } const percent = Math.min( 100, Math.max(0, current / total * 100) ); element.style.width = `${percent}%`; } function setRunningState(value) { running = value; const panel = getPanel(); if (!panel) return; panel.querySelector( '.tm-start' ).disabled = value; panel.querySelector( '.tm-file' ).disabled = value; panel.querySelector( '.tm-use-checkpoint' ).disabled = value; panel.querySelector( '.tm-start-index' ).disabled = value; panel.querySelector( '.tm-stop' ).disabled = !value; } function updateFileInformation() { const panel = getPanel(); if (!panel) return; const fileText = panel.querySelector( '.tm-file-info' ); const startInput = panel.querySelector( '.tm-start-index' ); if (!currentFile || currentProviders.length === 0) { fileText.textContent = '尚未选择 YAML'; return; } const suggestedStart = getSuggestedStart( currentFile, currentProviders.length ); fileText.textContent = `${currentFile.name} · ` + `${currentProviders.length} 个提供商 · ` + `建议从第 ${suggestedStart} 项开始`; /* * 选择新文件时自动填入建议断点, * 用户仍然可以手动修改。 */ startInput.value = String(suggestedStart); startInput.max = String( currentProviders.length ); } async function chooseYaml() { if (running) return; try { setStatus('请选择 YAML 文件…'); const selection = await selectAndParseYaml(); if (!selection) { setStatus('已取消选择文件'); return; } currentFile = selection.file; currentProviders = selection.providers; updateFileInformation(); setStatus( `已解析 ${currentProviders.length} 个提供商`, 'success' ); addSystemLog( `已载入 ${currentFile.name},` + `共 ${currentProviders.length} 个提供商` ); } catch (error) { currentFile = null; currentProviders = []; updateFileInformation(); setStatus( `YAML 解析失败:${error?.message || error}`, 'error' ); addSystemLog( `YAML 解析失败:${error?.message || error}` ); alert( `YAML 解析失败:\n` + `${error?.message || error}` ); } } function useCheckpoint() { if (!currentFile || currentProviders.length === 0) { alert('请先选择并解析 YAML 文件。'); return; } const checkpoint = loadCheckpoint(); if ( !checkpoint || checkpoint.fileIdentity !== getFileIdentity(currentFile) ) { alert( '没有找到当前 YAML 文件的有效断点。\n' + '文件名、大小和最后修改时间必须与上次一致。' ); return; } const suggestedStart = getSuggestedStart( currentFile, currentProviders.length ); const input = getPanel()?.querySelector( '.tm-start-index' ); if (input) { input.value = String(suggestedStart); } if (suggestedStart > currentProviders.length) { setStatus('该 YAML 上次已经全部检测完成'); } else { setStatus( `已获取断点,将从第 ${suggestedStart} 项开始` ); } } async function startDetection() { if (running) return; const confirmed1 = window.confirm( '开始检测前,请确保当前已打开“AI 提供商”列表页面。\n\n' + '检测期间请不要切换管理中心页面、刷新或关闭当前页面,' + '否则脚本可能无法同步提供商的开启/关闭状态。\n\n' + '点击“确定”继续检测;点击“取消”不执行检测。' ); if (!confirmed1) { setStatus('已取消检测:请先打开 AI 提供商列表'); return; } if (!currentFile || currentProviders.length === 0) { await chooseYaml(); if ( !currentFile || currentProviders.length === 0 ) { return; } } const startInput = getPanel()?.querySelector( '.tm-start-index' ); let startIndex = Number(startInput?.value); if ( !Number.isInteger(startIndex) || startIndex < 1 || startIndex > currentProviders.length ) { alert( `开始位置必须是 1 到 ` + `${currentProviders.length} 之间的整数。` ); return; } const confirmed = confirm( `即将从 YAML 第 ${startIndex} 项开始检测。\n\n` + `成功:开启提供商。\n` + `失败:关闭提供商。\n` + `异常:保持原开关状态。\n\n` + `是否继续?` ); if (!confirmed) return; stopRequested = false; setRunningState(true); const total = currentProviders.length; let processed = 0; let successCount = 0; let failedCount = 0; let abnormalCount = 0; setProgress(startIndex - 1, total); addSystemLog( `开始检测:第 ${startIndex} 至 ${total} 项` ); try { for ( let arrayIndex = startIndex - 1; arrayIndex < total; arrayIndex++ ) { if (stopRequested) break; const provider = currentProviders[arrayIndex]; const displayIndex = arrayIndex + 1; setStatus( `正在检测 ${displayIndex}/${total}:` + `${provider.name || '未命名'}` ); let checkResult; try { checkResult = await checkProvider(provider); } catch (error) { checkResult = { result: Result.ABNORMAL, reason: `检测代码异常:` + `${error?.message || error}`, keyResults: [] }; } if ( stopRequested || checkResult.result === Result.STOPPED ) { break; } let logType; let action; let detail = checkResult.reason; if (checkResult.result === Result.OK) { let switchResult; try { switchResult = await setProviderEnabled( provider.name, true ); } catch (error) { switchResult = { ok: false, abnormal: true, action: '修改失败', reason: `开关操作异常:` + `${error?.message || error}` }; } if (stopRequested || switchResult.stopped) { break; } if (switchResult.ok) { logType = LogType.SUCCESS; successCount++; action = switchResult.action; detail = `${checkResult.reason};` + `${switchResult.reason}`; } else { logType = LogType.ABNORMAL; abnormalCount++; action = switchResult.action || '保持原状态'; detail = `${checkResult.reason};` + `${switchResult.reason}`; } } else if ( checkResult.result === Result.FAILED ) { let switchResult; try { switchResult = await setProviderEnabled( provider.name, false ); } catch (error) { switchResult = { ok: false, abnormal: true, action: '修改失败', reason: `开关操作异常:` + `${error?.message || error}` }; } if (stopRequested || switchResult.stopped) { break; } if (switchResult.ok) { logType = LogType.FAILED; failedCount++; action = switchResult.action; detail = `${checkResult.reason};` + `${switchResult.reason}`; } else { /* * API 检测明确失败,但页面关闭未完成, * 整体归为异常,避免误认为已经关闭。 */ logType = LogType.ABNORMAL; abnormalCount++; action = switchResult.action || '关闭失败'; detail = `${checkResult.reason};` + `${switchResult.reason}`; } } else { logType = LogType.ABNORMAL; abnormalCount++; action = '保持原状态'; } addResultLog({ index: displayIndex, name: provider.name || `未命名-${displayIndex}`, result: logType, action, detail }); processed++; /* * 当前项完整处理结束后才保存下一项断点。 */ saveCheckpoint({ fileIdentity: getFileIdentity(currentFile), fileName: currentFile.name, total, nextIndex: displayIndex + 1, lastCompletedIndex: displayIndex }); setProgress(displayIndex, total); setStatus( `完成 ${displayIndex}/${total};` + `成功 ${successCount},` + `失败 ${failedCount},` + `异常 ${abnormalCount}` ); if (displayIndex < total) { await sleep(CONFIG.providerDelay); } } const checkpoint = loadCheckpoint(); const lastCompletedIndex = checkpoint?.fileIdentity === getFileIdentity(currentFile) ? Number( checkpoint.lastCompletedIndex || 0 ) : startIndex - 1; if (stopRequested) { const nextIndex = Math.min( lastCompletedIndex + 1, total ); setStatus( `已停止;下次可从第 ${nextIndex} 项开始。` + `本次成功 ${successCount},` + `失败 ${failedCount},` + `异常 ${abnormalCount}`, 'warning' ); addSystemLog( `用户停止。下次可从第 ${nextIndex} 项开始` ); } else { saveCheckpoint({ fileIdentity: getFileIdentity(currentFile), fileName: currentFile.name, total, nextIndex: total + 1, lastCompletedIndex: total, completed: true }); setProgress(total, total); setStatus( `检测完成;成功 ${successCount},` + `失败 ${failedCount},` + `异常 ${abnormalCount}`, 'success' ); addSystemLog( `检测完成:成功 ${successCount},` + `失败 ${failedCount},` + `异常 ${abnormalCount}` ); } } catch (error) { setStatus( `任务异常终止:${error?.message || error}`, 'error' ); addSystemLog( `任务异常终止:${error?.message || error}` ); console.error( '[YAML Provider Checker]', error ); } finally { setRunningState(false); stopRequested = false; } } function requestStop() { if (!running) return; const confirmed = confirm( '确定停止当前检测任务吗?\n\n' + '当前提供商处理完成后会保存断点,' + '下次可从断点继续。' ); if (!confirmed) return; stopRequested = true; setStatus('正在停止,请等待当前请求结束…', 'warning'); } /* ========================================================= * 样式 * ========================================================= */ function addStyle() { if (document.getElementById(CONFIG.styleId)) { return; } const style = document.createElement('style'); style.id = CONFIG.styleId; style.textContent = ` #${CONFIG.panelId} { position: fixed; z-index: 2147483647; width: 390px; max-width: calc(100vw - 20px); color: #222; background: rgba(255, 255, 255, .97); border: 1px solid #d5d5d5; border-radius: 12px; box-shadow: 0 10px 36px rgba(0, 0, 0, .24); overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 13px; backdrop-filter: blur(10px); } #${CONFIG.panelId} * { box-sizing: border-box; } #${CONFIG.panelId} button, #${CONFIG.panelId} input { font: inherit; } #${CONFIG.panelId} .tm-header { height: 42px; padding: 0 8px 0 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; background: #f4f4f4; border-bottom: 1px solid #ddd; cursor: move; user-select: none; } #${CONFIG.panelId} .tm-title { flex: 1; min-width: 0; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #${CONFIG.panelId} .tm-collapse { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: #555; background: transparent; cursor: pointer; font-size: 20px; line-height: 26px; } #${CONFIG.panelId} .tm-collapse:hover { background: rgba(0, 0, 0, .08); } #${CONFIG.panelId} .tm-body { padding: 12px; } #${CONFIG.panelId}.tm-collapsed { width: 220px; } #${CONFIG.panelId}.tm-collapsed .tm-body { display: none; } #${CONFIG.panelId}.tm-collapsed .tm-header { border-bottom: 0; } #${CONFIG.panelId} .tm-file-row { display: flex; gap: 7px; margin-bottom: 7px; } #${CONFIG.panelId} .tm-file { flex: 1; min-height: 34px; padding: 6px 10px; color: #fff; background: #2463eb; border: 1px solid #2463eb; border-radius: 7px; cursor: pointer; } #${CONFIG.panelId} .tm-file:hover:not(:disabled) { background: #1d4ed8; } #${CONFIG.panelId} .tm-file-info { min-height: 34px; margin-bottom: 8px; padding: 7px 9px; color: #555; background: #f5f5f5; border-radius: 7px; line-height: 1.5; overflow-wrap: anywhere; } #${CONFIG.panelId} .tm-start-row { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; } #${CONFIG.panelId} .tm-start-label { display: flex; flex: 1; align-items: center; gap: 5px; white-space: nowrap; } #${CONFIG.panelId} .tm-start-index { width: 62px; height: 32px; padding: 4px 7px; color: #222; background: #fff; border: 1px solid #bbb; border-radius: 6px; } #${CONFIG.panelId} .tm-use-checkpoint { flex: none; height: 32px; padding: 4px 9px; white-space: nowrap; color: #333; background: #fff; border: 1px solid #bbb; border-radius: 6px; cursor: pointer; font-size: 12px; } #${CONFIG.panelId} .tm-main-actions { display: grid; grid-template-columns: 1fr 88px; gap: 7px; margin-bottom: 8px; } #${CONFIG.panelId} .tm-start, #${CONFIG.panelId} .tm-stop { min-height: 34px; padding: 6px 10px; border-radius: 7px; cursor: pointer; } #${CONFIG.panelId} .tm-start { color: #fff; background: #16803d; border: 1px solid #16803d; } #${CONFIG.panelId} .tm-start:hover:not(:disabled) { background: #116b33; } #${CONFIG.panelId} .tm-stop { color: #fff; background: #c7392f; border: 1px solid #c7392f; } #${CONFIG.panelId} .tm-stop:hover:not(:disabled) { background: #aa2f27; } #${CONFIG.panelId} button:disabled, #${CONFIG.panelId} input:disabled { opacity: .5; cursor: not-allowed; } #${CONFIG.panelId} .tm-status { min-height: 40px; padding: 8px 9px; color: #555; background: #f5f5f5; border-radius: 7px; line-height: 1.5; overflow-wrap: anywhere; } #${CONFIG.panelId} .tm-status[data-type="success"] { color: #116b33; background: #eaf8ef; } #${CONFIG.panelId} .tm-status[data-type="warning"] { color: #8a5c00; background: #fff6dc; } #${CONFIG.panelId} .tm-status[data-type="error"] { color: #a32820; background: #fdeceb; } #${CONFIG.panelId} .tm-progress-track { height: 5px; margin: 8px 0 10px; background: #e4e4e4; border-radius: 99px; overflow: hidden; } #${CONFIG.panelId} .tm-progress { width: 0; height: 100%; background: #2463eb; transition: width .2s ease; } #${CONFIG.panelId} .tm-log-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; } #${CONFIG.panelId} .tm-log-title { font-weight: 700; } #${CONFIG.panelId} .tm-log-actions { display: flex; align-items: center; gap: 6px; } #${CONFIG.panelId} .tm-log-actions button { min-width: auto; padding: 4px 9px; color: #333; background: #fff; border: 1px solid #bbb; border-radius: 6px; cursor: pointer; font-size: 12px; } #${CONFIG.panelId} .tm-export-log { color: #fff !important; background: #2463eb !important; border-color: #2463eb !important; } #${CONFIG.panelId} .tm-export-log:hover { background: #1d4ed8 !important; } #${CONFIG.panelId} .tm-log-filter { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; padding-bottom: 2px; overflow-x: auto; white-space: nowrap; } #${CONFIG.panelId} .tm-log-filter-label { flex: none; color: #666; } #${CONFIG.panelId} .tm-log-filter button { flex: none; min-width: auto; padding: 4px 7px; color: #444; background: #fff; border: 1px solid #bbb; border-radius: 6px; cursor: pointer; font-size: 12px; } #${CONFIG.panelId} .tm-log-filter button.active { color: #fff; background: #2463eb; border-color: #2463eb; } #${CONFIG.panelId} .tm-log-list { max-height: 230px; padding: 4px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 7px; overflow: auto; } #${CONFIG.panelId} .tm-log-item { margin-bottom: 5px; padding: 7px 8px; background: #fff; border-left: 3px solid #aaa; border-radius: 5px; line-height: 1.45; overflow-wrap: anywhere; } #${CONFIG.panelId} .tm-log-item:last-child { margin-bottom: 0; } #${CONFIG.panelId} .tm-log-item[data-result="success"] { border-left-color: #16803d; } #${CONFIG.panelId} .tm-log-item[data-result="failed"] { border-left-color: #c7392f; } #${CONFIG.panelId} .tm-log-item[data-result="abnormal"] { border-left-color: #d18a00; } #${CONFIG.panelId} .tm-filter-hidden { display: none; } #${CONFIG.panelId} .tm-log-item-title { display: flex; align-items: flex-start; gap: 6px; font-weight: 700; } #${CONFIG.panelId} .tm-log-result { flex: none; } #${CONFIG.panelId} .tm-log-provider { min-width: 0; } #${CONFIG.panelId} .tm-log-item[data-result="success"] .tm-log-result { color: #16803d; } #${CONFIG.panelId} .tm-log-item[data-result="failed"] .tm-log-result { color: #c7392f; } #${CONFIG.panelId} .tm-log-item[data-result="abnormal"] .tm-log-result { color: #b47400; } #${CONFIG.panelId} .tm-log-detail { margin-top: 3px; color: #444; } #${CONFIG.panelId} .tm-log-meta { margin-top: 3px; color: #888; font-size: 11px; } #${CONFIG.panelId} .tm-system-log { color: #666; background: #fafafa; border-left-color: #888; } #${CONFIG.panelId} .tm-log-time { margin-right: 5px; color: #999; font-size: 11px; } @media (prefers-color-scheme: dark) { #${CONFIG.panelId} { color: #eee; background: rgba(30, 30, 30, .97); border-color: #555; } #${CONFIG.panelId} .tm-header { background: #292929; border-color: #555; } #${CONFIG.panelId} .tm-collapse { color: #ddd; } #${CONFIG.panelId} .tm-file-info, #${CONFIG.panelId} .tm-status, #${CONFIG.panelId} .tm-log-list { color: #ddd; background: #252525; border-color: #555; } #${CONFIG.panelId} .tm-start-index, #${CONFIG.panelId} .tm-use-checkpoint, #${CONFIG.panelId} .tm-log-actions button, #${CONFIG.panelId} .tm-log-filter button { color: #eee; background: #333; border-color: #666; } #${CONFIG.panelId} .tm-log-item { color: #eee; background: #303030; } #${CONFIG.panelId} .tm-log-detail { color: #ddd; } #${CONFIG.panelId} .tm-system-log { color: #ccc; background: #2b2b2b; } } `; document.head.appendChild(style); } /* ========================================================= * 拖动和折叠 * ========================================================= */ function keepPanelInViewport(panel) { const rect = panel.getBoundingClientRect(); const maxLeft = Math.max( 0, window.innerWidth - rect.width ); const maxTop = Math.max( 0, window.innerHeight - rect.height ); const left = Math.min( Math.max(rect.left, 0), maxLeft ); const top = Math.min( Math.max(rect.top, 0), maxTop ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; saveSettings({ left, top }); } function setupDragging(panel) { const header = panel.querySelector('.tm-header'); let dragging = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; header.addEventListener('mousedown', event => { if ( event.button !== 0 || event.target.closest('button') ) { return; } dragging = true; startX = event.clientX; startY = event.clientY; const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; document.body.style.userSelect = 'none'; event.preventDefault(); }); document.addEventListener('mousemove', event => { if (!dragging) return; const rect = panel.getBoundingClientRect(); const maxLeft = Math.max( 0, window.innerWidth - rect.width ); const maxTop = Math.max( 0, window.innerHeight - rect.height ); const left = Math.min( Math.max( startLeft + event.clientX - startX, 0 ), maxLeft ); const top = Math.min( Math.max( startTop + event.clientY - startY, 0 ), maxTop ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.style.userSelect = ''; const rect = panel.getBoundingClientRect(); saveSettings({ left: rect.left, top: rect.top }); }); window.addEventListener('resize', () => { keepPanelInViewport(panel); }); } function toggleCollapsed() { const panel = getPanel(); if (!panel) return; const collapsed = !panel.classList.contains( 'tm-collapsed' ); panel.classList.toggle( 'tm-collapsed', collapsed ); const button = panel.querySelector( '.tm-collapse' ); button.textContent = collapsed ? '+' : '−'; button.title = collapsed ? '展开检测面板' : '折叠检测面板'; saveSettings({ collapsed }); setTimeout(() => { keepPanelInViewport(panel); }, 0); } /* ========================================================= * 面板 * ========================================================= */ function createPanel() { if (getPanel()) return; addStyle(); const settings = loadSettings(); const panel = document.createElement('section'); panel.id = CONFIG.panelId; panel.style.left = `${settings.left}px`; panel.style.top = `${settings.top}px`; panel.classList.toggle( 'tm-collapsed', Boolean(settings.collapsed) ); panel.innerHTML = `
YAML 提供商检测
尚未选择 YAML
请选择 YAML 文件
检测日志
筛选:
`; document.body.appendChild(panel); panel .querySelector('.tm-collapse') .addEventListener( 'click', event => { event.stopPropagation(); toggleCollapsed(); } ); panel .querySelector('.tm-file') .addEventListener( 'click', chooseYaml ); panel .querySelector('.tm-use-checkpoint') .addEventListener( 'click', useCheckpoint ); panel .querySelector('.tm-start') .addEventListener( 'click', startDetection ); panel .querySelector('.tm-stop') .addEventListener( 'click', requestStop ); panel .querySelector('.tm-clear-log') .addEventListener( 'click', event => { event.stopPropagation(); clearLogs(); } ); panel .querySelector('.tm-export-log') .addEventListener( 'click', event => { event.stopPropagation(); exportLogs(); } ); panel .querySelectorAll( '.tm-log-filter button[data-filter]' ) .forEach(button => { button.addEventListener( 'click', () => { setLogFilter( button.dataset.filter ); } ); }); setupDragging(panel); setTimeout(() => { keepPanelInViewport(panel); }, 0); } /* ========================================================= * 初始化及 SPA 兼容 * ========================================================= */ function initialize() { createPanel(); } if (document.readyState === 'loading') { document.addEventListener( 'DOMContentLoaded', initialize, { once: true } ); } else { initialize(); } /* * 页面是 Hash SPA。即使页面内部切换路由, * 面板也会继续存在;若被页面重绘意外删除则重新创建。 */ const observer = new MutationObserver(() => { if (!getPanel()) { createPanel(); } }); observer.observe( document.documentElement, { childList: true, subtree: true } ); })();