diff --git a/internal/app/app.go b/internal/app/app.go index 26987145..7dbe860a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -234,6 +234,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { monitorHandler := handler.NewMonitorHandler(mcpServer, executor, db, log.Logger) monitorHandler.SetExternalMCPManager(externalMCPMgr) // 设置外部MCP管理器,以便获取外部MCP执行记录 conversationHandler := handler.NewConversationHandler(db, log.Logger) + groupHandler := handler.NewGroupHandler(db, log.Logger) authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger) attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger) configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger) @@ -255,6 +256,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { agentHandler, monitorHandler, conversationHandler, + groupHandler, configHandler, externalMCPHandler, attackChainHandler, @@ -323,6 +325,7 @@ func setupRoutes( agentHandler *handler.AgentHandler, monitorHandler *handler.MonitorHandler, conversationHandler *handler.ConversationHandler, + groupHandler *handler.GroupHandler, configHandler *handler.ConfigHandler, externalMCPHandler *handler.ExternalMCPHandler, attackChainHandler *handler.AttackChainHandler, @@ -357,7 +360,20 @@ func setupRoutes( protected.POST("/conversations", conversationHandler.CreateConversation) protected.GET("/conversations", conversationHandler.ListConversations) protected.GET("/conversations/:id", conversationHandler.GetConversation) + protected.PUT("/conversations/:id", conversationHandler.UpdateConversation) protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation) + protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned) + + // 对话分组 + protected.POST("/groups", groupHandler.CreateGroup) + protected.GET("/groups", groupHandler.ListGroups) + protected.GET("/groups/:id", groupHandler.GetGroup) + protected.PUT("/groups/:id", groupHandler.UpdateGroup) + protected.DELETE("/groups/:id", groupHandler.DeleteGroup) + protected.PUT("/groups/:id/pinned", groupHandler.UpdateGroupPinned) + protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations) + protected.POST("/groups/conversations", groupHandler.AddConversationToGroup) + protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup) // 监控 protected.GET("/monitor", monitorHandler.Monitor) diff --git a/internal/database/conversation.go b/internal/database/conversation.go index 31134b99..d28e483e 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -14,6 +14,7 @@ import ( type Conversation struct { ID string `json:"id"` Title string `json:"title"` + Pinned bool `json:"pinned"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Messages []Message `json:"messages,omitempty"` @@ -55,11 +56,12 @@ func (db *DB) CreateConversation(title string) (*Conversation, error) { func (db *DB) GetConversation(id string) (*Conversation, error) { var conv Conversation var createdAt, updatedAt string + var pinned int err := db.QueryRow( - "SELECT id, title, created_at, updated_at FROM conversations WHERE id = ?", + "SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE id = ?", id, - ).Scan(&conv.ID, &conv.Title, &createdAt, &updatedAt) + ).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("对话不存在") @@ -85,6 +87,8 @@ func (db *DB) GetConversation(id string) (*Conversation, error) { conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) } + conv.Pinned = pinned != 0 + // 加载消息 messages, err := db.GetMessages(id) if err != nil { @@ -138,7 +142,7 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati searchPattern := "%" + search + "%" // 使用DISTINCT避免重复,因为一个对话可能有多条消息匹配 rows, err = db.Query( - `SELECT DISTINCT c.id, c.title, c.created_at, c.updated_at + `SELECT DISTINCT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at FROM conversations c LEFT JOIN messages m ON c.id = m.conversation_id WHERE c.title LIKE ? OR m.content LIKE ? @@ -148,7 +152,7 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati ) } else { rows, err = db.Query( - "SELECT id, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?", + "SELECT id, title, COALESCE(pinned, 0), created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?", limit, offset, ) } @@ -162,8 +166,9 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati for rows.Next() { var conv Conversation var createdAt, updatedAt string + var pinned int - if err := rows.Scan(&conv.ID, &conv.Title, &createdAt, &updatedAt); err != nil { + if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt); err != nil { return nil, fmt.Errorf("扫描对话失败: %w", err) } @@ -185,6 +190,8 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) } + conv.Pinned = pinned != 0 + conversations = append(conversations, &conv) } diff --git a/internal/database/database.go b/internal/database/database.go index 514f7e3b..0ab14813 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -148,6 +148,28 @@ func (db *DB) initTables() error { FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL );` + // 创建对话分组表 + createConversationGroupsTable := ` + CREATE TABLE IF NOT EXISTS conversation_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + );` + + // 创建对话分组映射表 + createConversationGroupMappingsTable := ` + CREATE TABLE IF NOT EXISTS conversation_group_mappings ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + group_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES conversation_groups(id) ON DELETE CASCADE, + UNIQUE(conversation_id, group_id) + );` + // 创建索引 createIndexes := ` CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id); @@ -164,6 +186,9 @@ func (db *DB) initTables() error { CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_conversation ON knowledge_retrieval_logs(conversation_id); CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_message ON knowledge_retrieval_logs(message_id); CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_created_at ON knowledge_retrieval_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_conversation ON conversation_group_mappings(conversation_id); + CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id); + CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned); ` if _, err := db.Exec(createConversationsTable); err != nil { @@ -198,16 +223,29 @@ func (db *DB) initTables() error { return fmt.Errorf("创建knowledge_retrieval_logs表失败: %w", err) } - if _, err := db.Exec(createIndexes); err != nil { - return fmt.Errorf("创建索引失败: %w", err) + if _, err := db.Exec(createConversationGroupsTable); err != nil { + return fmt.Errorf("创建conversation_groups表失败: %w", err) } - // 为已有表添加新字段(如果不存在) + if _, err := db.Exec(createConversationGroupMappingsTable); err != nil { + return fmt.Errorf("创建conversation_group_mappings表失败: %w", err) + } + + // 为已有表添加新字段(如果不存在)- 必须在创建索引之前 if err := db.migrateConversationsTable(); err != nil { db.logger.Warn("迁移conversations表失败", zap.Error(err)) // 不返回错误,允许继续运行 } + if err := db.migrateConversationGroupsTable(); err != nil { + db.logger.Warn("迁移conversation_groups表失败", zap.Error(err)) + // 不返回错误,允许继续运行 + } + + if _, err := db.Exec(createIndexes); err != nil { + return fmt.Errorf("创建索引失败: %w", err) + } + db.logger.Info("数据库表初始化完成") return nil } @@ -251,6 +289,48 @@ func (db *DB) migrateConversationsTable() error { } } + // 检查pinned字段是否存在 + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='pinned'").Scan(&count) + if err != nil { + // 如果查询失败,尝试添加字段 + if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN pinned INTEGER DEFAULT 0"); addErr != nil { + // 如果字段已存在,忽略错误 + errMsg := strings.ToLower(addErr.Error()) + if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") { + db.logger.Warn("添加pinned字段失败", zap.Error(addErr)) + } + } + } else if count == 0 { + // 字段不存在,添加它 + if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN pinned INTEGER DEFAULT 0"); err != nil { + db.logger.Warn("添加pinned字段失败", zap.Error(err)) + } + } + + return nil +} + +// migrateConversationGroupsTable 迁移conversation_groups表,添加新字段 +func (db *DB) migrateConversationGroupsTable() error { + // 检查pinned字段是否存在 + var count int + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversation_groups') WHERE name='pinned'").Scan(&count) + if err != nil { + // 如果查询失败,尝试添加字段 + if _, addErr := db.Exec("ALTER TABLE conversation_groups ADD COLUMN pinned INTEGER DEFAULT 0"); addErr != nil { + // 如果字段已存在,忽略错误 + errMsg := strings.ToLower(addErr.Error()) + if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") { + db.logger.Warn("添加pinned字段失败", zap.Error(addErr)) + } + } + } else if count == 0 { + // 字段不存在,添加它 + if _, err := db.Exec("ALTER TABLE conversation_groups ADD COLUMN pinned INTEGER DEFAULT 0"); err != nil { + db.logger.Warn("添加pinned字段失败", zap.Error(err)) + } + } + return nil } diff --git a/internal/database/group.go b/internal/database/group.go new file mode 100644 index 00000000..cb9597da --- /dev/null +++ b/internal/database/group.go @@ -0,0 +1,319 @@ +package database + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// ConversationGroup 对话分组 +type ConversationGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Pinned bool `json:"pinned"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// GroupExistsByName 检查分组名称是否已存在 +func (db *DB) GroupExistsByName(name string, excludeID string) (bool, error) { + var count int + var err error + + if excludeID != "" { + err = db.QueryRow( + "SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND id != ?", + name, excludeID, + ).Scan(&count) + } else { + err = db.QueryRow( + "SELECT COUNT(*) FROM conversation_groups WHERE name = ?", + name, + ).Scan(&count) + } + + if err != nil { + return false, fmt.Errorf("检查分组名称失败: %w", err) + } + + return count > 0, nil +} + +// CreateGroup 创建分组 +func (db *DB) CreateGroup(name, icon string) (*ConversationGroup, error) { + // 检查名称是否已存在 + exists, err := db.GroupExistsByName(name, "") + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("分组名称已存在") + } + + id := uuid.New().String() + now := time.Now() + + if icon == "" { + icon = "📁" + } + + _, err = db.Exec( + "INSERT INTO conversation_groups (id, name, icon, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + id, name, icon, 0, now, now, + ) + if err != nil { + return nil, fmt.Errorf("创建分组失败: %w", err) + } + + return &ConversationGroup{ + ID: id, + Name: name, + Icon: icon, + Pinned: false, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// ListGroups 列出所有分组 +func (db *DB) ListGroups() ([]*ConversationGroup, error) { + rows, err := db.Query( + "SELECT id, name, icon, COALESCE(pinned, 0), created_at, updated_at FROM conversation_groups ORDER BY COALESCE(pinned, 0) DESC, created_at ASC", + ) + if err != nil { + return nil, fmt.Errorf("查询分组列表失败: %w", err) + } + defer rows.Close() + + var groups []*ConversationGroup + for rows.Next() { + var group ConversationGroup + var createdAt, updatedAt string + var pinned int + + if err := rows.Scan(&group.ID, &group.Name, &group.Icon, &pinned, &createdAt, &updatedAt); err != nil { + return nil, fmt.Errorf("扫描分组失败: %w", err) + } + + group.Pinned = pinned != 0 + + // 尝试多种时间格式解析 + var err1, err2 error + group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt) + if err1 != nil { + group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt) + } + if err1 != nil { + group.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + } + + group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt) + if err2 != nil { + group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt) + } + if err2 != nil { + group.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + } + + groups = append(groups, &group) + } + + return groups, nil +} + +// GetGroup 获取分组 +func (db *DB) GetGroup(id string) (*ConversationGroup, error) { + var group ConversationGroup + var createdAt, updatedAt string + var pinned int + + err := db.QueryRow( + "SELECT id, name, icon, COALESCE(pinned, 0), created_at, updated_at FROM conversation_groups WHERE id = ?", + id, + ).Scan(&group.ID, &group.Name, &group.Icon, &pinned, &createdAt, &updatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("分组不存在") + } + return nil, fmt.Errorf("查询分组失败: %w", err) + } + + // 尝试多种时间格式解析 + var err1, err2 error + group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt) + if err1 != nil { + group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt) + } + if err1 != nil { + group.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + } + + group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt) + if err2 != nil { + group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt) + } + if err2 != nil { + group.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + } + + group.Pinned = pinned != 0 + + return &group, nil +} + +// UpdateGroup 更新分组 +func (db *DB) UpdateGroup(id, name, icon string) error { + // 检查名称是否已存在(排除当前分组) + exists, err := db.GroupExistsByName(name, id) + if err != nil { + return err + } + if exists { + return fmt.Errorf("分组名称已存在") + } + + _, err = db.Exec( + "UPDATE conversation_groups SET name = ?, icon = ?, updated_at = ? WHERE id = ?", + name, icon, time.Now(), id, + ) + if err != nil { + return fmt.Errorf("更新分组失败: %w", err) + } + return nil +} + +// DeleteGroup 删除分组 +func (db *DB) DeleteGroup(id string) error { + _, err := db.Exec("DELETE FROM conversation_groups WHERE id = ?", id) + if err != nil { + return fmt.Errorf("删除分组失败: %w", err) + } + return nil +} + +// 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 (?, ?, ?, ?)", + id, conversationID, groupID, time.Now(), + ) + if err != nil { + return fmt.Errorf("添加对话到分组失败: %w", err) + } + return nil +} + +// RemoveConversationFromGroup 从分组中移除对话 +func (db *DB) RemoveConversationFromGroup(conversationID, groupID string) error { + _, err := db.Exec( + "DELETE FROM conversation_group_mappings WHERE conversation_id = ? AND group_id = ?", + conversationID, groupID, + ) + if err != nil { + return fmt.Errorf("从分组中移除对话失败: %w", err) + } + return nil +} + +// GetConversationsByGroup 获取分组中的所有对话 +func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) { + rows, err := db.Query( + `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at + FROM conversations c + INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id + WHERE cgm.group_id = ? + ORDER BY c.updated_at DESC`, + groupID, + ) + if err != nil { + return nil, fmt.Errorf("查询分组对话失败: %w", err) + } + defer rows.Close() + + var conversations []*Conversation + for rows.Next() { + var conv Conversation + var createdAt, updatedAt string + var pinned int + + if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt); err != nil { + return nil, fmt.Errorf("扫描对话失败: %w", err) + } + + // 尝试多种时间格式解析 + var err1, err2 error + conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt) + if err1 != nil { + conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt) + } + if err1 != nil { + conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + } + + conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt) + if err2 != nil { + conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt) + } + if err2 != nil { + conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + } + + conv.Pinned = pinned != 0 + + conversations = append(conversations, &conv) + } + + return conversations, nil +} + +// GetGroupByConversation 获取对话所属的分组 +func (db *DB) GetGroupByConversation(conversationID string) (string, error) { + var groupID string + err := db.QueryRow( + "SELECT group_id FROM conversation_group_mappings WHERE conversation_id = ? LIMIT 1", + conversationID, + ).Scan(&groupID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil // 没有分组 + } + return "", fmt.Errorf("查询对话分组失败: %w", err) + } + return groupID, nil +} + +// UpdateConversationPinned 更新对话置顶状态 +func (db *DB) UpdateConversationPinned(id string, pinned bool) error { + pinnedValue := 0 + if pinned { + pinnedValue = 1 + } + _, err := db.Exec( + "UPDATE conversations SET pinned = ?, updated_at = ? WHERE id = ?", + pinnedValue, time.Now(), id, + ) + if err != nil { + return fmt.Errorf("更新对话置顶状态失败: %w", err) + } + return nil +} + +// UpdateGroupPinned 更新分组置顶状态 +func (db *DB) UpdateGroupPinned(id string, pinned bool) error { + pinnedValue := 0 + if pinned { + pinnedValue = 1 + } + _, err := db.Exec( + "UPDATE conversation_groups SET pinned = ?, updated_at = ? WHERE id = ?", + pinnedValue, time.Now(), id, + ) + if err != nil { + return fmt.Errorf("更新分组置顶状态失败: %w", err) + } + return nil +} diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index db1629dc..4aa572e6 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -88,6 +88,43 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) { c.JSON(http.StatusOK, conv) } +// UpdateConversationRequest 更新对话请求 +type UpdateConversationRequest struct { + Title string `json:"title"` +} + +// UpdateConversation 更新对话 +func (h *ConversationHandler) UpdateConversation(c *gin.Context) { + id := c.Param("id") + + var req UpdateConversationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "标题不能为空"}) + return + } + + if err := h.db.UpdateConversationTitle(id, req.Title); err != nil { + h.logger.Error("更新对话失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // 返回更新后的对话 + conv, err := h.db.GetConversation(id) + if err != nil { + h.logger.Error("获取更新后的对话失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, conv) +} + // DeleteConversation 删除对话 func (h *ConversationHandler) DeleteConversation(c *gin.Context) { id := c.Param("id") diff --git a/internal/handler/group.go b/internal/handler/group.go new file mode 100644 index 00000000..54a4b219 --- /dev/null +++ b/internal/handler/group.go @@ -0,0 +1,238 @@ +package handler + +import ( + "net/http" + + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// GroupHandler 分组处理器 +type GroupHandler struct { + db *database.DB + logger *zap.Logger +} + +// NewGroupHandler 创建新的分组处理器 +func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler { + return &GroupHandler{ + db: db, + logger: logger, + } +} + +// CreateGroupRequest 创建分组请求 +type CreateGroupRequest struct { + Name string `json:"name"` + Icon string `json:"icon"` +} + +// CreateGroup 创建分组 +func (h *GroupHandler) CreateGroup(c *gin.Context) { + var req CreateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"}) + return + } + + group, err := h.db.CreateGroup(req.Name, req.Icon) + if err != nil { + h.logger.Error("创建分组失败", zap.Error(err)) + // 如果是名称重复错误,返回400状态码 + if err.Error() == "分组名称已存在" { + c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称已存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, group) +} + +// ListGroups 列出所有分组 +func (h *GroupHandler) ListGroups(c *gin.Context) { + groups, err := h.db.ListGroups() + if err != nil { + h.logger.Error("获取分组列表失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, groups) +} + +// GetGroup 获取分组 +func (h *GroupHandler) GetGroup(c *gin.Context) { + id := c.Param("id") + + group, err := h.db.GetGroup(id) + if err != nil { + h.logger.Error("获取分组失败", zap.Error(err)) + c.JSON(http.StatusNotFound, gin.H{"error": "分组不存在"}) + return + } + + c.JSON(http.StatusOK, group) +} + +// UpdateGroupRequest 更新分组请求 +type UpdateGroupRequest struct { + Name string `json:"name"` + Icon string `json:"icon"` +} + +// UpdateGroup 更新分组 +func (h *GroupHandler) UpdateGroup(c *gin.Context) { + id := c.Param("id") + + var req UpdateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"}) + return + } + + if err := h.db.UpdateGroup(id, req.Name, req.Icon); err != nil { + h.logger.Error("更新分组失败", zap.Error(err)) + // 如果是名称重复错误,返回400状态码 + if err.Error() == "分组名称已存在" { + c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称已存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + group, err := h.db.GetGroup(id) + if err != nil { + h.logger.Error("获取更新后的分组失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, group) +} + +// DeleteGroup 删除分组 +func (h *GroupHandler) DeleteGroup(c *gin.Context) { + id := c.Param("id") + + if err := h.db.DeleteGroup(id); err != nil { + h.logger.Error("删除分组失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} + +// AddConversationToGroupRequest 添加对话到分组请求 +type AddConversationToGroupRequest struct { + ConversationID string `json:"conversationId"` + GroupID string `json:"groupId"` +} + +// AddConversationToGroup 将对话添加到分组 +func (h *GroupHandler) AddConversationToGroup(c *gin.Context) { + var req AddConversationToGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.db.AddConversationToGroup(req.ConversationID, req.GroupID); err != nil { + h.logger.Error("添加对话到分组失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "添加成功"}) +} + +// RemoveConversationFromGroup 从分组中移除对话 +func (h *GroupHandler) RemoveConversationFromGroup(c *gin.Context) { + conversationID := c.Param("conversationId") + groupID := c.Param("id") + + if err := h.db.RemoveConversationFromGroup(conversationID, groupID); err != nil { + h.logger.Error("从分组中移除对话失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "移除成功"}) +} + +// GetGroupConversations 获取分组中的所有对话 +func (h *GroupHandler) GetGroupConversations(c *gin.Context) { + groupID := c.Param("id") + + conversations, err := h.db.GetConversationsByGroup(groupID) + if err != nil { + h.logger.Error("获取分组对话失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, conversations) +} + +// UpdateConversationPinnedRequest 更新对话置顶状态请求 +type UpdateConversationPinnedRequest struct { + Pinned bool `json:"pinned"` +} + +// UpdateConversationPinned 更新对话置顶状态 +func (h *GroupHandler) UpdateConversationPinned(c *gin.Context) { + conversationID := c.Param("id") + + var req UpdateConversationPinnedRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.db.UpdateConversationPinned(conversationID, req.Pinned); err != nil { + h.logger.Error("更新对话置顶状态失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} + +// UpdateGroupPinnedRequest 更新分组置顶状态请求 +type UpdateGroupPinnedRequest struct { + Pinned bool `json:"pinned"` +} + +// UpdateGroupPinned 更新分组置顶状态 +func (h *GroupHandler) UpdateGroupPinned(c *gin.Context) { + groupID := c.Param("id") + + var req UpdateGroupPinnedRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.db.UpdateGroupPinned(groupID, req.Pinned); err != nil { + h.logger.Error("更新分组置顶状态失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 6db7058e..291984cc 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -437,6 +437,7 @@ body { min-height: 0; width: 100%; height: 100%; + position: relative; } .conversation-sidebar { @@ -875,6 +876,29 @@ header { color: var(--text-muted); } +.conversation-group-tag { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 4px; + padding: 2px 6px; + background: rgba(0, 102, 255, 0.08); + border: 1px solid rgba(0, 102, 255, 0.2); + border-radius: 4px; + font-size: 0.7rem; + color: var(--accent-color); + line-height: 1.2; +} + +.group-tag-icon { + font-size: 0.75rem; + line-height: 1; +} + +.group-tag-name { + font-weight: 500; +} + .conversation-delete-btn { width: 28px; height: 28px; @@ -4464,6 +4488,7 @@ header { text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; } @@ -4539,6 +4564,7 @@ header { text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; + line-clamp: 3; -webkit-box-orient: vertical; } @@ -4950,3 +4976,614 @@ header { transform: rotate(360deg); } } + +/* 对话分组和最近对话样式 */ +.conversation-groups-section, +.recent-conversations-section { + margin-bottom: 24px; +} + +.conversation-groups-section:last-child, +.recent-conversations-section:last-child { + margin-bottom: 0; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 0 8px; +} + +.section-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.add-group-btn, +.batch-manage-btn { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.add-group-btn:hover, +.batch-manage-btn:hover { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.add-group-btn svg, +.batch-manage-btn svg { + width: 16px; + height: 16px; +} + +.conversation-groups-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.group-item { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + position: relative; +} + +.group-item:hover { + background: var(--bg-tertiary); +} + +.group-item.active { + background: rgba(0, 102, 255, 0.08); + border-color: var(--accent-color); +} + +.group-item-content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.group-item-icon { + font-size: 1rem; + flex-shrink: 0; +} + +.group-item-name { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + display: flex; + align-items: center; + gap: 4px; +} + +.group-item-pinned { + font-size: 0.75rem; + flex-shrink: 0; + opacity: 0.7; +} + +.group-item-menu { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.group-item:hover .group-item-menu { + opacity: 1; +} + +.group-item-menu:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.conversation-item { + position: relative; +} + +.conversation-item-pinned { + display: inline-flex; + align-items: center; + margin-left: 4px; + color: var(--accent-color); +} + +.conversation-item-menu { + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.conversation-item:hover .conversation-item-menu { + opacity: 1; +} + +.conversation-item-menu:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +/* 分组详情页面 */ +.group-detail-page { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-primary); + z-index: 10; + display: flex; + flex-direction: column; +} + +.group-detail-header { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.back-btn { + width: 32px; + height: 32px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.back-btn:hover { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.group-detail-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; + flex: 1; +} + +.group-detail-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.group-action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.group-action-btn:hover { + background: var(--bg-tertiary); + border-color: var(--accent-color); + color: var(--accent-color); +} + +.group-action-btn.delete-btn { + color: var(--error-color); +} + +.group-action-btn.delete-btn:hover { + background: rgba(220, 53, 69, 0.1); + border-color: var(--error-color); +} + +.group-detail-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.group-conversations-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.group-conversation-item { + padding: 16px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.group-conversation-item:hover { + border-color: var(--accent-color); + box-shadow: 0 2px 8px rgba(0, 102, 255, 0.1); +} + +.group-conversation-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.group-conversation-time { + font-size: 0.875rem; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: space-between; +} + +.group-conversation-content { + margin-top: 12px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 6px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8125rem; + color: var(--text-primary); + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* 批量管理模态框 */ +.batch-manage-modal-content { + max-width: 800px; + width: 90vw; +} + +.batch-manage-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.batch-search-box { + position: relative; + display: flex; + align-items: center; +} + +.batch-search-box input { + padding: 8px 32px 8px 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary); + color: var(--text-primary); + width: 200px; +} + +.batch-search-box svg { + position: absolute; + right: 8px; + width: 16px; + height: 16px; + color: var(--text-muted); + pointer-events: none; +} + +.batch-manage-body { + max-height: 60vh; + overflow-y: auto; +} + +.batch-conversations-table { + display: flex; + flex-direction: column; +} + +.batch-table-header { + display: grid; + grid-template-columns: 40px 1fr 180px 80px; + gap: 16px; + padding: 12px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.batch-conversations-list { + display: flex; + flex-direction: column; +} + +.batch-conversation-row { + display: grid; + grid-template-columns: 40px 1fr 180px 80px; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + align-items: center; + transition: background 0.2s ease; +} + +.batch-conversation-row:hover { + background: var(--bg-tertiary); +} + +.batch-table-col-checkbox { + display: flex; + align-items: center; + justify-content: center; +} + +.batch-table-col-name { + font-size: 0.875rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.batch-table-col-time { + font-size: 0.875rem; + color: var(--text-muted); +} + +.batch-table-col-action { + display: flex; + align-items: center; + justify-content: center; +} + +.batch-delete-btn { + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.batch-delete-btn:hover { + background: rgba(220, 53, 69, 0.1); + color: var(--error-color); +} + +.batch-manage-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-top: 1px solid var(--border-color); +} + +.select-all-checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary); +} + +.batch-footer-actions { + display: flex; + gap: 12px; +} + +/* 创建分组模态框 */ +.create-group-modal-content { + max-width: 500px; + width: 90vw; +} + +.create-group-body { + padding: 24px; +} + +.create-group-description { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 24px; +} + +.create-group-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.group-icon-input { + position: absolute; + left: 12px; + font-size: 1.2rem; + pointer-events: none; +} + +#create-group-name-input { + width: 100%; + padding: 12px 16px 12px 48px; + border: 2px solid var(--accent-color); + border-radius: 8px; + font-size: 0.9375rem; + background: var(--bg-primary); + color: var(--text-primary); + transition: all 0.2s ease; +} + +#create-group-name-input:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1); +} + +/* 上下文菜单 */ +.context-menu { + position: fixed; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 10000; + min-width: 180px; + padding: 4px; +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + color: var(--text-primary); + font-size: 0.875rem; + border-radius: 6px; + transition: all 0.2s ease; + position: relative; +} + +.context-menu-item:hover { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.context-menu-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.context-menu-item-has-submenu { + justify-content: space-between; +} + +.context-menu-item-has-submenu .submenu-arrow { + width: 12px; + height: 12px; + margin-left: auto; +} + +.context-menu-item-danger { + color: var(--error-color); +} + +.context-menu-item-danger:hover { + background: rgba(220, 53, 69, 0.1); + color: var(--error-color); +} + +.context-menu-divider { + height: 1px; + background: var(--border-color); + margin: 4px 0; +} + +.context-submenu { + position: absolute; + left: 100%; + top: 0; + margin-left: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 160px; + padding: 4px; +} + +.context-submenu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + color: var(--text-primary); + font-size: 0.875rem; + border-radius: 6px; + transition: all 0.2s ease; +} + +.context-submenu-item:hover { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.context-submenu-item.add-group-item { + color: var(--accent-color); + font-weight: 500; +} + +.context-submenu-item.add-group-item:hover { + background: rgba(0, 102, 255, 0.1); +} diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 746d1b68..09ffcb1a 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1259,7 +1259,9 @@ async function loadConversations(searchQuery = '') { section.appendChild(title); items.forEach(itemData => { - section.appendChild(createConversationListItem(itemData)); + // 判断是否置顶 + const isPinned = itemData.pinned || false; + section.appendChild(createConversationListItemWithMenu(itemData, isPinned)); }); fragment.appendChild(section); @@ -3602,3 +3604,1167 @@ function exportAttackChain(format) { } }, 100); // 小延迟确保图形已渲染 } + +// ============================================ +// 对话分组和批量管理功能 +// ============================================ + +// 分组数据管理(使用API) +let currentGroupId = null; +let contextMenuConversationId = null; +let contextMenuGroupId = null; +let groupsCache = []; +let conversationGroupMappingCache = {}; + +// 加载分组列表 +async function loadGroups() { + try { + const response = await apiFetch('/api/groups'); + groupsCache = await response.json(); + + const groupsList = document.getElementById('conversation-groups-list'); + if (!groupsList) return; + + groupsList.innerHTML = ''; + + if (!Array.isArray(groupsCache) || groupsCache.length === 0) { + return; + } + + // 对分组进行排序:置顶的分组在前(后端已经排序,这里只需要按顺序显示) + const sortedGroups = [...groupsCache]; + + sortedGroups.forEach(group => { + const groupItem = document.createElement('div'); + groupItem.className = 'group-item'; + if (currentGroupId === group.id) { + groupItem.classList.add('active'); + } + const isPinned = group.pinned || false; + if (isPinned) { + groupItem.classList.add('pinned'); + } + groupItem.dataset.groupId = group.id; + + const content = document.createElement('div'); + content.className = 'group-item-content'; + + const icon = document.createElement('span'); + icon.className = 'group-item-icon'; + icon.textContent = group.icon || '📁'; + + const name = document.createElement('span'); + name.className = 'group-item-name'; + name.textContent = group.name; + + content.appendChild(icon); + content.appendChild(name); + + // 如果是置顶分组,添加图钉图标 + if (isPinned) { + const pinIcon = document.createElement('span'); + pinIcon.className = 'group-item-pinned'; + pinIcon.innerHTML = '📌'; + pinIcon.title = '已置顶'; + name.appendChild(pinIcon); + } + groupItem.appendChild(content); + + const menuBtn = document.createElement('button'); + menuBtn.className = 'group-item-menu'; + menuBtn.innerHTML = '⋯'; + menuBtn.onclick = (e) => { + e.stopPropagation(); + showGroupContextMenu(e, group.id); + }; + groupItem.appendChild(menuBtn); + + groupItem.onclick = () => { + enterGroupDetail(group.id); + }; + + groupsList.appendChild(groupItem); + }); + } catch (error) { + console.error('加载分组列表失败:', error); + } +} + +// 加载对话列表(修改为支持分组和置顶) +async function loadConversationsWithGroups(searchQuery = '') { + try { + // 先加载分组列表(如果还没有加载) + if (groupsCache.length === 0) { + await loadGroups(); + } + // 先加载分组映射(如果还没有加载) + if (Object.keys(conversationGroupMappingCache).length === 0) { + await loadConversationGroupMapping(); + } + + // 如果有搜索关键词,使用更大的limit以获取所有匹配结果 + const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100; + let url = `/api/conversations?limit=${limit}`; + if (searchQuery && searchQuery.trim()) { + url += '&search=' + encodeURIComponent(searchQuery.trim()); + } + const response = await apiFetch(url); + const conversations = await response.json(); + + const listContainer = document.getElementById('conversations-list'); + if (!listContainer) { + return; + } + + const emptyStateHtml = '