// 连接与配置缓存核心模块 // 提供 API 基础地址规范化、请求封装、配置缓存以及统一数据加载能力 import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js'; import { secureStorage } from '../utils/secure-storage.js'; import { normalizeModelList, classifyModels } from '../utils/models.js'; const buildModelsEndpoint = (baseUrl) => { if (!baseUrl) return ''; const trimmed = String(baseUrl).trim().replace(/\/+$/g, ''); if (!trimmed) return ''; return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; }; const normalizeApiKeyList = (input) => { if (!Array.isArray(input)) return []; const seen = new Set(); const keys = []; input.forEach(item => { const value = typeof item === 'string' ? item : (item && item['api-key'] ? item['api-key'] : ''); const trimmed = String(value || '').trim(); if (!trimmed || seen.has(trimmed)) { return; } seen.add(trimmed); keys.push(trimmed); }); return keys; }; export const connectionModule = { // 规范化基础地址,移除尾部斜杠与 /v0/management normalizeBase(input) { return this.apiClient.normalizeBase(input); }, // 由基础地址生成完整管理 API 地址 computeApiUrl(base) { return this.apiClient.computeApiUrl(base); }, setApiBase(newBase) { this.apiClient.setApiBase(newBase); this.apiBase = this.apiClient.apiBase; this.apiUrl = this.apiClient.apiUrl; secureStorage.setItem('apiBase', this.apiBase); secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段 this.updateLoginConnectionInfo(); }, setManagementKey(key, { persist = true } = {}) { this.managementKey = key || ''; this.apiClient.setManagementKey(this.managementKey); if (persist) { secureStorage.setItem('managementKey', this.managementKey); } }, // 加载设置(简化版,仅加载内部状态) loadSettings() { secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']); const savedBase = secureStorage.getItem('apiBase'); const savedUrl = secureStorage.getItem('apiUrl'); const savedKey = secureStorage.getItem('managementKey'); if (savedBase) { this.setApiBase(savedBase); } else if (savedUrl) { const base = (savedUrl || '').replace(/\/?v0\/management\/?$/i, ''); this.setApiBase(base); } else { this.setApiBase(this.detectApiBaseFromLocation()); } this.setManagementKey(savedKey || '', { persist: false }); this.updateLoginConnectionInfo(); }, // 读取并填充管理中心版本号(可能来自构建时注入或占位符) initUiVersion() { const uiVersion = this.readUiVersionFromDom(); this.uiVersion = uiVersion; this.renderVersionInfo(); }, // 从 DOM 获取版本占位符,并处理空值、引号或未替换的占位符 readUiVersionFromDom() { const el = document.getElementById('ui-version'); if (!el) return null; const raw = (el.dataset && el.dataset.uiVersion) ? el.dataset.uiVersion : el.textContent; if (typeof raw !== 'string') return null; const cleaned = raw.replace(/^"+|"+$/g, '').trim(); if (!cleaned || cleaned === '__VERSION__') { return null; } return cleaned; }, // 根据响应头更新版本与构建时间 updateVersionFromHeaders(headers) { if (!headers || typeof headers.get !== 'function') { return; } const version = headers.get('X-CPA-VERSION'); const buildDate = headers.get('X-CPA-BUILD-DATE'); let updated = false; if (version && version !== this.serverVersion) { this.serverVersion = version; updated = true; } if (buildDate && buildDate !== this.serverBuildDate) { this.serverBuildDate = buildDate; updated = true; } if (updated) { this.renderVersionInfo(); } }, renderVersionCheckStatus({ currentVersion, latestVersion, message, status } = {}) { const resolvedCurrent = (typeof currentVersion === 'undefined' || currentVersion === null) ? this.serverVersion : currentVersion; const resolvedLatest = (typeof latestVersion === 'undefined' || latestVersion === null) ? this.latestVersion : latestVersion; const resolvedMessage = (typeof message === 'undefined' || message === null) ? (this.versionCheckMessage || i18n.t('system_info.version_check_idle')) : message; const resolvedStatus = status || this.versionCheckStatus || 'muted'; this.latestVersion = resolvedLatest || null; this.versionCheckMessage = resolvedMessage; this.versionCheckStatus = resolvedStatus; const currentEl = document.getElementById('version-check-current'); if (currentEl) { currentEl.textContent = resolvedCurrent || i18n.t('system_info.version_unknown'); } const latestEl = document.getElementById('version-check-latest'); if (latestEl) { latestEl.textContent = resolvedLatest || '-'; } const resultEl = document.getElementById('version-check-result'); if (resultEl) { resultEl.textContent = resolvedMessage; resultEl.className = `version-check-result ${resolvedStatus}`.trim(); } }, resetVersionCheckStatus() { this.latestVersion = null; this.versionCheckMessage = i18n.t('system_info.version_check_idle'); this.versionCheckStatus = 'muted'; this.renderVersionCheckStatus({ currentVersion: this.serverVersion, latestVersion: this.latestVersion, message: this.versionCheckMessage, status: this.versionCheckStatus }); }, // 渲染底栏的版本与构建时间 renderVersionInfo() { const versionEl = document.getElementById('api-version'); const buildDateEl = document.getElementById('api-build-date'); const uiVersionEl = document.getElementById('ui-version'); if (versionEl) { versionEl.textContent = this.serverVersion || '-'; } if (buildDateEl) { buildDateEl.textContent = this.serverBuildDate ? this.formatBuildDate(this.serverBuildDate) : '-'; } if (uiVersionEl) { const domVersion = this.readUiVersionFromDom(); uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev'; } this.renderVersionCheckStatus({ currentVersion: this.serverVersion, latestVersion: this.latestVersion, message: this.versionCheckMessage, status: this.versionCheckStatus }); }, // 清空版本信息(例如登出时) resetVersionInfo() { this.serverVersion = null; this.serverBuildDate = null; this.resetVersionCheckStatus(); this.renderVersionInfo(); }, // 格式化构建时间,优先使用界面语言对应的本地格式 formatBuildDate(buildDate) { if (!buildDate) return '-'; const parsed = Date.parse(buildDate); if (!Number.isNaN(parsed)) { const locale = i18n?.currentLanguage || undefined; return new Date(parsed).toLocaleString(locale); } return buildDate; }, parseVersionSegments(version) { if (!version || typeof version !== 'string') return null; const cleaned = version.trim().replace(/^v/i, ''); if (!cleaned) return null; const parts = cleaned.split(/[^0-9]+/).filter(Boolean).map(segment => { const parsed = parseInt(segment, 10); return Number.isFinite(parsed) ? parsed : 0; }); return parts.length ? parts : null; }, compareVersions(latestVersion, currentVersion) { const latestParts = this.parseVersionSegments(latestVersion); const currentParts = this.parseVersionSegments(currentVersion); if (!latestParts || !currentParts) { return null; } const length = Math.max(latestParts.length, currentParts.length); for (let i = 0; i < length; i++) { const latest = latestParts[i] || 0; const current = currentParts[i] || 0; if (latest > current) return 1; if (latest < current) return -1; } return 0; }, async checkLatestVersion() { if (!this.isConnected) { const message = i18n.t('notification.connection_required'); this.renderVersionCheckStatus({ currentVersion: this.serverVersion, latestVersion: this.latestVersion, message, status: 'warning' }); this.showNotification(message, 'error'); return; } const button = document.getElementById('version-check-btn'); const originalLabel = button ? button.innerHTML : ''; if (button) { button.disabled = true; button.innerHTML = `
${i18n.t('system_info.version_checking')}`; } this.renderVersionCheckStatus({ currentVersion: this.serverVersion, latestVersion: this.latestVersion, message: i18n.t('system_info.version_checking'), status: 'info' }); try { const data = await this.makeRequest('/latest-version'); const latestVersion = data?.['latest-version'] || data?.latest_version || ''; const latestParts = this.parseVersionSegments(latestVersion); const currentParts = this.parseVersionSegments(this.serverVersion); const comparison = (latestParts && currentParts) ? this.compareVersions(latestVersion, this.serverVersion) : null; let messageKey = 'system_info.version_check_error'; let statusClass = 'error'; if (!latestParts) { messageKey = 'system_info.version_check_error'; } else if (!currentParts) { messageKey = 'system_info.version_current_missing'; statusClass = 'warning'; } else if (comparison > 0) { messageKey = 'system_info.version_update_available'; statusClass = 'warning'; } else { messageKey = 'system_info.version_is_latest'; statusClass = 'success'; } const message = i18n.t(messageKey, latestVersion ? { version: latestVersion } : undefined); this.renderVersionCheckStatus({ currentVersion: this.serverVersion, latestVersion, message, status: statusClass }); if (latestVersion && comparison !== null) { const notifyKey = comparison > 0 ? 'system_info.version_update_available' : 'system_info.version_is_latest'; const notifyType = comparison > 0 ? 'warning' : 'success'; this.showNotification(i18n.t(notifyKey, { version: latestVersion }), notifyType); } } catch (error) { const message = `${i18n.t('system_info.version_check_error')}: ${error.message}`; this.renderVersionCheckStatus({ currentVersion: this.serverVersion, latestVersion: this.latestVersion, message, status: 'error' }); this.showNotification(message, 'error'); } finally { if (button) { button.disabled = false; button.innerHTML = originalLabel; } } }, // API 请求方法 async makeRequest(endpoint, options = {}) { try { return await this.apiClient.request(endpoint, options); } catch (error) { console.error('API请求失败:', error); throw error; } }, buildAvailableModelsEndpoint() { return buildModelsEndpoint(this.apiBase || this.apiClient?.apiBase || ''); }, setAvailableModelsStatus(message = '', type = 'info') { const statusEl = document.getElementById('available-models-status'); if (!statusEl) return; statusEl.textContent = message || ''; statusEl.className = `available-models-status ${type}`; }, renderAvailableModels(models = []) { const listEl = document.getElementById('available-models-list'); if (!listEl) return; if (!models.length) { listEl.innerHTML = `
${i18n.t('system_info.models_empty')}
`; return; } const language = (i18n?.currentLanguage || '').toLowerCase(); const otherLabel = language.startsWith('zh') ? '其他' : 'Other'; const groups = classifyModels(models, { otherLabel }); const groupHtml = groups.map(group => { const pills = group.items.map(model => { const name = this.escapeHtml(model.name || ''); const alias = model.alias ? `${this.escapeHtml(model.alias)}` : ''; const description = model.description ? this.escapeHtml(model.description) : ''; const titleAttr = description ? ` title="${description}"` : ''; return ` ${name} ${alias} `; }).join(''); const label = this.escapeHtml(group.label || group.id || ''); return `
${label} ${group.items.length}
${pills}
`; }).join(''); listEl.innerHTML = groupHtml; }, clearAvailableModels(messageKey = 'system_info.models_empty') { this.availableModels = []; this.availableModelApiKeysCache = null; const listEl = document.getElementById('available-models-list'); if (listEl) { listEl.innerHTML = ''; } this.setAvailableModelsStatus(i18n.t(messageKey), 'warning'); }, async resolveApiKeysForModels({ config = null, forceRefresh = false } = {}) { if (!forceRefresh && Array.isArray(this.availableModelApiKeysCache) && this.availableModelApiKeysCache.length) { return this.availableModelApiKeysCache; } const configKeys = normalizeApiKeyList(config?.['api-keys'] || this.configCache?.['api-keys']); if (configKeys.length) { this.availableModelApiKeysCache = configKeys; return configKeys; } try { const data = await this.makeRequest('/api-keys'); const keys = normalizeApiKeyList(data?.['api-keys']); if (keys.length) { this.availableModelApiKeysCache = keys; } return keys; } catch (error) { console.warn('自动获取 API Key 失败:', error); return []; } }, async loadAvailableModels({ config = null, forceRefresh = false } = {}) { const listEl = document.getElementById('available-models-list'); const statusEl = document.getElementById('available-models-status'); if (!listEl || !statusEl) { return; } if (!this.isConnected) { this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning'); listEl.innerHTML = ''; return; } const endpoint = this.buildAvailableModelsEndpoint(); if (!endpoint) { this.setAvailableModelsStatus(i18n.t('system_info.models_error'), 'error'); listEl.innerHTML = `
${i18n.t('login.error_invalid')}
`; return; } this.availableModelsLoading = true; this.setAvailableModelsStatus(i18n.t('system_info.models_loading'), 'info'); listEl.innerHTML = '
'; try { const headers = {}; const keys = await this.resolveApiKeysForModels({ config, forceRefresh }); if (keys.length) { headers.Authorization = `Bearer ${keys[0]}`; } const response = await fetch(endpoint, { headers }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } let data; try { data = await response.json(); } catch (err) { const text = await response.text(); throw new Error(text || err.message || 'Invalid JSON'); } const models = normalizeModelList(data, { dedupe: true }); this.availableModels = models; if (!models.length) { this.setAvailableModelsStatus(i18n.t('system_info.models_empty'), 'warning'); this.renderAvailableModels([]); return; } this.setAvailableModelsStatus(i18n.t('system_info.models_count', { count: models.length }), 'success'); this.renderAvailableModels(models); } catch (error) { console.error('加载可用模型失败:', error); this.availableModels = []; this.setAvailableModelsStatus(`${i18n.t('system_info.models_error')}: ${error.message}`, 'error'); listEl.innerHTML = `
${this.escapeHtml(error.message || '')}
`; } finally { this.availableModelsLoading = false; } }, // 测试连接(简化版,用于内部调用) async testConnection() { try { await this.makeRequest('/debug'); this.isConnected = true; this.updateConnectionStatus(); this.startStatusUpdateTimer(); await this.loadAllData(); return true; } catch (error) { this.isConnected = false; this.updateConnectionStatus(); this.stopStatusUpdateTimer(); throw error; } }, // 更新连接状态 updateConnectionStatus() { const statusButton = document.getElementById('connection-status'); const apiStatus = document.getElementById('api-status'); const configStatus = document.getElementById('config-status'); const lastUpdate = document.getElementById('last-update'); if (this.isConnected) { statusButton.innerHTML = ` ${i18n.t('common.connected')}`; statusButton.className = 'btn btn-success'; apiStatus.textContent = i18n.t('common.connected'); // 更新配置状态 if (this.isCacheValid()) { const fullTimestamp = this.cacheTimestamps && this.cacheTimestamps['__full__']; const cacheAge = fullTimestamp ? Math.floor((Date.now() - fullTimestamp) / 1000) : 0; configStatus.textContent = `${i18n.t('system_info.cache_data')} (${cacheAge}${i18n.t('system_info.seconds_ago')})`; configStatus.style.color = '#f59e0b'; // 橙色表示缓存 } else if (this.configCache && this.configCache['__full__']) { configStatus.textContent = i18n.t('system_info.real_time_data'); configStatus.style.color = '#10b981'; // 绿色表示实时 } else { configStatus.textContent = i18n.t('system_info.not_loaded'); configStatus.style.color = '#6b7280'; // 灰色表示未加载 } } else { statusButton.innerHTML = ` ${i18n.t('common.disconnected')}`; statusButton.className = 'btn btn-danger'; apiStatus.textContent = i18n.t('common.disconnected'); configStatus.textContent = i18n.t('system_info.not_loaded'); configStatus.style.color = '#6b7280'; this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning'); const modelsList = document.getElementById('available-models-list'); if (modelsList) { modelsList.innerHTML = ''; } } lastUpdate.textContent = new Date().toLocaleString('zh-CN'); if (this.lastEditorConnectionState !== this.isConnected) { this.updateConfigEditorAvailability(); } // 更新连接信息显示 this.updateConnectionInfo(); if (this.events && typeof this.events.emit === 'function') { const shouldEmit = this.lastConnectionStatusEmitted !== this.isConnected; if (shouldEmit) { this.events.emit('connection:status-changed', { isConnected: this.isConnected, apiBase: this.apiBase }); this.lastConnectionStatusEmitted = this.isConnected; } } }, // 检查连接状态 async checkConnectionStatus() { await this.testConnection(); }, // 刷新所有数据 async refreshAllData() { if (!this.isConnected) { this.showNotification(i18n.t('notification.connection_required'), 'error'); return; } const button = document.getElementById('refresh-all'); const originalText = button.innerHTML; button.innerHTML = `
${i18n.t('common.loading')}`; button.disabled = true; try { // 强制刷新,清除缓存 await this.loadAllData(true); this.showNotification(i18n.t('notification.data_refreshed'), 'success'); } catch (error) { this.showNotification(`${i18n.t('notification.refresh_failed')}: ${error.message}`, 'error'); } finally { button.innerHTML = originalText; button.disabled = false; } }, // 检查缓存是否有效 isCacheValid(section = null) { return this.configService.isCacheValid(section); }, // 获取配置(优先使用缓存,支持按段获取) async getConfig(section = null, forceRefresh = false) { try { const config = await this.configService.getConfig(section, forceRefresh); this.configCache = this.configService.cache; this.cacheTimestamps = this.configService.cacheTimestamps; this.updateConnectionStatus(); return config; } catch (error) { console.error('获取配置失败:', error); throw error; } }, // 清除缓存(支持清除特定配置段) clearCache(section = null) { this.configService.clearCache(section); this.configCache = this.configService.cache; this.cacheTimestamps = this.configService.cacheTimestamps; if (!section || section === 'api-keys') { this.availableModelApiKeysCache = null; } if (!section) { this.configYamlCache = ''; this.availableModels = []; } }, // 启动状态更新定时器 startStatusUpdateTimer() { if (this.statusUpdateTimer) { clearInterval(this.statusUpdateTimer); } this.statusUpdateTimer = setInterval(() => { if (this.isConnected) { this.updateConnectionStatus(); } }, STATUS_UPDATE_INTERVAL_MS); }, // 停止状态更新定时器 stopStatusUpdateTimer() { if (this.statusUpdateTimer) { clearInterval(this.statusUpdateTimer); this.statusUpdateTimer = null; } }, // 加载所有数据 - 使用新的 /config 端点一次性获取所有配置 async loadAllData(forceRefresh = false) { try { console.log(i18n.t('system_info.real_time_data')); // 使用新的 /config 端点一次性获取所有配置 // 注意:getConfig(section, forceRefresh),不传 section 表示获取全部 const config = await this.getConfig(null, forceRefresh); // 获取一次usage统计数据,供渲染函数和loadUsageStats复用 let usageData = null; let keyStats = null; try { const response = await this.makeRequest('/usage'); usageData = response?.usage || null; if (usageData) { keyStats = await this.getKeyStats(usageData); } } catch (error) { console.warn('获取usage统计失败:', error); } // 从配置中提取并设置各个设置项(现在传递keyStats) await this.updateSettingsFromConfig(config, keyStats); await this.loadAvailableModels({ config, forceRefresh }); if (this.events && typeof this.events.emit === 'function') { this.events.emit('data:config-loaded', { config, usageData, keyStats, forceRefresh }); } console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid()); } catch (error) { console.error('加载配置失败:', error); } }, // 从配置对象更新所有设置 —— 委派给 settings 模块,保持兼容旧调用 async updateSettingsFromConfig(config, keyStats = null) { if (typeof this.applySettingsFromConfig === 'function') { return this.applySettingsFromConfig(config, keyStats); } }, detectApiBaseFromLocation() { try { const { protocol, hostname, port } = window.location; const normalizedPort = port ? `:${port}` : ''; return this.normalizeBase(`${protocol}//${hostname}${normalizedPort}`); } catch (error) { console.warn('无法从当前地址检测 API 基础地址,使用默认设置', error); return this.normalizeBase(this.apiBase || `http://localhost:${DEFAULT_API_PORT}`); } } };