AIClient-2-API/static/app/modal.js
hex2077 b364341f67 feat(provider): 增强健康检测和自动配置关联功能
- 新增 provider-utils.js 公共模块,提取共用工具函数
- 添加提供商健康检测 API 端点和 UI 按钮
- 实现配置文件自动关联功能(启动时和手动触发)
- 支持 gemini-antigravity 新提供商类型
- 增强健康检测结果记录(时间、模型、错误信息)
- 添加提供商列表分页功能
- 修复 OpenAIResponsesConverter 中 systemMessages 未定义问题
- 更新默认健康检测模型配置
2025-12-10 15:18:42 +08:00

1381 lines
No EOL
56 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 { showToast, getFieldLabel, getProviderTypeFields } from './utils.js';
import { handleProviderPasswordToggle } from './event-handlers.js';
// 分页配置
const PROVIDERS_PER_PAGE = 5;
let currentPage = 1;
let currentProviders = [];
let currentProviderType = '';
let cachedModels = []; // 缓存模型列表
/**
* 显示提供商管理模态框
* @param {Object} data - 提供商数据
*/
function showProviderManagerModal(data) {
const { providerType, providers, totalCount, healthyCount } = data;
// 保存当前数据用于分页
currentProviders = providers;
currentProviderType = providerType;
currentPage = 1;
cachedModels = [];
// 移除已存在的模态框
const existingModal = document.querySelector('.provider-modal');
if (existingModal) {
// 清理事件监听器
if (existingModal.cleanup) {
existingModal.cleanup();
}
existingModal.remove();
}
const totalPages = Math.ceil(providers.length / PROVIDERS_PER_PAGE);
// 创建模态框
const modal = document.createElement('div');
modal.className = 'provider-modal';
modal.setAttribute('data-provider-type', providerType);
modal.innerHTML = `
<div class="provider-modal-content">
<div class="provider-modal-header">
<h3><i class="fas fa-cogs"></i> 管理 ${providerType} 提供商配置</h3>
<button class="modal-close" onclick="window.closeProviderModal(this)">
<i class="fas fa-times"></i>
</button>
</div>
<div class="provider-modal-body">
<div class="provider-summary">
<div class="provider-summary-item">
<span class="label">总账户数:</span>
<span class="value">${totalCount}</span>
</div>
<div class="provider-summary-item">
<span class="label">健康账户:</span>
<span class="value">${healthyCount}</span>
</div>
<div class="provider-summary-actions">
<button class="btn btn-success" onclick="window.showAddProviderForm('${providerType}')">
<i class="fas fa-plus"></i> 添加新提供商
</button>
<button class="btn btn-warning" onclick="window.resetAllProvidersHealth('${providerType}')" title="将所有节点的健康状态重置为健康">
<i class="fas fa-heartbeat"></i> 重置为健康
</button>
<button class="btn btn-info" onclick="window.performHealthCheck('${providerType}')" title="对所有节点执行健康检测">
<i class="fas fa-stethoscope"></i> 健康检测
</button>
</div>
</div>
${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''}
<div class="provider-list" id="providerList">
${renderProviderListPaginated(providers, 1)}
</div>
${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''}
</div>
</div>
`;
// 添加到页面
document.body.appendChild(modal);
// 添加模态框事件监听
addModalEventListeners(modal);
// 先获取该提供商类型的模型列表只调用一次API
const pageProviders = providers.slice(0, PROVIDERS_PER_PAGE);
loadModelsForProviderType(providerType, pageProviders);
}
/**
* 渲染分页控件
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数
* @param {number} totalItems - 总条目数
* @param {string} position - 位置标识 (top/bottom)
* @returns {string} HTML字符串
*/
function renderPagination(page, totalPages, totalItems, position = 'top') {
const startItem = (page - 1) * PROVIDERS_PER_PAGE + 1;
const endItem = Math.min(page * PROVIDERS_PER_PAGE, totalItems);
// 生成页码按钮
let pageButtons = '';
const maxVisiblePages = 5;
let startPage = Math.max(1, page - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
if (startPage > 1) {
pageButtons += `<button class="page-btn" onclick="window.goToProviderPage(1)">1</button>`;
if (startPage > 2) {
pageButtons += `<span class="page-ellipsis">...</span>`;
}
}
for (let i = startPage; i <= endPage; i++) {
pageButtons += `<button class="page-btn ${i === page ? 'active' : ''}" onclick="window.goToProviderPage(${i})">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pageButtons += `<span class="page-ellipsis">...</span>`;
}
pageButtons += `<button class="page-btn" onclick="window.goToProviderPage(${totalPages})">${totalPages}</button>`;
}
return `
<div class="pagination-container ${position}" data-position="${position}">
<div class="pagination-info">
显示 ${startItem}-${endItem} / 共 ${totalItems}
</div>
<div class="pagination-controls">
<button class="page-btn nav-btn" onclick="window.goToProviderPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
<i class="fas fa-chevron-left"></i>
</button>
${pageButtons}
<button class="page-btn nav-btn" onclick="window.goToProviderPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="pagination-jump">
<span>跳转到</span>
<input type="number" min="1" max="${totalPages}" value="${page}"
onkeypress="if(event.key==='Enter')window.goToProviderPage(parseInt(this.value))"
class="page-jump-input">
<span>页</span>
</div>
</div>
`;
}
/**
* 跳转到指定页
* @param {number} page - 目标页码
*/
function goToProviderPage(page) {
const totalPages = Math.ceil(currentProviders.length / PROVIDERS_PER_PAGE);
// 验证页码范围
if (page < 1) page = 1;
if (page > totalPages) page = totalPages;
currentPage = page;
// 更新提供商列表
const providerList = document.getElementById('providerList');
if (providerList) {
providerList.innerHTML = renderProviderListPaginated(currentProviders, page);
}
// 更新分页控件
const paginationContainers = document.querySelectorAll('.pagination-container');
paginationContainers.forEach(container => {
const position = container.getAttribute('data-position');
container.outerHTML = renderPagination(page, totalPages, currentProviders.length, position);
});
// 滚动到顶部
const modalBody = document.querySelector('.provider-modal-body');
if (modalBody) {
modalBody.scrollTop = 0;
}
// 为当前页的提供商加载模型列表
const startIndex = (page - 1) * PROVIDERS_PER_PAGE;
const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, currentProviders.length);
const pageProviders = currentProviders.slice(startIndex, endIndex);
// 如果已缓存模型列表,直接使用
if (cachedModels.length > 0) {
pageProviders.forEach(provider => {
renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []);
});
} else {
loadModelsForProviderType(currentProviderType, pageProviders);
}
}
/**
* 渲染分页后的提供商列表
* @param {Array} providers - 提供商数组
* @param {number} page - 当前页码
* @returns {string} HTML字符串
*/
function renderProviderListPaginated(providers, page) {
const startIndex = (page - 1) * PROVIDERS_PER_PAGE;
const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, providers.length);
const pageProviders = providers.slice(startIndex, endIndex);
return renderProviderList(pageProviders);
}
/**
* 为提供商类型加载模型列表优化只调用一次API并缓存结果
* @param {string} providerType - 提供商类型
* @param {Array} providers - 提供商列表
*/
async function loadModelsForProviderType(providerType, providers) {
try {
// 如果已有缓存,直接使用
if (cachedModels.length > 0) {
providers.forEach(provider => {
renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []);
});
return;
}
// 只调用一次API获取模型列表
const response = await window.apiClient.get(`/provider-models/${encodeURIComponent(providerType)}`);
const models = response.models || [];
// 缓存模型列表
cachedModels = models;
// 为每个提供商渲染模型选择器
providers.forEach(provider => {
renderNotSupportedModelsSelector(provider.uuid, models, provider.notSupportedModels || []);
});
} catch (error) {
console.error('Failed to load models for provider type:', error);
// 如果加载失败,为每个提供商显示错误信息
providers.forEach(provider => {
const container = document.querySelector(`.not-supported-models-container[data-uuid="${provider.uuid}"]`);
if (container) {
container.innerHTML = '<div class="error-message">加载模型列表失败</div>';
}
});
}
}
/**
* 为模态框添加事件监听器
* @param {HTMLElement} modal - 模态框元素
*/
function addModalEventListeners(modal) {
// ESC键关闭模态框
const handleEscKey = (event) => {
if (event.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', handleEscKey);
}
};
// 点击背景关闭模态框
const handleBackgroundClick = (event) => {
if (event.target === modal) {
modal.remove();
document.removeEventListener('keydown', handleEscKey);
}
};
// 防止模态框内容区域点击时关闭模态框
const modalContent = modal.querySelector('.provider-modal-content');
const handleContentClick = (event) => {
event.stopPropagation();
};
// 密码切换按钮事件处理
const handlePasswordToggleClick = (event) => {
const button = event.target.closest('.password-toggle');
if (button) {
event.preventDefault();
event.stopPropagation();
handleProviderPasswordToggle(button);
}
};
// 上传按钮事件处理
const handleUploadButtonClick = (event) => {
const button = event.target.closest('.upload-btn');
if (button) {
event.preventDefault();
event.stopPropagation();
const targetInputId = button.getAttribute('data-target');
const providerType = modal.getAttribute('data-provider-type');
if (targetInputId && window.fileUploadHandler) {
window.fileUploadHandler.handleFileUpload(button, targetInputId, providerType);
}
}
};
// 添加事件监听器
document.addEventListener('keydown', handleEscKey);
modal.addEventListener('click', handleBackgroundClick);
if (modalContent) {
modalContent.addEventListener('click', handleContentClick);
modalContent.addEventListener('click', handlePasswordToggleClick);
modalContent.addEventListener('click', handleUploadButtonClick);
}
// 清理函数,在模态框关闭时调用
modal.cleanup = () => {
document.removeEventListener('keydown', handleEscKey);
modal.removeEventListener('click', handleBackgroundClick);
if (modalContent) {
modalContent.removeEventListener('click', handleContentClick);
modalContent.removeEventListener('click', handlePasswordToggleClick);
modalContent.removeEventListener('click', handleUploadButtonClick);
}
};
}
/**
* 关闭模态框并清理事件监听器
* @param {HTMLElement} button - 关闭按钮
*/
function closeProviderModal(button) {
const modal = button.closest('.provider-modal');
if (modal) {
if (modal.cleanup) {
modal.cleanup();
}
modal.remove();
}
}
/**
* 渲染提供商列表
* @param {Array} providers - 提供商数组
* @returns {string} HTML字符串
*/
function renderProviderList(providers) {
return providers.map(provider => {
const isHealthy = provider.isHealthy;
const isDisabled = provider.isDisabled || false;
const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用';
const lastHealthCheckTime = provider.lastHealthCheckTime ? new Date(provider.lastHealthCheckTime).toLocaleString() : '从未检测';
const lastHealthCheckModel = provider.lastHealthCheckModel || '-';
const healthClass = isHealthy ? 'healthy' : 'unhealthy';
const disabledClass = isDisabled ? 'disabled' : '';
const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning';
const healthText = isHealthy ? '正常' : '异常';
const disabledText = isDisabled ? '已禁用' : '已启用';
const disabledIcon = isDisabled ? 'fas fa-ban text-muted' : 'fas fa-play text-success';
const toggleButtonText = isDisabled ? '启用' : '禁用';
const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning';
// 构建错误信息显示
let errorInfoHtml = '';
if (!isHealthy && provider.lastErrorMessage) {
const escapedErrorMsg = provider.lastErrorMessage.replace(/</g, '&lt;').replace(/>/g, '&gt;');
errorInfoHtml = `
<div class="provider-error-info">
<i class="fas fa-exclamation-circle text-danger"></i>
<span class="error-label">最后错误:</span>
<span class="error-message" title="${escapedErrorMsg}">${escapedErrorMsg}</span>
</div>
`;
}
return `
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
<div class="provider-info">
<div class="provider-name">${provider.uuid}</div>
<div class="provider-meta">
<span class="health-status">
<i class="${healthIcon}"></i>
健康状态: ${healthText}
</span> |
<span class="disabled-status">
<i class="${disabledIcon}"></i>
状态: ${disabledText}
</span> |
使用次数: ${provider.usageCount || 0} |
失败次数: ${provider.errorCount || 0} |
最后使用: ${lastUsed}
</div>
<div class="provider-health-meta">
<span class="health-check-time">
<i class="fas fa-clock"></i>
最后检测: ${lastHealthCheckTime}
</span> |
<span class="health-check-model">
<i class="fas fa-cube"></i>
检测模型: ${lastHealthCheckModel}
</span>
</div>
${errorInfoHtml}
</div>
<div class="provider-actions-group">
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${provider.uuid}', event)" title="${toggleButtonText}此提供商">
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
<i class="fas fa-edit"></i> 编辑
</button>
<button class="btn-small btn-delete" onclick="window.deleteProvider('${provider.uuid}', event)">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</div>
<div class="provider-item-content" id="content-${provider.uuid}">
<div class="">
${renderProviderConfig(provider)}
</div>
</div>
</div>
`;
}).join('');
}
/**
* 渲染提供商配置
* @param {Object} provider - 提供商对象
* @returns {string} HTML字符串
*/
function renderProviderConfig(provider) {
// 获取字段映射,确保顺序一致
const fieldOrder = getFieldOrder(provider);
// 先渲染基础配置字段checkModelName 和 checkHealth
let html = '<div class="form-grid">';
const baseFields = ['checkModelName', 'checkHealth'];
baseFields.forEach(fieldKey => {
const displayLabel = getFieldLabel(fieldKey);
const value = provider[fieldKey];
const displayValue = value || '';
// 如果是 checkHealth 字段,使用下拉选择框
if (fieldKey === 'checkHealth') {
// 如果没有值,默认为 false
const actualValue = value !== undefined ? value : false;
const isEnabled = actualValue === true || actualValue === 'true';
html += `
<div class="config-item">
<label>${displayLabel}</label>
<select class="form-control"
data-config-key="${fieldKey}"
data-config-value="${actualValue}"
disabled>
<option value="true" ${isEnabled ? 'selected' : ''}>启用</option>
<option value="false" ${!isEnabled ? 'selected' : ''}>禁用</option>
</select>
</div>
`;
} else {
// checkModelName 字段始终显示
html += `
<div class="config-item">
<label>${displayLabel}</label>
<input type="text"
value="${displayValue}"
readonly
data-config-key="${fieldKey}"
data-config-value="${value || ''}">
</div>
`;
}
});
html += '</div>';
// 渲染其他配置字段每行2列
const otherFields = fieldOrder.filter(key => !baseFields.includes(key));
for (let i = 0; i < otherFields.length; i += 2) {
html += '<div class="form-grid">';
const field1Key = otherFields[i];
const field1Label = getFieldLabel(field1Key);
const field1Value = provider[field1Key];
const field1IsPassword = field1Key.toLowerCase().includes('key') || field1Key.toLowerCase().includes('password');
const field1IsOAuthFilePath = field1Key.includes('OAUTH_CREDS_FILE_PATH');
const field1DisplayValue = field1IsPassword && field1Value ? '••••••••' : (field1Value || '');
if (field1IsPassword) {
html += `
<div class="config-item">
<label>${field1Label}</label>
<div class="password-input-wrapper">
<input type="password"
value="${field1DisplayValue}"
readonly
data-config-key="${field1Key}"
data-config-value="${field1Value || ''}">
<button type="button" class="password-toggle" data-target="${field1Key}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (field1IsOAuthFilePath) {
// OAuth凭据文件路径字段添加上传按钮
html += `
<div class="config-item">
<label>${field1Label}</label>
<div class="file-input-group">
<input type="text"
id="edit-${provider.uuid}-${field1Key}"
value="${field1Value || ''}"
readonly
data-config-key="${field1Key}"
data-config-value="${field1Value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="edit-${provider.uuid}-${field1Key}" aria-label="上传文件" disabled>
<i class="fas fa-upload"></i>
</button>
</div>
</div>
`;
} else {
html += `
<div class="config-item">
<label>${field1Label}</label>
<input type="text"
value="${field1DisplayValue}"
readonly
data-config-key="${field1Key}"
data-config-value="${field1Value || ''}">
</div>
`;
}
// 如果有第二个字段
if (i + 1 < otherFields.length) {
const field2Key = otherFields[i + 1];
const field2Label = getFieldLabel(field2Key);
const field2Value = provider[field2Key];
const field2IsPassword = field2Key.toLowerCase().includes('key') || field2Key.toLowerCase().includes('password');
const field2IsOAuthFilePath = field2Key.includes('OAUTH_CREDS_FILE_PATH');
const field2DisplayValue = field2IsPassword && field2Value ? '••••••••' : (field2Value || '');
if (field2IsPassword) {
html += `
<div class="config-item">
<label>${field2Label}</label>
<div class="password-input-wrapper">
<input type="password"
value="${field2DisplayValue}"
readonly
data-config-key="${field2Key}"
data-config-value="${field2Value || ''}">
<button type="button" class="password-toggle" data-target="${field2Key}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (field2IsOAuthFilePath) {
// OAuth凭据文件路径字段添加上传按钮
html += `
<div class="config-item">
<label>${field2Label}</label>
<div class="file-input-group">
<input type="text"
id="edit-${provider.uuid}-${field2Key}"
value="${field2Value || ''}"
readonly
data-config-key="${field2Key}"
data-config-value="${field2Value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="edit-${provider.uuid}-${field2Key}" aria-label="上传文件" disabled>
<i class="fas fa-upload"></i>
</button>
</div>
</div>
`;
} else {
html += `
<div class="config-item">
<label>${field2Label}</label>
<input type="text"
value="${field2DisplayValue}"
readonly
data-config-key="${field2Key}"
data-config-value="${field2Value || ''}">
</div>
`;
}
}
html += '</div>';
}
// 添加 notSupportedModels 配置区域
html += '<div class="form-grid full-width">';
html += `
<div class="config-item not-supported-models-section">
<label>
<i class="fas fa-ban"></i> 不支持的模型
<span class="help-text">选择此提供商不支持的模型,系统会自动排除这些模型</span>
</label>
<div class="not-supported-models-container" data-uuid="${provider.uuid}">
<div class="models-loading">
<i class="fas fa-spinner fa-spin"></i> 加载模型列表...
</div>
</div>
</div>
`;
html += '</div>';
return html;
}
/**
* 获取字段显示顺序
* @param {Object} provider - 提供商对象
* @returns {Array} 字段键数组
*/
function getFieldOrder(provider) {
const orderedFields = ['checkModelName', 'checkHealth'];
// 需要排除的内部状态字段
const excludedFields = [
'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime',
'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage'
];
// 获取所有其他配置项
const otherFields = Object.keys(provider).filter(key =>
!excludedFields.includes(key) && !orderedFields.includes(key)
);
// 按字母顺序排序其他字段
otherFields.sort();
return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key));
}
/**
* 切换提供商详情显示
* @param {string} uuid - 提供商UUID
*/
function toggleProviderDetails(uuid) {
const content = document.getElementById(`content-${uuid}`);
if (content) {
content.classList.toggle('expanded');
}
}
/**
* 编辑提供商
* @param {string} uuid - 提供商UUID
* @param {Event} event - 事件对象
*/
function editProvider(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
const content = providerDetail.querySelector(`#content-${uuid}`);
// 如果还没有展开,则自动展开编辑框
if (content && !content.classList.contains('expanded')) {
toggleProviderDetails(uuid);
}
// 等待一小段时间让展开动画完成,然后切换输入框为可编辑状态
setTimeout(() => {
// 切换输入框为可编辑状态
configInputs.forEach(input => {
input.readOnly = false;
if (input.type === 'password') {
const actualValue = input.dataset.configValue;
input.value = actualValue;
}
});
// 启用文件上传按钮
const uploadButtons = providerDetail.querySelectorAll('.upload-btn');
uploadButtons.forEach(button => {
button.disabled = false;
});
// 启用下拉选择框
configSelects.forEach(select => {
select.disabled = false;
});
// 启用模型复选框
const modelCheckboxes = providerDetail.querySelectorAll('.model-checkbox');
modelCheckboxes.forEach(checkbox => {
checkbox.disabled = false;
});
// 添加编辑状态类
providerDetail.classList.add('editing');
// 替换编辑按钮为保存和取消按钮,但保留禁用/启用按钮
const actionsGroup = providerDetail.querySelector('.provider-actions-group');
const toggleButton = actionsGroup.querySelector('[onclick*="toggleProviderStatus"]');
const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`);
const isCurrentlyDisabled = currentProvider.classList.contains('disabled');
const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用';
const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning';
actionsGroup.innerHTML = `
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${uuid}', event)" title="${toggleButtonText}此提供商">
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-save" onclick="window.saveProvider('${uuid}', event)">
<i class="fas fa-save"></i> 保存
</button>
<button class="btn-small btn-cancel" onclick="window.cancelEdit('${uuid}', event)">
<i class="fas fa-times"></i> 取消
</button>
`;
}, 100);
}
/**
* 取消编辑
* @param {string} uuid - 提供商UUID
* @param {Event} event - 事件对象
*/
function cancelEdit(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
// 恢复输入框为只读状态
configInputs.forEach(input => {
input.readOnly = true;
// 恢复显示为密码格式(如果有的话)
if (input.type === 'password') {
const actualValue = input.dataset.configValue;
input.value = actualValue ? '••••••••' : '';
}
});
// 禁用模型复选框
const modelCheckboxes = providerDetail.querySelectorAll('.model-checkbox');
modelCheckboxes.forEach(checkbox => {
checkbox.disabled = true;
});
// 移除编辑状态类
providerDetail.classList.remove('editing');
// 禁用文件上传按钮
const uploadButtons = providerDetail.querySelectorAll('.upload-btn');
uploadButtons.forEach(button => {
button.disabled = true;
});
// 禁用下拉选择框
configSelects.forEach(select => {
select.disabled = true;
// 恢复原始值
const originalValue = select.dataset.configValue;
select.value = originalValue || '';
});
// 恢复原来的编辑和删除按钮,但保留禁用/启用按钮
const actionsGroup = providerDetail.querySelector('.provider-actions-group');
const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`);
const isCurrentlyDisabled = currentProvider.classList.contains('disabled');
const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用';
const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning';
actionsGroup.innerHTML = `
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${uuid}', event)" title="${toggleButtonText}此提供商">
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
<i class="fas fa-edit"></i> 编辑
</button>
<button class="btn-small btn-delete" onclick="window.deleteProvider('${uuid}', event)">
<i class="fas fa-trash"></i> 删除
</button>
`;
}
/**
* 保存提供商
* @param {string} uuid - 提供商UUID
* @param {Event} event - 事件对象
*/
async function saveProvider(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
const providerConfig = {};
configInputs.forEach(input => {
const key = input.dataset.configKey;
const value = input.value;
providerConfig[key] = value;
});
configSelects.forEach(select => {
const key = select.dataset.configKey;
const value = select.value === 'true';
providerConfig[key] = value;
});
// 收集不支持的模型列表
const modelCheckboxes = providerDetail.querySelectorAll(`.model-checkbox[data-uuid="${uuid}"]:checked`);
const notSupportedModels = Array.from(modelCheckboxes).map(checkbox => checkbox.value);
providerConfig.notSupportedModels = notSupportedModels;
try {
await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig });
await window.apiClient.post('/reload-config');
showToast('提供商配置更新成功', 'success');
// 重新获取该提供商类型的最新配置
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to update provider:', error);
showToast('更新失败: ' + error.message, 'error');
}
}
/**
* 删除提供商
* @param {string} uuid - 提供商UUID
* @param {Event} event - 事件对象
*/
async function deleteProvider(uuid, event) {
event.stopPropagation();
if (!confirm('确定要删除这个提供商配置吗?此操作不可恢复。')) {
return;
}
const providerDetail = event.target.closest('.provider-item-detail');
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
try {
await window.apiClient.delete(`/providers/${encodeURIComponent(providerType)}/${uuid}`);
await window.apiClient.post('/reload-config');
showToast('提供商配置删除成功', 'success');
// 重新获取最新配置
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to delete provider:', error);
showToast('删除失败: ' + error.message, 'error');
}
}
/**
* 重新获取并刷新提供商配置
* @param {string} providerType - 提供商类型
*/
async function refreshProviderConfig(providerType) {
try {
// 重新获取该提供商类型的最新数据
const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`);
// 如果当前显示的是该提供商类型的模态框,则更新模态框
const modal = document.querySelector('.provider-modal');
if (modal && modal.getAttribute('data-provider-type') === providerType) {
// 更新缓存的提供商数据
currentProviders = data.providers;
currentProviderType = providerType;
// 更新统计信息
const totalCountElement = modal.querySelector('.provider-summary-item .value');
if (totalCountElement) {
totalCountElement.textContent = data.totalCount;
}
const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1];
if (healthyCountElement) {
healthyCountElement.textContent = data.healthyCount;
}
const totalPages = Math.ceil(data.providers.length / PROVIDERS_PER_PAGE);
// 确保当前页不超过总页数
if (currentPage > totalPages) {
currentPage = Math.max(1, totalPages);
}
// 重新渲染提供商列表(分页)
const providerList = modal.querySelector('.provider-list');
if (providerList) {
providerList.innerHTML = renderProviderListPaginated(data.providers, currentPage);
}
// 更新分页控件
const paginationContainers = modal.querySelectorAll('.pagination-container');
if (totalPages > 1) {
paginationContainers.forEach(container => {
const position = container.getAttribute('data-position');
container.outerHTML = renderPagination(currentPage, totalPages, data.providers.length, position);
});
// 如果之前没有分页控件,需要添加
if (paginationContainers.length === 0) {
const modalBody = modal.querySelector('.provider-modal-body');
const providerListEl = modal.querySelector('.provider-list');
if (modalBody && providerListEl) {
providerListEl.insertAdjacentHTML('beforebegin', renderPagination(currentPage, totalPages, data.providers.length, 'top'));
providerListEl.insertAdjacentHTML('afterend', renderPagination(currentPage, totalPages, data.providers.length, 'bottom'));
}
}
} else {
// 如果只有一页,移除分页控件
paginationContainers.forEach(container => container.remove());
}
// 重新加载当前页的模型列表
const startIndex = (currentPage - 1) * PROVIDERS_PER_PAGE;
const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, data.providers.length);
const pageProviders = data.providers.slice(startIndex, endIndex);
loadModelsForProviderType(providerType, pageProviders);
}
// 同时更新主界面的提供商统计数据
if (typeof window.loadProviders === 'function') {
await window.loadProviders();
}
} catch (error) {
console.error('Failed to refresh provider config:', error);
}
}
/**
* 显示添加提供商表单
* @param {string} providerType - 提供商类型
*/
function showAddProviderForm(providerType) {
const modal = document.querySelector('.provider-modal');
const existingForm = modal.querySelector('.add-provider-form');
if (existingForm) {
existingForm.remove();
return;
}
const form = document.createElement('div');
form.className = 'add-provider-form';
form.innerHTML = `
<h4><i class="fas fa-plus"></i> 添加新提供商配置</h4>
<div class="form-grid">
<div class="form-group">
<label>检查模型名称 <span class="optional-mark">(选填)</span></label>
<input type="text" id="newCheckModelName" placeholder="例如: gpt-3.5-turbo">
</div>
<div class="form-group">
<label>健康检查</label>
<select id="newCheckHealth">
<option value="false">禁用</option>
<option value="true">启用</option>
</select>
</div>
</div>
<div id="dynamicConfigFields">
<!-- 动态配置字段将在这里显示 -->
</div>
<div class="form-actions" style="margin-top: 15px;">
<button class="btn btn-success" onclick="window.addProvider('${providerType}')">
<i class="fas fa-save"></i> 保存
</button>
<button class="btn btn-secondary" onclick="this.closest('.add-provider-form').remove()">
<i class="fas fa-times"></i> 取消
</button>
</div>
`;
// 添加动态配置字段
addDynamicConfigFields(form, providerType);
// 为添加表单中的密码切换按钮绑定事件监听器
bindAddFormPasswordToggleListeners(form);
// 插入到提供商列表前面
const providerList = modal.querySelector('.provider-list');
providerList.parentNode.insertBefore(form, providerList);
}
/**
* 添加动态配置字段
* @param {HTMLElement} form - 表单元素
* @param {string} providerType - 提供商类型
*/
function addDynamicConfigFields(form, providerType) {
const configFields = form.querySelector('#dynamicConfigFields');
// 获取该提供商类型的字段配置
const providerFields = getProviderTypeFields(providerType);
let fields = '';
if (providerFields.length > 0) {
// 分组显示,每行两个字段
for (let i = 0; i < providerFields.length; i += 2) {
fields += '<div class="form-grid">';
const field1 = providerFields[i];
// 检查是否为密码类型字段
const isPassword1 = field1.type === 'password';
// 检查是否为OAuth凭据文件路径字段
const isOAuthFilePath1 = field1.id.includes('OauthCredsFilePath');
if (isPassword1) {
fields += `
<div class="form-group">
<label>${field1.label}</label>
<div class="password-input-wrapper">
<input type="password" id="new${field1.id}" placeholder="${field1.placeholder || ''}" value="${field1.value || ''}">
<button type="button" class="password-toggle" data-target="new${field1.id}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (isOAuthFilePath1) {
// OAuth凭据文件路径字段添加上传按钮
const isKiroField = field1.id.includes('Kiro');
fields += `
<div class="form-group">
<label>${field1.label}</label>
<div class="file-input-group">
<input type="text" id="new${field1.id}" class="form-control" placeholder="${field1.placeholder || ''}" value="${field1.value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="new${field1.id}" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
${isKiroField ? '<small class="form-text"><i class="fas fa-info-circle"></i> 使用 AWS 登录方式时,请确保授权文件中包含 <code>clientId</code> 和 <code>clientSecret</code> 字段</small>' : ''}
</div>
`;
} else {
fields += `
<div class="form-group">
<label>${field1.label}</label>
<input type="${field1.type}" id="new${field1.id}" placeholder="${field1.placeholder || ''}" value="${field1.value || ''}">
</div>
`;
}
const field2 = providerFields[i + 1];
if (field2) {
// 检查是否为密码类型字段
const isPassword2 = field2.type === 'password';
// 检查是否为OAuth凭据文件路径字段
const isOAuthFilePath2 = field2.id.includes('OauthCredsFilePath');
if (isPassword2) {
fields += `
<div class="form-group">
<label>${field2.label}</label>
<div class="password-input-wrapper">
<input type="password" id="new${field2.id}" placeholder="${field2.placeholder || ''}" value="${field2.value || ''}">
<button type="button" class="password-toggle" data-target="new${field2.id}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (isOAuthFilePath2) {
// OAuth凭据文件路径字段添加上传按钮
const isKiroField = field2.id.includes('Kiro');
fields += `
<div class="form-group">
<label>${field2.label}</label>
<div class="file-input-group">
<input type="text" id="new${field2.id}" class="form-control" placeholder="${field2.placeholder || ''}" value="${field2.value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="new${field2.id}" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
${isKiroField ? '<small class="form-text"><i class="fas fa-info-circle"></i> 使用 AWS 登录方式时,请确保授权文件中包含 <code>clientId</code> 和 <code>clientSecret</code> 字段</small>' : ''}
</div>
`;
} else {
fields += `
<div class="form-group">
<label>${field2.label}</label>
<input type="${field2.type}" id="new${field2.id}" placeholder="${field2.placeholder || ''}" value="${field2.value || ''}">
</div>
`;
}
}
fields += '</div>';
}
} else {
fields = '<p>不支持的提供商类型</p>';
}
configFields.innerHTML = fields;
}
/**
* 为添加新提供商表单中的密码切换按钮绑定事件监听器
* @param {HTMLElement} form - 表单元素
*/
function bindAddFormPasswordToggleListeners(form) {
const passwordToggles = form.querySelectorAll('.password-toggle');
passwordToggles.forEach(button => {
button.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const input = document.getElementById(targetId);
const icon = this.querySelector('i');
if (!input || !icon) return;
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
});
});
}
/**
* 添加新提供商
* @param {string} providerType - 提供商类型
*/
async function addProvider(providerType) {
const checkModelName = document.getElementById('newCheckModelName')?.value;
const checkHealth = document.getElementById('newCheckHealth')?.value === 'true';
const providerConfig = {
checkModelName: checkModelName || '', // 允许为空
checkHealth
};
// 根据提供商类型收集配置
switch (providerType) {
case 'openai-custom':
providerConfig.OPENAI_API_KEY = document.getElementById('newOpenaiApiKey')?.value || '';
providerConfig.OPENAI_BASE_URL = document.getElementById('newOpenaiBaseUrl')?.value || '';
break;
case 'openaiResponses-custom':
providerConfig.OPENAI_API_KEY = document.getElementById('newOpenaiApiKey')?.value || '';
providerConfig.OPENAI_BASE_URL = document.getElementById('newOpenaiBaseUrl')?.value || '';
break;
case 'claude-custom':
providerConfig.CLAUDE_API_KEY = document.getElementById('newClaudeApiKey')?.value || '';
providerConfig.CLAUDE_BASE_URL = document.getElementById('newClaudeBaseUrl')?.value || '';
break;
case 'gemini-cli-oauth':
providerConfig.PROJECT_ID = document.getElementById('newProjectId')?.value || '';
providerConfig.GEMINI_OAUTH_CREDS_FILE_PATH = document.getElementById('newGeminiOauthCredsFilePath')?.value || '';
break;
case 'claude-kiro-oauth':
providerConfig.KIRO_OAUTH_CREDS_FILE_PATH = document.getElementById('newKiroOauthCredsFilePath')?.value || '';
break;
case 'openai-qwen-oauth':
providerConfig.QWEN_OAUTH_CREDS_FILE_PATH = document.getElementById('newQwenOauthCredsFilePath')?.value || '';
break;
case 'gemini-antigravity':
providerConfig.PROJECT_ID = document.getElementById('newProjectId')?.value || '';
providerConfig.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH = document.getElementById('newAntigravityOauthCredsFilePath')?.value || '';
break;
}
try {
await window.apiClient.post('/providers', {
providerType,
providerConfig
});
await window.apiClient.post('/reload-config');
showToast('提供商配置添加成功', 'success');
// 移除添加表单
const form = document.querySelector('.add-provider-form');
if (form) {
form.remove();
}
// 重新获取最新配置数据
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to add provider:', error);
showToast('添加失败: ' + error.message, 'error');
}
}
/**
* 切换提供商禁用/启用状态
* @param {string} uuid - 提供商UUID
* @param {Event} event - 事件对象
*/
async function toggleProviderStatus(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`);
// 获取当前提供商信息
const isCurrentlyDisabled = currentProvider.classList.contains('disabled');
const action = isCurrentlyDisabled ? 'enable' : 'disable';
const confirmMessage = isCurrentlyDisabled ?
`确定要启用这个提供商配置吗?` :
`确定要禁用这个提供商配置吗?禁用后该提供商将不会被选中使用。`;
if (!confirm(confirmMessage)) {
return;
}
try {
await window.apiClient.post(`/providers/${encodeURIComponent(providerType)}/${uuid}/${action}`, { action });
await window.apiClient.post('/reload-config');
showToast(`提供商${isCurrentlyDisabled ? '启用' : '禁用'}成功`, 'success');
// 重新获取该提供商类型的最新配置
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to toggle provider status:', error);
showToast(`操作失败: ${error.message}`, 'error');
}
}
/**
* 重置所有提供商的健康状态
* @param {string} providerType - 提供商类型
*/
async function resetAllProvidersHealth(providerType) {
if (!confirm(`确定要将 ${providerType} 的所有节点重置为健康状态吗?\n\n这将清除所有节点的错误计数和错误时间。`)) {
return;
}
try {
showToast('正在重置健康状态...', 'info');
const response = await window.apiClient.post(
`/providers/${encodeURIComponent(providerType)}/reset-health`,
{}
);
if (response.success) {
showToast(`成功重置 ${response.resetCount} 个节点的健康状态`, 'success');
// 重新加载配置
await window.apiClient.post('/reload-config');
// 刷新提供商配置显示
await refreshProviderConfig(providerType);
} else {
showToast('重置健康状态失败', 'error');
}
} catch (error) {
console.error('重置健康状态失败:', error);
showToast(`重置健康状态失败: ${error.message}`, 'error');
}
}
/**
* 执行健康检测
* @param {string} providerType - 提供商类型
*/
async function performHealthCheck(providerType) {
if (!confirm(`确定要对 ${providerType} 的所有节点执行健康检测吗?\n\n这将向每个节点发送测试请求来验证其可用性。`)) {
return;
}
try {
showToast('正在执行健康检测,请稍候...', 'info');
const response = await window.apiClient.post(
`/providers/${encodeURIComponent(providerType)}/health-check`,
{}
);
if (response.success) {
const { successCount, failCount, totalCount, results } = response;
// 统计跳过的数量checkHealth 未启用的)
const skippedCount = results ? results.filter(r => r.success === null).length : 0;
let message = `健康检测完成: ${successCount} 健康`;
if (failCount > 0) message += `, ${failCount} 异常`;
if (skippedCount > 0) message += `, ${skippedCount} 跳过(未启用)`;
showToast(message, failCount > 0 ? 'warning' : 'success');
// 重新加载配置
await window.apiClient.post('/reload-config');
// 刷新提供商配置显示
await refreshProviderConfig(providerType);
} else {
showToast('健康检测失败', 'error');
}
} catch (error) {
console.error('健康检测失败:', error);
showToast(`健康检测失败: ${error.message}`, 'error');
}
}
/**
* 渲染不支持的模型选择器不调用API直接使用传入的模型列表
* @param {string} uuid - 提供商UUID
* @param {Array} models - 模型列表
* @param {Array} notSupportedModels - 当前不支持的模型列表
*/
function renderNotSupportedModelsSelector(uuid, models, notSupportedModels = []) {
const container = document.querySelector(`.not-supported-models-container[data-uuid="${uuid}"]`);
if (!container) return;
if (models.length === 0) {
container.innerHTML = '<div class="no-models">该提供商类型暂无可用模型列表</div>';
return;
}
// 渲染模型复选框列表
let html = '<div class="models-checkbox-grid">';
models.forEach(model => {
const isChecked = notSupportedModels.includes(model);
html += `
<label class="model-checkbox-label">
<input type="checkbox"
class="model-checkbox"
value="${model}"
data-uuid="${uuid}"
${isChecked ? 'checked' : ''}
disabled>
<span class="model-name">${model}</span>
</label>
`;
});
html += '</div>';
container.innerHTML = html;
}
// 导出所有函数并挂载到window对象供HTML调用
export {
showProviderManagerModal,
closeProviderModal,
toggleProviderDetails,
editProvider,
cancelEdit,
saveProvider,
deleteProvider,
refreshProviderConfig,
showAddProviderForm,
addProvider,
toggleProviderStatus,
resetAllProvidersHealth,
performHealthCheck,
loadModelsForProviderType,
renderNotSupportedModelsSelector,
goToProviderPage
};
// 将函数挂载到window对象
window.closeProviderModal = closeProviderModal;
window.toggleProviderDetails = toggleProviderDetails;
window.editProvider = editProvider;
window.cancelEdit = cancelEdit;
window.saveProvider = saveProvider;
window.deleteProvider = deleteProvider;
window.showAddProviderForm = showAddProviderForm;
window.addProvider = addProvider;
window.toggleProviderStatus = toggleProviderStatus;
window.resetAllProvidersHealth = resetAllProvidersHealth;
window.performHealthCheck = performHealthCheck;
window.goToProviderPage = goToProviderPage;