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 = '
暂无历史对话
'; + listContainer.innerHTML = ''; + + if (!Array.isArray(conversations) || conversations.length === 0) { + listContainer.innerHTML = emptyStateHtml; + return; + } + + // 分离置顶和普通对话 + const pinnedConvs = []; + const normalConvs = []; + const hasSearchQuery = searchQuery && searchQuery.trim(); + + conversations.forEach(conv => { + // 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的) + if (hasSearchQuery) { + // 搜索时显示所有匹配的对话,不管是否在分组中 + if (conv.pinned) { + pinnedConvs.push(conv); + } else { + normalConvs.push(conv); + } + return; + } + + // 如果没有搜索关键词,使用原有逻辑 + // 如果对话在某个分组中,且当前不在分组详情页,则跳过 + if (currentGroupId === null && conversationGroupMappingCache[conv.id]) { + return; + } + + // 如果当前在分组详情页,只显示该分组的对话 + if (currentGroupId !== null && conversationGroupMappingCache[conv.id] !== currentGroupId) { + return; + } + + if (conv.pinned) { + pinnedConvs.push(conv); + } else { + normalConvs.push(conv); + } + }); + + // 按时间排序 + const sortByTime = (a, b) => { + const timeA = a.updatedAt ? new Date(a.updatedAt) : new Date(0); + const timeB = b.updatedAt ? new Date(b.updatedAt) : new Date(0); + return timeB - timeA; + }; + + pinnedConvs.sort(sortByTime); + normalConvs.sort(sortByTime); + + const fragment = document.createDocumentFragment(); + + // 添加置顶对话 + if (pinnedConvs.length > 0) { + pinnedConvs.forEach(conv => { + fragment.appendChild(createConversationListItemWithMenu(conv, true)); + }); + } + + // 添加普通对话 + normalConvs.forEach(conv => { + fragment.appendChild(createConversationListItemWithMenu(conv, false)); + }); + + if (fragment.children.length === 0) { + listContainer.innerHTML = emptyStateHtml; + return; + } + + listContainer.appendChild(fragment); + updateActiveConversation(); + } catch (error) { + console.error('加载对话列表失败:', error); + } +} + +// 创建带菜单的对话项 +function createConversationListItemWithMenu(conversation, isPinned) { + const item = document.createElement('div'); + item.className = 'conversation-item'; + item.dataset.conversationId = conversation.id; + if (conversation.id === currentConversationId) { + item.classList.add('active'); + } + + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'conversation-content'; + + const titleWrapper = document.createElement('div'); + titleWrapper.style.display = 'flex'; + titleWrapper.style.alignItems = 'center'; + titleWrapper.style.gap = '4px'; + + const title = document.createElement('div'); + title.className = 'conversation-title'; + title.textContent = conversation.title || '未命名对话'; + titleWrapper.appendChild(title); + + if (isPinned) { + const pinIcon = document.createElement('span'); + pinIcon.className = 'conversation-item-pinned'; + pinIcon.innerHTML = '📌'; + pinIcon.title = '已置顶'; + titleWrapper.appendChild(pinIcon); + } + + contentWrapper.appendChild(titleWrapper); + + const time = document.createElement('div'); + time.className = 'conversation-time'; + const dateObj = conversation.updatedAt ? new Date(conversation.updatedAt) : new Date(); + time.textContent = formatConversationTimestamp(dateObj); + contentWrapper.appendChild(time); + + // 如果对话属于某个分组,显示分组标签 + const groupId = conversationGroupMappingCache[conversation.id]; + if (groupId) { + const group = groupsCache.find(g => g.id === groupId); + if (group) { + const groupTag = document.createElement('div'); + groupTag.className = 'conversation-group-tag'; + groupTag.innerHTML = `${group.icon || '📁'}${group.name}`; + groupTag.title = `分组: ${group.name}`; + contentWrapper.appendChild(groupTag); + } + } + + item.appendChild(contentWrapper); + + const menuBtn = document.createElement('button'); + menuBtn.className = 'conversation-item-menu'; + menuBtn.innerHTML = '⋯'; + menuBtn.onclick = (e) => { + e.stopPropagation(); + contextMenuConversationId = conversation.id; + showConversationContextMenu(e); + }; + item.appendChild(menuBtn); + + item.onclick = () => { + if (currentGroupId) { + exitGroupDetail(); + } + loadConversation(conversation.id); + }; + + return item; +} + +// 显示对话上下文菜单 +function showConversationContextMenu(event) { + const menu = document.getElementById('conversation-context-menu'); + if (!menu) return; + + // 先显示菜单以获取尺寸 + menu.style.display = 'block'; + menu.style.visibility = 'visible'; + menu.style.opacity = '1'; + + // 强制重排以获取正确尺寸 + void menu.offsetHeight; + + // 计算菜单位置,确保不超出屏幕 + const menuRect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = event.clientX; + let top = event.clientY; + + // 如果菜单会超出右边界,调整到左侧 + if (left + menuRect.width > viewportWidth) { + left = event.clientX - menuRect.width; + } + + // 如果菜单会超出下边界,调整到上方 + if (top + menuRect.height > viewportHeight) { + top = event.clientY - menuRect.height; + } + + // 确保不超出左边界 + if (left < 0) { + left = 8; + } + + // 确保不超出上边界 + if (top < 0) { + top = 8; + } + + menu.style.left = left + 'px'; + menu.style.top = top + 'px'; + + // 点击外部关闭菜单 + const closeMenu = (e) => { + if (!menu.contains(e.target)) { + menu.style.display = 'none'; + document.removeEventListener('click', closeMenu); + } + }; + setTimeout(() => { + document.addEventListener('click', closeMenu); + }, 0); +} + +// 显示分组上下文菜单 +function showGroupContextMenu(event, groupId) { + const menu = document.getElementById('group-context-menu'); + if (!menu) return; + + contextMenuGroupId = groupId; + + // 先显示菜单以获取尺寸 + menu.style.display = 'block'; + menu.style.visibility = 'visible'; + menu.style.opacity = '1'; + + // 强制重排以获取正确尺寸 + void menu.offsetHeight; + + // 计算菜单位置,确保不超出屏幕 + const menuRect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = event.clientX; + let top = event.clientY; + + // 如果菜单会超出右边界,调整到左侧 + if (left + menuRect.width > viewportWidth) { + left = event.clientX - menuRect.width; + } + + // 如果菜单会超出下边界,调整到上方 + if (top + menuRect.height > viewportHeight) { + top = event.clientY - menuRect.height; + } + + // 确保不超出左边界 + if (left < 0) { + left = 8; + } + + // 确保不超出上边界 + if (top < 0) { + top = 8; + } + + menu.style.left = left + 'px'; + menu.style.top = top + 'px'; + + // 点击外部关闭菜单 + const closeMenu = (e) => { + if (!menu.contains(e.target)) { + menu.style.display = 'none'; + document.removeEventListener('click', closeMenu); + } + }; + setTimeout(() => { + document.addEventListener('click', closeMenu); + }, 0); +} + +// 重命名对话 +async function renameConversation() { + const convId = contextMenuConversationId; + if (!convId) return; + + const newTitle = prompt('请输入新标题:', ''); + if (newTitle === null || !newTitle.trim()) { + closeContextMenu(); + return; + } + + try { + const response = await apiFetch(`/api/conversations/${convId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title: newTitle.trim() }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || '更新失败'); + } + + // 更新前端显示 + const item = document.querySelector(`[data-conversation-id="${convId}"]`); + if (item) { + const titleEl = item.querySelector('.conversation-title'); + if (titleEl) { + titleEl.textContent = newTitle.trim(); + } + } + + // 如果在分组详情页,也需要更新 + const groupItem = document.querySelector(`.group-conversation-item[data-conversation-id="${convId}"]`); + if (groupItem) { + const groupTitleEl = groupItem.querySelector('.group-conversation-title'); + if (groupTitleEl) { + groupTitleEl.textContent = newTitle.trim(); + } + } + + // 重新加载对话列表 + loadConversationsWithGroups(); + } catch (error) { + console.error('重命名对话失败:', error); + alert('重命名失败: ' + (error.message || '未知错误')); + } + + closeContextMenu(); +} + +// 置顶对话 +async function pinConversation() { + const convId = contextMenuConversationId; + if (!convId) return; + + try { + // 获取当前对话的置顶状态 + const response = await apiFetch(`/api/conversations/${convId}`); + const conv = await response.json(); + const newPinned = !conv.pinned; + + // 更新置顶状态 + await apiFetch(`/api/conversations/${convId}/pinned`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pinned: newPinned }), + }); + + loadConversationsWithGroups(); + } catch (error) { + console.error('置顶对话失败:', error); + alert('置顶失败: ' + (error.message || '未知错误')); + } + + closeContextMenu(); +} + +// 显示移动到分组子菜单 +async function showMoveToGroupSubmenu() { + const submenu = document.getElementById('move-to-group-submenu'); + if (!submenu) return; + + submenu.innerHTML = ''; + + // 确保分组列表已加载 + if (groupsCache.length === 0) { + await loadGroups(); + } + + // 如果有分组,显示所有分组 + if (groupsCache.length > 0) { + groupsCache.forEach(group => { + const item = document.createElement('div'); + item.className = 'context-submenu-item'; + item.textContent = group.name; + item.onclick = () => { + moveConversationToGroup(contextMenuConversationId, group.id); + }; + submenu.appendChild(item); + }); + } + + // 始终显示"创建分组"选项 + const addItem = document.createElement('div'); + addItem.className = 'context-submenu-item add-group-item'; + addItem.textContent = '+ 创建分组'; + addItem.onclick = () => { + showCreateGroupModal(true); + }; + submenu.appendChild(addItem); + + submenu.style.display = 'block'; +} + +// 移动对话到分组 +async function moveConversationToGroup(convId, groupId) { + try { + await apiFetch('/api/groups/conversations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + conversationId: convId, + groupId: groupId, + }), + }); + + // 更新缓存 + conversationGroupMappingCache[convId] = groupId; + loadConversationsWithGroups(); + } catch (error) { + console.error('移动对话到分组失败:', error); + alert('移动失败: ' + (error.message || '未知错误')); + } + + closeContextMenu(); +} + +// 加载对话分组映射 +async function loadConversationGroupMapping() { + try { + // 获取所有分组,然后获取每个分组的对话 + const groups = groupsCache.length > 0 ? groupsCache : await (await apiFetch('/api/groups')).json(); + conversationGroupMappingCache = {}; + + for (const group of groups) { + const response = await apiFetch(`/api/groups/${group.id}/conversations`); + const conversations = await response.json(); + conversations.forEach(conv => { + conversationGroupMappingCache[conv.id] = group.id; + }); + } + } catch (error) { + console.error('加载对话分组映射失败:', error); + } +} + +// 从上下文菜单删除对话 +function deleteConversationFromContext() { + const convId = contextMenuConversationId; + if (!convId) return; + + if (confirm('确定要删除此对话吗?')) { + deleteConversation(convId); + } + closeContextMenu(); +} + +// 关闭上下文菜单 +function closeContextMenu() { + const menu = document.getElementById('conversation-context-menu'); + if (menu) { + menu.style.display = 'none'; + } + const submenu = document.getElementById('move-to-group-submenu'); + if (submenu) { + submenu.style.display = 'none'; + } + contextMenuConversationId = null; +} + +// 显示批量管理模态框 +let allConversationsForBatch = []; + +async function showBatchManageModal() { + try { + const response = await apiFetch('/api/conversations?limit=1000'); + allConversationsForBatch = await response.json(); + + const modal = document.getElementById('batch-manage-modal'); + const countEl = document.getElementById('batch-manage-count'); + if (countEl) { + countEl.textContent = allConversationsForBatch.length; + } + + renderBatchConversations(); + if (modal) { + modal.style.display = 'flex'; + } + } catch (error) { + console.error('加载对话列表失败:', error); + alert('加载对话列表失败'); + } +} + +// 渲染批量管理对话列表 +function renderBatchConversations(filtered = null) { + const list = document.getElementById('batch-conversations-list'); + if (!list) return; + + const conversations = filtered || allConversationsForBatch; + list.innerHTML = ''; + + conversations.forEach(conv => { + const row = document.createElement('div'); + row.className = 'batch-conversation-row'; + row.dataset.conversationId = conv.id; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'batch-conversation-checkbox'; + checkbox.dataset.conversationId = conv.id; + + const name = document.createElement('div'); + name.className = 'batch-table-col-name'; + name.textContent = conv.title || '未命名对话'; + + const time = document.createElement('div'); + time.className = 'batch-table-col-time'; + const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); + time.textContent = dateObj.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + const action = document.createElement('div'); + action.className = 'batch-table-col-action'; + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'batch-delete-btn'; + deleteBtn.innerHTML = '🗑️'; + deleteBtn.onclick = () => deleteConversation(conv.id); + action.appendChild(deleteBtn); + + row.appendChild(checkbox); + row.appendChild(name); + row.appendChild(time); + row.appendChild(action); + + list.appendChild(row); + }); +} + +// 筛选批量管理对话 +function filterBatchConversations(query) { + if (!query || !query.trim()) { + renderBatchConversations(); + return; + } + + const filtered = allConversationsForBatch.filter(conv => { + const title = (conv.title || '').toLowerCase(); + return title.includes(query.toLowerCase()); + }); + + renderBatchConversations(filtered); +} + +// 全选/取消全选 +function toggleSelectAllBatch() { + const selectAll = document.getElementById('batch-select-all'); + const checkboxes = document.querySelectorAll('.batch-conversation-checkbox'); + + checkboxes.forEach(cb => { + cb.checked = selectAll.checked; + }); +} + +// 删除选中的对话 +async function deleteSelectedConversations() { + const checkboxes = document.querySelectorAll('.batch-conversation-checkbox:checked'); + if (checkboxes.length === 0) { + alert('请先选择要删除的对话'); + return; + } + + if (!confirm(`确定要删除选中的 ${checkboxes.length} 条对话吗?`)) { + return; + } + + const ids = Array.from(checkboxes).map(cb => cb.dataset.conversationId); + + try { + for (const id of ids) { + await deleteConversation(id); + } + closeBatchManageModal(); + loadConversationsWithGroups(); + } catch (error) { + console.error('删除失败:', error); + alert('删除失败: ' + (error.message || '未知错误')); + } +} + +// 关闭批量管理模态框 +function closeBatchManageModal() { + const modal = document.getElementById('batch-manage-modal'); + if (modal) { + modal.style.display = 'none'; + } + const selectAll = document.getElementById('batch-select-all'); + if (selectAll) { + selectAll.checked = false; + } + allConversationsForBatch = []; +} + +// 显示创建分组模态框 +function showCreateGroupModal(andMoveConversation = false) { + const modal = document.getElementById('create-group-modal'); + const input = document.getElementById('create-group-name-input'); + if (input) { + input.value = ''; + } + if (modal) { + modal.style.display = 'flex'; + modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false'; + if (input) { + setTimeout(() => input.focus(), 100); + } + } +} + +// 关闭创建分组模态框 +function closeCreateGroupModal() { + const modal = document.getElementById('create-group-modal'); + if (modal) { + modal.style.display = 'none'; + } + const input = document.getElementById('create-group-name-input'); + if (input) { + input.value = ''; + } +} + +// 创建分组 +async function createGroup(event) { + // 阻止事件冒泡 + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + const input = document.getElementById('create-group-name-input'); + if (!input) { + console.error('找不到输入框'); + return; + } + + const name = input.value.trim(); + if (!name) { + alert('请输入分组名称'); + return; + } + + // 前端校验:检查名称是否已存在 + try { + const groups = groupsCache.length > 0 ? groupsCache : await (await apiFetch('/api/groups')).json(); + const nameExists = groups.some(g => g.name === name); + if (nameExists) { + alert('分组名称已存在,请使用其他名称'); + return; + } + } catch (error) { + console.error('检查分组名称失败:', error); + } + + try { + const response = await apiFetch('/api/groups', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: name, + icon: '📁', + }), + }); + + if (!response.ok) { + const error = await response.json(); + if (error.error && error.error.includes('已存在')) { + alert('分组名称已存在,请使用其他名称'); + return; + } + throw new Error(error.error || '创建失败'); + } + + const newGroup = await response.json(); + await loadGroups(); + + const modal = document.getElementById('create-group-modal'); + const shouldMove = modal && modal.dataset.moveConversation === 'true'; + + closeCreateGroupModal(); + + if (shouldMove && contextMenuConversationId) { + moveConversationToGroup(contextMenuConversationId, newGroup.id); + } + } catch (error) { + console.error('创建分组失败:', error); + alert('创建失败: ' + (error.message || '未知错误')); + } +} + +// 进入分组详情 +async function enterGroupDetail(groupId) { + currentGroupId = groupId; + + try { + const response = await apiFetch(`/api/groups/${groupId}`); + const group = await response.json(); + + if (!group) { + currentGroupId = null; + return; + } + + // 隐藏侧边栏,显示分组详情页 + const sidebar = document.querySelector('.conversation-sidebar'); + const groupDetailPage = document.getElementById('group-detail-page'); + const titleEl = document.getElementById('group-detail-title'); + + if (sidebar) sidebar.style.display = 'none'; + if (groupDetailPage) groupDetailPage.style.display = 'flex'; + if (titleEl) titleEl.textContent = group.name; + + loadGroupConversations(groupId); + } catch (error) { + console.error('加载分组失败:', error); + currentGroupId = null; + } +} + +// 退出分组详情 +function exitGroupDetail() { + currentGroupId = null; + const sidebar = document.querySelector('.conversation-sidebar'); + const groupDetailPage = document.getElementById('group-detail-page'); + + if (sidebar) sidebar.style.display = 'flex'; + if (groupDetailPage) groupDetailPage.style.display = 'none'; + + loadConversationsWithGroups(); +} + +// 加载分组中的对话 +async function loadGroupConversations(groupId) { + try { + const response = await apiFetch(`/api/groups/${groupId}/conversations`); + const groupConvs = await response.json(); + + const list = document.getElementById('group-conversations-list'); + if (!list) return; + + list.innerHTML = ''; + + if (!Array.isArray(groupConvs) || groupConvs.length === 0) { + list.innerHTML = '
该分组暂无对话
'; + return; + } + + // 加载每个对话的详细信息以获取消息 + for (const conv of groupConvs) { + try { + const convResponse = await apiFetch(`/api/conversations/${conv.id}`); + const fullConv = await convResponse.json(); + + const item = document.createElement('div'); + item.className = 'group-conversation-item'; + item.onclick = () => { + exitGroupDetail(); + loadConversation(conv.id); + }; + + const title = document.createElement('div'); + title.className = 'group-conversation-title'; + title.textContent = fullConv.title || conv.title || '未命名对话'; + + const timeWrapper = document.createElement('div'); + timeWrapper.className = 'group-conversation-time'; + const dateObj = fullConv.updatedAt ? new Date(fullConv.updatedAt) : new Date(); + timeWrapper.textContent = dateObj.toLocaleString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + item.appendChild(title); + item.appendChild(timeWrapper); + + // 如果有第一条消息,显示内容预览 + if (fullConv.messages && fullConv.messages.length > 0) { + const firstMsg = fullConv.messages.find(m => m.role === 'user' && m.content); + if (firstMsg && firstMsg.content) { + const content = document.createElement('div'); + content.className = 'group-conversation-content'; + let preview = firstMsg.content.substring(0, 200); + if (firstMsg.content.length > 200) { + preview += '...'; + } + content.textContent = preview; + item.appendChild(content); + } + } + + list.appendChild(item); + } catch (err) { + console.error(`加载对话 ${conv.id} 失败:`, err); + } + } + } catch (error) { + console.error('加载分组对话失败:', error); + } +} + +// 编辑分组 +async function editGroup() { + if (!currentGroupId) return; + + try { + const response = await apiFetch(`/api/groups/${currentGroupId}`); + const group = await response.json(); + if (!group) return; + + const newName = prompt('请输入新名称:', group.name); + if (newName === null || !newName.trim()) return; + + const trimmedName = newName.trim(); + + // 前端校验:检查名称是否已存在(排除当前分组) + const groups = groupsCache.length > 0 ? groupsCache : await (await apiFetch('/api/groups')).json(); + const nameExists = groups.some(g => g.name === trimmedName && g.id !== currentGroupId); + if (nameExists) { + alert('分组名称已存在,请使用其他名称'); + return; + } + + const updateResponse = await apiFetch(`/api/groups/${currentGroupId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: trimmedName, + icon: group.icon || '📁', + }), + }); + + if (!updateResponse.ok) { + const error = await updateResponse.json(); + if (error.error && error.error.includes('已存在')) { + alert('分组名称已存在,请使用其他名称'); + return; + } + throw new Error(error.error || '更新失败'); + } + + loadGroups(); + + const titleEl = document.getElementById('group-detail-title'); + if (titleEl) { + titleEl.textContent = trimmedName; + } + } catch (error) { + console.error('编辑分组失败:', error); + alert('编辑失败: ' + (error.message || '未知错误')); + } +} + +// 删除分组 +async function deleteGroup() { + if (!currentGroupId) return; + + if (!confirm('确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。')) { + return; + } + + try { + await apiFetch(`/api/groups/${currentGroupId}`, { + method: 'DELETE', + }); + + // 更新缓存 + groupsCache = groupsCache.filter(g => g.id !== currentGroupId); + Object.keys(conversationGroupMappingCache).forEach(convId => { + if (conversationGroupMappingCache[convId] === currentGroupId) { + delete conversationGroupMappingCache[convId]; + } + }); + + exitGroupDetail(); + loadGroups(); + } catch (error) { + console.error('删除分组失败:', error); + alert('删除失败: ' + (error.message || '未知错误')); + } +} + +// 从上下文菜单重命名分组 +async function renameGroupFromContext() { + const groupId = contextMenuGroupId; + if (!groupId) return; + + try { + const response = await apiFetch(`/api/groups/${groupId}`); + const group = await response.json(); + if (!group) return; + + const newName = prompt('请输入新名称:', group.name); + if (newName === null || !newName.trim()) { + closeGroupContextMenu(); + return; + } + + const trimmedName = newName.trim(); + + // 前端校验:检查名称是否已存在(排除当前分组) + const groups = groupsCache.length > 0 ? groupsCache : await (await apiFetch('/api/groups')).json(); + const nameExists = groups.some(g => g.name === trimmedName && g.id !== groupId); + if (nameExists) { + alert('分组名称已存在,请使用其他名称'); + return; + } + + const updateResponse = await apiFetch(`/api/groups/${groupId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: trimmedName, + icon: group.icon || '📁', + }), + }); + + if (!updateResponse.ok) { + const error = await updateResponse.json(); + if (error.error && error.error.includes('已存在')) { + alert('分组名称已存在,请使用其他名称'); + return; + } + throw new Error(error.error || '更新失败'); + } + + loadGroups(); + + // 如果当前在分组详情页,更新标题 + if (currentGroupId === groupId) { + const titleEl = document.getElementById('group-detail-title'); + if (titleEl) { + titleEl.textContent = trimmedName; + } + } + } catch (error) { + console.error('重命名分组失败:', error); + alert('重命名失败: ' + (error.message || '未知错误')); + } + + closeGroupContextMenu(); +} + +// 从上下文菜单置顶分组 +async function pinGroupFromContext() { + const groupId = contextMenuGroupId; + if (!groupId) return; + + try { + // 获取当前分组信息 + const response = await apiFetch(`/api/groups/${groupId}`); + const group = await response.json(); + if (!group) return; + + const newPinnedState = !group.pinned; + + // 调用 API 更新置顶状态 + const updateResponse = await apiFetch(`/api/groups/${groupId}/pinned`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + pinned: newPinnedState, + }), + }); + + if (!updateResponse.ok) { + const error = await updateResponse.json(); + throw new Error(error.error || '更新失败'); + } + + // 重新加载分组列表以更新显示顺序 + loadGroups(); + } catch (error) { + console.error('置顶分组失败:', error); + alert('置顶失败: ' + (error.message || '未知错误')); + } + + closeGroupContextMenu(); +} + +// 从上下文菜单删除分组 +async function deleteGroupFromContext() { + const groupId = contextMenuGroupId; + if (!groupId) return; + + if (!confirm('确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。')) { + closeGroupContextMenu(); + return; + } + + try { + await apiFetch(`/api/groups/${groupId}`, { + method: 'DELETE', + }); + + // 更新缓存 + groupsCache = groupsCache.filter(g => g.id !== groupId); + Object.keys(conversationGroupMappingCache).forEach(convId => { + if (conversationGroupMappingCache[convId] === groupId) { + delete conversationGroupMappingCache[convId]; + } + }); + + // 如果当前在分组详情页,退出详情页 + if (currentGroupId === groupId) { + exitGroupDetail(); + } + + loadGroups(); + } catch (error) { + console.error('删除分组失败:', error); + alert('删除失败: ' + (error.message || '未知错误')); + } + + closeGroupContextMenu(); +} + +// 关闭分组上下文菜单 +function closeGroupContextMenu() { + const menu = document.getElementById('group-context-menu'); + if (menu) { + menu.style.display = 'none'; + } + contextMenuGroupId = null; +} + + +// 在分组中搜索(占位函数) +function searchInGroup() { + alert('搜索功能待实现'); +} + +// 初始化时加载分组 +document.addEventListener('DOMContentLoaded', async () => { + await loadGroups(); + // 替换原来的loadConversations调用 + if (typeof loadConversations === 'function') { + // 保留原函数,但使用新函数 + const originalLoad = loadConversations; + loadConversations = function(...args) { + loadConversationsWithGroups(...args); + }; + } + await loadConversationsWithGroups(); +}); diff --git a/web/templates/index.html b/web/templates/index.html index 40133b49..cb27b2b7 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -141,8 +141,8 @@ + + + + + + + + + + + +