feat(provider): 增强健康检测和自动配置关联功能
- 新增 provider-utils.js 公共模块,提取共用工具函数 - 添加提供商健康检测 API 端点和 UI 按钮 - 实现配置文件自动关联功能(启动时和手动触发) - 支持 gemini-antigravity 新提供商类型 - 增强健康检测结果记录(时间、模型、错误信息) - 添加提供商列表分页功能 - 修复 OpenAIResponsesConverter 中 systemMessages 未定义问题 - 更新默认健康检测模型配置
This commit is contained in:
parent
c5cd1ab2c7
commit
b364341f67
11 changed files with 1625 additions and 264 deletions
|
|
@ -257,6 +257,10 @@ export class OpenAIResponsesConverter extends BaseConverter {
|
||||||
|
|
||||||
// 如果有标准的 messages 字段,也支持
|
// 如果有标准的 messages 字段,也支持
|
||||||
if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) {
|
if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) {
|
||||||
|
const { systemMessages, otherMessages } = extractSystemMessages(
|
||||||
|
responsesRequest.messages
|
||||||
|
);
|
||||||
|
|
||||||
if (!claudeRequest.system && systemMessages.length > 0) {
|
if (!claudeRequest.system && systemMessages.length > 0) {
|
||||||
const systemTexts = systemMessages.map(msg => extractText(msg.content));
|
const systemTexts = systemMessages.map(msg => extractText(msg.content));
|
||||||
claudeRequest.system = systemTexts.join('\n');
|
claudeRequest.system = systemTexts.join('\n');
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import * as fs from 'fs'; // Import fs module
|
import * as fs from 'fs'; // Import fs module
|
||||||
import { getServiceAdapter } from './adapter.js';
|
import { getServiceAdapter } from './adapter.js';
|
||||||
import { MODEL_PROVIDER } from './common.js';
|
import { MODEL_PROVIDER } from './common.js';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a pool of API service providers, handling their health and selection.
|
* Manages a pool of API service providers, handling their health and selection.
|
||||||
*/
|
*/
|
||||||
export class ProviderPoolManager {
|
export class ProviderPoolManager {
|
||||||
// 默认健康检查模型配置
|
// 默认健康检查模型配置
|
||||||
|
// 键名必须与 MODEL_PROVIDER 常量值一致
|
||||||
static DEFAULT_HEALTH_CHECK_MODELS = {
|
static DEFAULT_HEALTH_CHECK_MODELS = {
|
||||||
'gemini-cli': 'gemini-2.5-flash',
|
'gemini-cli-oauth': 'gemini-2.5-flash',
|
||||||
|
'gemini-antigravity': 'gemini-2.5-flash',
|
||||||
'openai-custom': 'gpt-3.5-turbo',
|
'openai-custom': 'gpt-3.5-turbo',
|
||||||
'claude-custom': 'claude-3-7-sonnet-20250219',
|
'claude-custom': 'claude-3-7-sonnet-20250219',
|
||||||
'kiro-api': 'claude-3-7-sonnet-20250219',
|
'claude-kiro-oauth': 'claude-haiku-4-5',
|
||||||
'qwen-api': 'qwen3-coder-flash',
|
'openai-qwen-oauth': 'qwen3-coder-flash',
|
||||||
'openai-custom-responses': 'gpt-5-low'
|
'openaiResponses-custom': 'gpt-4o-mini'
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(providerPools, options = {}) {
|
constructor(providerPools, options = {}) {
|
||||||
|
|
@ -82,6 +85,11 @@ export class ProviderPoolManager {
|
||||||
? providerConfig.lastErrorTime.toISOString()
|
? providerConfig.lastErrorTime.toISOString()
|
||||||
: (providerConfig.lastErrorTime || null);
|
: (providerConfig.lastErrorTime || null);
|
||||||
|
|
||||||
|
// 健康检测相关字段
|
||||||
|
providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null;
|
||||||
|
providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null;
|
||||||
|
providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null;
|
||||||
|
|
||||||
this.providerStatus[providerType].push({
|
this.providerStatus[providerType].push({
|
||||||
config: providerConfig,
|
config: providerConfig,
|
||||||
uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access
|
uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access
|
||||||
|
|
@ -165,8 +173,9 @@ export class ProviderPoolManager {
|
||||||
* Marks a provider as unhealthy (e.g., after an API error).
|
* Marks a provider as unhealthy (e.g., after an API error).
|
||||||
* @param {string} providerType - The type of the provider.
|
* @param {string} providerType - The type of the provider.
|
||||||
* @param {object} providerConfig - The configuration of the provider to mark.
|
* @param {object} providerConfig - The configuration of the provider to mark.
|
||||||
|
* @param {string} [errorMessage] - Optional error message to store.
|
||||||
*/
|
*/
|
||||||
markProviderUnhealthy(providerType, providerConfig) {
|
markProviderUnhealthy(providerType, providerConfig, errorMessage = null) {
|
||||||
if (!providerConfig?.uuid) {
|
if (!providerConfig?.uuid) {
|
||||||
this._log('error', 'Invalid providerConfig in markProviderUnhealthy');
|
this._log('error', 'Invalid providerConfig in markProviderUnhealthy');
|
||||||
return;
|
return;
|
||||||
|
|
@ -177,6 +186,11 @@ export class ProviderPoolManager {
|
||||||
provider.config.errorCount++;
|
provider.config.errorCount++;
|
||||||
provider.config.lastErrorTime = new Date().toISOString();
|
provider.config.lastErrorTime = new Date().toISOString();
|
||||||
|
|
||||||
|
// 保存错误信息
|
||||||
|
if (errorMessage) {
|
||||||
|
provider.config.lastErrorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider.config.errorCount >= this.maxErrorCount) {
|
if (provider.config.errorCount >= this.maxErrorCount) {
|
||||||
provider.config.isHealthy = false;
|
provider.config.isHealthy = false;
|
||||||
this._log('warn', `Marked provider as unhealthy: ${providerConfig.uuid} for type ${providerType}. Total errors: ${provider.config.errorCount}`);
|
this._log('warn', `Marked provider as unhealthy: ${providerConfig.uuid} for type ${providerType}. Total errors: ${provider.config.errorCount}`);
|
||||||
|
|
@ -192,9 +206,10 @@ export class ProviderPoolManager {
|
||||||
* Marks a provider as healthy.
|
* Marks a provider as healthy.
|
||||||
* @param {string} providerType - The type of the provider.
|
* @param {string} providerType - The type of the provider.
|
||||||
* @param {object} providerConfig - The configuration of the provider to mark.
|
* @param {object} providerConfig - The configuration of the provider to mark.
|
||||||
* @param {boolean} isInit - Whether to reset usage count (optional, default: false).
|
* @param {boolean} resetUsageCount - Whether to reset usage count (optional, default: false).
|
||||||
|
* @param {string} [healthCheckModel] - Optional model name used for health check.
|
||||||
*/
|
*/
|
||||||
markProviderHealthy(providerType, providerConfig, resetUsageCount = false) {
|
markProviderHealthy(providerType, providerConfig, resetUsageCount = false, healthCheckModel = null) {
|
||||||
if (!providerConfig?.uuid) {
|
if (!providerConfig?.uuid) {
|
||||||
this._log('error', 'Invalid providerConfig in markProviderHealthy');
|
this._log('error', 'Invalid providerConfig in markProviderHealthy');
|
||||||
return;
|
return;
|
||||||
|
|
@ -205,6 +220,14 @@ export class ProviderPoolManager {
|
||||||
provider.config.isHealthy = true;
|
provider.config.isHealthy = true;
|
||||||
provider.config.errorCount = 0;
|
provider.config.errorCount = 0;
|
||||||
provider.config.lastErrorTime = null;
|
provider.config.lastErrorTime = null;
|
||||||
|
provider.config.lastErrorMessage = null;
|
||||||
|
|
||||||
|
// 更新健康检测信息
|
||||||
|
provider.config.lastHealthCheckTime = new Date().toISOString();
|
||||||
|
if (healthCheckModel) {
|
||||||
|
provider.config.lastHealthCheckModel = healthCheckModel;
|
||||||
|
}
|
||||||
|
|
||||||
// 只有在明确要求重置使用计数时才重置
|
// 只有在明确要求重置使用计数时才重置
|
||||||
if (resetUsageCount) {
|
if (resetUsageCount) {
|
||||||
provider.config.usageCount = 0;
|
provider.config.usageCount = 0;
|
||||||
|
|
@ -295,122 +318,165 @@ export class ProviderPoolManager {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Perform actual health check based on provider type
|
// Perform actual health check based on provider type
|
||||||
const isHealthy = await this._checkProviderHealth(providerType, providerConfig);
|
const healthResult = await this._checkProviderHealth(providerType, providerConfig);
|
||||||
|
|
||||||
if (isHealthy === null) {
|
if (healthResult === null) {
|
||||||
this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}) skipped: Check not implemented.`);
|
this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}) skipped: Check not implemented.`);
|
||||||
this.resetProviderCounters(providerType, providerConfig);
|
this.resetProviderCounters(providerType, providerConfig);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHealthy) {
|
if (healthResult.success) {
|
||||||
if (!providerStatus.config.isHealthy) {
|
if (!providerStatus.config.isHealthy) {
|
||||||
// Provider was unhealthy but is now healthy
|
// Provider was unhealthy but is now healthy
|
||||||
// 恢复健康时不重置使用计数,保持原有值
|
// 恢复健康时不重置使用计数,保持原有值
|
||||||
this.markProviderHealthy(providerType, providerConfig, true);
|
this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName);
|
||||||
this._log('info', `Health check for ${providerConfig.uuid} (${providerType}): Marked Healthy (actual check)`);
|
this._log('info', `Health check for ${providerConfig.uuid} (${providerType}): Marked Healthy (actual check)`);
|
||||||
} else {
|
} else {
|
||||||
// Provider was already healthy and still is
|
// Provider was already healthy and still is
|
||||||
// 只在初始化时重置使用计数
|
// 只在初始化时重置使用计数
|
||||||
this.markProviderHealthy(providerType, providerConfig, true);
|
this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName);
|
||||||
this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}): Still Healthy`);
|
this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}): Still Healthy`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Provider is not healthy
|
// Provider is not healthy
|
||||||
this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: Provider is not responding correctly.`);
|
this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${healthResult.errorMessage || 'Provider is not responding correctly.'}`);
|
||||||
this.markProviderUnhealthy(providerType, providerConfig);
|
this.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage);
|
||||||
|
|
||||||
|
// 更新健康检测时间和模型(即使失败也记录)
|
||||||
|
providerStatus.config.lastHealthCheckTime = new Date().toISOString();
|
||||||
|
if (healthResult.modelName) {
|
||||||
|
providerStatus.config.lastHealthCheckModel = healthResult.modelName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._log('error', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${error.message}`);
|
this._log('error', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${error.message}`);
|
||||||
// If a health check fails, mark it unhealthy, which will update error count and lastErrorTime
|
// If a health check fails, mark it unhealthy, which will update error count and lastErrorTime
|
||||||
this.markProviderUnhealthy(providerType, providerConfig);
|
this.markProviderUnhealthy(providerType, providerConfig, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建健康检查请求
|
* 构建健康检查请求(返回多种格式用于重试)
|
||||||
* @private
|
* @private
|
||||||
|
* @returns {Array} 请求格式数组,按优先级排序
|
||||||
*/
|
*/
|
||||||
_buildHealthCheckRequest(providerType, modelName) {
|
_buildHealthCheckRequests(providerType, modelName) {
|
||||||
const baseMessage = { role: 'user', content: 'Hello, are you ok?' };
|
const baseMessage = { role: 'user', content: 'Hi' };
|
||||||
|
const requests = [];
|
||||||
|
|
||||||
// Gemini 使用不同的请求格式
|
// Gemini 使用 contents 格式
|
||||||
if (providerType.startsWith('gemini')) {
|
if (providerType.startsWith('gemini')) {
|
||||||
return {
|
requests.push({
|
||||||
contents: [{
|
contents: [{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [{ text: baseMessage.content }]
|
parts: [{ text: baseMessage.content }]
|
||||||
}]
|
}]
|
||||||
};
|
});
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiro OAuth 同时支持 messages 和 contents 格式
|
||||||
|
if (providerType.startsWith('claude-kiro')) {
|
||||||
|
// 优先使用 messages 格式
|
||||||
|
requests.push({
|
||||||
|
messages: [baseMessage],
|
||||||
|
model: modelName,
|
||||||
|
max_tokens: 1
|
||||||
|
});
|
||||||
|
// 备用 contents 格式
|
||||||
|
requests.push({
|
||||||
|
contents: [{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: baseMessage.content }]
|
||||||
|
}],
|
||||||
|
max_tokens: 1
|
||||||
|
});
|
||||||
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI Custom Responses 使用特殊格式
|
// OpenAI Custom Responses 使用特殊格式
|
||||||
if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) {
|
if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) {
|
||||||
return {
|
requests.push({
|
||||||
input: [baseMessage],
|
input: [baseMessage],
|
||||||
model: modelName
|
model: modelName
|
||||||
};
|
});
|
||||||
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他提供商(OpenAI、Claude、Kiro、Qwen)使用标准格式
|
// 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式
|
||||||
return {
|
requests.push({
|
||||||
messages: [baseMessage],
|
messages: [baseMessage],
|
||||||
model: modelName
|
model: modelName
|
||||||
};
|
});
|
||||||
|
|
||||||
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs an actual health check for a specific provider.
|
* Performs an actual health check for a specific provider.
|
||||||
* @param {string} providerType - The type of the provider.
|
* @param {string} providerType - The type of the provider.
|
||||||
* @param {object} providerConfig - The configuration of the provider to check.
|
* @param {object} providerConfig - The configuration of the provider to check.
|
||||||
* @returns {Promise<boolean|null>} - True if healthy, false if unhealthy, null if check not implemented.
|
* @param {boolean} forceCheck - If true, ignore checkHealth config and force the check.
|
||||||
|
* @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented.
|
||||||
*/
|
*/
|
||||||
async _checkProviderHealth(providerType, providerConfig) {
|
async _checkProviderHealth(providerType, providerConfig, forceCheck = false) {
|
||||||
try {
|
// 确定健康检查使用的模型名称
|
||||||
// 如果未启用健康检查,返回 null
|
const modelName = providerConfig.checkModelName ||
|
||||||
if (!providerConfig.checkHealth) {
|
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType];
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并全局配置和 provider 配置(简化代理配置)
|
// 如果未启用健康检查且不是强制检查,返回 null
|
||||||
const proxyKeys = ['GEMINI', 'OPENAI', 'CLAUDE', 'QWEN', 'KIRO'];
|
if (!providerConfig.checkHealth && !forceCheck) {
|
||||||
const tempConfig = {
|
return null;
|
||||||
...providerConfig,
|
|
||||||
MODEL_PROVIDER: providerType
|
|
||||||
};
|
|
||||||
|
|
||||||
// 动态添加代理配置
|
|
||||||
proxyKeys.forEach(key => {
|
|
||||||
const proxyKey = `USE_SYSTEM_PROXY_${key}`;
|
|
||||||
if (this.globalConfig[proxyKey] !== undefined) {
|
|
||||||
tempConfig[proxyKey] = this.globalConfig[proxyKey];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const serviceAdapter = getServiceAdapter(tempConfig);
|
|
||||||
|
|
||||||
// 确定健康检查使用的模型名称
|
|
||||||
const modelName = providerConfig.checkModelName ||
|
|
||||||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType];
|
|
||||||
|
|
||||||
if (!modelName) {
|
|
||||||
this._log('warn', `Unknown provider type for health check: ${providerType}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建健康检查请求
|
|
||||||
const healthCheckRequest = this._buildHealthCheckRequest(providerType, modelName);
|
|
||||||
|
|
||||||
this._log('debug', `Health check request for ${modelName}: ${JSON.stringify(healthCheckRequest)}`);
|
|
||||||
await serviceAdapter.generateContent(modelName, healthCheckRequest);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this._log('error', `Health check failed for ${providerType}: ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!modelName) {
|
||||||
|
this._log('warn', `Unknown provider type for health check: ${providerType}`);
|
||||||
|
return { success: false, modelName: null, errorMessage: 'Unknown provider type for health check' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用内部服务适配器方式进行健康检查
|
||||||
|
const proxyKeys = ['GEMINI', 'OPENAI', 'CLAUDE', 'QWEN', 'KIRO'];
|
||||||
|
const tempConfig = {
|
||||||
|
...providerConfig,
|
||||||
|
MODEL_PROVIDER: providerType
|
||||||
|
};
|
||||||
|
|
||||||
|
proxyKeys.forEach(key => {
|
||||||
|
const proxyKey = `USE_SYSTEM_PROXY_${key}`;
|
||||||
|
if (this.globalConfig[proxyKey] !== undefined) {
|
||||||
|
tempConfig[proxyKey] = this.globalConfig[proxyKey];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceAdapter = getServiceAdapter(tempConfig);
|
||||||
|
|
||||||
|
// 获取所有可能的请求格式
|
||||||
|
const healthCheckRequests = this._buildHealthCheckRequests(providerType, modelName);
|
||||||
|
|
||||||
|
// 重试机制:尝试不同的请求格式
|
||||||
|
const maxRetries = healthCheckRequests.length;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
const healthCheckRequest = healthCheckRequests[i];
|
||||||
|
try {
|
||||||
|
this._log('debug', `Health check attempt ${i + 1}/${maxRetries} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`);
|
||||||
|
await serviceAdapter.generateContent(modelName, healthCheckRequest);
|
||||||
|
return { success: true, modelName, errorMessage: null };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`);
|
||||||
|
// 继续尝试下一个格式
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有尝试都失败
|
||||||
|
this._log('error', `Health check failed for ${providerType} after ${maxRetries} attempts: ${lastError?.message}`);
|
||||||
|
return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -472,6 +538,9 @@ export class ProviderPoolManager {
|
||||||
if (config.lastErrorTime instanceof Date) {
|
if (config.lastErrorTime instanceof Date) {
|
||||||
config.lastErrorTime = config.lastErrorTime.toISOString();
|
config.lastErrorTime = config.lastErrorTime.toISOString();
|
||||||
}
|
}
|
||||||
|
if (config.lastHealthCheckTime instanceof Date) {
|
||||||
|
config.lastHealthCheckTime = config.lastHealthCheckTime.toISOString();
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
342
src/provider-utils.js
Normal file
342
src/provider-utils.js
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
/**
|
||||||
|
* 提供商工具模块
|
||||||
|
* 包含 ui-manager.js 和 service-manager.js 共用的工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供商目录映射配置
|
||||||
|
* 定义目录名称到提供商类型的映射关系
|
||||||
|
*/
|
||||||
|
export const PROVIDER_MAPPINGS = [
|
||||||
|
{
|
||||||
|
// Kiro OAuth 配置
|
||||||
|
dirName: 'kiro',
|
||||||
|
patterns: ['configs/kiro/', '/kiro/'],
|
||||||
|
providerType: 'claude-kiro-oauth',
|
||||||
|
credPathKey: 'KIRO_OAUTH_CREDS_FILE_PATH',
|
||||||
|
defaultCheckModel: 'claude-haiku-4-5',
|
||||||
|
displayName: 'Claude Kiro OAuth',
|
||||||
|
needsProjectId: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Gemini CLI OAuth 配置
|
||||||
|
dirName: 'gemini',
|
||||||
|
patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'],
|
||||||
|
providerType: 'gemini-cli-oauth',
|
||||||
|
credPathKey: 'GEMINI_OAUTH_CREDS_FILE_PATH',
|
||||||
|
defaultCheckModel: 'gemini-2.5-flash',
|
||||||
|
displayName: 'Gemini CLI OAuth',
|
||||||
|
needsProjectId: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Qwen OAuth 配置
|
||||||
|
dirName: 'qwen',
|
||||||
|
patterns: ['configs/qwen/', '/qwen/'],
|
||||||
|
providerType: 'openai-qwen-oauth',
|
||||||
|
credPathKey: 'QWEN_OAUTH_CREDS_FILE_PATH',
|
||||||
|
defaultCheckModel: 'qwen3-coder-plus',
|
||||||
|
displayName: 'Qwen OAuth',
|
||||||
|
needsProjectId: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Antigravity OAuth 配置
|
||||||
|
dirName: 'antigravity',
|
||||||
|
patterns: ['configs/antigravity/', '/antigravity/'],
|
||||||
|
providerType: 'gemini-antigravity',
|
||||||
|
credPathKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH',
|
||||||
|
defaultCheckModel: 'gemini-2.5-computer-use-preview-10-2025',
|
||||||
|
displayName: 'Gemini Antigravity',
|
||||||
|
needsProjectId: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 UUID
|
||||||
|
* @returns {string} UUID 字符串
|
||||||
|
*/
|
||||||
|
export function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化路径,用于跨平台兼容
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @returns {string} 使用正斜杠的标准化路径
|
||||||
|
*/
|
||||||
|
export function normalizePath(filePath) {
|
||||||
|
if (!filePath) return filePath;
|
||||||
|
|
||||||
|
// 使用 path 模块标准化,然后转换为正斜杠
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
|
return normalized.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从路径中提取文件名
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @returns {string} 文件名
|
||||||
|
*/
|
||||||
|
export function getFileName(filePath) {
|
||||||
|
return path.basename(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化相对路径为当前系统的路径格式
|
||||||
|
* @param {string} relativePath - 相对路径
|
||||||
|
* @returns {string} 格式化后的路径(带有 ./ 或 .\ 前缀)
|
||||||
|
*/
|
||||||
|
export function formatSystemPath(relativePath) {
|
||||||
|
if (!relativePath) return relativePath;
|
||||||
|
|
||||||
|
// 根据操作系统判断使用对应的路径分隔符
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const separator = isWindows ? '\\' : '/';
|
||||||
|
// 统一转换路径分隔符为当前系统的分隔符
|
||||||
|
const systemPath = relativePath.replace(/[\/\\]/g, separator);
|
||||||
|
return systemPath.startsWith('.' + separator) ? systemPath : '.' + separator + systemPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查两个路径是否指向同一文件(跨平台兼容)
|
||||||
|
* @param {string} path1 - 第一个路径
|
||||||
|
* @param {string} path2 - 第二个路径
|
||||||
|
* @returns {boolean} 如果路径指向同一文件则返回 true
|
||||||
|
*/
|
||||||
|
export function pathsEqual(path1, path2) {
|
||||||
|
if (!path1 || !path2) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 标准化两个路径
|
||||||
|
const normalized1 = normalizePath(path1);
|
||||||
|
const normalized2 = normalizePath(path2);
|
||||||
|
|
||||||
|
// 直接匹配
|
||||||
|
if (normalized1 === normalized2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除开头的 './' 后比较
|
||||||
|
const clean1 = normalized1.replace(/^\.\//, '');
|
||||||
|
const clean2 = normalized2.replace(/^\.\//, '');
|
||||||
|
|
||||||
|
if (clean1 === clean2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查一个是否是另一个的子集(用于相对路径与绝对路径比较)
|
||||||
|
if (normalized1.endsWith('/' + clean2) || normalized2.endsWith('/' + clean1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Path Comparison] Error comparing paths: ${path1} vs ${path2}`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件路径是否正在被使用(跨平台兼容)
|
||||||
|
* @param {string} relativePath - 相对路径
|
||||||
|
* @param {string} fileName - 文件名
|
||||||
|
* @param {Set} usedPaths - 已使用路径的集合
|
||||||
|
* @returns {boolean} 如果文件正在被使用则返回 true
|
||||||
|
*/
|
||||||
|
export function isPathUsed(relativePath, fileName, usedPaths) {
|
||||||
|
if (!relativePath) return false;
|
||||||
|
|
||||||
|
// 标准化相对路径
|
||||||
|
const normalizedRelativePath = normalizePath(relativePath);
|
||||||
|
const cleanRelativePath = normalizedRelativePath.replace(/^\.\//, '');
|
||||||
|
|
||||||
|
// 从相对路径获取文件名
|
||||||
|
const relativeFileName = getFileName(normalizedRelativePath);
|
||||||
|
|
||||||
|
// 遍历所有已使用路径进行匹配
|
||||||
|
for (const usedPath of usedPaths) {
|
||||||
|
if (!usedPath) continue;
|
||||||
|
|
||||||
|
// 1. 直接路径匹配
|
||||||
|
if (pathsEqual(relativePath, usedPath) || pathsEqual(relativePath, './' + usedPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 标准化路径匹配
|
||||||
|
if (pathsEqual(normalizedRelativePath, usedPath) ||
|
||||||
|
pathsEqual(normalizedRelativePath, './' + usedPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 清理后的路径匹配
|
||||||
|
if (pathsEqual(cleanRelativePath, usedPath) ||
|
||||||
|
pathsEqual(cleanRelativePath, './' + usedPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 文件名匹配(确保不是误匹配)
|
||||||
|
const usedFileName = getFileName(usedPath);
|
||||||
|
if (usedFileName === fileName || usedFileName === relativeFileName) {
|
||||||
|
// 确保是同一个目录下的文件
|
||||||
|
const usedDir = path.dirname(usedPath);
|
||||||
|
const relativeDir = path.dirname(normalizedRelativePath);
|
||||||
|
|
||||||
|
if (pathsEqual(usedDir, relativeDir) ||
|
||||||
|
pathsEqual(usedDir, cleanRelativePath.replace(/\/[^\/]+$/, '')) ||
|
||||||
|
pathsEqual(relativeDir.replace(/^\.\//, ''), usedDir.replace(/^\.\//, ''))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 绝对路径匹配(Windows 和 Unix)
|
||||||
|
try {
|
||||||
|
const resolvedUsedPath = path.resolve(usedPath);
|
||||||
|
const resolvedRelativePath = path.resolve(relativePath);
|
||||||
|
|
||||||
|
if (resolvedUsedPath === resolvedRelativePath) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略路径解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件路径检测提供商类型
|
||||||
|
* @param {string} normalizedPath - 标准化的文件路径(小写,正斜杠)
|
||||||
|
* @returns {Object|null} 提供商映射对象,如果未检测到则返回 null
|
||||||
|
*/
|
||||||
|
export function detectProviderFromPath(normalizedPath) {
|
||||||
|
// 遍历映射关系,查找匹配的提供商
|
||||||
|
for (const mapping of PROVIDER_MAPPINGS) {
|
||||||
|
for (const pattern of mapping.patterns) {
|
||||||
|
if (normalizedPath.includes(pattern)) {
|
||||||
|
return {
|
||||||
|
providerType: mapping.providerType,
|
||||||
|
credPathKey: mapping.credPathKey,
|
||||||
|
defaultCheckModel: mapping.defaultCheckModel,
|
||||||
|
displayName: mapping.displayName,
|
||||||
|
needsProjectId: mapping.needsProjectId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据目录名获取提供商映射
|
||||||
|
* @param {string} dirName - 目录名称
|
||||||
|
* @returns {Object|null} 提供商映射对象,如果未找到则返回 null
|
||||||
|
*/
|
||||||
|
export function getProviderMappingByDirName(dirName) {
|
||||||
|
return PROVIDER_MAPPINGS.find(m => m.dirName === dirName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件是否是有效的 OAuth 凭据文件
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @returns {Promise<boolean>} 是否有效
|
||||||
|
*/
|
||||||
|
export async function isValidOAuthCredentials(filePath) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
const jsonData = JSON.parse(content);
|
||||||
|
|
||||||
|
// 检查是否包含 OAuth 相关字段
|
||||||
|
// 凭据通常包含 access_token/accessToken, refresh_token/refreshToken, client_id 等字段
|
||||||
|
// 支持下划线命名(access_token)和驼峰命名(accessToken)两种格式
|
||||||
|
if (jsonData.access_token || jsonData.refresh_token ||
|
||||||
|
jsonData.accessToken || jsonData.refreshToken ||
|
||||||
|
jsonData.client_id || jsonData.client_secret ||
|
||||||
|
jsonData.token || jsonData.credentials) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 也可能是包含嵌套结构的凭据文件
|
||||||
|
if (jsonData.installed || jsonData.web) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
// 如果无法解析,认为不是有效的凭据文件
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的提供商配置对象
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {string} options.credPathKey - 凭据路径键名
|
||||||
|
* @param {string} options.credPath - 凭据文件路径
|
||||||
|
* @param {string} options.defaultCheckModel - 默认检测模型
|
||||||
|
* @param {boolean} options.needsProjectId - 是否需要 PROJECT_ID
|
||||||
|
* @returns {Object} 新的提供商配置对象
|
||||||
|
*/
|
||||||
|
export function createProviderConfig(options) {
|
||||||
|
const { credPathKey, credPath, defaultCheckModel, needsProjectId } = options;
|
||||||
|
|
||||||
|
const newProvider = {
|
||||||
|
[credPathKey]: credPath,
|
||||||
|
uuid: generateUUID(),
|
||||||
|
checkModelName: defaultCheckModel,
|
||||||
|
checkHealth: true,
|
||||||
|
isHealthy: true,
|
||||||
|
isDisabled: false,
|
||||||
|
lastUsed: null,
|
||||||
|
usageCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
lastErrorTime: null,
|
||||||
|
lastHealthCheckTime: null,
|
||||||
|
lastHealthCheckModel: null,
|
||||||
|
lastErrorMessage: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果需要 PROJECT_ID,添加空字符串占位
|
||||||
|
if (needsProjectId) {
|
||||||
|
newProvider.PROJECT_ID = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return newProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将路径添加到已使用路径集合(标准化多种格式)
|
||||||
|
* @param {Set} usedPaths - 已使用路径的集合
|
||||||
|
* @param {string} filePath - 要添加的文件路径
|
||||||
|
*/
|
||||||
|
export function addToUsedPaths(usedPaths, filePath) {
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
usedPaths.add(filePath);
|
||||||
|
usedPaths.add(normalizedPath);
|
||||||
|
if (normalizedPath.startsWith('./')) {
|
||||||
|
usedPaths.add(normalizedPath.slice(2));
|
||||||
|
} else {
|
||||||
|
usedPaths.add('./' + normalizedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查路径是否已关联(用于自动关联检测)
|
||||||
|
* @param {string} relativePath - 相对路径
|
||||||
|
* @param {Set} linkedPaths - 已关联路径的集合
|
||||||
|
* @returns {boolean} 是否已关联
|
||||||
|
*/
|
||||||
|
export function isPathLinked(relativePath, linkedPaths) {
|
||||||
|
return linkedPaths.has(relativePath) ||
|
||||||
|
linkedPaths.has('./' + relativePath) ||
|
||||||
|
linkedPaths.has(relativePath.replace(/^\.\//, ''));
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,168 @@
|
||||||
import { getServiceAdapter, serviceInstances } from './adapter.js';
|
import { getServiceAdapter, serviceInstances } from './adapter.js';
|
||||||
import { ProviderPoolManager } from './provider-pool-manager.js';
|
import { ProviderPoolManager } from './provider-pool-manager.js';
|
||||||
import deepmerge from 'deepmerge';
|
import deepmerge from 'deepmerge';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { promises as pfs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
PROVIDER_MAPPINGS,
|
||||||
|
createProviderConfig,
|
||||||
|
addToUsedPaths,
|
||||||
|
isPathUsed,
|
||||||
|
getFileName,
|
||||||
|
formatSystemPath
|
||||||
|
} from './provider-utils.js';
|
||||||
|
|
||||||
// 存储 ProviderPoolManager 实例
|
// 存储 ProviderPoolManager 实例
|
||||||
let providerPoolManager = null;
|
let providerPoolManager = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描 configs 目录并自动关联未关联的配置文件到对应的提供商
|
||||||
|
* @param {Object} config - 服务器配置对象
|
||||||
|
* @returns {Promise<Object>} 更新后的 providerPools 对象
|
||||||
|
*/
|
||||||
|
async function autoLinkProviderConfigs(config) {
|
||||||
|
// 确保 providerPools 对象存在
|
||||||
|
if (!config.providerPools) {
|
||||||
|
config.providerPools = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalNewProviders = 0;
|
||||||
|
const allNewProviders = {};
|
||||||
|
|
||||||
|
// 遍历所有提供商映射
|
||||||
|
for (const mapping of PROVIDER_MAPPINGS) {
|
||||||
|
const configsPath = path.join(process.cwd(), 'configs', mapping.dirName);
|
||||||
|
const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = mapping;
|
||||||
|
|
||||||
|
// 确保提供商类型数组存在
|
||||||
|
if (!config.providerPools[providerType]) {
|
||||||
|
config.providerPools[providerType] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查目录是否存在
|
||||||
|
if (!fs.existsSync(configsPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取已关联的配置文件路径集合
|
||||||
|
const linkedPaths = new Set();
|
||||||
|
for (const provider of config.providerPools[providerType]) {
|
||||||
|
if (provider[credPathKey]) {
|
||||||
|
// 使用公共方法添加路径的所有变体格式
|
||||||
|
addToUsedPaths(linkedPaths, provider[credPathKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归扫描目录
|
||||||
|
const newProviders = [];
|
||||||
|
await scanProviderDirectory(configsPath, linkedPaths, newProviders, {
|
||||||
|
credPathKey,
|
||||||
|
defaultCheckModel,
|
||||||
|
needsProjectId
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果有新的配置文件需要关联
|
||||||
|
if (newProviders.length > 0) {
|
||||||
|
config.providerPools[providerType].push(...newProviders);
|
||||||
|
totalNewProviders += newProviders.length;
|
||||||
|
allNewProviders[displayName] = newProviders;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有新的配置文件需要关联,保存更新后的 provider_pools.json
|
||||||
|
if (totalNewProviders > 0) {
|
||||||
|
const filePath = config.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
|
||||||
|
try {
|
||||||
|
await pfs.writeFile(filePath, JSON.stringify(config.providerPools, null, 2), 'utf8');
|
||||||
|
console.log(`[Auto-Link] Added ${totalNewProviders} new config(s) to provider pools:`);
|
||||||
|
for (const [displayName, providers] of Object.entries(allNewProviders)) {
|
||||||
|
console.log(` ${displayName}: ${providers.length} config(s)`);
|
||||||
|
providers.forEach(p => {
|
||||||
|
// 获取凭据路径键
|
||||||
|
const credKey = Object.keys(p).find(k => k.endsWith('_CREDS_FILE_PATH'));
|
||||||
|
if (credKey) {
|
||||||
|
console.log(` - ${p[credKey]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Auto-Link] Failed to save provider_pools.json: ${error.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Auto-Link] No new configs to link');
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.providerPools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归扫描提供商配置目录
|
||||||
|
* @param {string} dirPath - 目录路径
|
||||||
|
* @param {Set} linkedPaths - 已关联的路径集合
|
||||||
|
* @param {Array} newProviders - 新提供商配置数组
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {string} options.credPathKey - 凭据路径键名
|
||||||
|
* @param {string} options.defaultCheckModel - 默认检测模型
|
||||||
|
* @param {boolean} options.needsProjectId - 是否需要 PROJECT_ID
|
||||||
|
*/
|
||||||
|
async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options) {
|
||||||
|
const { credPathKey, defaultCheckModel, needsProjectId } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await pfs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dirPath, file.name);
|
||||||
|
|
||||||
|
if (file.isFile()) {
|
||||||
|
const ext = path.extname(file.name).toLowerCase();
|
||||||
|
// 只处理 JSON 文件
|
||||||
|
if (ext === '.json') {
|
||||||
|
const relativePath = path.relative(process.cwd(), fullPath);
|
||||||
|
const fileName = getFileName(fullPath);
|
||||||
|
|
||||||
|
// 使用与 ui-manager.js 相同的 isPathUsed 函数检查是否已关联
|
||||||
|
const isLinked = isPathUsed(relativePath, fileName, linkedPaths);
|
||||||
|
|
||||||
|
if (!isLinked) {
|
||||||
|
// 使用公共方法创建新的提供商配置
|
||||||
|
const newProvider = createProviderConfig({
|
||||||
|
credPathKey,
|
||||||
|
credPath: formatSystemPath(relativePath),
|
||||||
|
defaultCheckModel,
|
||||||
|
needsProjectId
|
||||||
|
});
|
||||||
|
|
||||||
|
newProviders.push(newProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (file.isDirectory()) {
|
||||||
|
// 递归扫描子目录(限制深度为 3 层)
|
||||||
|
const relativePath = path.relative(process.cwd(), fullPath);
|
||||||
|
const depth = relativePath.split(path.sep).length;
|
||||||
|
if (depth < 5) { // configs/{provider}/subfolder/subsubfolder
|
||||||
|
await scanProviderDirectory(fullPath, linkedPaths, newProviders, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Auto-Link] Failed to scan directory ${dirPath}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:isValidOAuthCredentials 已移至 provider-utils.js 公共模块
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize API services and provider pool manager
|
* Initialize API services and provider pool manager
|
||||||
* @param {Object} config - The server configuration
|
* @param {Object} config - The server configuration
|
||||||
* @returns {Promise<Object>} The initialized services
|
* @returns {Promise<Object>} The initialized services
|
||||||
*/
|
*/
|
||||||
export async function initApiService(config) {
|
export async function initApiService(config) {
|
||||||
|
// 自动关联 configs 目录中的配置文件到对应的提供商
|
||||||
|
console.log('[Initialization] Checking for unlinked provider configs...');
|
||||||
|
await autoLinkProviderConfigs(config);
|
||||||
|
|
||||||
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
|
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
|
||||||
providerPoolManager = new ProviderPoolManager(config.providerPools, {
|
providerPoolManager = new ProviderPoolManager(config.providerPools, {
|
||||||
globalConfig: config,
|
globalConfig: config,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,18 @@ import { CONFIG } from './config-manager.js';
|
||||||
import { serviceInstances } from './adapter.js';
|
import { serviceInstances } from './adapter.js';
|
||||||
import { initApiService } from './service-manager.js';
|
import { initApiService } from './service-manager.js';
|
||||||
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth } from './oauth-handlers.js';
|
import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth } from './oauth-handlers.js';
|
||||||
|
import {
|
||||||
|
generateUUID,
|
||||||
|
normalizePath,
|
||||||
|
getFileName,
|
||||||
|
pathsEqual,
|
||||||
|
isPathUsed,
|
||||||
|
detectProviderFromPath,
|
||||||
|
isValidOAuthCredentials,
|
||||||
|
createProviderConfig,
|
||||||
|
addToUsedPaths,
|
||||||
|
formatSystemPath
|
||||||
|
} from './provider-utils.js';
|
||||||
|
|
||||||
// Token存储到本地文件中
|
// Token存储到本地文件中
|
||||||
const TOKEN_STORE_FILE = 'token-store.json';
|
const TOKEN_STORE_FILE = 'token-store.json';
|
||||||
|
|
@ -706,11 +718,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
||||||
|
|
||||||
// Generate UUID if not provided
|
// Generate UUID if not provided
|
||||||
if (!providerConfig.uuid) {
|
if (!providerConfig.uuid) {
|
||||||
providerConfig.uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
providerConfig.uuid = generateUUID();
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default values
|
// Set default values
|
||||||
|
|
@ -1091,6 +1099,118 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform health check for all providers of a specific type
|
||||||
|
const healthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/health-check$/);
|
||||||
|
if (method === 'POST' && healthCheckMatch) {
|
||||||
|
const providerType = decodeURIComponent(healthCheckMatch[1]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!providerPoolManager) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = providerPoolManager.providerStatus[providerType] || [];
|
||||||
|
|
||||||
|
if (providers.length === 0) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'No providers found for this type' } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[UI API] Starting health check for ${providers.length} providers in ${providerType}`);
|
||||||
|
|
||||||
|
// 执行健康检测(强制检查,忽略 checkHealth 配置)
|
||||||
|
const results = [];
|
||||||
|
for (const providerStatus of providers) {
|
||||||
|
const providerConfig = providerStatus.config;
|
||||||
|
try {
|
||||||
|
// 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置
|
||||||
|
const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true);
|
||||||
|
|
||||||
|
if (healthResult === null) {
|
||||||
|
results.push({
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: null,
|
||||||
|
message: '健康检测不支持此提供商类型'
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthResult.success) {
|
||||||
|
providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName);
|
||||||
|
results.push({
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: true,
|
||||||
|
modelName: healthResult.modelName,
|
||||||
|
message: '健康'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
providerPoolManager.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage);
|
||||||
|
providerStatus.config.lastHealthCheckTime = new Date().toISOString();
|
||||||
|
if (healthResult.modelName) {
|
||||||
|
providerStatus.config.lastHealthCheckModel = healthResult.modelName;
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: false,
|
||||||
|
modelName: healthResult.modelName,
|
||||||
|
message: healthResult.errorMessage || '检测失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
providerPoolManager.markProviderUnhealthy(providerType, providerConfig, error.message);
|
||||||
|
results.push({
|
||||||
|
uuid: providerConfig.uuid,
|
||||||
|
success: false,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存更新后的状态到文件
|
||||||
|
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
|
||||||
|
|
||||||
|
// 从 providerStatus 构建 providerPools 对象并保存
|
||||||
|
const providerPools = {};
|
||||||
|
for (const pType in providerPoolManager.providerStatus) {
|
||||||
|
providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config);
|
||||||
|
}
|
||||||
|
writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8');
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.success === true).length;
|
||||||
|
const failCount = results.filter(r => r.success === false).length;
|
||||||
|
|
||||||
|
console.log(`[UI API] Health check completed for ${providerType}: ${successCount} healthy, ${failCount} unhealthy`);
|
||||||
|
|
||||||
|
// 广播更新事件
|
||||||
|
broadcastEvent('config_update', {
|
||||||
|
action: 'health_check',
|
||||||
|
filePath: filePath,
|
||||||
|
providerType,
|
||||||
|
results,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `健康检测完成: ${successCount} 个健康, ${failCount} 个异常`,
|
||||||
|
successCount,
|
||||||
|
failCount,
|
||||||
|
totalCount: providers.length,
|
||||||
|
results
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UI API] Health check error:', error);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate OAuth authorization URL for providers
|
// Generate OAuth authorization URL for providers
|
||||||
const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/);
|
const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/);
|
||||||
if (method === 'POST' && generateAuthUrlMatch) {
|
if (method === 'POST' && generateAuthUrlMatch) {
|
||||||
|
|
@ -1309,6 +1429,125 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick link config to corresponding provider based on directory
|
||||||
|
if (method === 'POST' && pathParam === '/api/quick-link-provider') {
|
||||||
|
try {
|
||||||
|
const body = await getRequestBody(req);
|
||||||
|
const { filePath } = body;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'filePath is required' } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
|
||||||
|
// 根据文件路径自动识别提供商类型
|
||||||
|
const providerMapping = detectProviderFromPath(normalizedPath);
|
||||||
|
|
||||||
|
if (!providerMapping) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: '无法识别配置文件对应的提供商类型,请确保文件位于 configs/kiro/、configs/gemini/、configs/qwen/ 或 configs/antigravity/ 目录下'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { providerType, credPathKey, defaultCheckModel, displayName } = providerMapping;
|
||||||
|
const poolsFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
|
||||||
|
|
||||||
|
// Load existing pools
|
||||||
|
let providerPools = {};
|
||||||
|
if (existsSync(poolsFilePath)) {
|
||||||
|
try {
|
||||||
|
const fileContent = readFileSync(poolsFilePath, 'utf8');
|
||||||
|
providerPools = JSON.parse(fileContent);
|
||||||
|
} catch (readError) {
|
||||||
|
console.warn('[UI API] Failed to read existing provider pools:', readError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure provider type array exists
|
||||||
|
if (!providerPools[providerType]) {
|
||||||
|
providerPools[providerType] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already linked - 使用标准化路径进行比较
|
||||||
|
const normalizedForComparison = filePath.replace(/\\/g, '/');
|
||||||
|
const isAlreadyLinked = providerPools[providerType].some(p => {
|
||||||
|
const existingPath = p[credPathKey];
|
||||||
|
if (!existingPath) return false;
|
||||||
|
const normalizedExistingPath = existingPath.replace(/\\/g, '/');
|
||||||
|
return normalizedExistingPath === normalizedForComparison ||
|
||||||
|
normalizedExistingPath === './' + normalizedForComparison ||
|
||||||
|
'./' + normalizedExistingPath === normalizedForComparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAlreadyLinked) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: '该配置文件已关联' } }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new provider config based on provider type
|
||||||
|
const newProvider = createProviderConfig({
|
||||||
|
credPathKey,
|
||||||
|
credPath: formatSystemPath(filePath),
|
||||||
|
defaultCheckModel,
|
||||||
|
needsProjectId: providerMapping.needsProjectId
|
||||||
|
});
|
||||||
|
|
||||||
|
providerPools[providerType].push(newProvider);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf8');
|
||||||
|
console.log(`[UI API] Quick linked config: ${filePath} -> ${providerType}`);
|
||||||
|
|
||||||
|
// Update provider pool manager if available
|
||||||
|
if (providerPoolManager) {
|
||||||
|
providerPoolManager.providerPools = providerPools;
|
||||||
|
providerPoolManager.initializeProviderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update event
|
||||||
|
broadcastEvent('config_update', {
|
||||||
|
action: 'quick_link',
|
||||||
|
filePath: poolsFilePath,
|
||||||
|
providerType,
|
||||||
|
newProvider,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastEvent('provider_update', {
|
||||||
|
action: 'add',
|
||||||
|
providerType,
|
||||||
|
providerConfig: newProvider,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `配置已成功关联到 ${displayName}`,
|
||||||
|
provider: newProvider,
|
||||||
|
providerType: providerType
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UI API] Quick link failed:', error);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: '关联失败: ' + error.message
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reload configuration files
|
// Reload configuration files
|
||||||
if (method === 'POST' && pathParam === '/api/reload-config') {
|
if (method === 'POST' && pathParam === '/api/reload-config') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1431,30 +1670,9 @@ async function scanConfigFiles(currentConfig, providerPoolManager) {
|
||||||
const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态
|
const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态
|
||||||
|
|
||||||
// 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式
|
// 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式
|
||||||
if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) {
|
addToUsedPaths(usedPaths, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH);
|
||||||
const normalizedPath = currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/');
|
addToUsedPaths(usedPaths, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH);
|
||||||
usedPaths.add(currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH);
|
addToUsedPaths(usedPaths, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH);
|
||||||
usedPaths.add(normalizedPath);
|
|
||||||
if (normalizedPath.startsWith('./')) {
|
|
||||||
usedPaths.add(normalizedPath.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) {
|
|
||||||
const normalizedPath = currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/');
|
|
||||||
usedPaths.add(currentConfig.KIRO_OAUTH_CREDS_FILE_PATH);
|
|
||||||
usedPaths.add(normalizedPath);
|
|
||||||
if (normalizedPath.startsWith('./')) {
|
|
||||||
usedPaths.add(normalizedPath.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) {
|
|
||||||
const normalizedPath = currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/');
|
|
||||||
usedPaths.add(currentConfig.QWEN_OAUTH_CREDS_FILE_PATH);
|
|
||||||
usedPaths.add(normalizedPath);
|
|
||||||
if (normalizedPath.startsWith('./')) {
|
|
||||||
usedPaths.add(normalizedPath.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用最新的提供商池数据
|
// 使用最新的提供商池数据
|
||||||
let providerPools = currentConfig.providerPools;
|
let providerPools = currentConfig.providerPools;
|
||||||
|
|
@ -1466,30 +1684,10 @@ async function scanConfigFiles(currentConfig, providerPoolManager) {
|
||||||
if (providerPools) {
|
if (providerPools) {
|
||||||
for (const [providerType, providers] of Object.entries(providerPools)) {
|
for (const [providerType, providers] of Object.entries(providerPools)) {
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
if (provider.GEMINI_OAUTH_CREDS_FILE_PATH) {
|
addToUsedPaths(usedPaths, provider.GEMINI_OAUTH_CREDS_FILE_PATH);
|
||||||
const normalizedPath = provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/');
|
addToUsedPaths(usedPaths, provider.KIRO_OAUTH_CREDS_FILE_PATH);
|
||||||
usedPaths.add(provider.GEMINI_OAUTH_CREDS_FILE_PATH);
|
addToUsedPaths(usedPaths, provider.QWEN_OAUTH_CREDS_FILE_PATH);
|
||||||
usedPaths.add(normalizedPath);
|
addToUsedPaths(usedPaths, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH);
|
||||||
if (normalizedPath.startsWith('./')) {
|
|
||||||
usedPaths.add(normalizedPath.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (provider.KIRO_OAUTH_CREDS_FILE_PATH) {
|
|
||||||
const normalizedPath = provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/');
|
|
||||||
usedPaths.add(provider.KIRO_OAUTH_CREDS_FILE_PATH);
|
|
||||||
usedPaths.add(normalizedPath);
|
|
||||||
if (normalizedPath.startsWith('./')) {
|
|
||||||
usedPaths.add(normalizedPath.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (provider.QWEN_OAUTH_CREDS_FILE_PATH) {
|
|
||||||
const normalizedPath = provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/');
|
|
||||||
usedPaths.add(provider.QWEN_OAUTH_CREDS_FILE_PATH);
|
|
||||||
usedPaths.add(normalizedPath);
|
|
||||||
if (normalizedPath.startsWith('./')) {
|
|
||||||
usedPaths.add(normalizedPath.slice(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1696,6 +1894,18 @@ function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH &&
|
||||||
|
(pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) ||
|
||||||
|
pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) {
|
||||||
|
providerUsages.push({
|
||||||
|
type: '提供商池',
|
||||||
|
location: `Antigravity OAuth凭据 (节点${index + 1})`,
|
||||||
|
providerType: providerType,
|
||||||
|
providerIndex: index,
|
||||||
|
configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (providerUsages.length > 0) {
|
if (providerUsages.length > 0) {
|
||||||
usageInfo.usageType = 'provider_pool';
|
usageInfo.usageType = 'provider_pool';
|
||||||
usageInfo.usageDetails.push(...providerUsages);
|
usageInfo.usageDetails.push(...providerUsages);
|
||||||
|
|
@ -1754,131 +1964,5 @@ async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
// 注意:normalizePath, getFileName, pathsEqual, isPathUsed, detectProviderFromPath
|
||||||
* Normalize a path for cross-platform compatibility
|
// 已移至 provider-utils.js 公共模块
|
||||||
* @param {string} filePath - The file path to normalize
|
|
||||||
* @returns {string} Normalized path using forward slashes
|
|
||||||
*/
|
|
||||||
function normalizePath(filePath) {
|
|
||||||
if (!filePath) return filePath;
|
|
||||||
|
|
||||||
// Use path module to normalize and then convert to forward slashes
|
|
||||||
const normalized = path.normalize(filePath);
|
|
||||||
return normalized.replace(/\\/g, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract filename from any path format
|
|
||||||
* @param {string} filePath - The file path
|
|
||||||
* @returns {string} Filename
|
|
||||||
*/
|
|
||||||
function getFileName(filePath) {
|
|
||||||
return path.basename(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if two paths refer to the same file (cross-platform compatible)
|
|
||||||
* @param {string} path1 - First path
|
|
||||||
* @param {string} path2 - Second path
|
|
||||||
* @returns {boolean} True if paths refer to same file
|
|
||||||
*/
|
|
||||||
function pathsEqual(path1, path2) {
|
|
||||||
if (!path1 || !path2) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Normalize both paths
|
|
||||||
const normalized1 = normalizePath(path1);
|
|
||||||
const normalized2 = normalizePath(path2);
|
|
||||||
|
|
||||||
// Direct match
|
|
||||||
if (normalized1 === normalized2) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove leading './' if present
|
|
||||||
const clean1 = normalized1.replace(/^\.\//, '');
|
|
||||||
const clean2 = normalized2.replace(/^\.\//, '');
|
|
||||||
|
|
||||||
if (clean1 === clean2) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if one is a subset of the other (for relative vs absolute)
|
|
||||||
if (normalized1.endsWith('/' + clean2) || normalized2.endsWith('/' + clean1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[Path Comparison] Error comparing paths: ${path1} vs ${path2}`, error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file path is being used (cross-platform compatible)
|
|
||||||
* @param {string} relativePath - Relative path
|
|
||||||
* @param {string} fileName - File name
|
|
||||||
* @param {Set} usedPaths - Set of used paths
|
|
||||||
* @returns {boolean} True if the file is being used
|
|
||||||
*/
|
|
||||||
function isPathUsed(relativePath, fileName, usedPaths) {
|
|
||||||
if (!relativePath) return false;
|
|
||||||
|
|
||||||
// Normalize the relative path
|
|
||||||
const normalizedRelativePath = normalizePath(relativePath);
|
|
||||||
const cleanRelativePath = normalizedRelativePath.replace(/^\.\//, '');
|
|
||||||
|
|
||||||
// Get the filename from relative path
|
|
||||||
const relativeFileName = getFileName(normalizedRelativePath);
|
|
||||||
|
|
||||||
// 遍历所有已使用路径进行匹配
|
|
||||||
for (const usedPath of usedPaths) {
|
|
||||||
if (!usedPath) continue;
|
|
||||||
|
|
||||||
// 1. 直接路径匹配
|
|
||||||
if (pathsEqual(relativePath, usedPath) || pathsEqual(relativePath, './' + usedPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 标准化路径匹配
|
|
||||||
if (pathsEqual(normalizedRelativePath, usedPath) ||
|
|
||||||
pathsEqual(normalizedRelativePath, './' + usedPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 清理后的路径匹配
|
|
||||||
if (pathsEqual(cleanRelativePath, usedPath) ||
|
|
||||||
pathsEqual(cleanRelativePath, './' + usedPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 文件名匹配(确保不是误匹配)
|
|
||||||
const usedFileName = getFileName(usedPath);
|
|
||||||
if (usedFileName === fileName || usedFileName === relativeFileName) {
|
|
||||||
// 确保是同一个目录下的文件
|
|
||||||
const usedDir = path.dirname(usedPath);
|
|
||||||
const relativeDir = path.dirname(normalizedRelativePath);
|
|
||||||
|
|
||||||
if (pathsEqual(usedDir, relativeDir) ||
|
|
||||||
pathsEqual(usedDir, cleanRelativePath.replace(/\/[^\/]+$/, '')) ||
|
|
||||||
pathsEqual(relativeDir.replace(/^\.\//, ''), usedDir.replace(/^\.\//, ''))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 绝对路径匹配(Windows和Unix)
|
|
||||||
try {
|
|
||||||
const resolvedUsedPath = path.resolve(usedPath);
|
|
||||||
const resolvedRelativePath = path.resolve(relativePath);
|
|
||||||
|
|
||||||
if (resolvedUsedPath === resolvedRelativePath) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore path resolution errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class FileUploadHandler {
|
||||||
getProviderKey(provider) {
|
getProviderKey(provider) {
|
||||||
const providerMap = {
|
const providerMap = {
|
||||||
'gemini-cli-oauth': 'gemini',
|
'gemini-cli-oauth': 'gemini',
|
||||||
|
'gemini-antigravity': 'antigravity',
|
||||||
'claude-kiro-oauth': 'kiro',
|
'claude-kiro-oauth': 'kiro',
|
||||||
'openai-qwen-oauth': 'qwen'
|
'openai-qwen-oauth': 'qwen'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js';
|
import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js';
|
||||||
import { handleProviderPasswordToggle } from './event-handlers.js';
|
import { handleProviderPasswordToggle } from './event-handlers.js';
|
||||||
|
|
||||||
|
// 分页配置
|
||||||
|
const PROVIDERS_PER_PAGE = 5;
|
||||||
|
let currentPage = 1;
|
||||||
|
let currentProviders = [];
|
||||||
|
let currentProviderType = '';
|
||||||
|
let cachedModels = []; // 缓存模型列表
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示提供商管理模态框
|
* 显示提供商管理模态框
|
||||||
* @param {Object} data - 提供商数据
|
* @param {Object} data - 提供商数据
|
||||||
|
|
@ -10,6 +17,12 @@ import { handleProviderPasswordToggle } from './event-handlers.js';
|
||||||
function showProviderManagerModal(data) {
|
function showProviderManagerModal(data) {
|
||||||
const { providerType, providers, totalCount, healthyCount } = data;
|
const { providerType, providers, totalCount, healthyCount } = data;
|
||||||
|
|
||||||
|
// 保存当前数据用于分页
|
||||||
|
currentProviders = providers;
|
||||||
|
currentProviderType = providerType;
|
||||||
|
currentPage = 1;
|
||||||
|
cachedModels = [];
|
||||||
|
|
||||||
// 移除已存在的模态框
|
// 移除已存在的模态框
|
||||||
const existingModal = document.querySelector('.provider-modal');
|
const existingModal = document.querySelector('.provider-modal');
|
||||||
if (existingModal) {
|
if (existingModal) {
|
||||||
|
|
@ -20,6 +33,8 @@ function showProviderManagerModal(data) {
|
||||||
existingModal.remove();
|
existingModal.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(providers.length / PROVIDERS_PER_PAGE);
|
||||||
|
|
||||||
// 创建模态框
|
// 创建模态框
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'provider-modal';
|
modal.className = 'provider-modal';
|
||||||
|
|
@ -49,12 +64,19 @@ function showProviderManagerModal(data) {
|
||||||
<button class="btn btn-warning" onclick="window.resetAllProvidersHealth('${providerType}')" title="将所有节点的健康状态重置为健康">
|
<button class="btn btn-warning" onclick="window.resetAllProvidersHealth('${providerType}')" title="将所有节点的健康状态重置为健康">
|
||||||
<i class="fas fa-heartbeat"></i> 重置为健康
|
<i class="fas fa-heartbeat"></i> 重置为健康
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-info" onclick="window.performHealthCheck('${providerType}')" title="对所有节点执行健康检测">
|
||||||
|
<i class="fas fa-stethoscope"></i> 健康检测
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''}
|
||||||
|
|
||||||
<div class="provider-list" id="providerList">
|
<div class="provider-list" id="providerList">
|
||||||
${renderProviderList(providers)}
|
${renderProviderListPaginated(providers, 1)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -66,20 +88,158 @@ function showProviderManagerModal(data) {
|
||||||
addModalEventListeners(modal);
|
addModalEventListeners(modal);
|
||||||
|
|
||||||
// 先获取该提供商类型的模型列表(只调用一次API)
|
// 先获取该提供商类型的模型列表(只调用一次API)
|
||||||
loadModelsForProviderType(providerType, providers);
|
const pageProviders = providers.slice(0, PROVIDERS_PER_PAGE);
|
||||||
|
loadModelsForProviderType(providerType, pageProviders);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为提供商类型加载模型列表(优化:只调用一次API)
|
* 渲染分页控件
|
||||||
|
* @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 {string} providerType - 提供商类型
|
||||||
* @param {Array} providers - 提供商列表
|
* @param {Array} providers - 提供商列表
|
||||||
*/
|
*/
|
||||||
async function loadModelsForProviderType(providerType, providers) {
|
async function loadModelsForProviderType(providerType, providers) {
|
||||||
try {
|
try {
|
||||||
|
// 如果已有缓存,直接使用
|
||||||
|
if (cachedModels.length > 0) {
|
||||||
|
providers.forEach(provider => {
|
||||||
|
renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 只调用一次API获取模型列表
|
// 只调用一次API获取模型列表
|
||||||
const response = await window.apiClient.get(`/provider-models/${encodeURIComponent(providerType)}`);
|
const response = await window.apiClient.get(`/provider-models/${encodeURIComponent(providerType)}`);
|
||||||
const models = response.models || [];
|
const models = response.models || [];
|
||||||
|
|
||||||
|
// 缓存模型列表
|
||||||
|
cachedModels = models;
|
||||||
|
|
||||||
// 为每个提供商渲染模型选择器
|
// 为每个提供商渲染模型选择器
|
||||||
providers.forEach(provider => {
|
providers.forEach(provider => {
|
||||||
renderNotSupportedModelsSelector(provider.uuid, models, provider.notSupportedModels || []);
|
renderNotSupportedModelsSelector(provider.uuid, models, provider.notSupportedModels || []);
|
||||||
|
|
@ -192,6 +352,8 @@ function renderProviderList(providers) {
|
||||||
const isHealthy = provider.isHealthy;
|
const isHealthy = provider.isHealthy;
|
||||||
const isDisabled = provider.isDisabled || false;
|
const isDisabled = provider.isDisabled || false;
|
||||||
const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用';
|
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 healthClass = isHealthy ? 'healthy' : 'unhealthy';
|
||||||
const disabledClass = isDisabled ? 'disabled' : '';
|
const disabledClass = isDisabled ? 'disabled' : '';
|
||||||
const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning';
|
const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning';
|
||||||
|
|
@ -202,6 +364,19 @@ function renderProviderList(providers) {
|
||||||
const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban';
|
const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban';
|
||||||
const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning';
|
const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning';
|
||||||
|
|
||||||
|
// 构建错误信息显示
|
||||||
|
let errorInfoHtml = '';
|
||||||
|
if (!isHealthy && provider.lastErrorMessage) {
|
||||||
|
const escapedErrorMsg = provider.lastErrorMessage.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
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 `
|
return `
|
||||||
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
|
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
|
||||||
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
|
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
|
||||||
|
|
@ -220,6 +395,17 @@ function renderProviderList(providers) {
|
||||||
失败次数: ${provider.errorCount || 0} |
|
失败次数: ${provider.errorCount || 0} |
|
||||||
最后使用: ${lastUsed}
|
最后使用: ${lastUsed}
|
||||||
</div>
|
</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>
|
||||||
<div class="provider-actions-group">
|
<div class="provider-actions-group">
|
||||||
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${provider.uuid}', event)" title="${toggleButtonText}此提供商">
|
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${provider.uuid}', event)" title="${toggleButtonText}此提供商">
|
||||||
|
|
@ -442,11 +628,15 @@ function renderProviderConfig(provider) {
|
||||||
function getFieldOrder(provider) {
|
function getFieldOrder(provider) {
|
||||||
const orderedFields = ['checkModelName', 'checkHealth'];
|
const orderedFields = ['checkModelName', 'checkHealth'];
|
||||||
|
|
||||||
|
// 需要排除的内部状态字段
|
||||||
|
const excludedFields = [
|
||||||
|
'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime',
|
||||||
|
'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage'
|
||||||
|
];
|
||||||
|
|
||||||
// 获取所有其他配置项
|
// 获取所有其他配置项
|
||||||
const otherFields = Object.keys(provider).filter(key =>
|
const otherFields = Object.keys(provider).filter(key =>
|
||||||
key !== 'isHealthy' && key !== 'lastUsed' && key !== 'usageCount' &&
|
!excludedFields.includes(key) && !orderedFields.includes(key)
|
||||||
key !== 'errorCount' && key !== 'lastErrorTime' && key !== 'uuid' &&
|
|
||||||
key !== 'isDisabled' && !orderedFields.includes(key)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 按字母顺序排序其他字段
|
// 按字母顺序排序其他字段
|
||||||
|
|
@ -687,6 +877,10 @@ async function refreshProviderConfig(providerType) {
|
||||||
// 如果当前显示的是该提供商类型的模态框,则更新模态框
|
// 如果当前显示的是该提供商类型的模态框,则更新模态框
|
||||||
const modal = document.querySelector('.provider-modal');
|
const modal = document.querySelector('.provider-modal');
|
||||||
if (modal && modal.getAttribute('data-provider-type') === providerType) {
|
if (modal && modal.getAttribute('data-provider-type') === providerType) {
|
||||||
|
// 更新缓存的提供商数据
|
||||||
|
currentProviders = data.providers;
|
||||||
|
currentProviderType = providerType;
|
||||||
|
|
||||||
// 更新统计信息
|
// 更新统计信息
|
||||||
const totalCountElement = modal.querySelector('.provider-summary-item .value');
|
const totalCountElement = modal.querySelector('.provider-summary-item .value');
|
||||||
if (totalCountElement) {
|
if (totalCountElement) {
|
||||||
|
|
@ -698,14 +892,46 @@ async function refreshProviderConfig(providerType) {
|
||||||
healthyCountElement.textContent = data.healthyCount;
|
healthyCountElement.textContent = data.healthyCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新渲染提供商列表
|
const totalPages = Math.ceil(data.providers.length / PROVIDERS_PER_PAGE);
|
||||||
const providerList = modal.querySelector('.provider-list');
|
|
||||||
if (providerList) {
|
// 确保当前页不超过总页数
|
||||||
providerList.innerHTML = renderProviderList(data.providers);
|
if (currentPage > totalPages) {
|
||||||
|
currentPage = Math.max(1, totalPages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新加载模型列表
|
// 重新渲染提供商列表(分页)
|
||||||
loadModelsForProviderType(providerType, data.providers);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同时更新主界面的提供商统计数据
|
// 同时更新主界面的提供商统计数据
|
||||||
|
|
@ -946,6 +1172,10 @@ async function addProvider(providerType) {
|
||||||
case 'openai-qwen-oauth':
|
case 'openai-qwen-oauth':
|
||||||
providerConfig.QWEN_OAUTH_CREDS_FILE_PATH = document.getElementById('newQwenOauthCredsFilePath')?.value || '';
|
providerConfig.QWEN_OAUTH_CREDS_FILE_PATH = document.getElementById('newQwenOauthCredsFilePath')?.value || '';
|
||||||
break;
|
break;
|
||||||
|
case 'gemini-antigravity':
|
||||||
|
providerConfig.PROJECT_ID = document.getElementById('newProjectId')?.value || '';
|
||||||
|
providerConfig.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH = document.getElementById('newAntigravityOauthCredsFilePath')?.value || '';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1037,6 +1267,49 @@ async function resetAllProvidersHealth(providerType) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行健康检测
|
||||||
|
* @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,直接使用传入的模型列表)
|
* 渲染不支持的模型选择器(不调用API,直接使用传入的模型列表)
|
||||||
* @param {string} uuid - 提供商UUID
|
* @param {string} uuid - 提供商UUID
|
||||||
|
|
@ -1087,8 +1360,10 @@ export {
|
||||||
addProvider,
|
addProvider,
|
||||||
toggleProviderStatus,
|
toggleProviderStatus,
|
||||||
resetAllProvidersHealth,
|
resetAllProvidersHealth,
|
||||||
|
performHealthCheck,
|
||||||
loadModelsForProviderType,
|
loadModelsForProviderType,
|
||||||
renderNotSupportedModelsSelector
|
renderNotSupportedModelsSelector,
|
||||||
|
goToProviderPage
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将函数挂载到window对象
|
// 将函数挂载到window对象
|
||||||
|
|
@ -1102,3 +1377,5 @@ window.showAddProviderForm = showAddProviderForm;
|
||||||
window.addProvider = addProvider;
|
window.addProvider = addProvider;
|
||||||
window.toggleProviderStatus = toggleProviderStatus;
|
window.toggleProviderStatus = toggleProviderStatus;
|
||||||
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
||||||
|
window.performHealthCheck = performHealthCheck;
|
||||||
|
window.goToProviderPage = goToProviderPage;
|
||||||
|
|
@ -1409,6 +1409,49 @@ input:checked + .toggle-slider:before {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-health-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b95a5;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-health-meta i {
|
||||||
|
margin-right: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-error-info {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: linear-gradient(135deg, #fff5f5 0%, #fed7d7 100%);
|
||||||
|
border: 1px solid #feb2b2;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-error-info i {
|
||||||
|
color: #e53e3e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-error-info .error-label {
|
||||||
|
color: #c53030;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-error-info .error-message {
|
||||||
|
color: #742a2a;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 60px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.provider-actions-group {
|
.provider-actions-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -1445,6 +1488,59 @@ input:checked + .toggle-slider:before {
|
||||||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
|
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-quick-link {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quick-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-quick-link i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-batch-link-kiro {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-batch-link-kiro:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-batch-link-kiro i {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-delete:hover {
|
.btn-delete:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
||||||
|
|
@ -2810,6 +2906,21 @@ input:checked + .toggle-slider:before {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 健康检测按钮样式 */
|
||||||
|
.btn-info {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* 提供商状态指示器 */
|
/* 提供商状态指示器 */
|
||||||
.provider-status .disabled-indicator {
|
.provider-status .disabled-indicator {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -3414,3 +3525,139 @@ input:checked + .toggle-slider:before {
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==================== 分页控件样式 ==================== */
|
||||||
|
.pagination-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container.top {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container.bottom {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled) {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn.nav-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-ellipsis {
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-jump {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input {
|
||||||
|
width: 60px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移除数字输入框的上下箭头 */
|
||||||
|
.page-jump-input::-webkit-outer-spin-button,
|
||||||
|
.page-jump-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式分页 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pagination-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-jump {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,14 @@ function createConfigItemElement(config, index) {
|
||||||
// 生成关联详情HTML
|
// 生成关联详情HTML
|
||||||
const usageInfoHtml = generateUsageInfoHtml(config);
|
const usageInfoHtml = generateUsageInfoHtml(config);
|
||||||
|
|
||||||
|
// 判断是否可以一键关联(未关联且路径包含支持的提供商目录)
|
||||||
|
const providerInfo = detectProviderFromPath(config.path);
|
||||||
|
const canQuickLink = !config.isUsed && providerInfo !== null;
|
||||||
|
const quickLinkBtnHtml = canQuickLink ?
|
||||||
|
`<button class="btn-quick-link" data-path="${config.path}" title="一键关联到 ${providerInfo.displayName}">
|
||||||
|
<i class="fas fa-link"></i> ${providerInfo.shortName}
|
||||||
|
</button>` : '';
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="config-item-header">
|
<div class="config-item-header">
|
||||||
<div class="config-item-name">${config.name}</div>
|
<div class="config-item-name">${config.name}</div>
|
||||||
|
|
@ -90,6 +98,7 @@ function createConfigItemElement(config, index) {
|
||||||
<div class="config-item-status">
|
<div class="config-item-status">
|
||||||
<i class="fas ${statusIcon}"></i>
|
<i class="fas ${statusIcon}"></i>
|
||||||
${statusText}
|
${statusText}
|
||||||
|
${quickLinkBtnHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item-details">
|
<div class="config-item-details">
|
||||||
|
|
@ -141,6 +150,15 @@ function createConfigItemElement(config, index) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 一键关联按钮事件
|
||||||
|
const quickLinkBtn = item.querySelector('.btn-quick-link');
|
||||||
|
if (quickLinkBtn) {
|
||||||
|
quickLinkBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
quickLinkProviderConfig(config.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 添加点击事件展开/折叠详情
|
// 添加点击事件展开/折叠详情
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.config-item-actions')) {
|
if (!e.target.closest('.config-item-actions')) {
|
||||||
|
|
@ -706,6 +724,12 @@ function initUploadConfigManager() {
|
||||||
refreshBtn.addEventListener('click', loadConfigList);
|
refreshBtn.addEventListener('click', loadConfigList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量关联配置按钮
|
||||||
|
const batchLinkBtn = document.getElementById('batchLinkKiroBtn') || document.getElementById('batchLinkProviderBtn');
|
||||||
|
if (batchLinkBtn) {
|
||||||
|
batchLinkBtn.addEventListener('click', batchLinkProviderConfigs);
|
||||||
|
}
|
||||||
|
|
||||||
// 初始加载配置列表
|
// 初始加载配置列表
|
||||||
loadConfigList();
|
loadConfigList();
|
||||||
}
|
}
|
||||||
|
|
@ -738,6 +762,150 @@ async function reloadConfig() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件路径检测对应的提供商类型
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @returns {Object|null} 提供商信息对象或null
|
||||||
|
*/
|
||||||
|
function detectProviderFromPath(filePath) {
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
|
||||||
|
// 定义目录到提供商的映射关系
|
||||||
|
const providerMappings = [
|
||||||
|
{
|
||||||
|
patterns: ['configs/kiro/', '/kiro/'],
|
||||||
|
providerType: 'claude-kiro-oauth',
|
||||||
|
displayName: 'Claude Kiro OAuth',
|
||||||
|
shortName: 'kiro-oauth'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'],
|
||||||
|
providerType: 'gemini-cli-oauth',
|
||||||
|
displayName: 'Gemini CLI OAuth',
|
||||||
|
shortName: 'gemini-oauth'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: ['configs/qwen/', '/qwen/'],
|
||||||
|
providerType: 'openai-qwen-oauth',
|
||||||
|
displayName: 'Qwen OAuth',
|
||||||
|
shortName: 'qwen-oauth'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: ['configs/antigravity/', '/antigravity/'],
|
||||||
|
providerType: 'gemini-antigravity',
|
||||||
|
displayName: 'Gemini Antigravity',
|
||||||
|
shortName: 'antigravity'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 遍历映射关系,查找匹配的提供商
|
||||||
|
for (const mapping of providerMappings) {
|
||||||
|
for (const pattern of mapping.patterns) {
|
||||||
|
if (normalizedPath.includes(pattern)) {
|
||||||
|
return {
|
||||||
|
providerType: mapping.providerType,
|
||||||
|
displayName: mapping.displayName,
|
||||||
|
shortName: mapping.shortName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键关联配置到对应的提供商
|
||||||
|
* @param {string} filePath - 配置文件路径
|
||||||
|
*/
|
||||||
|
async function quickLinkProviderConfig(filePath) {
|
||||||
|
try {
|
||||||
|
const providerInfo = detectProviderFromPath(filePath);
|
||||||
|
if (!providerInfo) {
|
||||||
|
showToast('无法识别配置文件对应的提供商类型', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`正在关联配置到 ${providerInfo.displayName}...`, 'info');
|
||||||
|
|
||||||
|
const result = await window.apiClient.post('/quick-link-provider', {
|
||||||
|
filePath: filePath
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast(result.message || '配置关联成功', 'success');
|
||||||
|
|
||||||
|
// 刷新配置列表
|
||||||
|
await loadConfigList();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('一键关联失败:', error);
|
||||||
|
showToast('关联失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量关联所有支持的提供商目录下的未关联配置
|
||||||
|
*/
|
||||||
|
async function batchLinkProviderConfigs() {
|
||||||
|
// 筛选出所有支持的提供商目录下的未关联配置
|
||||||
|
const unlinkedConfigs = allConfigs.filter(config => {
|
||||||
|
if (config.isUsed) return false;
|
||||||
|
const providerInfo = detectProviderFromPath(config.path);
|
||||||
|
return providerInfo !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unlinkedConfigs.length === 0) {
|
||||||
|
showToast('没有需要关联的配置文件', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按提供商类型分组统计
|
||||||
|
const groupedByProvider = {};
|
||||||
|
unlinkedConfigs.forEach(config => {
|
||||||
|
const providerInfo = detectProviderFromPath(config.path);
|
||||||
|
if (providerInfo) {
|
||||||
|
if (!groupedByProvider[providerInfo.displayName]) {
|
||||||
|
groupedByProvider[providerInfo.displayName] = 0;
|
||||||
|
}
|
||||||
|
groupedByProvider[providerInfo.displayName]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerSummary = Object.entries(groupedByProvider)
|
||||||
|
.map(([name, count]) => `${name}: ${count}个`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const confirmMsg = `确定要批量关联 ${unlinkedConfigs.length} 个配置吗?\n\n${providerSummary}`;
|
||||||
|
if (!confirm(confirmMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`正在批量关联 ${unlinkedConfigs.length} 个配置...`, 'info');
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const config of unlinkedConfigs) {
|
||||||
|
try {
|
||||||
|
await window.apiClient.post('/quick-link-provider', {
|
||||||
|
filePath: config.path
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`关联失败: ${config.path}`, error);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新配置列表
|
||||||
|
await loadConfigList();
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
showToast(`成功关联 ${successCount} 个配置`, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(`关联完成: 成功 ${successCount} 个, 失败 ${failCount} 个`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 防抖函数
|
* 防抖函数
|
||||||
* @param {Function} func - 要防抖的函数
|
* @param {Function} func - 要防抖的函数
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,20 @@ function getProviderTypeFields(providerType) {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: '例如: ~/.qwen/oauth_creds.json'
|
placeholder: '例如: ~/.qwen/oauth_creds.json'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
'gemini-antigravity': [
|
||||||
|
{
|
||||||
|
id: 'ProjectId',
|
||||||
|
label: '项目ID (选填)',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Google Cloud项目ID (留空自动发现)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AntigravityOauthCredsFilePath',
|
||||||
|
label: 'OAuth凭据文件路径',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '例如: ~/.antigravity/oauth_creds.json'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -768,6 +768,9 @@
|
||||||
<span id="configCount">共 0 个配置文件</span>
|
<span id="configCount">共 0 个配置文件</span>
|
||||||
<span id="usedConfigCount" class="status-used">已关联: 0</span>
|
<span id="usedConfigCount" class="status-used">已关联: 0</span>
|
||||||
<span id="unusedConfigCount" class="status-unused">未关联: 0</span>
|
<span id="unusedConfigCount" class="status-unused">未关联: 0</span>
|
||||||
|
<button id="batchLinkKiroBtn" class="btn-batch-link-kiro" title="批量关联 configs/ 下的未关联配置">
|
||||||
|
<i class="fas fa-link"></i> 自动关联oauth
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="configList" class="config-list">
|
<div id="configList" class="config-list">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue