diff --git a/internal/database/group.go b/internal/database/group.go index 2e9eac49..5e61a501 100644 --- a/internal/database/group.go +++ b/internal/database/group.go @@ -195,10 +195,21 @@ func (db *DB) DeleteGroup(id string) error { } // AddConversationToGroup 将对话添加到分组 +// 注意:一个对话只能属于一个分组,所以在添加新分组之前,会先删除该对话的所有旧分组关联 func (db *DB) AddConversationToGroup(conversationID, groupID string) error { - id := uuid.New().String() + // 先删除该对话的所有旧分组关联,确保一个对话只属于一个分组 _, err := db.Exec( - "INSERT OR REPLACE INTO conversation_group_mappings (id, conversation_id, group_id, created_at) VALUES (?, ?, ?, ?)", + "DELETE FROM conversation_group_mappings WHERE conversation_id = ?", + conversationID, + ) + if err != nil { + return fmt.Errorf("删除对话旧分组关联失败: %w", err) + } + + // 然后插入新的分组关联 + id := uuid.New().String() + _, err = db.Exec( + "INSERT INTO conversation_group_mappings (id, conversation_id, group_id, created_at) VALUES (?, ?, ?, ?)", id, conversationID, groupID, time.Now(), ) if err != nil { diff --git a/internal/database/vulnerability.go b/internal/database/vulnerability.go index 9d13d05b..1423759b 100644 --- a/internal/database/vulnerability.go +++ b/internal/database/vulnerability.go @@ -90,7 +90,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { } // ListVulnerabilities 列出漏洞 -func (db *DB) ListVulnerabilities(limit, offset int, conversationID, severity, status string) ([]*Vulnerability, error) { +func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severity, status string) ([]*Vulnerability, error) { query := ` SELECT id, conversation_id, title, description, severity, status, vulnerability_type, target, proof, impact, recommendation, @@ -100,6 +100,10 @@ func (db *DB) ListVulnerabilities(limit, offset int, conversationID, severity, s ` args := []interface{}{} + if id != "" { + query += " AND id = ?" + args = append(args, id) + } if conversationID != "" { query += " AND conversation_id = ?" args = append(args, conversationID) diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 1b536351..eed96615 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "unicode/utf8" "time" "cyberstrike-ai/internal/agent" @@ -16,6 +17,47 @@ import ( "go.uber.org/zap" ) +// safeTruncateString 安全截断字符串,避免在 UTF-8 字符中间截断 +func safeTruncateString(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= maxLen { + return s + } + + // 将字符串转换为 rune 切片以正确计算字符数 + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + + // 截断到最大长度 + truncated := string(runes[:maxLen]) + + // 尝试在标点符号或空格处截断,使截断更自然 + // 在截断点往前查找合适的断点(不超过20%的长度) + searchRange := maxLen / 5 + if searchRange > maxLen { + searchRange = maxLen + } + breakChars := []rune(",。、 ,.;:!?!?/\\-_") + bestBreakPos := len(runes[:maxLen]) + + for i := bestBreakPos - 1; i >= bestBreakPos-searchRange && i >= 0; i-- { + for _, breakChar := range breakChars { + if runes[i] == breakChar { + bestBreakPos = i + 1 // 在标点符号后断开 + goto found + } + } + } + +found: + truncated = string(runes[:bestBreakPos]) + return truncated + "..." +} + // AgentHandler Agent处理器 type AgentHandler struct { agent *agent.Agent @@ -74,10 +116,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) { // 如果没有对话ID,创建新对话 conversationID := req.ConversationID if conversationID == "" { - title := req.Message - if len(title) > 50 { - title = title[:50] + "..." - } + title := safeTruncateString(req.Message, 50) conv, err := h.db.CreateConversation(title) if err != nil { h.logger.Error("创建对话失败", zap.Error(err)) @@ -237,10 +276,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { // 如果没有对话ID,创建新对话 conversationID := req.ConversationID if conversationID == "" { - title := req.Message - if len(title) > 50 { - title = title[:50] + "..." - } + title := safeTruncateString(req.Message, 50) conv, err := h.db.CreateConversation(title) if err != nil { h.logger.Error("创建对话失败", zap.Error(err)) diff --git a/internal/handler/vulnerability.go b/internal/handler/vulnerability.go index 47b00ab7..3bd42af2 100644 --- a/internal/handler/vulnerability.go +++ b/internal/handler/vulnerability.go @@ -86,6 +86,7 @@ func (h *VulnerabilityHandler) GetVulnerability(c *gin.Context) { func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) { limitStr := c.DefaultQuery("limit", "50") offsetStr := c.DefaultQuery("offset", "0") + id := c.Query("id") conversationID := c.Query("conversation_id") severity := c.Query("severity") status := c.Query("status") @@ -97,7 +98,7 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) { limit = 50 } - vulnerabilities, err := h.db.ListVulnerabilities(limit, offset, conversationID, severity, status) + vulnerabilities, err := h.db.ListVulnerabilities(limit, offset, id, conversationID, severity, status) if err != nil { h.logger.Error("获取漏洞列表失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/web/static/css/style.css b/web/static/css/style.css index 4c04e966..9f58c8da 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -5427,8 +5427,10 @@ header { font-size: 0.875rem; color: var(--text-primary); overflow: hidden; - text-overflow: ellipsis; + text-overflow: clip; white-space: nowrap; + word-break: keep-all; + /* 完全依赖JavaScript截断,禁用CSS的ellipsis以避免在UTF-8多字节字符中间截断 */ } .batch-table-col-time { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 76e48e0e..15bdb903 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1356,7 +1356,9 @@ function createConversationListItem(conversation) { const title = document.createElement('div'); title.className = 'conversation-title'; - title.textContent = conversation.title || '未命名对话'; + const titleText = conversation.title || '未命名对话'; + title.textContent = safeTruncateText(titleText, 60); + title.title = titleText; // 设置完整标题以便悬停查看 contentWrapper.appendChild(title); const time = document.createElement('div'); @@ -3916,7 +3918,9 @@ function createConversationListItemWithMenu(conversation, isPinned) { const title = document.createElement('div'); title.className = 'conversation-title'; - title.textContent = conversation.title || '未命名对话'; + const titleText = conversation.title || '未命名对话'; + title.textContent = safeTruncateText(titleText, 60); + title.title = titleText; // 设置完整标题以便悬停查看 titleWrapper.appendChild(title); if (isPinned) { @@ -4394,8 +4398,13 @@ async function showMoveToGroupSubmenu() { groupsCache = []; } - // 如果有分组,显示所有分组(排除当前分组) + // 如果有分组,显示所有分组(排除对话已所在的分组) if (groupsCache.length > 0) { + // 检查对话当前所在的分组ID + const conversationCurrentGroupId = contextMenuConversationId + ? conversationGroupMappingCache[contextMenuConversationId] + : null; + groupsCache.forEach(group => { // 验证分组对象是否有效 if (!group || !group.id || !group.name) { @@ -4403,8 +4412,8 @@ async function showMoveToGroupSubmenu() { return; } - // 如果当前在分组详情页面,不显示当前分组 - if (currentGroupId && group.id === currentGroupId) { + // 如果对话已经在当前分组中,不显示该分组(因为已经在里面了) + if (conversationCurrentGroupId && group.id === conversationCurrentGroupId) { return; } @@ -4570,16 +4579,23 @@ async function moveConversationToGroup(convId, groupId) { currentConversationGroupId = groupId; } + // 重新加载分组映射缓存,确保数据同步 + await loadConversationGroupMapping(); + // 如果当前在分组详情页面,重新加载分组对话 if (currentGroupId) { // 如果从当前分组移出,或者移动到当前分组,都需要重新加载 if (currentGroupId === oldGroupId || currentGroupId === groupId) { - loadGroupConversations(currentGroupId); + await loadGroupConversations(currentGroupId); } } else { + // 如果不在分组详情页面,刷新最近对话列表 loadConversationsWithGroups(); } + // 如果旧分组和新分组不同,且用户正在查看旧分组,也需要刷新旧分组 + // 但上面的逻辑已经处理了这种情况(currentGroupId === oldGroupId) + // 刷新分组列表,更新高亮状态 await loadGroups(); } catch (error) { @@ -4718,6 +4734,45 @@ async function showBatchManageModal() { } } +// 安全截断中文字符串,避免在汉字中间截断 +function safeTruncateText(text, maxLength = 50) { + if (!text || typeof text !== 'string') { + return text || ''; + } + + // 使用 Array.from 将字符串转换为字符数组(正确处理 Unicode 代理对) + const chars = Array.from(text); + + // 如果文本长度未超过限制,直接返回 + if (chars.length <= maxLength) { + return text; + } + + // 截断到最大长度(基于字符数,而不是代码单元) + let truncatedChars = chars.slice(0, maxLength); + + // 尝试在标点符号或空格处截断,使截断更自然 + // 在截断点往前查找合适的断点(不超过20%的长度) + const searchRange = Math.floor(maxLength * 0.2); + const breakChars = [',', '。', '、', ' ', ',', '.', ';', ':', '!', '?', '!', '?', '/', '\\', '-', '_']; + let bestBreakPos = truncatedChars.length; + + for (let i = truncatedChars.length - 1; i >= truncatedChars.length - searchRange && i >= 0; i--) { + if (breakChars.includes(truncatedChars[i])) { + bestBreakPos = i + 1; // 在标点符号后断开 + break; + } + } + + // 如果找到合适的断点,使用它;否则使用原截断位置 + if (bestBreakPos < truncatedChars.length) { + truncatedChars = truncatedChars.slice(0, bestBreakPos); + } + + // 将字符数组转换回字符串,并添加省略号 + return truncatedChars.join('') + '...'; +} + // 渲染批量管理对话列表 function renderBatchConversations(filtered = null) { const list = document.getElementById('batch-conversations-list'); @@ -4738,7 +4793,12 @@ function renderBatchConversations(filtered = null) { const name = document.createElement('div'); name.className = 'batch-table-col-name'; - name.textContent = conv.title || '未命名对话'; + const originalTitle = conv.title || '未命名对话'; + // 使用安全截断函数,限制最大长度为45个字符(留出空间显示省略号) + const truncatedTitle = safeTruncateText(originalTitle, 45); + name.textContent = truncatedTitle; + // 设置title属性以显示完整文本(鼠标悬停时) + name.title = originalTitle; const time = document.createElement('div'); time.className = 'batch-table-col-time'; @@ -5114,7 +5174,9 @@ async function loadGroupConversations(groupId) { const title = document.createElement('div'); title.className = 'group-conversation-title'; - title.textContent = fullConv.title || conv.title || '未命名对话'; + const titleText = fullConv.title || conv.title || '未命名对话'; + title.textContent = safeTruncateText(titleText, 60); + title.title = titleText; // 设置完整标题以便悬停查看 titleWrapper.appendChild(title); // 如果对话在分组中置顶,显示置顶图标 diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js index 994dc37d..94b21b01 100644 --- a/web/static/js/vulnerability.js +++ b/web/static/js/vulnerability.js @@ -2,6 +2,7 @@ let currentVulnerabilityId = null; let vulnerabilityFilters = { + id: '', conversation_id: '', severity: '', status: '' @@ -80,6 +81,9 @@ async function loadVulnerabilities() { params.append('limit', '100'); params.append('offset', '0'); + if (vulnerabilityFilters.id) { + params.append('id', vulnerabilityFilters.id); + } if (vulnerabilityFilters.conversation_id) { params.append('conversation_id', vulnerabilityFilters.conversation_id); } @@ -172,6 +176,7 @@ function renderVulnerabilities(vulnerabilities) {