diff --git a/.gitignore b/.gitignore index 91d2c32..f001742 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ # 忽略所有数据库文件 -*.db \ No newline at end of file +*.db + +# 忽略日志文件 +logs/ +*.log +server.log diff --git a/ERROR_HANDLING.md b/ERROR_HANDLING.md new file mode 100644 index 0000000..2c2f340 --- /dev/null +++ b/ERROR_HANDLING.md @@ -0,0 +1,47 @@ +# 错误处理增强说明 + +本次更新增强了系统的错误处理和日志记录功能,特别是针对"socket hang up"等网络连接问题。 + +## 主要改进 + +### 1. 服务器端增强 (server.js) + +- **详细错误日志**:现在会记录完整的错误对象,包括: + - 错误消息、代码和堆栈 + - 请求参数和配置 + - 响应状态和数据 + - 时间戳和重试信息 + +- **日志文件记录**: + - 所有API错误现在会写入 `logs/api-errors.log` 文件 + - 包含完整的错误上下文和诊断信息 + +- **增强的错误响应**: + - API响应中包含更详细的错误信息 + - 提供针对性的故障排除建议 + +### 2. 前端增强 + +- **增强的错误消息显示**: + - 添加了"显示详情"按钮,可展开查看完整错误信息 + - 添加了"复制错误"按钮,方便分享错误信息 + +- **改进的CSS样式**: + - 更美观的错误消息提示 + - 支持错误、成功、信息和警告四种消息类型 + - 添加了消息动画效果 + +### 3. 使用方法 + +当遇到"socket hang up"或其他API调用错误时: + +1. 错误消息会显示在页面右上角 +2. 点击"显示详情"查看完整错误信息 +3. 点击"复制错误"可复制错误详情用于报告问题 +4. 查看 `logs/api-errors.log` 获取更多诊断信息 + +## 技术细节 + +- 错误日志会自动过滤敏感信息(如API密钥) +- 使用指数退避算法进行重试 +- 针对不同类型的错误提供定制化的解决建议 \ No newline at end of file diff --git a/model-settings.json b/model-settings.json index 432e4f5..489c927 100644 --- a/model-settings.json +++ b/model-settings.json @@ -8,5 +8,12 @@ "model": "gemini-2.5-pro", "apiKey": "xx" }, + "custom": { + "name": "自定义AI", + "apiUrl": "https://api.example.com/v1", + "model": "custom-model", + "apiKey": "", + "format": "openai" + }, "updatedAt": "2025-07-01T11:15:26.302Z" } diff --git a/public/css/ai-settings.css b/public/css/ai-settings.css new file mode 100644 index 0000000..223164c --- /dev/null +++ b/public/css/ai-settings.css @@ -0,0 +1,262 @@ +/* AI设置模态框样式 */ +.ai-settings-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + overflow-y: auto; +} + +.ai-settings-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.ai-settings-content { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + animation: slideDown 0.3s ease; +} + +.ai-settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + border-bottom: 1px solid #eee; +} + +.ai-settings-header h3 { + margin: 0; + font-size: 18px; + color: #333; +} + +.ai-settings-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; +} + +.ai-settings-close:hover { + color: #333; +} + +.ai-settings-body { + padding: 20px; +} + +.settings-group { + margin-bottom: 20px; +} + +.settings-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #333; +} + +.settings-group input[type="text"], +.settings-group select, +.settings-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.settings-group textarea { + min-height: 120px; + resize: vertical; +} + +.settings-actions { + display: flex; + justify-content: flex-end; + margin-top: 20px; + gap: 10px; +} + +.save-settings-btn, +.reset-settings-btn, +.delete-item-btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 5px; +} + +.save-settings-btn { + background-color: #4CAF50; + color: white; +} + +.reset-settings-btn { + background-color: #f0f0f0; + color: #333; +} + +.delete-item-btn { + background-color: #f44336; + color: white; + margin-right: auto; +} + +.save-settings-btn:hover { + background-color: #45a049; +} + +.reset-settings-btn:hover { + background-color: #e0e0e0; +} + +.delete-item-btn:hover { + background-color: #d32f2f; +} + +.custom-date-range { + margin-top: 10px; +} + +.date-inputs { + display: flex; + gap: 10px; +} + +.date-inputs > div { + flex: 1; +} + +/* 搜索下拉框样式 */ +.searchable-select { + position: relative; +} + +.group-search-input { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + margin-bottom: 8px; + font-size: 14px; +} + +.search-results-count { + position: absolute; + right: 10px; + top: 10px; + font-size: 12px; + color: #666; + background-color: #f0f0f0; + padding: 2px 6px; + border-radius: 10px; +} + +/* 全局设置区域样式 */ +.global-settings-section { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.global-settings-section h4 { + margin-top: 0; + margin-bottom: 15px; + font-size: 16px; + color: #333; +} + +/* 复选框组样式 */ +.checkbox-group { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.checkbox-label { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; +} + +.checkbox-label input[type="checkbox"] { + margin-right: 8px; + cursor: pointer; +} + +.checkbox-text { + font-weight: normal; + margin-right: 5px; +} + +/* 提示图标样式 */ +.tooltip-icon { + display: inline-block; + width: 16px; + height: 16px; + background-color: #f0f0f0; + border-radius: 50%; + text-align: center; + line-height: 16px; + font-size: 12px; + color: #666; + cursor: help; + margin-left: 5px; +} + +.tooltip-icon:hover { + background-color: #e0e0e0; +} + +/* 提示框样式 */ +.settings-tooltip { + position: absolute; + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 4px; + font-size: 12px; + z-index: 1100; + max-width: 200px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); +} + +.settings-tooltip::before { + content: ''; + position: absolute; + top: -5px; + left: 10px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid #333; +} + +@keyframes slideDown { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css index a611186..7f242f3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -244,15 +244,15 @@ body { } .ai-settings-btn { - background: rgba(142, 142, 147, 0.12); - color: #8E8E93; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; border: none; - padding: 0.875rem; - border-radius: 12px; + border-radius: 0; cursor: pointer; - transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); + transition: all 0.2s ease; } .ai-settings-btn:hover { @@ -674,23 +674,107 @@ body { background: #a8a8a8; } -/* 错误消息样式 */ +/* 消息提示样式 - 增强版 */ +.error-message, +.success-message, +.info-message, +.warning-message { + position: fixed; + top: 20px; + right: 20px; + padding: 1rem; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 2000; + max-width: 300px; + animation: slideIn 0.3s ease; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.9rem; +} + .error-message { background: #ffebee; color: #c62828; - padding: 1rem; - border-radius: 8px; border-left: 4px solid #c62828; - margin: 1rem 0; } .success-message { background: #e8f5e8; color: #2e7d32; - padding: 1rem; - border-radius: 8px; border-left: 4px solid #2e7d32; - margin: 1rem 0; +} + +.info-message { + background: #e3f2fd; + color: #0277bd; + border-left: 4px solid #0277bd; +} + +.warning-message { + background: #fff8e1; + color: #ff8f00; + border-left: 4px solid #ff8f00; +} + +/* 错误详情样式 */ +.error-details { + background: rgba(0, 0, 0, 0.03); + border-radius: 6px; + padding: 0.75rem; + margin-top: 0.5rem; + max-height: 300px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; +} + +.error-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.show-details-btn, +.copy-error-btn { + background: rgba(0, 0, 0, 0.1); + border: none; + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.2s; +} + +.show-details-btn:hover, +.copy-error-btn:hover { + background: rgba(0, 0, 0, 0.2); +} + +/* 消息动画 */ +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } } /* AI分析功能样式 - 顶部特色区域 */ @@ -747,12 +831,25 @@ body { /* AI按钮组和设置按钮样式 */ .ai-btn-group { display: flex; - align-items: center; - gap: 0.5rem; + align-items: stretch; + margin-bottom: 0.75rem; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.08); + transition: all 0.2s ease; } +/* 修改按钮圆角,使其适应三按钮布局 */ .ai-btn-group .ai-btn { - flex: 1; + border-radius: 6px 0 0 6px; +} + +.ai-btn-group .ai-settings-btn { + border-radius: 0; +} + +.ai-btn-group .ai-delete-btn { + border-radius: 0 6px 6px 0; } .ai-settings-btn { @@ -780,21 +877,49 @@ body { font-size: 0.9rem; } +/* 删除按钮样式 */ +.ai-delete-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(220, 53, 69, 0.12); + color: #dc3545; + border: none; + border-radius: 0 6px 6px 0; + cursor: pointer; + transition: all 0.2s ease; +} + +.ai-delete-btn:hover { + background: rgba(220, 53, 69, 0.2); + color: #c82333; +} + +.ai-delete-btn:active { + background: rgba(220, 53, 69, 0.3); +} + +.ai-delete-btn i { + font-size: 0.9rem; +} + .ai-btn { + flex: 1; + padding: 0.75rem 1rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; - padding: 0.8rem 1rem; - border-radius: 8px; - cursor: pointer; - transition: all 0.3s ease; - font-size: 0.9rem; + border-radius: 6px 0 0 6px; font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; display: flex; align-items: center; + justify-content: center; gap: 0.5rem; - position: relative; - overflow: hidden; + min-width: 150px; } .ai-btn:hover { diff --git a/public/js/ai-settings.js b/public/js/ai-settings.js index 1b08800..a330192 100644 --- a/public/js/ai-settings.js +++ b/public/js/ai-settings.js @@ -23,6 +23,11 @@ class AISettingsManager { } }; + // 全局设置 + this.globalSettings = { + filterImages: true // 默认开启图片过滤 + }; + // 动态分析项管理 this.dynamicAnalysisItems = this.loadDynamicItems(); this.currentEditingType = null; @@ -150,6 +155,7 @@ class AISettingsManager { const resetBtn = document.getElementById('resetSettingsBtn'); const deleteBtn = document.getElementById('deleteItemBtn'); const groupSearch = document.getElementById('settingsGroupSearch'); + const filterImages = document.getElementById('settingsFilterImages'); if (closeBtn) { closeBtn.addEventListener('click', () => { @@ -188,6 +194,35 @@ class AISettingsManager { this.deleteCurrentItem(); }); } + + // 图片过滤选项 + if (filterImages) { + filterImages.checked = this.globalSettings.filterImages; + filterImages.addEventListener('change', (e) => { + this.globalSettings.filterImages = e.target.checked; + }); + + // 添加悬浮提示 + const tooltipIcons = document.querySelectorAll('.tooltip-icon'); + tooltipIcons.forEach(icon => { + icon.addEventListener('mouseenter', function() { + const tooltip = document.createElement('div'); + tooltip.className = 'settings-tooltip'; + tooltip.textContent = this.getAttribute('title'); + + // 计算位置 + const rect = this.getBoundingClientRect(); + tooltip.style.top = `${rect.bottom + 5}px`; + tooltip.style.left = `${rect.left}px`; + + document.body.appendChild(tooltip); + + this.addEventListener('mouseleave', function() { + tooltip.remove(); + }, { once: true }); + }); + }); + } // 群聊搜索功能 - 添加防抖优化 if (groupSearch) { @@ -282,7 +317,15 @@ class AISettingsManager { loadSettings() { try { const saved = localStorage.getItem('aiAnalysisSettings'); - return saved ? JSON.parse(saved) : {}; + const settings = saved ? JSON.parse(saved) : {}; + + // 加载全局设置 + const globalSaved = localStorage.getItem('aiGlobalSettings'); + if (globalSaved) { + this.globalSettings = {...this.globalSettings, ...JSON.parse(globalSaved)}; + } + + return settings; } catch (error) { console.warn('加载AI设置失败:', error); return {}; @@ -293,11 +336,42 @@ class AISettingsManager { saveSettings() { try { localStorage.setItem('aiAnalysisSettings', JSON.stringify(this.settings)); + localStorage.setItem('aiGlobalSettings', JSON.stringify(this.globalSettings)); console.log('AI设置已保存'); + + // 同步到服务器 + this.syncSettingsToServer(); } catch (error) { console.error('保存AI设置失败:', error); } } + + // 同步设置到服务器 + async syncSettingsToServer() { + try { + // 构建完整的设置对象 + const fullSettings = { + ...this.settings, + filterImages: this.globalSettings.filterImages + }; + + // 发送到服务器 + const response = await fetch('/api/save-analysis-config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ analysisConfig: fullSettings }) + }); + + const result = await response.json(); + if (!result.success) { + console.error('同步设置到服务器失败:', result.error); + } + } catch (error) { + console.error('同步设置到服务器失败:', error); + } + } // 加载动态分析项 loadDynamicItems() { @@ -422,6 +496,19 @@ class AISettingsManager { + + +
+

