diff --git a/internal/app/app.go b/internal/app/app.go index 34caccff..02aeaa60 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -694,6 +694,18 @@ func setupRoutes( } app.knowledgeHandler.Search(c) }) + knowledgeRoutes.GET("/stats", func(c *gin.Context) { + if app.knowledgeHandler == nil { + c.JSON(http.StatusOK, gin.H{ + "enabled": false, + "total_categories": 0, + "total_items": 0, + "message": "知识库功能未启用,请前往系统设置启用知识检索功能", + }) + return + } + app.knowledgeHandler.GetStats(c) + }) } // 漏洞管理 diff --git a/internal/handler/knowledge.go b/internal/handler/knowledge.go index 79addac8..6578de72 100644 --- a/internal/handler/knowledge.go +++ b/internal/handler/knowledge.go @@ -15,11 +15,11 @@ import ( // KnowledgeHandler 知识库处理器 type KnowledgeHandler struct { - manager *knowledge.Manager + manager *knowledge.Manager retriever *knowledge.Retriever - indexer *knowledge.Indexer - db *database.DB - logger *zap.Logger + indexer *knowledge.Indexer + db *database.DB + logger *zap.Logger } // NewKnowledgeHandler 创建新的知识库处理器 @@ -55,7 +55,7 @@ func (h *KnowledgeHandler) GetCategories(c *gin.Context) { func (h *KnowledgeHandler) GetItems(c *gin.Context) { category := c.Query("category") searchKeyword := c.Query("search") // 搜索关键字 - + // 如果提供了搜索关键字,执行关键字搜索(在所有数据中搜索) if searchKeyword != "" { items, err := h.manager.SearchItemsByKeyword(searchKeyword, category) @@ -102,10 +102,10 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) { }) return } - + // 分页模式:categoryPage=true 表示按分类分页,否则按项分页(向后兼容) categoryPageMode := c.Query("categoryPage") != "false" // 默认使用分类分页 - + // 分页参数 limit := 50 // 默认每页50条(分类分页时为分类数,项分页时为项数) offset := 0 @@ -192,9 +192,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "items": items, - "total": total, - "limit": limit, + "items": items, + "total": total, + "limit": limit, "offset": offset, }) } else { @@ -207,9 +207,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "items": items, - "total": total, - "limit": limit, + "items": items, + "total": total, + "limit": limit, "offset": offset, }) } @@ -341,12 +341,12 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) { consecutiveFailures := 0 var firstFailureItemID string var firstFailureError error - + for i, itemID := range itemsToIndex { if err := h.indexer.IndexItem(ctx, itemID); err != nil { failedCount++ consecutiveFailures++ - + // 只在第一个失败时记录详细日志 if consecutiveFailures == 1 { firstFailureItemID = itemID @@ -357,7 +357,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) { zap.Error(err), ) } - + // 如果连续失败2次,立即停止增量索引 if consecutiveFailures >= 2 { h.logger.Error("连续索引失败次数过多,立即停止增量索引", @@ -371,14 +371,14 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) { } continue } - + // 成功时重置连续失败计数 if consecutiveFailures > 0 { consecutiveFailures = 0 firstFailureItemID = "" firstFailureError = nil } - + // 减少进度日志频率 if (i+1)%10 == 0 || i+1 == len(itemsToIndex) { h.logger.Info("索引进度", zap.Int("current", i+1), zap.Int("total", len(itemsToIndex)), zap.Int("failed", failedCount)) @@ -388,7 +388,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) { }() c.JSON(http.StatusOK, gin.H{ - "message": fmt.Sprintf("扫描完成,开始索引 %d 个新添加或更新的知识项", len(itemsToIndex)), + "message": fmt.Sprintf("扫描完成,开始索引 %d 个新添加或更新的知识项", len(itemsToIndex)), "items_to_index": len(itemsToIndex), }) } @@ -470,10 +470,25 @@ func (h *KnowledgeHandler) Search(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"results": results}) } +// GetStats 获取知识库统计信息 +func (h *KnowledgeHandler) GetStats(c *gin.Context) { + totalCategories, totalItems, err := h.manager.GetStats() + if err != nil { + h.logger.Error("获取知识库统计信息失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "enabled": true, + "total_categories": totalCategories, + "total_items": totalItems, + }) +} + // 辅助函数:解析整数 func parseInt(s string) (int, error) { var result int _, err := fmt.Sscanf(s, "%d", &result) return result, err } - diff --git a/internal/knowledge/manager.go b/internal/knowledge/manager.go index 761a8c16..ec72abad 100644 --- a/internal/knowledge/manager.go +++ b/internal/knowledge/manager.go @@ -153,6 +153,25 @@ func (m *Manager) GetCategories() ([]string, error) { return categories, nil } +// GetStats 获取知识库统计信息 +func (m *Manager) GetStats() (int, int, error) { + // 获取分类总数 + categories, err := m.GetCategories() + if err != nil { + return 0, 0, fmt.Errorf("获取分类失败: %w", err) + } + totalCategories := len(categories) + + // 获取知识项总数 + var totalItems int + err = m.db.QueryRow("SELECT COUNT(*) FROM knowledge_base_items").Scan(&totalItems) + if err != nil { + return totalCategories, 0, fmt.Errorf("获取知识项总数失败: %w", err) + } + + return totalCategories, totalItems, nil +} + // GetCategoriesWithItems 按分类分页获取知识项(每个分类包含其下的所有知识项) // limit: 每页分类数量(0表示不限制) // offset: 偏移量(按分类偏移) @@ -359,7 +378,7 @@ func (m *Manager) SearchItemsByKeyword(keyword string, category string) ([]*Know // SQLite的LIKE不区分大小写,使用COLLATE NOCASE或LOWER()函数 // 使用%keyword%进行模糊匹配 searchPattern := "%" + keyword + "%" - + query = ` SELECT id, category, title, file_path, created_at, updated_at FROM knowledge_base_items diff --git a/web/static/css/style.css b/web/static/css/style.css index 9097edc5..2adfc4a0 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -8405,6 +8405,24 @@ header { font-variant-numeric: tabular-nums; } +.dashboard-tools-bar-tooltip { + display: none; + position: fixed; + left: 0; + top: 0; + z-index: 10000; + max-width: 320px; + padding: 6px 10px; + font-size: 0.8125rem; + line-height: 1.4; + color: #fff; + background: rgba(15, 23, 42, 0.95); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + pointer-events: none; + word-break: break-all; +} + .dashboard-cta-block { position: relative; margin-top: 24px; diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index fda44c2a..f9fcc84f 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -29,11 +29,12 @@ async function refreshDashboard() { } try { - const [tasksRes, vulnRes, batchRes, monitorRes, skillsRes] = await Promise.all([ + const [tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes] = await Promise.all([ apiFetch('/api/agent-loop/tasks').then(r => r.ok ? r.json() : null).catch(() => null), apiFetch('/api/vulnerabilities/stats').then(r => r.ok ? r.json() : null).catch(() => null), apiFetch('/api/batch-tasks?limit=500&page=1').then(r => r.ok ? r.json() : null).catch(() => null), apiFetch('/api/monitor/stats').then(r => r.ok ? r.json() : null).catch(() => null), + apiFetch('/api/knowledge/stats').then(r => r.ok ? r.json() : null).catch(() => null), apiFetch('/api/skills/stats').then(r => r.ok ? r.json() : null).catch(() => null) ]); @@ -108,6 +109,20 @@ async function refreshDashboard() { renderDashboardToolsBar(null); } + // 知识:{ enabled, total_categories, total_items, ... } + const knowledgeValueEl = document.getElementById('dashboard-knowledge-value'); + if (knowledgeRes && typeof knowledgeRes === 'object') { + if (knowledgeRes.enabled === false) { + if (knowledgeValueEl) knowledgeValueEl.textContent = '知识功能暂未启用'; + } else { + const categories = knowledgeRes.total_categories ?? 0; + const items = knowledgeRes.total_items ?? 0; + if (knowledgeValueEl) knowledgeValueEl.textContent = `${categories} 个分类,共 ${items} 项`; + } + } else { + if (knowledgeValueEl) knowledgeValueEl.textContent = '-'; + } + // Skills:{ total_skills, total_calls, ... } if (skillsRes && typeof skillsRes === 'object') { setEl('dashboard-skills-count', String(skillsRes.total_skills ?? '-')); @@ -137,6 +152,8 @@ function setEl(id, text) { function setDashboardOverviewPlaceholder(t) { ['dashboard-batch-pending', 'dashboard-batch-running', 'dashboard-batch-done', 'dashboard-tools-count', 'dashboard-tools-calls', 'dashboard-skills-count', 'dashboard-skills-calls'].forEach(id => setEl(id, t)); + const knowledgeValueEl = document.getElementById('dashboard-knowledge-value'); + if (knowledgeValueEl) knowledgeValueEl.textContent = t; } // Top 30 工具执行次数柱状图颜色(30 色不重复,柔和、易区分) @@ -190,11 +207,63 @@ function renderDashboardToolsBar(monitorRes) { var pct = maxCalls > 0 ? (e.totalCalls / maxCalls) * 100 : 0; var label = e.name.length > 12 ? e.name.slice(0, 10) + '…' : e.name; var color = DASHBOARD_BAR_COLORS[i % DASHBOARD_BAR_COLORS.length]; - html += '
'; - html += '' + esc(label) + ''; + var fullName = esc(e.name); + html += '
'; + html += '' + esc(label) + ''; html += '
'; html += '' + e.totalCalls + ''; html += '
'; }); barChartEl.innerHTML = html; + attachDashboardBarTooltips(barChartEl); +} + +var dashboardBarTooltipEl = null; +var dashboardBarTooltipTimer = null; + +function attachDashboardBarTooltips(barChartEl) { + if (!barChartEl) return; + if (!dashboardBarTooltipEl) { + dashboardBarTooltipEl = document.createElement('div'); + dashboardBarTooltipEl.className = 'dashboard-tools-bar-tooltip'; + dashboardBarTooltipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(dashboardBarTooltipEl); + } + barChartEl.removeEventListener('mouseover', dashboardBarTooltipOnOver); + barChartEl.removeEventListener('mouseout', dashboardBarTooltipOnOut); + barChartEl.addEventListener('mouseover', dashboardBarTooltipOnOver); + barChartEl.addEventListener('mouseout', dashboardBarTooltipOnOut); +} + +function dashboardBarTooltipOnOver(ev) { + var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-tools-bar-item'); + if (!item || !dashboardBarTooltipEl) return; + var text = item.getAttribute('data-tooltip'); + if (!text) return; + clearTimeout(dashboardBarTooltipTimer); + dashboardBarTooltipTimer = setTimeout(function () { + dashboardBarTooltipEl.textContent = text; + dashboardBarTooltipEl.style.display = 'block'; + requestAnimationFrame(function () { + var rect = item.getBoundingClientRect(); + var ttRect = dashboardBarTooltipEl.getBoundingClientRect(); + var x = rect.left + (rect.width / 2) - (ttRect.width / 2); + var y = rect.top - ttRect.height - 6; + if (y < 8) y = rect.bottom + 6; + var pad = 8; + if (x < pad) x = pad; + if (x + ttRect.width > window.innerWidth - pad) x = window.innerWidth - ttRect.width - pad; + dashboardBarTooltipEl.style.left = x + 'px'; + dashboardBarTooltipEl.style.top = y + 'px'; + }); + }, 180); +} + +function dashboardBarTooltipOnOut(ev) { + var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-tools-bar-item'); + var related = ev.relatedTarget && ev.relatedTarget.closest && ev.relatedTarget.closest('.dashboard-tools-bar-item'); + if (item && item === related) return; + clearTimeout(dashboardBarTooltipTimer); + dashboardBarTooltipTimer = null; + if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none'; } diff --git a/web/templates/index.html b/web/templates/index.html index bea971dd..5eb8249b 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -253,6 +253,10 @@
工具调用- 个工具,共 -
+
+ +
知识-
+
Skills- 个 Skill,共 - 次调用
@@ -262,11 +266,12 @@

快捷入口