From d48238f6a013729396105284131eb89386e34b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:02:13 +0800 Subject: [PATCH] Add files via upload --- internal/database/monitor.go | 39 +++++++++++++++++---- internal/handler/monitor.go | 33 +++++++++++------- internal/knowledge/manager.go | 18 +++++++++- web/static/css/style.css | 41 ++++++++++++++++++++++ web/static/js/chat.js | 65 ++++++++++++++++++++++++----------- web/static/js/monitor.js | 50 +++++++++++++++++++++------ web/templates/index.html | 6 +++- 7 files changed, 200 insertions(+), 52 deletions(-) diff --git a/internal/database/monitor.go b/internal/database/monitor.go index 5e3b704f..226ef117 100644 --- a/internal/database/monitor.go +++ b/internal/database/monitor.go @@ -3,9 +3,11 @@ package database import ( "database/sql" "encoding/json" + "strings" "time" "cyberstrike-ai/internal/mcp" + "go.uber.org/zap" ) @@ -70,13 +72,25 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error { } // CountToolExecutions 统计工具执行记录总数 -func (db *DB) CountToolExecutions(status string) (int, error) { +func (db *DB) CountToolExecutions(status, toolName string) (int, error) { query := `SELECT COUNT(*) FROM tool_executions` args := []interface{}{} + conditions := []string{} if status != "" { - query += ` WHERE status = ?` + conditions = append(conditions, "status = ?") args = append(args, status) } + if toolName != "" { + // 支持部分匹配(模糊搜索),不区分大小写 + conditions = append(conditions, "LOWER(tool_name) LIKE ?") + args = append(args, "%"+strings.ToLower(toolName)+"%") + } + if len(conditions) > 0 { + query += ` WHERE ` + conditions[0] + for i := 1; i < len(conditions); i++ { + query += ` AND ` + conditions[i] + } + } var count int err := db.QueryRow(query, args...).Scan(&count) if err != nil { @@ -87,30 +101,43 @@ func (db *DB) CountToolExecutions(status string) (int, error) { // LoadToolExecutions 加载所有工具执行记录(支持分页) func (db *DB) LoadToolExecutions() ([]*mcp.ToolExecution, error) { - return db.LoadToolExecutionsWithPagination(0, 1000, "") + return db.LoadToolExecutionsWithPagination(0, 1000, "", "") } // LoadToolExecutionsWithPagination 分页加载工具执行记录 // limit: 最大返回记录数,0 表示使用默认值 1000 // offset: 跳过的记录数,用于分页 // status: 状态筛选,空字符串表示不过滤 -func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status string) ([]*mcp.ToolExecution, error) { +// toolName: 工具名称筛选,空字符串表示不过滤 +func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolName string) ([]*mcp.ToolExecution, error) { if limit <= 0 { limit = 1000 // 默认限制 } if limit > 10000 { limit = 10000 // 最大限制,防止一次性加载过多数据 } - + query := ` SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms FROM tool_executions ` args := []interface{}{} + conditions := []string{} if status != "" { - query += ` WHERE status = ?` + conditions = append(conditions, "status = ?") args = append(args, status) } + if toolName != "" { + // 支持部分匹配(模糊搜索),不区分大小写 + conditions = append(conditions, "LOWER(tool_name) LIKE ?") + args = append(args, "%"+strings.ToLower(toolName)+"%") + } + if len(conditions) > 0 { + query += ` WHERE ` + conditions[0] + for i := 1; i < len(conditions); i++ { + query += ` AND ` + conditions[i] + } + } query += ` ORDER BY start_time DESC LIMIT ? OFFSET ?` args = append(args, limit, offset) diff --git a/internal/handler/monitor.go b/internal/handler/monitor.go index 6999d983..dab472ac 100644 --- a/internal/handler/monitor.go +++ b/internal/handler/monitor.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "strings" "time" "cyberstrike-ai/internal/database" @@ -66,8 +67,10 @@ func (h *MonitorHandler) Monitor(c *gin.Context) { // 解析状态筛选参数 status := c.Query("status") + // 解析工具筛选参数 + toolName := c.Query("tool") - executions, total := h.loadExecutionsWithPagination(page, pageSize, status) + executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName) stats := h.loadStats() totalPages := (total + pageSize - 1) / pageSize @@ -87,18 +90,21 @@ func (h *MonitorHandler) Monitor(c *gin.Context) { } func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution { - executions, _ := h.loadExecutionsWithPagination(1, 1000, "") + executions, _ := h.loadExecutionsWithPagination(1, 1000, "", "") return executions } -func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status string) ([]*mcp.ToolExecution, int) { +func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) { if h.db == nil { allExecutions := h.mcpServer.GetAllExecutions() - // 如果指定了状态筛选,先进行筛选 - if status != "" { + // 如果指定了状态筛选或工具筛选,先进行筛选 + if status != "" || toolName != "" { filtered := make([]*mcp.ToolExecution, 0) for _, exec := range allExecutions { - if exec.Status == status { + matchStatus := status == "" || exec.Status == status + // 支持部分匹配(模糊搜索) + matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName)) + if matchStatus && matchTool { filtered = append(filtered, exec) } } @@ -117,15 +123,18 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status } offset := (page - 1) * pageSize - executions, err := h.db.LoadToolExecutionsWithPagination(offset, pageSize, status) + executions, err := h.db.LoadToolExecutionsWithPagination(offset, pageSize, status, toolName) if err != nil { h.logger.Warn("从数据库加载执行记录失败,回退到内存数据", zap.Error(err)) allExecutions := h.mcpServer.GetAllExecutions() - // 如果指定了状态筛选,先进行筛选 - if status != "" { + // 如果指定了状态筛选或工具筛选,先进行筛选 + if status != "" || toolName != "" { filtered := make([]*mcp.ToolExecution, 0) for _, exec := range allExecutions { - if exec.Status == status { + matchStatus := status == "" || exec.Status == status + // 支持部分匹配(模糊搜索) + matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName)) + if matchStatus && matchTool { filtered = append(filtered, exec) } } @@ -143,8 +152,8 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status return allExecutions[offset:end], total } - // 获取总数(考虑状态筛选) - total, err := h.db.CountToolExecutions(status) + // 获取总数(考虑状态筛选和工具筛选) + total, err := h.db.CountToolExecutions(status, toolName) if err != nil { h.logger.Warn("获取执行记录总数失败", zap.Error(err)) // 回退:使用已加载的记录数估算 diff --git a/internal/knowledge/manager.go b/internal/knowledge/manager.go index 611dca85..761a8c16 100644 --- a/internal/knowledge/manager.go +++ b/internal/knowledge/manager.go @@ -639,7 +639,12 @@ func (m *Manager) UpdateItem(id, category, title, content string) (*KnowledgeIte // 删除旧目录(如果为空) oldDir := filepath.Dir(item.FilePath) if entries, err := os.ReadDir(oldDir); err == nil && len(entries) == 0 { - os.Remove(oldDir) + // 只有当目录不是知识库根目录时才删除(避免删除根目录) + if oldDir != m.basePath { + if err := os.Remove(oldDir); err != nil { + m.logger.Warn("删除空目录失败", zap.String("dir", oldDir), zap.Error(err)) + } + } } } @@ -686,6 +691,17 @@ func (m *Manager) DeleteItem(id string) error { return fmt.Errorf("删除知识项失败: %w", err) } + // 删除空目录(如果为空) + dir := filepath.Dir(filePath) + if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 { + // 只有当目录不是知识库根目录时才删除(避免删除根目录) + if dir != m.basePath { + if err := os.Remove(dir); err != nil { + m.logger.Warn("删除空目录失败", zap.String("dir", dir), zap.Error(err)) + } + } + } + return nil } diff --git a/web/static/css/style.css b/web/static/css/style.css index 81705980..ff83bd83 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3192,6 +3192,47 @@ header { background: var(--bg-secondary); color: var(--text-primary); font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + min-width: 140px; + max-width: 200px; +} + +.monitor-section .section-actions select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); +} + +.monitor-section .section-actions select:hover { + border-color: var(--accent-color); +} + +.monitor-section .section-actions input[type="text"] { + margin-left: 6px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.875rem; + transition: all 0.2s; + min-width: 180px; + max-width: 250px; +} + +.monitor-section .section-actions input[type="text"]:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); +} + +.monitor-section .section-actions input[type="text"]:hover { + border-color: var(--accent-color); +} + +.monitor-section .section-actions input[type="text"]::placeholder { + color: var(--text-muted); } .monitor-stats-grid { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 8554db22..548d9bf7 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -3772,6 +3772,7 @@ let contextMenuConversationId = null; let contextMenuGroupId = null; let groupsCache = []; let conversationGroupMappingCache = {}; +let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况) // 加载分组列表 async function loadGroups() { @@ -3901,13 +3902,10 @@ async function loadConversationsWithGroups(searchQuery = '') { } // 如果没有搜索关键词,使用原有逻辑 - // 如果对话在某个分组中,且当前不在分组详情页,则跳过 - if (currentGroupId === null && conversationGroupMappingCache[conv.id]) { - return; - } - - // 如果当前在分组详情页,只显示该分组的对话 - if (currentGroupId !== null && conversationGroupMappingCache[conv.id] !== currentGroupId) { + // "最近对话"列表应该只显示不在任何分组中的对话 + // 无论是否在分组详情页,都不应该在"最近对话"中显示分组中的对话 + if (conversationGroupMappingCache[conv.id]) { + // 对话在某个分组中,不应该显示在"最近对话"列表中 return; } @@ -4050,8 +4048,12 @@ async function showConversationContextMenu(event) { if (convId) { try { let isPinned = false; - if (currentGroupId) { - // 如果在分组详情页面,获取分组内置顶状态 + // 检查对话是否真的在当前分组中 + const conversationGroupId = conversationGroupMappingCache[convId]; + const isInCurrentGroup = currentGroupId && conversationGroupId === currentGroupId; + + if (isInCurrentGroup) { + // 对话在当前分组中,获取分组内置顶状态 const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); if (response.ok) { const groupConvs = await response.json(); @@ -4061,7 +4063,7 @@ async function showConversationContextMenu(event) { } } } else { - // 不在分组详情页面,获取全局置顶状态 + // 不在分组详情页面,或者对话不在当前分组中,获取全局置顶状态 const response = await apiFetch(`/api/conversations/${convId}`); if (response.ok) { const conv = await response.json(); @@ -4316,8 +4318,14 @@ async function pinConversation() { if (!convId) return; try { - // 如果当前在分组详情页面,使用分组内置顶 - if (currentGroupId) { + // 检查对话是否真的在当前分组中 + // 如果对话已经从分组移出,conversationGroupMappingCache 中不会有该对话的映射 + // 或者映射的分组ID不等于当前分组ID + const conversationGroupId = conversationGroupMappingCache[convId]; + const isInCurrentGroup = currentGroupId && conversationGroupId === currentGroupId; + + // 如果当前在分组详情页面,且对话确实在当前分组中,使用分组内置顶 + if (isInCurrentGroup) { // 获取当前对话在分组中的置顶状态 const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); const groupConvs = await response.json(); @@ -4339,7 +4347,7 @@ async function pinConversation() { // 重新加载分组对话 loadGroupConversations(currentGroupId); } else { - // 不在分组详情页面,使用全局置顶 + // 不在分组详情页面,或者对话不在当前分组中,使用全局置顶 const response = await apiFetch(`/api/conversations/${convId}`); const conv = await response.json(); const newPinned = !conv.pinned; @@ -4629,27 +4637,30 @@ async function moveConversationToGroup(convId, groupId) { const oldGroupId = conversationGroupMappingCache[convId]; conversationGroupMappingCache[convId] = groupId; + // 将新移动的对话添加到待保留映射中,防止后端API延迟导致映射丢失 + pendingGroupMappings[convId] = groupId; + // 如果移动的是当前对话,更新 currentConversationGroupId if (currentConversationId === convId) { currentConversationGroupId = groupId; } - // 重新加载分组映射缓存,确保数据同步 - await loadConversationGroupMapping(); - // 如果当前在分组详情页面,重新加载分组对话 if (currentGroupId) { // 如果从当前分组移出,或者移动到当前分组,都需要重新加载 if (currentGroupId === oldGroupId || currentGroupId === groupId) { await loadGroupConversations(currentGroupId); } - } else { - // 如果不在分组详情页面,刷新最近对话列表 - loadConversationsWithGroups(); } - // 如果旧分组和新分组不同,且用户正在查看旧分组,也需要刷新旧分组 - // 但上面的逻辑已经处理了这种情况(currentGroupId === oldGroupId) + // 无论是否在分组详情页面,都需要刷新最近对话列表 + // 因为最近对话列表会根据分组映射缓存来过滤显示,需要立即更新 + // loadConversationsWithGroups 内部会调用 loadConversationGroupMapping, + // loadConversationGroupMapping 会保留 pendingGroupMappings 中的映射 + await loadConversationsWithGroups(); + + // 注意:pendingGroupMappings 中的映射会在下次 loadConversationGroupMapping + // 成功从后端加载时自动清理(在 loadConversationGroupMapping 中处理) // 刷新分组列表,更新高亮状态 await loadGroups(); @@ -4670,6 +4681,8 @@ async function removeConversationFromGroup(convId, groupId) { // 更新缓存 - 立即删除,确保后续加载时能正确识别 delete conversationGroupMappingCache[convId]; + // 同时从待保留映射中移除 + delete pendingGroupMappings[convId]; // 如果移除的是当前对话,清除 currentConversationGroupId if (currentConversationId === convId) { @@ -4719,6 +4732,9 @@ async function loadConversationGroupMapping() { groups = []; } + // 保存待保留的映射 + const preservedMappings = { ...pendingGroupMappings }; + conversationGroupMappingCache = {}; for (const group of groups) { @@ -4728,9 +4744,16 @@ async function loadConversationGroupMapping() { if (Array.isArray(conversations)) { conversations.forEach(conv => { conversationGroupMappingCache[conv.id] = group.id; + // 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了) + if (preservedMappings[conv.id] === group.id) { + delete pendingGroupMappings[conv.id]; + } }); } } + + // 恢复待保留的映射(这些是后端API尚未同步的映射) + Object.assign(conversationGroupMappingCache, preservedMappings); } catch (error) { console.error('加载对话分组映射失败:', error); } diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 2d098db3..08de8de5 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -974,12 +974,17 @@ async function refreshMonitorPanel(page = null) { // 获取当前的筛选条件 const statusFilter = document.getElementById('monitor-status-filter'); - const currentFilter = statusFilter ? statusFilter.value : 'all'; + const toolFilter = document.getElementById('monitor-tool-filter'); + const currentStatusFilter = statusFilter ? statusFilter.value : 'all'; + const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all'; // 构建请求 URL let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`; - if (currentFilter && currentFilter !== 'all') { - url += `&status=${encodeURIComponent(currentFilter)}`; + if (currentStatusFilter && currentStatusFilter !== 'all') { + url += `&status=${encodeURIComponent(currentStatusFilter)}`; + } + if (currentToolFilter && currentToolFilter !== 'all') { + url += `&tool=${encodeURIComponent(currentToolFilter)}`; } const response = await apiFetch(url, { method: 'GET' }); @@ -1003,7 +1008,7 @@ async function refreshMonitorPanel(page = null) { } renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt); - renderMonitorExecutions(monitorState.executions, currentFilter); + renderMonitorExecutions(monitorState.executions, currentStatusFilter); renderMonitorPagination(); } catch (error) { console.error('刷新监控面板失败:', error); @@ -1016,14 +1021,30 @@ async function refreshMonitorPanel(page = null) { } } -async function applyMonitorFilters() { - const statusFilter = document.getElementById('monitor-status-filter'); - const status = statusFilter ? statusFilter.value : 'all'; - // 当筛选条件改变时,从后端重新获取数据 - await refreshMonitorPanelWithFilter(status); +// 处理工具搜索输入(防抖) +let toolFilterDebounceTimer = null; +function handleToolFilterInput() { + // 清除之前的定时器 + if (toolFilterDebounceTimer) { + clearTimeout(toolFilterDebounceTimer); + } + + // 设置新的定时器,500ms后执行筛选 + toolFilterDebounceTimer = setTimeout(() => { + applyMonitorFilters(); + }, 500); } -async function refreshMonitorPanelWithFilter(statusFilter = 'all') { +async function applyMonitorFilters() { + const statusFilter = document.getElementById('monitor-status-filter'); + const toolFilter = document.getElementById('monitor-tool-filter'); + const status = statusFilter ? statusFilter.value : 'all'; + const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all'; + // 当筛选条件改变时,从后端重新获取数据 + await refreshMonitorPanelWithFilter(status, tool); +} + +async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter = 'all') { const statsContainer = document.getElementById('monitor-stats'); const execContainer = document.getElementById('monitor-executions'); @@ -1036,6 +1057,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all') { if (statusFilter && statusFilter !== 'all') { url += `&status=${encodeURIComponent(statusFilter)}`; } + if (toolFilter && toolFilter !== 'all') { + url += `&tool=${encodeURIComponent(toolFilter)}`; + } const response = await apiFetch(url, { method: 'GET' }); const result = await response.json().catch(() => ({})); @@ -1071,6 +1095,7 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all') { } } + function renderMonitorStats(statsMap = {}, lastFetchedAt = null) { const container = document.getElementById('monitor-stats'); if (!container) { @@ -1151,7 +1176,10 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { if (!Array.isArray(executions) || executions.length === 0) { // 根据是否有筛选条件显示不同的提示 - if (statusFilter && statusFilter !== 'all') { + const toolFilter = document.getElementById('monitor-tool-filter'); + const currentToolFilter = toolFilter ? toolFilter.value : 'all'; + const hasFilter = (statusFilter && statusFilter !== 'all') || (currentToolFilter && currentToolFilter !== 'all'); + if (hasFilter) { container.innerHTML = '
当前筛选条件下暂无记录
'; } else { container.innerHTML = '
暂无执行记录
'; diff --git a/web/templates/index.html b/web/templates/index.html index 10a1d7eb..ac6c6851 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -248,7 +248,7 @@
- +
@@ -277,6 +277,10 @@

最新执行记录

+