全局设置

+
+ +
+
+
+ +
+ + `; + } + + messageDiv.innerHTML = messageContent; + // 添加到页面 document.body.appendChild(messageDiv); @@ -922,11 +968,50 @@ class ChatlogApp { top: '20px', right: '20px', zIndex: '2000', - maxWidth: '300px', + maxWidth: type === 'error' && errorDetails ? '600px' : '300px', animation: 'slideIn 0.3s ease' }); - // 3秒后移除 + // 添加事件监听器(如果是错误消息且有详细信息) + if (type === 'error' && errorDetails) { + const showDetailsBtn = messageDiv.querySelector('.show-details-btn'); + const copyErrorBtn = messageDiv.querySelector('.copy-error-btn'); + const errorDetailsDiv = messageDiv.querySelector('.error-details'); + + // 显示/隐藏详情 + if (showDetailsBtn) { + showDetailsBtn.addEventListener('click', () => { + if (errorDetailsDiv.style.display === 'none') { + errorDetailsDiv.style.display = 'block'; + showDetailsBtn.textContent = '隐藏详情'; + } else { + errorDetailsDiv.style.display = 'none'; + showDetailsBtn.textContent = '显示详情'; + } + }); + } + + // 复制错误信息 + if (copyErrorBtn) { + copyErrorBtn.addEventListener('click', () => { + navigator.clipboard.writeText(JSON.stringify(errorDetails, null, 2)) + .then(() => { + copyErrorBtn.textContent = '已复制'; + setTimeout(() => { + copyErrorBtn.textContent = '复制错误'; + }, 2000); + }) + .catch(err => { + console.error('复制失败:', err); + }); + }); + } + } + + // 错误消息停留时间更长 + const timeout = type === 'error' ? 10000 : 3000; + + // 超时后移除 setTimeout(() => { messageDiv.style.animation = 'slideOut 0.3s ease'; setTimeout(() => { @@ -939,6 +1024,14 @@ class ChatlogApp { // AI分析相关方法 async startAIAnalysis(groupName, analysisType) { + // 获取图片过滤设置 + let filterImages = true; // 默认开启图片过滤 + if (window.aiSettingsManager && + window.aiSettingsManager.globalSettings && + window.aiSettingsManager.globalSettings.filterImages !== undefined) { + filterImages = window.aiSettingsManager.globalSettings.filterImages; + } + try { // 显示加载状态 this.showAILoading(true); @@ -950,6 +1043,7 @@ class ChatlogApp { let settings = {}; let timeRange = '2024-01-01~2025-12-31'; let customPrompt = ''; + let filterImages = true; // 默认开启图片过滤 if (window.aiSettingsManager) { settings = window.aiSettingsManager.getSettings(analysisType); @@ -960,6 +1054,11 @@ class ChatlogApp { if (settings.prompt) { customPrompt = settings.prompt; } + // 获取全局设置中的图片过滤选项 + if (window.aiSettingsManager.globalSettings && + window.aiSettingsManager.globalSettings.filterImages !== undefined) { + filterImages = window.aiSettingsManager.globalSettings.filterImages; + } } // 调用AI分析API @@ -972,7 +1071,8 @@ class ChatlogApp { groupName: groupName, analysisType: analysisType, customPrompt: customPrompt, - timeRange: timeRange + timeRange: timeRange, + filterImages: filterImages }) }); @@ -997,7 +1097,31 @@ class ChatlogApp { } catch (error) { console.error('AI分析失败:', error); - this.showMessage('AI分析失败: ' + error.message, 'error'); + + // 创建详细的错误信息对象 + const errorDetails = { + message: error.message, + type: error.name, + timestamp: new Date().toISOString(), + requestInfo: { + groupName, + analysisType, + timeRange + } + }; + + // 如果有响应数据,添加到错误详情中 + if (error.response) { + try { + errorDetails.response = await error.response.json(); + } catch (e) { + errorDetails.responseText = await error.response.text(); + } + errorDetails.status = error.response.status; + errorDetails.statusText = error.response.statusText; + } + + this.showMessage('AI分析失败: ' + error.message, 'error', errorDetails); } finally { this.showAILoading(false); this.setAIButtonsEnabled(true); @@ -1095,7 +1219,7 @@ class ChatlogApp { // 获取时间范围,默认使用昨天 let timeRange = customTimeRange; - // 如果没有指定时间范围或选择了"昨天",计算昨天的日期 + // 如果没有指定时间范围或选择了\"昨天\",计算昨天的日期 if (!customTimeRange || customTimeRange === 'yesterday') { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); @@ -1107,6 +1231,14 @@ class ChatlogApp { timeRange = `${dateStr}~${dateStr}`; } + // 获取图片过滤设置 + let filterImages = true; // 默认开启图片过滤 + if (window.aiSettingsManager && + window.aiSettingsManager.globalSettings && + window.aiSettingsManager.globalSettings.filterImages !== undefined) { + filterImages = window.aiSettingsManager.globalSettings.filterImages; + } + try { // 显示加载状态 this.showAILoading(true); @@ -1122,7 +1254,8 @@ class ChatlogApp { groupName: groupName, analysisType: 'custom', customPrompt: customPrompt, - timeRange: timeRange + timeRange: timeRange, + filterImages: filterImages }) }); @@ -1150,7 +1283,32 @@ class ChatlogApp { } catch (error) { console.error('自定义分析失败:', error); - this.showMessage('自定义分析失败: ' + error.message, 'error'); + + // 创建详细的错误信息对象 + const errorDetails = { + message: error.message, + type: error.name, + timestamp: new Date().toISOString(), + requestInfo: { + groupName, + analysisType: 'custom', + customPrompt: customPrompt ? customPrompt.substring(0, 100) + '...' : null, + timeRange + } + }; + + // 如果有响应数据,添加到错误详情中 + if (error.response) { + try { + errorDetails.response = await error.response.json(); + } catch (e) { + errorDetails.responseText = await error.response.text(); + } + errorDetails.status = error.response.status; + errorDetails.statusText = error.response.statusText; + } + + this.showMessage('自定义分析失败: ' + error.message, 'error', errorDetails); } finally { this.showAILoading(false); this.setAIButtonsEnabled(true); @@ -1306,6 +1464,9 @@ class ChatlogApp { + `; @@ -1317,8 +1478,9 @@ class ChatlogApp { // 绑定动态分析项事件 bindDynamicAnalysisEvents(itemId) { - const analysisBtn = document.querySelector(`[data-type="${itemId}"]:not(.ai-settings-btn)`); + const analysisBtn = document.querySelector(`[data-type="${itemId}"]:not(.ai-settings-btn):not(.ai-delete-btn)`); const settingsBtn = document.querySelector(`[data-type="${itemId}"].ai-settings-btn`); + const deleteBtn = document.querySelector(`[data-type="${itemId}"].ai-delete-btn`); if (analysisBtn) { analysisBtn.addEventListener('click', () => { @@ -1333,6 +1495,12 @@ class ChatlogApp { } }); } + + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + this.deleteAnalysisItem(itemId); + }); + } } // 执行动态分析 @@ -1384,6 +1552,60 @@ class ChatlogApp { element.remove(); } } + + // 删除分析项(处理所有类型的分析项) + deleteAnalysisItem(itemId) { + // 确认是否删除 + const isDynamic = itemId.startsWith('dynamic_'); + const itemType = isDynamic ? '自定义分析项' : '默认分析项'; + + // 获取分析项名称 + let itemName = itemId; + if (window.aiSettingsManager) { + const settings = window.aiSettingsManager.getSettings(itemId); + if (settings && settings.displayName) { + itemName = settings.displayName; + } + } + + const confirmMessage = `确定要删除${itemType}「${itemName}」吗?`; + + if (!confirm(confirmMessage)) { + return; + } + + if (isDynamic) { + // 动态分析项:完全删除 + if (window.aiSettingsManager) { + window.aiSettingsManager.removeDynamicAnalysisItem(itemId); + } + } else { + // 默认分析项:也完全删除 + // 使用更兼容的选择器方式 + const analysisBtn = document.querySelector(`[data-type="${itemId}"]:not(.ai-settings-btn):not(.ai-delete-btn)`); + if (analysisBtn) { + const btnGroup = analysisBtn.closest('.ai-btn-group'); + if (btnGroup) { + btnGroup.style.display = 'none'; // 隐藏整个按钮组 + + // 标记该分析项为已删除 + if (window.aiSettingsManager) { + // 确保设置对象存在 + if (!window.aiSettingsManager.settings[itemId]) { + window.aiSettingsManager.settings[itemId] = {}; + } + + // 标记为已删除 + window.aiSettingsManager.settings[itemId].isDeleted = true; + window.aiSettingsManager.saveSettings(); + } + } + } + } + + // 显示成功消息 + this.showMessage(`已删除分析项:${itemName}`, 'success'); + } // 删除分析历史记录 async deleteAnalysisHistory(recordId, recordTitle) { @@ -1507,10 +1729,11 @@ class ChatlogApp { { id: 'reading', name: '读者群分析', type: 'default' } ]; - // 检查每个默认分析项是否有配置和群聊选择 + // 检查每个默认分析项是否有配置和群聊选择,且未被删除 defaultItems.forEach(item => { const settings = window.aiSettingsManager?.getSettings(item.id); - if (settings && settings.groupName) { + // 只添加未被标记为删除的项 + if (settings && settings.groupName && !settings.isDeleted) { analysisItems.push({ id: item.id, name: settings.displayName || item.name, @@ -1640,6 +1863,14 @@ class ChatlogApp { // 执行单个分析 async executeSingleAnalysis(analysisItem) { try { + // 获取图片过滤设置 + let filterImages = true; // 默认开启图片过滤 + if (window.aiSettingsManager && + window.aiSettingsManager.globalSettings && + window.aiSettingsManager.globalSettings.filterImages !== undefined) { + filterImages = window.aiSettingsManager.globalSettings.filterImages; + } + const response = await fetch('/api/ai-analysis', { method: 'POST', headers: { @@ -1649,7 +1880,8 @@ class ChatlogApp { groupName: analysisItem.groupName, analysisType: analysisItem.analysisType, customPrompt: analysisItem.customPrompt, - timeRange: analysisItem.timeRange + timeRange: analysisItem.timeRange, + filterImages: filterImages }) }); @@ -2138,14 +2370,12 @@ class ChatlogApp { container.innerHTML = '
正在加载分析项...
'; try { - const response = await fetch('/api/scheduled-analysis-status'); - const data = await response.json(); + // 使用本地数据而不是从服务器获取,确保显示的是最新状态(包括删除状态) + const analysisItems = this.getAllAnalysisItems(); + this.displayAnalysisItemsForConfig(analysisItems); - if (data.success) { - this.displayAnalysisItemsForConfig(data.analysisItems); - } else { - container.innerHTML = '
❌ 加载失败
'; - } + // 同步最新配置到服务器 + await this.syncAnalysisConfigToServer(); } catch (error) { console.error('加载分析项失败:', error); container.innerHTML = '
❌ 加载失败
'; @@ -2436,14 +2666,33 @@ class ChatlogApp { dynamicAnalysisItems: window.aiSettingsManager?.dynamicAnalysisItems || [] }; - // 添加所有配置的分析项设置 + // 添加所有配置的分析项设置(只添加未被删除的项) const allItems = this.getAllAnalysisItems(); - allItems.forEach(item => { - const settings = window.aiSettingsManager?.getSettings(item.id); - if (settings && settings.groupName) { - analysisConfig[item.id] = settings; - } - }); + + // 添加已配置但未被删除的分析项 + if (window.aiSettingsManager) { + // 获取所有默认分析项ID + const defaultItemIds = ['programming', 'science', 'reading']; + + // 遍历所有默认分析项,只添加未被删除的 + defaultItemIds.forEach(itemId => { + const settings = window.aiSettingsManager.getSettings(itemId); + // 只添加未被标记为删除的项 + if (settings && settings.groupName && !settings.isDeleted) { + analysisConfig[itemId] = settings; + } + }); + + // 添加动态分析项(这些已经通过getAllAnalysisItems过滤) + allItems.forEach(item => { + if (item.id.startsWith('dynamic_')) { + const settings = window.aiSettingsManager.getSettings(item.id); + if (settings && settings.groupName) { + analysisConfig[item.id] = settings; + } + } + }); + } // 发送到服务器 const response = await fetch('/api/save-analysis-config', { diff --git a/public/js/model-settings.js b/public/js/model-settings.js index 6a229c8..69b1d75 100644 --- a/public/js/model-settings.js +++ b/public/js/model-settings.js @@ -69,6 +69,10 @@ class ModelSettings { document.getElementById('geminiApiKey').addEventListener('input', (e) => { this.validateApiKey('gemini', e.target.value); }); + + document.getElementById('customApiKey').addEventListener('input', (e) => { + this.validateApiKey('custom', e.target.value); + }); } // 打开弹窗 @@ -87,13 +91,20 @@ class ModelSettings { switchProvider(provider) { const deepseekConfig = document.getElementById('deepseekConfig'); const geminiConfig = document.getElementById('geminiConfig'); + const customConfig = document.getElementById('customConfig'); + + // 隐藏所有配置 + deepseekConfig.style.display = 'none'; + geminiConfig.style.display = 'none'; + customConfig.style.display = 'none'; + // 显示选中的配置 if (provider === 'DeepSeek') { deepseekConfig.style.display = 'block'; - geminiConfig.style.display = 'none'; } else if (provider === 'Gemini') { - deepseekConfig.style.display = 'none'; geminiConfig.style.display = 'block'; + } else if (provider === 'Custom') { + customConfig.style.display = 'block'; } } @@ -131,6 +142,10 @@ class ModelSettings { } else if (provider === 'gemini') { isValid = apiKey.startsWith('AI') && apiKey.length > 20; message = isValid ? '✓ API Key 格式正确' : '✗ Gemini API Key 应以 AI 开头'; + } else if (provider === 'custom') { + // 自定义API Key格式较为宽松,只要长度足够即可 + isValid = apiKey.length > 8; + message = isValid ? '✓ API Key 长度合适' : '✗ API Key 长度过短'; } statusElement.textContent = message; @@ -201,6 +216,13 @@ class ModelSettings { gemini: { model: document.getElementById('geminiModel').value, apiKey: document.getElementById('geminiApiKey').value + }, + custom: { + name: document.getElementById('customName').value, + apiUrl: document.getElementById('customApiUrl').value, + model: document.getElementById('customModel').value, + apiKey: document.getElementById('customApiKey').value, + format: document.getElementById('customFormat').value } }; } @@ -283,6 +305,13 @@ class ModelSettings { gemini: { model: 'gemini-2.5-pro', apiKey: '' + }, + custom: { + name: '自定义AI', + apiUrl: 'https://api.example.com/v1', + model: 'custom-model', + apiKey: '', + format: 'openai' } }; } @@ -311,6 +340,19 @@ class ModelSettings { if (settings.gemini.apiKey) { this.validateApiKey('gemini', settings.gemini.apiKey); } + + // 设置自定义AI配置 + if (settings.custom) { + document.getElementById('customName').value = settings.custom.name || '自定义AI'; + document.getElementById('customApiUrl').value = settings.custom.apiUrl || ''; + document.getElementById('customModel').value = settings.custom.model || ''; + document.getElementById('customApiKey').value = settings.custom.apiKey || ''; + document.getElementById('customFormat').value = settings.custom.format || 'openai'; + + if (settings.custom.apiKey) { + this.validateApiKey('custom', settings.custom.apiKey); + } + } } // 显示通知 diff --git a/server.js b/server.js index 87d51eb..63c7b4f 100644 --- a/server.js +++ b/server.js @@ -392,12 +392,94 @@ async function callAI(prompt, systemPrompt, retryCount = 0) { }); return response.data.candidates[0].content.parts[0].text; + } else if (provider === 'Custom') { + // 自定义AI提供商处理 + console.log('📊 使用自定义AI提供商:', config.name); + + // 根据format决定请求格式 + if (config.format === 'openai') { + // OpenAI兼容格式 + response = await axios.post(`${config.apiUrl}/chat/completions`, { + model: config.model, + messages: [ + { + role: 'system', + content: systemPrompt + }, + { + role: 'user', + content: prompt + } + ], + temperature: 1.0, + max_tokens: 64000, + stream: false + }, { + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: timeoutDuration, + httpAgent: new (require('http').Agent)({ + keepAlive: true, + maxSockets: 1, + timeout: timeoutDuration + }), + httpsAgent: new (require('https').Agent)({ + keepAlive: true, + maxSockets: 1, + timeout: timeoutDuration + }) + }); + + return response.data.choices[0].message.content; + } else { + // 自定义格式,使用通用请求 + throw new Error('目前仅支持OpenAI兼容格式的自定义API'); + } } throw new Error('不支持的AI提供商'); } catch (error) { + // 创建详细的错误日志对象 + const errorLog = { + timestamp: new Date().toISOString(), + attempt: retryCount + 1, + errorMessage: error.message, + errorCode: error.code || 'UNKNOWN', + errorName: error.name, + errorStack: error.stack + }; + + // 记录请求配置信息(如果存在) + if (error.config) { + errorLog.request = { + url: error.config.url, + method: error.config.method, + headers: { ...error.config.headers }, + timeout: error.config.timeout + }; + + // 移除敏感信息 + if (errorLog.request.headers && errorLog.request.headers.Authorization) { + errorLog.request.headers.Authorization = '[REDACTED]'; + } + } + + // 记录响应信息(如果存在) + if (error.response) { + errorLog.response = { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + data: error.response.data + }; + } + + // 输出格式化的错误信息 console.error(`❌ AI API调用失败 (第${retryCount + 1}次):`, error.message); + console.error('详细错误信息:', JSON.stringify(errorLog, null, 2)); // 判断是否需要重试 const shouldRetry = retryCount < maxRetries && ( @@ -413,13 +495,31 @@ async function callAI(prompt, systemPrompt, retryCount = 0) { const delay = baseDelay * Math.pow(2, retryCount); // 指数退避 console.log(`⏳ ${delay/1000}秒后进行第${retryCount + 2}次重试...`); + // 记录重试信息 + console.log(`重试信息: 尝试次数=${retryCount+1}, 最大重试次数=${maxRetries}, 延迟=${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); return await callAI(prompt, systemPrompt, retryCount + 1); } - // 记录详细错误信息 - if (error.response) { - console.error('API错误响应:', error.response.status, error.response.data); + // 将错误信息写入日志文件 + try { + const fs = require('fs'); + const logDir = './logs'; + + // 确保日志目录存在 + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + // 写入错误日志 + fs.appendFileSync( + `${logDir}/api-errors.log`, + `[${new Date().toISOString()}] API调用失败 (第${retryCount + 1}次): ${error.message}\n` + + `详细信息: ${JSON.stringify(errorLog, null, 2)}\n\n` + ); + } catch (logError) { + console.error('写入错误日志失败:', logError); } throw error; @@ -438,7 +538,8 @@ async function callDeepSeekAPI(prompt, systemPrompt) { async function checkAIModelHealth() { const results = { deepseek: { available: false, responseTime: null, error: null }, - gemini: { available: false, responseTime: null, error: null } + gemini: { available: false, responseTime: null, error: null }, + custom: { available: false, responseTime: null, error: null } }; // 测试DeepSeek @@ -477,6 +578,42 @@ async function checkAIModelHealth() { results.gemini.error = error.message; } + // 测试自定义AI提供商 + try { + // 读取自定义AI配置 + const fs = require('fs'); + const path = require('path'); + const modelSettingsPath = path.join(__dirname, 'model-settings.json'); + + if (fs.existsSync(modelSettingsPath)) { + const settings = JSON.parse(fs.readFileSync(modelSettingsPath, 'utf8')); + + if (settings.custom && settings.custom.apiUrl && settings.custom.apiKey) { + const startTime = Date.now(); + + // 根据format决定请求格式 + if (settings.custom.format === 'openai') { + await axios.post(`${settings.custom.apiUrl}/chat/completions`, { + model: settings.custom.model || 'default', + messages: [{ role: 'user', content: 'test' }], + max_tokens: 1 + }, { + headers: { + 'Authorization': `Bearer ${settings.custom.apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 10000 + }); + } + + results.custom.available = true; + results.custom.responseTime = Date.now() - startTime; + } + } + } catch (error) { + results.custom.error = error.message; + } + return results; } @@ -492,20 +629,26 @@ app.get('/api/ai-model-recommendation', async (req, res) => { }; // 基于响应时间和可用性推荐 - if (health.deepseek.available && health.gemini.available) { - if (health.deepseek.responseTime < health.gemini.responseTime) { - recommendation.recommended = 'deepseek'; - recommendation.reason = `DeepSeek响应更快 (${health.deepseek.responseTime}ms vs ${health.gemini.responseTime}ms)`; + const availableModels = []; + if (health.deepseek.available) availableModels.push({name: 'deepseek', time: health.deepseek.responseTime}); + if (health.gemini.available) availableModels.push({name: 'gemini', time: health.gemini.responseTime}); + if (health.custom.available) availableModels.push({name: 'custom', time: health.custom.responseTime}); + + if (availableModels.length > 0) { + // 按响应时间排序 + availableModels.sort((a, b) => a.time - b.time); + const fastest = availableModels[0]; + + recommendation.recommended = fastest.name; + + if (availableModels.length > 1) { + const comparisons = availableModels.slice(1).map(model => + `${model.name} (${model.time}ms)` + ).join(', '); + recommendation.reason = `${fastest.name}响应最快 (${fastest.time}ms vs ${comparisons})`; } else { - recommendation.recommended = 'gemini'; - recommendation.reason = `Gemini响应更快 (${health.gemini.responseTime}ms vs ${health.deepseek.responseTime}ms)`; + recommendation.reason = `${fastest.name}是唯一可用的模型 (${fastest.time}ms)`; } - } else if (health.deepseek.available) { - recommendation.recommended = 'deepseek'; - recommendation.reason = 'Gemini当前不可用'; - } else if (health.gemini.available) { - recommendation.recommended = 'gemini'; - recommendation.reason = 'DeepSeek当前不可用'; } else { recommendation.recommended = null; recommendation.reason = '所有AI模型当前都不可用'; @@ -560,7 +703,20 @@ async function getModelConfig() { } } -function generatePromptTemplate(analysisType, chatData, customPrompt = '') { +function generatePromptTemplate(analysisType, chatData, customPrompt = '', shouldFilterImages = true) { + // 获取AI设置 + const aiSettingsPath = path.join(__dirname, 'ai-settings.json'); + let filterImages = shouldFilterImages; + + try { + if (fs.existsSync(aiSettingsPath)) { + const aiSettings = JSON.parse(fs.readFileSync(aiSettingsPath, 'utf8')); + filterImages = aiSettings.filterImages === true; + } + } catch (error) { + console.warn('读取AI设置失败,使用默认设置', error); + } + // 不做任何限制,保留完整数据 const validMessages = chatData.filter(msg => msg.content && msg.content.trim().length > 0); const userStats = {}; @@ -572,6 +728,21 @@ function generatePromptTemplate(analysisType, chatData, customPrompt = '') { } }); + // 处理消息内容,过滤图片消息 + const processedMessages = validMessages.map(msg => { + let content = msg.content; + + // 如果启用了图片过滤,检测并替换图片消息 + if (filterImages && content) { + // 检测...格式的图片消息 + if (/.*?<\/msg>/s.test(content)) { + content = "此处是用户发聊天图,无法解析"; + } + } + + return `${msg.time} [${msg.senderName}]: ${content}`; + }); + const basicInfo = ` 聊天数据概况: - 群聊名称: ${chatData[0]?.talkerName || '未知群聊'} @@ -581,7 +752,7 @@ function generatePromptTemplate(analysisType, chatData, customPrompt = '') { - 主要发言用户: ${Object.entries(userStats).sort((a,b) => b[1] - a[1]).slice(0, 5).map(([name, count]) => `${name}(${count}条)`).join(', ')} 完整聊天数据: -${validMessages.map(msg => `${msg.time} [${msg.senderName}]: ${msg.content}`).join('\n')} +${processedMessages.join('\n')} `; // 如果有自定义提示词,直接使用 @@ -600,9 +771,31 @@ ${customPrompt}`; // AI分析接口(修改为返回historyId) app.post('/api/ai-analysis', async (req, res) => { try { - const { groupName, analysisType, customPrompt, timeRange } = req.body; + const { groupName, analysisType, customPrompt, timeRange, filterImages } = req.body; - console.log('AI分析请求:', { groupName, analysisType, customPrompt, timeRange }); + console.log('AI分析请求:', { groupName, analysisType, customPrompt, timeRange, filterImages }); + + // 如果提供了filterImages参数,更新ai-settings.json + if (filterImages !== undefined) { + try { + const aiSettingsPath = path.join(__dirname, 'ai-settings.json'); + let aiSettings = {}; + + // 读取现有设置 + if (fs.existsSync(aiSettingsPath)) { + aiSettings = JSON.parse(fs.readFileSync(aiSettingsPath, 'utf8')); + } + + // 更新filterImages设置 + aiSettings.filterImages = filterImages === true; + + // 保存设置 + fs.writeFileSync(aiSettingsPath, JSON.stringify(aiSettings, null, 2)); + console.log('已更新图片过滤设置:', filterImages); + } catch (error) { + console.error('保存AI设置失败:', error); + } + } if (!groupName) { return res.status(400).json({ error: '请指定群聊名称' }); @@ -657,8 +850,70 @@ app.post('/api/ai-analysis', async (req, res) => { }); } catch (error) { - console.error('AI分析失败:', error.message); + console.error('AI分析失败:', error); + // 创建详细的错误日志对象 + const errorLog = { + timestamp: new Date().toISOString(), + errorMessage: error.message, + errorCode: error.code || 'UNKNOWN', + errorName: error.name, + errorStack: error.stack, + requestParams: { + groupName: req.body.groupName, + analysisType: req.body.analysisType, + timeRange: req.body.timeRange, + customPromptLength: req.body.customPrompt ? req.body.customPrompt.length : 0 + } + }; + + // 记录请求配置信息(如果存在) + if (error.config) { + errorLog.request = { + url: error.config.url, + method: error.config.method, + timeout: error.config.timeout + }; + + // 移除敏感信息 + if (error.config.headers && error.config.headers.Authorization) { + errorLog.request.hasAuthHeader = true; + } + } + + // 记录响应信息(如果存在) + if (error.response) { + errorLog.response = { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data + }; + } + + // 输出格式化的错误信息到控制台 + console.error('详细错误信息:', JSON.stringify(errorLog, null, 2)); + + // 将错误信息写入日志文件 + try { + const fs = require('fs'); + const logDir = './logs'; + + // 确保日志目录存在 + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + // 写入错误日志 + fs.appendFileSync( + `${logDir}/api-errors.log`, + `[${new Date().toISOString()}] AI分析失败: ${error.message}\n` + + `详细信息: ${JSON.stringify(errorLog, null, 2)}\n\n` + ); + } catch (logError) { + console.error('写入错误日志失败:', logError); + } + + // 用户友好的错误消息 let errorMessage = 'AI分析失败: ' + error.message; let suggestions = []; @@ -692,12 +947,15 @@ app.post('/api/ai-analysis', async (req, res) => { ]; } + // 返回详细的错误信息 res.json({ success: false, error: errorMessage, suggestions: suggestions, errorCode: error.code, - httpStatus: error.response?.status + httpStatus: error.response?.status, + errorDetails: errorLog, + timestamp: new Date().toISOString() }); } }); @@ -1804,7 +2062,7 @@ app.post('/api/test-cron-expression', (req, res) => { // 保存模型设置 app.post('/api/model-settings', (req, res) => { try { - const { modelProvider, deepseek, gemini } = req.body; + const { modelProvider, deepseek, gemini, custom } = req.body; if (!modelProvider) { return res.status(400).json({ @@ -1814,7 +2072,27 @@ app.post('/api/model-settings', (req, res) => { } // 验证配置 - const selectedConfig = modelProvider === 'DeepSeek' ? deepseek : gemini; + let selectedConfig; + if (modelProvider === 'DeepSeek') { + selectedConfig = deepseek; + } else if (modelProvider === 'Gemini') { + selectedConfig = gemini; + } else if (modelProvider === 'Custom') { + selectedConfig = custom; + // 自定义提供商需要额外验证API URL + if (!selectedConfig.apiUrl || selectedConfig.apiUrl.trim() === '') { + return res.status(400).json({ + success: false, + error: 'API URL 不能为空' + }); + } + } else { + return res.status(400).json({ + success: false, + error: '不支持的模型提供商' + }); + } + if (!selectedConfig || !selectedConfig.apiKey || !selectedConfig.model) { return res.status(400).json({ success: false, @@ -1841,6 +2119,15 @@ app.post('/api/model-settings', (req, res) => { 'GEMINI_MODEL': gemini.model }; + // 如果有自定义AI提供商配置,也添加到环境变量 + if (custom) { + envUpdates['CUSTOM_API_URL'] = custom.apiUrl || ''; + envUpdates['CUSTOM_API_KEY'] = custom.apiKey || ''; + envUpdates['CUSTOM_MODEL'] = custom.model || ''; + envUpdates['CUSTOM_NAME'] = custom.name || '自定义AI'; + envUpdates['CUSTOM_FORMAT'] = custom.format || 'openai'; + } + Object.keys(envUpdates).forEach(key => { const value = envUpdates[key]; const regex = new RegExp(`^${key}=.*$`, 'm'); @@ -1860,6 +2147,7 @@ app.post('/api/model-settings', (req, res) => { modelProvider, deepseek, gemini, + custom, updatedAt: new Date().toISOString() }; @@ -1894,15 +2182,23 @@ app.get('/api/model-settings', (req, res) => { // 出于安全考虑,不返回完整的API Key const safeSettings = { ...settings, - deepseek: { + deepseek: { ...settings.deepseek, apiKey: settings.deepseek.apiKey ? settings.deepseek.apiKey.substring(0, 8) + '...' : '' }, gemini: { ...settings.gemini, apiKey: settings.gemini.apiKey ? settings.gemini.apiKey.substring(0, 8) + '...' : '' + } + }; + + // 如果有自定义AI提供商配置,也处理其API Key + if (settings.custom) { + safeSettings.custom = { + ...settings.custom, + apiKey: settings.custom.apiKey ? settings.custom.apiKey.substring(0, 8) + '...' : '' + }; } - }; res.json({ success: true, @@ -1921,6 +2217,13 @@ app.get('/api/model-settings', (req, res) => { gemini: { model: 'gemini-2.5-pro', apiKey: '' + }, + custom: { + name: '自定义AI', + apiUrl: 'https://api.example.com/v1', + model: 'custom-model', + apiKey: '', + format: 'openai' } } }); @@ -1973,6 +2276,8 @@ app.post('/api/model-settings/test', async (req, res) => { testResult = await testDeepSeekConnection(config.apiKey, config.model); } else if (provider === 'Gemini') { testResult = await testGeminiConnection(config.apiKey, config.model); + } else if (provider === 'Custom') { + testResult = await testCustomConnection(config); } else { return res.status(400).json({ success: false, @@ -2118,6 +2423,103 @@ async function testGeminiConnection(apiKey, model) { } } +// 自定义AI连接测试函数 +async function testCustomConnection(config) { + try { + // 验证必要参数 + if (!config.apiUrl) { + return { + success: false, + error: 'API URL 不能为空' + }; + } + + if (!config.apiKey) { + return { + success: false, + error: 'API Key 不能为空' + }; + } + + if (!config.model) { + return { + success: false, + error: '模型名称不能为空' + }; + } + + // 根据format决定请求格式 + if (config.format === 'openai') { + // OpenAI兼容格式 + const response = await axios.post(`${config.apiUrl}/chat/completions`, { + model: config.model, + messages: [ + { + role: 'user', + content: '你好,请回复"连接测试成功"' + } + ], + max_tokens: 50, + temperature: 0.1 + }, { + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 10000 + }); + + if (response.status === 200 && response.data.choices && response.data.choices[0]) { + return { + success: true, + message: '连接测试成功', + model: config.model, + response: response.data.choices[0].message.content + }; + } else { + return { + success: false, + error: '模型响应格式异常' + }; + } + } else { + return { + success: false, + error: '目前仅支持OpenAI兼容格式的自定义API' + }; + } + } catch (error) { + console.error('自定义AI连接测试失败:', error.message); + + if (error.response) { + const statusCode = error.response.status; + const errorData = error.response.data; + + if (statusCode === 401) { + return { + success: false, + error: 'API Key 无效,请检查您的密钥' + }; + } else if (statusCode === 429) { + return { + success: false, + error: 'API 调用频率超限,请稍后重试' + }; + } else { + return { + success: false, + error: `API 错误 (${statusCode}): ${errorData?.error?.message || '未知错误'}` + }; + } + } else { + return { + success: false, + error: error.code === 'ECONNABORTED' ? '连接超时,请检查网络' : error.message + }; + } + } +} + // 启动服务器 app.listen(PORT, () => { console.log(`\n🚀 聊天记录查询网站已启动`); diff --git a/views/index.ejs b/views/index.ejs index 2f35ed5..94b8d9c 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -5,6 +5,7 @@ 聊天记录查询 +