AIClient-2-API/static/app/provider-manager.js
hex2077 f3761a4254 docs: 更新README文档并优化安装脚本和UI交互
- 更新三语言README,简化快速启动流程,强调Web UI可视化配置
- 移除详细的启动参数文档和模型协议关系图,简化文档结构
- 优化install-and-run脚本,添加--pull参数支持代码更新,统一依赖安装流程
- 改进Kiro服务的机器码生成机制,基于配置生成唯一ID而非MAC地址
- 优化OAuth授权UI,移除冗余的文件路径提示和回调地址警告
- 修复modal.js中OAuth凭据文件路径字段的兼容性检查
- 添加Qwen OAuth端点的初始化配置
- 优化service-manager降级逻辑注释说明
2025-12-20 15:23:56 +08:00

593 lines
No EOL
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 提供商管理功能模块
import { providerStats, updateProviderStats } from './constants.js';
import { showToast, formatUptime } from './utils.js';
import { fileUploadHandler } from './file-upload.js';
// 保存初始服务器时间和运行时间
let initialServerTime = null;
let initialUptime = null;
let initialLoadTime = null;
/**
* 加载系统信息
*/
async function loadSystemInfo() {
try {
const data = await window.apiClient.get('/system');
const nodeVersionEl = document.getElementById('nodeVersion');
const serverTimeEl = document.getElementById('serverTime');
const memoryUsageEl = document.getElementById('memoryUsage');
const uptimeEl = document.getElementById('uptime');
if (nodeVersionEl) nodeVersionEl.textContent = data.nodeVersion || '--';
if (memoryUsageEl) memoryUsageEl.textContent = data.memoryUsage || '--';
// 保存初始时间用于本地计算
if (data.serverTime && data.uptime !== undefined) {
initialServerTime = new Date(data.serverTime);
initialUptime = data.uptime;
initialLoadTime = Date.now();
}
// 初始显示
if (serverTimeEl) serverTimeEl.textContent = data.serverTime || '--';
if (uptimeEl) uptimeEl.textContent = data.uptime ? formatUptime(data.uptime) : '--';
} catch (error) {
console.error('Failed to load system info:', error);
}
}
/**
* 更新服务器时间和运行时间显示(本地计算)
*/
function updateTimeDisplay() {
if (!initialServerTime || initialUptime === null || !initialLoadTime) {
return;
}
const serverTimeEl = document.getElementById('serverTime');
const uptimeEl = document.getElementById('uptime');
// 计算经过的秒数
const elapsedSeconds = Math.floor((Date.now() - initialLoadTime) / 1000);
// 更新服务器时间
if (serverTimeEl) {
const currentServerTime = new Date(initialServerTime.getTime() + elapsedSeconds * 1000);
serverTimeEl.textContent = currentServerTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
// 更新运行时间
if (uptimeEl) {
const currentUptime = initialUptime + elapsedSeconds;
uptimeEl.textContent = formatUptime(currentUptime);
}
}
/**
* 加载提供商列表
*/
async function loadProviders() {
try {
const data = await window.apiClient.get('/providers');
renderProviders(data);
} catch (error) {
console.error('Failed to load providers:', error);
}
}
/**
* 渲染提供商列表
* @param {Object} providers - 提供商数据
*/
function renderProviders(providers) {
const container = document.getElementById('providersList');
if (!container) return;
container.innerHTML = '';
// 检查是否有提供商池数据
const hasProviders = Object.keys(providers).length > 0;
const statsGrid = document.querySelector('#providers .stats-grid');
// 始终显示统计卡片
if (statsGrid) statsGrid.style.display = 'grid';
// 定义所有支持的提供商显示顺序
const providerDisplayOrder = [
'gemini-cli-oauth',
'gemini-antigravity',
'openai-custom',
'claude-custom',
'claude-kiro-oauth',
'openai-qwen-oauth',
'openaiResponses-custom'
];
// 获取所有提供商类型并按指定顺序排序
// 优先显示预定义的所有提供商类型,即使某些提供商没有数据也要显示
let allProviderTypes;
if (hasProviders) {
// 合并预定义类型和实际存在的类型,确保显示所有预定义提供商
const actualProviderTypes = Object.keys(providers);
allProviderTypes = [...new Set([...providerDisplayOrder, ...actualProviderTypes])];
} else {
allProviderTypes = providerDisplayOrder;
}
const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type))
.concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type)));
// 计算总统计
let totalAccounts = 0;
let totalHealthy = 0;
// 按照排序后的提供商类型渲染
sortedProviderTypes.forEach((providerType) => {
const accounts = hasProviders ? providers[providerType] || [] : [];
const providerDiv = document.createElement('div');
providerDiv.className = 'provider-item';
providerDiv.dataset.providerType = providerType;
providerDiv.style.cursor = 'pointer';
const healthyCount = accounts.filter(acc => acc.isHealthy).length;
const totalCount = accounts.length;
const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0);
const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0);
totalAccounts += totalCount;
totalHealthy += healthyCount;
// 更新全局统计变量
if (!providerStats.providerTypeStats[providerType]) {
providerStats.providerTypeStats[providerType] = {
totalAccounts: 0,
healthyAccounts: 0,
totalUsage: 0,
totalErrors: 0,
lastUpdate: null
};
}
const typeStats = providerStats.providerTypeStats[providerType];
typeStats.totalAccounts = totalCount;
typeStats.healthyAccounts = healthyCount;
typeStats.totalUsage = usageCount;
typeStats.totalErrors = errorCount;
typeStats.lastUpdate = new Date().toISOString();
// 为无数据状态设置特殊样式
const isEmptyState = !hasProviders || totalCount === 0;
const statusClass = isEmptyState ? 'status-empty' : (healthyCount === totalCount ? 'status-healthy' : 'status-unhealthy');
const statusIcon = isEmptyState ? 'fa-info-circle' : (healthyCount === totalCount ? 'fa-check-circle' : 'fa-exclamation-triangle');
const statusText = isEmptyState ? '0/0 节点' : `${healthyCount}/${totalCount} 健康`;
providerDiv.innerHTML = `
<div class="provider-header">
<div class="provider-name">
<span class="provider-type-text">${providerType}</span>
</div>
<div class="provider-header-right">
${generateAuthButton(providerType)}
<div class="provider-status ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
<span>${statusText}</span>
</div>
</div>
</div>
<div class="provider-stats">
<div class="provider-stat">
<span class="provider-stat-label">总账户</span>
<span class="provider-stat-value">${totalCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">健康账户</span>
<span class="provider-stat-value">${healthyCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">使用次数</span>
<span class="provider-stat-value">${usageCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">错误次数</span>
<span class="provider-stat-value">${errorCount}</span>
</div>
</div>
`;
// 如果是空状态,添加特殊样式
if (isEmptyState) {
providerDiv.classList.add('empty-provider');
}
// 添加点击事件 - 整个提供商组都可以点击
providerDiv.addEventListener('click', (e) => {
e.preventDefault();
openProviderManager(providerType);
});
container.appendChild(providerDiv);
// 为授权按钮添加事件监听
const authBtn = providerDiv.querySelector('.generate-auth-btn');
if (authBtn) {
authBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡到父元素
handleGenerateAuthUrl(providerType);
});
}
});
// 更新统计卡片数据
const activeProviders = hasProviders ? Object.keys(providers).length : 0;
updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts);
}
/**
* 更新提供商统计信息
* @param {number} activeProviders - 活跃提供商数
* @param {number} healthyProviders - 健康提供商数
* @param {number} totalAccounts - 总账户数
*/
function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAccounts) {
// 更新全局统计变量
const newStats = {
activeProviders,
healthyProviders,
totalAccounts,
lastUpdateTime: new Date().toISOString()
};
updateProviderStats(newStats);
// 计算总请求数和错误数
let totalUsage = 0;
let totalErrors = 0;
Object.values(providerStats.providerTypeStats).forEach(typeStats => {
totalUsage += typeStats.totalUsage || 0;
totalErrors += typeStats.totalErrors || 0;
});
const finalStats = {
...newStats,
totalRequests: totalUsage,
totalErrors: totalErrors
};
updateProviderStats(finalStats);
// 修改:根据使用次数统计"活跃提供商"和"活动连接"
// "活跃提供商":统计有使用次数(usageCount > 0)的提供商类型数量
let activeProvidersByUsage = 0;
Object.entries(providerStats.providerTypeStats).forEach(([providerType, typeStats]) => {
if (typeStats.totalUsage > 0) {
activeProvidersByUsage++;
}
});
// "活动连接":统计所有提供商账户的使用次数总和
const activeConnections = totalUsage;
// 更新页面显示
const activeProvidersEl = document.getElementById('activeProviders');
const healthyProvidersEl = document.getElementById('healthyProviders');
const activeConnectionsEl = document.getElementById('activeConnections');
if (activeProvidersEl) activeProvidersEl.textContent = activeProvidersByUsage;
if (healthyProvidersEl) healthyProvidersEl.textContent = healthyProviders;
if (activeConnectionsEl) activeConnectionsEl.textContent = activeConnections;
// 打印调试信息到控制台
console.log('Provider Stats Updated:', {
activeProviders,
activeProvidersByUsage,
healthyProviders,
totalAccounts,
totalUsage,
totalErrors,
providerTypeStats: providerStats.providerTypeStats
});
}
/**
* 打开提供商管理模态框
* @param {string} providerType - 提供商类型
*/
async function openProviderManager(providerType) {
try {
const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`);
showProviderManagerModal(data);
} catch (error) {
console.error('Failed to load provider details:', error);
showToast('加载提供商详情失败', 'error');
}
}
/**
* 生成授权按钮HTML
* @param {string} providerType - 提供商类型
* @returns {string} 授权按钮HTML
*/
function generateAuthButton(providerType) {
// 只为支持OAuth的提供商显示授权按钮
const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth'];
if (!oauthProviders.includes(providerType)) {
return '';
}
return `
<button class="generate-auth-btn" title="生成OAuth授权链接">
<i class="fas fa-key"></i>
<span>生成授权</span>
</button>
`;
}
/**
* 处理生成授权链接
* @param {string} providerType - 提供商类型
*/
async function handleGenerateAuthUrl(providerType) {
try {
showToast('正在生成授权链接...', 'info');
// 使用 fileUploadHandler 中的 getProviderKey 获取目录名称
const providerDir = fileUploadHandler.getProviderKey(providerType);
const response = await window.apiClient.post(
`/providers/${encodeURIComponent(providerType)}/generate-auth-url`,
{
saveToConfigs: true,
providerDir: providerDir
}
);
if (response.success && response.authUrl) {
// 显示授权信息模态框
showAuthModal(response.authUrl, response.authInfo);
} else {
showToast('生成授权链接失败', 'error');
}
} catch (error) {
console.error('生成授权链接失败:', error);
showToast(`生成授权链接失败: ${error.message}`, 'error');
}
}
/**
* 获取提供商的授权文件路径
* @param {string} provider - 提供商类型
* @returns {string} 授权文件路径
*/
function getAuthFilePath(provider) {
const authFilePaths = {
'gemini-cli-oauth': '~/.gemini/oauth_creds.json',
'gemini-antigravity': '~/.antigravity/oauth_creds.json',
'openai-qwen-oauth': '~/.qwen/oauth_creds.json',
'claude-kiro-oauth': '~/.aws/sso/cache/kiro-auth-token.json'
};
return authFilePaths[provider] || '未知路径';
}
/**
* 显示授权信息模态框
* @param {string} authUrl - 授权URL
* @param {Object} authInfo - 授权信息
*/
function showAuthModal(authUrl, authInfo) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.display = 'flex';
// 获取授权文件路径
const authFilePath = getAuthFilePath(authInfo.provider);
let instructionsHtml = '';
if (authInfo.provider === 'openai-qwen-oauth') {
instructionsHtml = `
<div class="auth-instructions">
<h4>授权步骤:</h4>
<ol>
<li>点击下方按钮在浏览器中打开授权页面</li>
<li>完成授权后,系统会自动获取凭据文件</li>
<li>凭据文件可在上传配置管理中查看和管理</li>
<li>授权有效期: ${Math.floor(authInfo.expiresIn / 60)} 分钟</li>
</ol>
</div>
`;
} else {
instructionsHtml = `
<div class="auth-instructions">
<h4>授权步骤:</h4>
<ol>
<li>点击下方按钮在浏览器中打开授权页面</li>
<li>使用您的Google账号登录并授权</li>
<li>授权完成后,凭据文件会自动保存</li>
<li>凭据文件可在上传配置管理中查看和管理</li>
</ol>
</div>
`;
}
modal.innerHTML = `
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h3><i class="fas fa-key"></i>OAuth 授权</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="auth-info">
<p><strong>提供商:</strong> ${authInfo.provider}</p>
${instructionsHtml}
<div class="auth-url-section">
<label>授权链接:</label>
<div class="auth-url-container">
<input type="text" readonly value="${authUrl}" class="auth-url-input">
<button class="copy-btn" title="复制链接">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="modal-cancel">关闭</button>
<button class="open-auth-btn">
<i class="fas fa-external-link-alt"></i>
在浏览器中打开
</button>
</div>
</div>
`;
document.body.appendChild(modal);
// 关闭按钮事件
const closeBtn = modal.querySelector('.modal-close');
const cancelBtn = modal.querySelector('.modal-cancel');
[closeBtn, cancelBtn].forEach(btn => {
btn.addEventListener('click', () => {
modal.remove();
});
});
// 复制链接按钮
const copyBtn = modal.querySelector('.copy-btn');
copyBtn.addEventListener('click', () => {
const input = modal.querySelector('.auth-url-input');
input.select();
document.execCommand('copy');
showToast('授权链接已复制到剪贴板', 'success');
});
// 在浏览器中打开按钮
const openBtn = modal.querySelector('.open-auth-btn');
openBtn.addEventListener('click', () => {
// 使用子窗口打开,以便监听 URL 变化
const width = 600;
const height = 700;
const left = (window.screen.width - width) / 2 + 600;
const top = (window.screen.height - height) / 2;
const authWindow = window.open(
authUrl,
'OAuthAuthWindow',
`width=${width},height=${height},left=${left},top=${top},status=no,resizable=yes,scrollbars=yes`
);
// 监听 OAuth 成功事件,自动关闭窗口和模态框
const handleOAuthSuccess = () => {
if (authWindow && !authWindow.closed) {
authWindow.close();
}
modal.remove();
window.removeEventListener('oauth_success_event', handleOAuthSuccess);
};
window.addEventListener('oauth_success_event', handleOAuthSuccess);
if (authWindow) {
showToast('已打开授权窗口,请在窗口中完成操作', 'info');
// 添加手动输入回调 URL 的 UI
const urlSection = modal.querySelector('.auth-url-section');
if (urlSection && !modal.querySelector('.manual-callback-section')) {
const manualInputHtml = `
<div class="manual-callback-section" style="margin-top: 20px; padding: 15px; background: #fffbeb; border: 1px solid #fef3c7; border-radius: 8px;">
<h4 style="color: #92400e; margin-bottom: 8px;"><i class="fas fa-exclamation-circle"></i> 自动监听受阻?</h4>
<p style="font-size: 0.875rem; color: #b45309; margin-bottom: 10px;">如果授权窗口重定向后显示“无法访问”,请将该窗口地址栏的 <strong>完整 URL</strong> 粘贴到下方:</p>
<div class="auth-url-container" style="display: flex; gap: 5px;">
<input type="text" class="manual-callback-input" placeholder="粘贴回调 URL (包含 code=...)" style="flex: 1; padding: 8px; border: 1px solid #fcd34d; border-radius: 4px; background: white; color: black;">
<button class="btn btn-success apply-callback-btn" style="padding: 8px 15px; white-space: nowrap; background: #059669; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="fas fa-check"></i> 提交
</button>
</div>
</div>
`;
urlSection.insertAdjacentHTML('afterend', manualInputHtml);
}
const manualInput = modal.querySelector('.manual-callback-input');
const applyBtn = modal.querySelector('.apply-callback-btn');
// 处理回调 URL 的核心逻辑
const processCallback = (urlStr) => {
try {
// 尝试清理 URL有些用户可能会复制多余的文字
const cleanUrlStr = urlStr.trim().match(/https?:\/\/[^\s]+/)?.[0] || urlStr.trim();
const url = new URL(cleanUrlStr);
if (url.searchParams.has('code') || url.searchParams.has('token')) {
clearInterval(pollTimer);
// 构造本地可处理的 URL只修改 hostname保持原始 URL 的端口号不变
const localUrl = new URL(url.href);
localUrl.hostname = window.location.hostname;
localUrl.protocol = window.location.protocol;
showToast('正在完成授权...', 'info');
// 优先在子窗口中跳转(如果没关)
if (authWindow && !authWindow.closed) {
authWindow.location.href = localUrl.href;
} else {
// 备选方案:通过隐藏 iframe 或者是 fetch
const img = new Image();
img.src = localUrl.href;
}
} else {
showToast('该 URL 似乎不包含有效的授权代码', 'warning');
}
} catch (err) {
console.error('处理回调失败:', err);
showToast('无效的 URL 格式', 'error');
}
};
applyBtn.addEventListener('click', () => {
processCallback(manualInput.value);
});
// 启动定时器轮询子窗口 URL
const pollTimer = setInterval(() => {
try {
if (authWindow.closed) {
clearInterval(pollTimer);
return;
}
// 如果能读到说明回到了同域
const currentUrl = authWindow.location.href;
if (currentUrl && (currentUrl.includes('code=') || currentUrl.includes('token='))) {
processCallback(currentUrl);
}
} catch (e) {
// 跨域受限是正常的
}
}, 1000);
} else {
showToast('授权窗口被浏览器拦截,请允许弹出窗口', 'error');
}
});
}
export {
loadSystemInfo,
updateTimeDisplay,
loadProviders,
renderProviders,
updateProviderStatsDisplay,
openProviderManager,
showAuthModal
};