diff --git a/README.md b/README.md index 50773efb..cde30b85 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ CyberStrikeAI connects planning, execution, human oversight, evidence, and repla ### Security operations -- 📁 **Conversation management** provides grouping, pinning, renaming, and batch organization. +- 📁 **Conversation management** provides pinning, renaming, and batch organization. - 📂 **Projects and attack chains** connect cross-session facts, risk scoring, graph views, and step-by-step replay. - 🗂️ **Asset management** normalizes and deduplicates domains, IP addresses, ports, and services; supports XLSX/CSV import and export, advanced filters and saved views, ownership and business metadata, cross-page bulk maintenance, and duplicate merging; and tracks scan coverage, linked vulnerabilities, and risk state. See the [Asset Management guide](docs/en-US/asset-management.md). - 🛡️ **Vulnerability management** provides severity classification, lifecycle tracking, filtering, and statistics. diff --git a/docs/en-US/rbac.md b/docs/en-US/rbac.md index 9526bc75..3f76f261 100644 --- a/docs/en-US/rbac.md +++ b/docs/en-US/rbac.md @@ -89,7 +89,6 @@ Permissions use `module:action`. Common actions are `read`, `write`, `delete`, a | Attack chain | `attackchain:read`, `attackchain:write` | | Network-space search / Reconnaissance | `fofa:execute` | | OpenAPI | `openapi:read` | -| Chat groups | `group:read`, `group:write`, `group:delete` | | Monitor | `monitor:read`, `monitor:write`, `monitor:delete` | Important distinctions: diff --git a/docs/zh-CN/rbac.md b/docs/zh-CN/rbac.md index 46709afa..36ff4efe 100644 --- a/docs/zh-CN/rbac.md +++ b/docs/zh-CN/rbac.md @@ -96,7 +96,6 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角 | 攻击链 | `attackchain:read`、`attackchain:write` | | 网络空间测绘 / 信息收集 | `fofa:execute` | | OpenAPI | `openapi:read` | -| 对话分组 | `group:read`、`group:write`、`group:delete` | | 执行监控 | `monitor:read`、`monitor:write`、`monitor:delete` | 特殊权限说明: diff --git a/internal/app/app.go b/internal/app/app.go index f0886eeb..688b8580 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -391,7 +391,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error monitorHandler.SetTaskManager(agentHandler.TaskManager()) monitorHandler.SetAgentHandler(agentHandler) notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger) - groupHandler := handler.NewGroupHandler(db, log.Logger) authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger) authHandler.SetAudit(auditSvc) attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger) @@ -567,7 +566,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error conversationHandler, robotHandler, wechatRobotHandler, - groupHandler, configHandler, externalMCPHandler, attackChainHandler, @@ -870,7 +868,6 @@ func setupRoutes( conversationHandler *handler.ConversationHandler, robotHandler *handler.RobotHandler, wechatRobotHandler *handler.WechatRobotHandler, - groupHandler *handler.GroupHandler, configHandler *handler.ConfigHandler, externalMCPHandler *handler.ExternalMCPHandler, attackChainHandler *handler.AttackChainHandler, @@ -1041,20 +1038,7 @@ func setupRoutes( protected.PUT("/conversations/:id/project", conversationHandler.SetConversationProject) protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation) protected.POST("/conversations/:id/delete-turn", conversationHandler.DeleteConversationTurn) - 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.GET("/groups/mappings", groupHandler.GetAllMappings) - protected.POST("/groups/conversations", groupHandler.AddConversationToGroup) - protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup) - protected.PUT("/groups/:id/conversations/:conversationId/pinned", groupHandler.UpdateConversationPinnedInGroup) + protected.PUT("/conversations/:id/pinned", conversationHandler.UpdateConversationPinned) // 监控 protected.GET("/monitor", monitorHandler.Monitor) diff --git a/internal/database/conversation.go b/internal/database/conversation.go index a5eeca5f..a736a962 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -665,81 +665,6 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) { return conversations, rows.Err() } -const ungroupedConversationsSQL = ` - FROM conversations c - WHERE NOT EXISTS ( - SELECT 1 FROM conversation_group_mappings cgm WHERE cgm.conversation_id = c.id - )` - -// CountUngroupedConversations 统计不在任何分组中的对话数量。 -func (db *DB) CountUngroupedConversations(projectID string) (int, error) { - where := ungroupedConversationsSQL - args := []interface{}{} - where, args = appendConversationProjectFilter(where, args, projectID, "c") - var count int - if err := db.QueryRow(`SELECT COUNT(*) `+where, args...).Scan(&count); err != nil { - return 0, fmt.Errorf("统计未分组对话失败: %w", err) - } - return count, nil -} - -func (db *DB) CountUngroupedConversationsForAccess(projectID, userID, scope string) (int, error) { - where := ungroupedConversationsSQL - args := []interface{}{} - where, args = appendConversationProjectFilter(where, args, projectID, "c") - where, args = appendConversationAccessFilter(where, args, userID, scope, "c") - var count int - if err := db.QueryRow(`SELECT COUNT(*) `+where, args...).Scan(&count); err != nil { - return 0, fmt.Errorf("统计未分组对话失败: %w", err) - } - return count, nil -} - -// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。 -func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID string) ([]*Conversation, error) { - orderClause := conversationOrderClause(sortBy, "c") - where := ungroupedConversationsSQL - args := []interface{}{} - where, args = appendConversationProjectFilter(where, args, projectID, "c") - args = append(args, limit, offset) - rows, err := db.Query( - `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+ - where+` - `+orderClause+` - LIMIT ? OFFSET ?`, - args..., - ) - if err != nil { - return nil, fmt.Errorf("查询未分组对话失败: %w", err) - } - defer rows.Close() - return scanConversationRows(rows) -} - -func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, projectID, userID, scope string) ([]*Conversation, error) { - if scope == RBACScopeAll || strings.TrimSpace(userID) == "" { - return db.ListUngroupedConversations(limit, offset, sortBy, projectID) - } - orderClause := conversationOrderClause(sortBy, "c") - where := ungroupedConversationsSQL - args := []interface{}{} - where, args = appendConversationProjectFilter(where, args, projectID, "c") - where, args = appendConversationAccessFilter(where, args, userID, scope, "c") - args = append(args, limit, offset) - rows, err := db.Query( - `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+ - where+` - `+orderClause+` - LIMIT ? OFFSET ?`, - args..., - ) - if err != nil { - return nil, fmt.Errorf("查询未分组对话失败: %w", err) - } - defer rows.Close() - return scanConversationRows(rows) -} - // GetConversationTitle 获取对话标题(轻量查询,不加载消息) func (db *DB) GetConversationTitle(id string) (string, error) { var title string @@ -766,6 +691,22 @@ func (db *DB) UpdateConversationTitle(id, title string) error { return 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 +} + // UpdateConversationTime 更新对话时间 func (db *DB) UpdateConversationTime(id string) error { _, err := db.Exec( @@ -784,7 +725,6 @@ func (db *DB) UpdateConversationTime(id string) error { // - process_details(过程详情) // - attack_chain_nodes(攻击链节点) // - attack_chain_edges(攻击链边) -// - conversation_group_mappings(分组映射) // 漏洞记录会保留:vulnerabilities.conversation_id 使用 ON DELETE SET NULL,仅解除与会话的关联。 // 注意:knowledge_retrieval_logs 在删除前会被显式清理。 func (db *DB) DeleteConversation(id string) error { diff --git a/internal/database/database.go b/internal/database/database.go index 43fb2192..a68728f7 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -329,29 +329,6 @@ 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, - owner_user_id 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) - );` - // 机器人会话绑定表(用于跨重启保持「平台+租户+用户」到 conversation 的映射) createRobotUserSessionsTable := ` CREATE TABLE IF NOT EXISTS robot_user_sessions ( @@ -759,8 +736,6 @@ 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_robot_user_sessions_updated_at ON robot_user_sessions(updated_at); CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id); @@ -864,13 +839,6 @@ func (db *DB) initTables() error { return fmt.Errorf("创建knowledge_retrieval_logs表失败: %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.Exec(createRobotUserSessionsTable); err != nil { return fmt.Errorf("创建robot_user_sessions表失败: %w", err) } @@ -966,16 +934,6 @@ func (db *DB) initTables() error { // 不返回错误,允许继续运行 } - if err := db.migrateConversationGroupsTable(); err != nil { - db.logger.Warn("迁移conversation_groups表失败", zap.Error(err)) - // 不返回错误,允许继续运行 - } - - if err := db.migrateConversationGroupMappingsTable(); err != nil { - db.logger.Warn("迁移conversation_group_mappings表失败", zap.Error(err)) - // 不返回错误,允许继续运行 - } - if err := db.migrateBatchTaskQueuesTable(); err != nil { db.logger.Warn("迁移batch_task_queues表失败", zap.Error(err)) // 不返回错误,允许继续运行 @@ -1237,54 +1195,6 @@ func (db *DB) migrateConversationsTable() error { 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 -} - -// migrateConversationGroupMappingsTable 迁移conversation_group_mappings表,添加新字段 -func (db *DB) migrateConversationGroupMappingsTable() error { - // 检查pinned字段是否存在 - var count int - err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversation_group_mappings') WHERE name='pinned'").Scan(&count) - if err != nil { - // 如果查询失败,尝试添加字段 - if _, addErr := db.Exec("ALTER TABLE conversation_group_mappings 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_group_mappings ADD COLUMN pinned INTEGER DEFAULT 0"); err != nil { - db.logger.Warn("添加pinned字段失败", zap.Error(err)) - } - } - - return nil -} - // migrateBatchTaskQueuesTable 迁移batch_task_queues表,补充新字段 func (db *DB) migrateBatchTaskQueuesTable() error { // 检查title字段是否存在 diff --git a/internal/database/group.go b/internal/database/group.go deleted file mode 100644 index 0739ded4..00000000 --- a/internal/database/group.go +++ /dev/null @@ -1,486 +0,0 @@ -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"` - OwnerUserID string `json:"-"` -} - -// GroupExistsByName 检查分组名称是否已存在 -func (db *DB) GroupExistsByName(name string, excludeID string) (bool, error) { - return db.groupExistsByNameForOwner(name, excludeID, "") -} - -func (db *DB) groupExistsByNameForOwner(name, excludeID, ownerUserID string) (bool, error) { - var count int - var err error - if ownerUserID != "" && excludeID != "" { - err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ? AND id != ?", name, ownerUserID, excludeID).Scan(&count) - } else if ownerUserID != "" { - err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ?", name, ownerUserID).Scan(&count) - } else 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, owners ...string) (*ConversationGroup, error) { - ownerUserID := "" - if len(owners) > 0 { - ownerUserID = owners[0] - } - // 检查名称是否已存在 - exists, err := db.groupExistsByNameForOwner(name, "", ownerUserID) - 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, owner_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - id, name, icon, 0, ownerUserID, 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, - OwnerUserID: ownerUserID, - }, nil -} - -// ListGroups 列出所有分组 -func (db *DB) ListGroups() ([]*ConversationGroup, error) { - return db.ListGroupsForAccess("", RBACScopeAll) -} - -func (db *DB) ListGroupsForAccess(userID, scope string) ([]*ConversationGroup, error) { - query := "SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups" - args := []interface{}{} - if scope != RBACScopeAll { - query += " WHERE owner_user_id = ?" - args = append(args, userID) - } - query += " ORDER BY COALESCE(pinned, 0) DESC, created_at ASC" - rows, err := db.Query( - query, args..., - ) - 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, &group.OwnerUserID, &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), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups WHERE id = ?", - id, - ).Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &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 -} - -func (db *DB) UserCanAccessGroup(userID, scope, groupID string) bool { - if scope == RBACScopeAll { - return true - } - var count int - err := db.QueryRow(`SELECT COUNT(*) FROM conversation_groups WHERE id = ? AND owner_user_id = ?`, groupID, userID).Scan(&count) - return err == nil && count > 0 -} - -// UpdateGroup 更新分组 -func (db *DB) UpdateGroup(id, name, icon string) error { - existing, err := db.GetGroup(id) - if err != nil { - return err - } - // 检查名称是否已存在(排除当前分组) - exists, err := db.groupExistsByNameForOwner(name, id, existing.OwnerUserID) - 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 { - // 先删除该对话的所有旧分组关联,确保一个对话只属于一个分组 - _, err := db.Exec( - "DELETE FROM conversation_group_mappings WHERE conversation_id = ?", - conversationID, - ) - if err != nil { - return fmt.Errorf("删除对话旧分组关联失败: %w", err) - } - - // 然后插入新的分组关联 - id := uuid.New().String() - _, err = db.Exec( - "INSERT INTO conversation_group_mappings (id, conversation_id, group_id, created_at) VALUES (?, ?, ?, ?)", - id, conversationID, groupID, time.Now(), - ) - if err != nil { - 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, COALESCE(cgm.pinned, 0) as group_pinned - FROM conversations c - INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id - WHERE cgm.group_id = ? - ORDER BY COALESCE(cgm.pinned, 0) DESC, 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 - var groupPinned int - - if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); 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 -} - -// SearchConversationsByGroup 搜索分组中的对话(按标题和消息内容模糊匹配) -func (db *DB) SearchConversationsByGroup(groupID string, searchQuery string) ([]*Conversation, error) { - // 构建SQL查询,支持按标题和消息内容搜索 - // 使用 DISTINCT 避免因为一个对话有多条匹配消息而重复 - query := `SELECT DISTINCT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, COALESCE(cgm.pinned, 0) as group_pinned - FROM conversations c - INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id - WHERE cgm.group_id = ?` - - args := []interface{}{groupID} - - // 如果有搜索关键词,添加标题和消息内容搜索条件 - if searchQuery != "" { - searchPattern := "%" + searchQuery + "%" - // 搜索标题或消息内容 - // 使用 LEFT JOIN 连接消息表,这样即使没有消息的对话也能被搜索到(通过标题) - query += ` AND ( - LOWER(c.title) LIKE LOWER(?) - OR EXISTS ( - SELECT 1 FROM messages m - WHERE m.conversation_id = c.id - AND LOWER(m.content) LIKE LOWER(?) - ) - )` - args = append(args, searchPattern, searchPattern) - } - - query += " ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC" - - rows, err := db.Query(query, args...) - 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 - var groupPinned int - - if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); 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 - } - // 注意:不更新 updated_at,因为置顶操作不应该改变对话的更新时间 - _, err := db.Exec( - "UPDATE conversations SET pinned = ? WHERE id = ?", - pinnedValue, 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 -} - -// GroupMapping 分组映射关系 -type GroupMapping struct { - ConversationID string `json:"conversationId"` - GroupID string `json:"groupId"` -} - -// GetAllGroupMappings 批量获取所有分组映射(消除 N+1 查询) -func (db *DB) GetAllGroupMappings() ([]GroupMapping, error) { - rows, err := db.Query("SELECT conversation_id, group_id FROM conversation_group_mappings") - if err != nil { - return nil, fmt.Errorf("查询分组映射失败: %w", err) - } - defer rows.Close() - - var mappings []GroupMapping - for rows.Next() { - var m GroupMapping - if err := rows.Scan(&m.ConversationID, &m.GroupID); err != nil { - return nil, fmt.Errorf("扫描分组映射失败: %w", err) - } - mappings = append(mappings, m) - } - - if mappings == nil { - mappings = []GroupMapping{} - } - return mappings, nil -} - -// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态 -func (db *DB) UpdateConversationPinnedInGroup(conversationID, groupID string, pinned bool) error { - pinnedValue := 0 - if pinned { - pinnedValue = 1 - } - _, err := db.Exec( - "UPDATE conversation_group_mappings SET pinned = ? WHERE conversation_id = ? AND group_id = ?", - pinnedValue, conversationID, groupID, - ) - if err != nil { - return fmt.Errorf("更新分组对话置顶状态失败: %w", err) - } - return nil -} diff --git a/internal/database/rbac.go b/internal/database/rbac.go index 28e86502..192bed4d 100644 --- a/internal/database/rbac.go +++ b/internal/database/rbac.go @@ -201,7 +201,6 @@ func (db *DB) migrateRBACOwnershipColumns() error { {"webshell_connections", "owner_user_id", "ALTER TABLE webshell_connections ADD COLUMN owner_user_id TEXT"}, {"batch_task_queues", "owner_user_id", "ALTER TABLE batch_task_queues ADD COLUMN owner_user_id TEXT"}, {"c2_listeners", "owner_user_id", "ALTER TABLE c2_listeners ADD COLUMN owner_user_id TEXT"}, - {"conversation_groups", "owner_user_id", "ALTER TABLE conversation_groups ADD COLUMN owner_user_id TEXT"}, {"tool_executions", "owner_user_id", "ALTER TABLE tool_executions ADD COLUMN owner_user_id TEXT"}, {"tool_executions", "conversation_id", "ALTER TABLE tool_executions ADD COLUMN conversation_id TEXT"}, } { diff --git a/internal/database/rbac_access_test.go b/internal/database/rbac_access_test.go index 4ecef6b6..34c1dde6 100644 --- a/internal/database/rbac_access_test.go +++ b/internal/database/rbac_access_test.go @@ -58,27 +58,8 @@ func TestRBACToolExecutionOwnershipAccess(t *testing.T) { } } -func TestRBACGroupAndUploadOwnership(t *testing.T) { +func TestRBACUploadOwnership(t *testing.T) { db := newRBACTestDB(t) - group1, err := db.CreateGroup("u1 group", "", "u1") - if err != nil { - t.Fatal(err) - } - group2, err := db.CreateGroup("u2 group", "", "u2") - if err != nil { - t.Fatal(err) - } - groups, err := db.ListGroupsForAccess("u1", RBACScopeAssigned) - if err != nil { - t.Fatal(err) - } - if len(groups) != 1 || groups[0].ID != group1.ID { - t.Fatalf("groups = %#v, want only %s (not %s)", groups, group1.ID, group2.ID) - } - if db.UserCanAccessGroup("u1", RBACScopeAssigned, group2.ID) { - t.Fatal("foreign group was accessible") - } - conversation, err := db.CreateConversation("upload", ConversationCreateMeta{}) if err != nil { t.Fatal(err) diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index c03d259b..7db31fcf 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -160,24 +160,15 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) { limit = 1000 } - excludeGrouped := strings.TrimSpace(search) == "" && projectID == "" && - (c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1") sortBy := strings.TrimSpace(c.Query("sort_by")) session, _ := security.CurrentSession(c) var conversations []*database.Conversation var total int var err error - if excludeGrouped { - conversations, err = h.db.ListUngroupedConversationsForAccess(limit, offset, sortBy, projectID, session.UserID, session.Scope) - if err == nil { - total, err = h.db.CountUngroupedConversationsForAccess(projectID, session.UserID, session.Scope) - } - } else { - conversations, err = h.db.ListConversationsForAccess(limit, offset, search, sortBy, projectID, session.UserID, session.Scope) - if err == nil { - total, err = h.db.CountConversationsForAccess(search, projectID, session.UserID, session.Scope) - } + conversations, err = h.db.ListConversationsForAccess(limit, offset, search, sortBy, projectID, session.UserID, session.Scope) + if err == nil { + total, err = h.db.CountConversationsForAccess(search, projectID, session.UserID, session.Scope) } if err != nil { h.logger.Error("获取对话列表失败", zap.Error(err)) @@ -195,6 +186,35 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) { }) } +// UpdateConversationPinnedRequest 更新对话置顶状态请求 +type UpdateConversationPinnedRequest struct { + Pinned bool `json:"pinned"` +} + +// UpdateConversationPinned 更新对话置顶状态 +func (h *ConversationHandler) UpdateConversationPinned(c *gin.Context) { + conversationID := c.Param("id") + session, ok := security.CurrentSession(c) + if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) + return + } + + 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": "更新成功"}) +} + // GetConversation 获取对话 func (h *ConversationHandler) GetConversation(c *gin.Context) { id := c.Param("id") diff --git a/internal/handler/group.go b/internal/handler/group.go deleted file mode 100644 index b3df88ca..00000000 --- a/internal/handler/group.go +++ /dev/null @@ -1,438 +0,0 @@ -package handler - -import ( - "errors" - "net/http" - "strings" - "time" - "unicode/utf8" - - "cyberstrike-ai/internal/database" - "cyberstrike-ai/internal/security" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -// GroupHandler 分组处理器 -type GroupHandler struct { - db *database.DB - logger *zap.Logger -} - -const ( - maxGroupNameRunes = 64 - maxGroupIconRunes = 16 -) - -// NewGroupHandler 创建新的分组处理器 -func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler { - return &GroupHandler{ - db: db, - logger: logger, - } -} - -func validateGroupTextField(field, value string, maxRunes int, required bool) (string, error) { - value = strings.TrimSpace(value) - if value == "" { - if required { - return "", errors.New(field + "不能为空") - } - return "", nil - } - if utf8.RuneCountInString(value) > maxRunes { - return "", errors.New(field + "过长") - } - for _, r := range value { - switch r { - case '<', '>', '"', '\'', '`': - return "", errors.New(field + "包含非法字符") - } - if r < 0x20 || r == 0x7f { - return "", errors.New(field + "包含非法控制字符") - } - } - return value, nil -} - -func validateGroupFields(name, icon string) (string, string, error) { - validName, err := validateGroupTextField("分组名称", name, maxGroupNameRunes, true) - if err != nil { - return "", "", err - } - validIcon, err := validateGroupTextField("分组图标", icon, maxGroupIconRunes, false) - if err != nil { - return "", "", err - } - return validName, validIcon, nil -} - -// 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 - } - - name, icon, err := validateGroupFields(req.Name, req.Icon) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - session, _ := security.CurrentSession(c) - group, err := h.db.CreateGroup(name, icon, session.UserID) - 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) { - session, _ := security.CurrentSession(c) - groups, err := h.db.ListGroupsForAccess(session.UserID, session.Scope) - 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") - if !h.groupAllowed(c, id) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - - 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") - if !h.groupAllowed(c, id) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - - var req UpdateGroupRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - name, icon, err := validateGroupFields(req.Name, req.Icon) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.db.UpdateGroup(id, name, 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 !h.groupAllowed(c, id) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - - 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 !h.groupConversationAllowed(c, req.ConversationID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - if !h.groupAllowed(c, req.GroupID) { - c.JSON(http.StatusForbidden, gin.H{"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 !h.groupAllowed(c, groupID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"}) - return - } - if !h.groupConversationAllowed(c, conversationID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - - 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": "移除成功"}) -} - -// GroupConversation 分组对话响应结构 -type GroupConversation struct { - ID string `json:"id"` - Title string `json:"title"` - Pinned bool `json:"pinned"` - GroupPinned bool `json:"groupPinned"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// GetGroupConversations 获取分组中的所有对话 -func (h *GroupHandler) GetGroupConversations(c *gin.Context) { - groupID := c.Param("id") - if !h.groupAllowed(c, groupID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"}) - return - } - searchQuery := c.Query("search") // 获取搜索参数 - - var conversations []*database.Conversation - var err error - - // 如果有搜索关键词,使用搜索方法;否则使用普通方法 - if searchQuery != "" { - conversations, err = h.db.SearchConversationsByGroup(groupID, searchQuery) - } else { - 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 - } - - // 获取每个对话在分组中的置顶状态 - groupConvs := make([]GroupConversation, 0, len(conversations)) - for _, conv := range conversations { - if conv == nil || !h.groupConversationAllowed(c, conv.ID) { - continue - } - // 查询分组内置顶状态 - var groupPinned int - err := h.db.QueryRow( - "SELECT COALESCE(pinned, 0) FROM conversation_group_mappings WHERE conversation_id = ? AND group_id = ?", - conv.ID, groupID, - ).Scan(&groupPinned) - if err != nil { - h.logger.Warn("查询分组内置顶状态失败", zap.String("conversationId", conv.ID), zap.Error(err)) - groupPinned = 0 - } - - groupConvs = append(groupConvs, GroupConversation{ - ID: conv.ID, - Title: conv.Title, - Pinned: conv.Pinned, - GroupPinned: groupPinned != 0, - CreatedAt: conv.CreatedAt, - UpdatedAt: conv.UpdatedAt, - }) - } - - c.JSON(http.StatusOK, groupConvs) -} - -// GetAllMappings 批量获取所有分组映射(消除前端 N+1 请求) -func (h *GroupHandler) GetAllMappings(c *gin.Context) { - mappings, err := h.db.GetAllGroupMappings() - if err != nil { - h.logger.Error("获取分组映射失败", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - filtered := mappings[:0] - for _, mapping := range mappings { - if h.groupConversationAllowed(c, mapping.ConversationID) && h.groupAllowed(c, mapping.GroupID) { - filtered = append(filtered, mapping) - } - } - - c.JSON(http.StatusOK, filtered) -} - -// UpdateConversationPinnedRequest 更新对话置顶状态请求 -type UpdateConversationPinnedRequest struct { - Pinned bool `json:"pinned"` -} - -// UpdateConversationPinned 更新对话置顶状态 -func (h *GroupHandler) UpdateConversationPinned(c *gin.Context) { - conversationID := c.Param("id") - if !h.groupConversationAllowed(c, conversationID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - - 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") - if !h.groupAllowed(c, groupID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"}) - return - } - - 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": "更新成功"}) -} - -// UpdateConversationPinnedInGroupRequest 更新分组对话置顶状态请求 -type UpdateConversationPinnedInGroupRequest struct { - Pinned bool `json:"pinned"` -} - -// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态 -func (h *GroupHandler) UpdateConversationPinnedInGroup(c *gin.Context) { - groupID := c.Param("id") - conversationID := c.Param("conversationId") - if !h.groupAllowed(c, groupID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"}) - return - } - if !h.groupConversationAllowed(c, conversationID) { - c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) - return - } - - var req UpdateConversationPinnedInGroupRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.db.UpdateConversationPinnedInGroup(conversationID, 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": "更新成功"}) -} - -func (h *GroupHandler) groupConversationAllowed(c *gin.Context, conversationID string) bool { - session, ok := security.CurrentSession(c) - if !ok { - return false - } - return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID) -} - -func (h *GroupHandler) groupAllowed(c *gin.Context, groupID string) bool { - session, ok := security.CurrentSession(c) - return ok && h.db.UserCanAccessGroup(session.UserID, session.Scope, groupID) -} diff --git a/internal/handler/group_test.go b/internal/handler/group_test.go deleted file mode 100644 index 1e7cbde8..00000000 --- a/internal/handler/group_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package handler - -import ( - "strings" - "testing" -) - -func TestValidateGroupFieldsAllowsNormalNamesAndIcons(t *testing.T) { - name, icon, err := validateGroupFields(" 日常安全巡检 ", " 📁 ") - if err != nil { - t.Fatalf("validateGroupFields returned error: %v", err) - } - if name != "日常安全巡检" { - t.Fatalf("name = %q, want trimmed normal name", name) - } - if icon != "📁" { - t.Fatalf("icon = %q, want trimmed icon", icon) - } -} - -func TestValidateGroupFieldsRejectsStoredXSSPayloads(t *testing.T) { - tests := []struct { - name string - icon string - }{ - {name: ``, icon: "📁"}, - {name: "日常安全巡检", icon: ``}, - {name: "日常安全巡检`onmouseover=alert(1)", icon: "📁"}, - {name: "日常安全巡检\x00", icon: "📁"}, - {name: strings.Repeat("分", maxGroupNameRunes+1), icon: "📁"}, - {name: "日常安全巡检", icon: strings.Repeat("📁", maxGroupIconRunes+1)}, - } - - for _, tt := range tests { - t.Run(tt.name+"/"+tt.icon, func(t *testing.T) { - if _, _, err := validateGroupFields(tt.name, tt.icon); err == nil { - t.Fatal("validateGroupFields returned nil error for unsafe input") - } - }) - } -} diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index fad523d0..e5d03f71 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -456,75 +456,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, - "Group": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "id": map[string]interface{}{ - "type": "string", - "description": "分组ID", - }, - "name": map[string]interface{}{ - "type": "string", - "description": "分组名称", - }, - "icon": map[string]interface{}{ - "type": "string", - "description": "分组图标", - }, - "createdAt": map[string]interface{}{ - "type": "string", - "format": "date-time", - "description": "创建时间", - }, - "updatedAt": map[string]interface{}{ - "type": "string", - "format": "date-time", - "description": "更新时间", - }, - }, - }, - "CreateGroupRequest": map[string]interface{}{ - "type": "object", - "required": []string{"name"}, - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "分组名称", - }, - "icon": map[string]interface{}{ - "type": "string", - "description": "分组图标(可选)", - }, - }, - }, - "UpdateGroupRequest": map[string]interface{}{ - "type": "object", - "required": []string{"name"}, - "properties": map[string]interface{}{ - "name": map[string]interface{}{ - "type": "string", - "description": "分组名称", - }, - "icon": map[string]interface{}{ - "type": "string", - "description": "分组图标", - }, - }, - }, - "AddConversationToGroupRequest": map[string]interface{}{ - "type": "object", - "required": []string{"conversationId", "groupId"}, - "properties": map[string]interface{}{ - "conversationId": map[string]interface{}{ - "type": "string", - "description": "对话ID", - }, - "groupId": map[string]interface{}{ - "type": "string", - "description": "分组ID", - }, - }, - }, "BatchTaskRequest": map[string]interface{}{ "type": "object", "required": []string{"tasks"}, @@ -1401,15 +1332,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "type": "string", }, }, - { - "name": "exclude_grouped", - "in": "query", - "required": false, - "description": "为 true 时排除已加入分组的对话(默认在未搜索且未按项目筛选时启用)", - "schema": map[string]interface{}{ - "type": "boolean", - }, - }, { "name": "sort_by", "in": "query", @@ -2315,290 +2237,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, - "/api/groups": map[string]interface{}{ - "post": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "创建分组", - "description": "创建一个新的对话分组", - "operationId": "createGroup", - "requestBody": map[string]interface{}{ - "required": true, - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "$ref": "#/components/schemas/CreateGroupRequest", - }, - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "创建成功", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "$ref": "#/components/schemas/Group", - }, - }, - }, - }, - "400": map[string]interface{}{ - "description": "请求参数错误或分组名称已存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - "get": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "列出分组", - "description": "获取所有对话分组", - "operationId": "listGroups", - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "获取成功", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{ - "$ref": "#/components/schemas/Group", - }, - }, - }, - }, - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, - "/api/groups/{id}": map[string]interface{}{ - "get": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "获取分组", - "description": "获取指定分组的详细信息", - "operationId": "getGroup", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "获取成功", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "$ref": "#/components/schemas/Group", - }, - }, - }, - }, - "404": map[string]interface{}{ - "description": "分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - "put": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "更新分组", - "description": "更新分组信息", - "operationId": "updateGroup", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "requestBody": map[string]interface{}{ - "required": true, - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "$ref": "#/components/schemas/UpdateGroupRequest", - }, - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "更新成功", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "$ref": "#/components/schemas/Group", - }, - }, - }, - }, - "400": map[string]interface{}{ - "description": "请求参数错误或分组名称已存在", - }, - "404": map[string]interface{}{ - "description": "分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - "delete": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "删除分组", - "description": "删除指定分组", - "operationId": "deleteGroup", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "删除成功", - }, - "404": map[string]interface{}{ - "description": "分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, - "/api/groups/{id}/conversations": map[string]interface{}{ - "get": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "获取分组中的对话", - "description": "获取指定分组中的所有对话", - "operationId": "getGroupConversations", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "获取成功", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{ - "$ref": "#/components/schemas/Conversation", - }, - }, - }, - }, - }, - "404": map[string]interface{}{ - "description": "分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, - "/api/groups/conversations": map[string]interface{}{ - "post": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "添加对话到分组", - "description": "将对话添加到指定分组", - "operationId": "addConversationToGroup", - "requestBody": map[string]interface{}{ - "required": true, - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "$ref": "#/components/schemas/AddConversationToGroupRequest", - }, - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "添加成功", - }, - "400": map[string]interface{}{ - "description": "请求参数错误", - }, - "404": map[string]interface{}{ - "description": "对话或分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, - "/api/groups/{id}/conversations/{conversationId}": map[string]interface{}{ - "delete": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "从分组移除对话", - "description": "从指定分组中移除对话", - "operationId": "removeConversationFromGroup", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - { - "name": "conversationId", - "in": "path", - "required": true, - "description": "对话ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "移除成功", - }, - "404": map[string]interface{}{ - "description": "对话或分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, "/api/assets/import": map[string]interface{}{ "post": map[string]interface{}{ "tags": []string{"资产管理"}, @@ -4266,109 +3904,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, - "/api/groups/{id}/pinned": map[string]interface{}{ - "put": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "设置分组置顶", - "description": "设置或取消分组的置顶状态", - "operationId": "updateGroupPinned", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "requestBody": map[string]interface{}{ - "required": true, - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "type": "object", - "required": []string{"pinned"}, - "properties": map[string]interface{}{ - "pinned": map[string]interface{}{ - "type": "boolean", - "description": "是否置顶", - }, - }, - }, - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "更新成功", - }, - "404": map[string]interface{}{ - "description": "分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, - "/api/groups/{id}/conversations/{conversationId}/pinned": map[string]interface{}{ - "put": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "设置分组中对话的置顶", - "description": "设置或取消分组中对话的置顶状态", - "operationId": "updateConversationPinnedInGroup", - "parameters": []map[string]interface{}{ - { - "name": "id", - "in": "path", - "required": true, - "description": "分组ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - { - "name": "conversationId", - "in": "path", - "required": true, - "description": "对话ID", - "schema": map[string]interface{}{ - "type": "string", - }, - }, - }, - "requestBody": map[string]interface{}{ - "required": true, - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "type": "object", - "required": []string{"pinned"}, - "properties": map[string]interface{}{ - "pinned": map[string]interface{}{ - "type": "boolean", - "description": "是否置顶", - }, - }, - }, - }, - }, - }, - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "更新成功", - }, - "404": map[string]interface{}{ - "description": "对话或分组不存在", - }, - "401": map[string]interface{}{ - "description": "未授权", - }, - }, - }, - }, "/api/knowledge/categories": map[string]interface{}{ "get": map[string]interface{}{ "tags": []string{"知识库"}, @@ -5194,38 +4729,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, - - // ==================== 对话分组 - 缺失端点 ==================== - "/api/groups/mappings": map[string]interface{}{ - "get": map[string]interface{}{ - "tags": []string{"对话分组"}, - "summary": "获取所有分组映射", - "description": "获取所有对话与分组之间的映射关系列表。", - "operationId": "getAllGroupMappings", - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "获取成功", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "conversation_id": map[string]interface{}{"type": "string", "description": "对话ID"}, - "group_id": map[string]interface{}{"type": "string", "description": "分组ID"}, - "pinned": map[string]interface{}{"type": "boolean", "description": "是否置顶"}, - }, - }, - }, - }, - }, - }, - "401": map[string]interface{}{"description": "未授权"}, - }, - }, - }, - // ==================== FOFA信息收集 ==================== "/api/fofa/search": map[string]interface{}{ "post": map[string]interface{}{ diff --git a/internal/handler/openapi_i18n.go b/internal/handler/openapi_i18n.go index d0480c0f..67978871 100644 --- a/internal/handler/openapi_i18n.go +++ b/internal/handler/openapi_i18n.go @@ -5,7 +5,7 @@ package handler var apiDocI18nTagToKey = map[string]string{ "认证": "auth", "对话管理": "conversationManagement", "对话交互": "conversationInteraction", - "批量任务": "batchTasks", "对话分组": "conversationGroups", "漏洞管理": "vulnerabilityManagement", + "批量任务": "batchTasks", "漏洞管理": "vulnerabilityManagement", "角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring", "配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain", "知识库": "knowledgeBase", "MCP": "mcp", @@ -24,10 +24,7 @@ var apiDocI18nSummaryToKey = map[string]string{ "删除批量任务队列": "deleteBatchQueue", "启动批量任务队列": "startBatchQueue", "暂停批量任务队列": "pauseBatchQueue", "添加任务到队列": "addTaskToQueue", "SQL注入扫描": "sqlInjectionScan", "端口扫描": "portScan", "更新批量任务": "updateBatchTask", "删除批量任务": "deleteBatchTask", - "创建分组": "createGroup", "列出分组": "listGroups", "获取分组": "getGroup", "更新分组": "updateGroup", - "删除分组": "deleteGroup", "获取分组中的对话": "getGroupConversations", "添加对话到分组": "addConversationToGroup", - "从分组移除对话": "removeConversationFromGroup", - "列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats", + "列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats", "获取漏洞": "getVulnerability", "更新漏洞": "updateVulnerability", "删除漏洞": "deleteVulnerability", "列出角色": "listRoles", "创建角色": "createRole", "获取角色": "getRole", "更新角色": "updateRole", "删除角色": "deleteRole", "获取可用Skills列表": "getAvailableSkills", "列出Skills": "listSkills", "创建Skill": "createSkill", @@ -40,8 +37,8 @@ var apiDocI18nSummaryToKey = map[string]string{ "添加或更新外部MCP": "addOrUpdateExternalMCP", "stdio模式配置": "stdioModeConfig", "SSE模式配置": "sseModeConfig", "删除外部MCP": "deleteExternalMCP", "启动外部MCP": "startExternalMCP", "停止外部MCP": "stopExternalMCP", "获取攻击链": "getAttackChain", "重新生成攻击链": "regenerateAttackChain", - "设置对话置顶": "pinConversation", "设置分组置顶": "pinGroup", "设置分组中对话的置顶": "pinGroupConversation", - "获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem", + "设置对话置顶": "pinConversation", + "获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem", "获取知识项": "getKnowledgeItem", "更新知识项": "updateKnowledgeItem", "删除知识项": "deleteKnowledgeItem", "获取索引状态": "getIndexStatus", "构建索引": "startKnowledgeIndex", "扫描知识库": "scanKnowledgeBase", "搜索知识库": "searchKnowledgeBase", "基础搜索": "basicSearch", "按风险类型搜索": "searchByRiskType", @@ -52,8 +49,7 @@ var apiDocI18nSummaryToKey = map[string]string{ "删除对话轮次": "deleteConversationTurn", "获取消息过程详情": "getMessageProcessDetails", "重跑批量任务队列": "rerunBatchQueue", "修改队列元数据": "updateBatchQueueMetadata", "修改队列调度配置": "updateBatchQueueSchedule", "开关Cron自动调度": "setBatchQueueScheduleEnabled", - "获取所有分组映射": "getAllGroupMappings", - "FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse", + "FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse", "测试OpenAI API连接": "testOpenAI", "执行终端命令": "terminalRun", "流式执行终端命令": "terminalRunStream", "WebSocket终端": "terminalWS", "列出WebShell连接": "listWebshellConnections", "创建WebShell连接": "createWebshellConnection", @@ -84,7 +80,6 @@ var apiDocI18nResponseDescToKey = map[string]string{ "获取成功": "getSuccess", "未授权": "unauthorized", "未授权,需要有效的Token": "unauthorizedToken", "创建成功": "createSuccess", "请求参数错误": "badRequest", "对话不存在": "conversationNotFound", "对话不存在或结果不存在": "conversationOrResultNotFound", "请求参数错误(如task为空)": "badRequestTaskEmpty", - "请求参数错误或分组名称已存在": "badRequestGroupNameExists", "分组不存在": "groupNotFound", "请求参数错误(如配置格式不正确、缺少必需字段等)": "badRequestConfig", "请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed", "登录成功": "loginSuccess", "密码错误": "invalidPassword", "登出成功": "logoutSuccess", @@ -92,7 +87,7 @@ var apiDocI18nResponseDescToKey = map[string]string{ "对话创建成功": "conversationCreated", "服务器内部错误": "internalError", "更新成功": "updateSuccess", "删除成功": "deleteSuccess", "队列不存在": "queueNotFound", "启动成功": "startSuccess", "暂停成功": "pauseSuccess", "添加成功": "addSuccess", - "任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound", + "任务不存在": "taskNotFound", "取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask", "消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events)": "streamResponse", // 新增缺失端点响应 diff --git a/internal/security/rbac.go b/internal/security/rbac.go index 1c5b766f..70158e51 100644 --- a/internal/security/rbac.go +++ b/internal/security/rbac.go @@ -78,9 +78,6 @@ var PermissionCatalog = map[string]string{ "attackchain:write": "Regenerate attack chains", "fofa:execute": "Run FOFA searches and query parsing", "openapi:read": "Read OpenAPI aggregation results", - "group:read": "View conversation groups", - "group:write": "Create and update conversation groups", - "group:delete": "Delete conversation groups", "monitor:read": "View execution monitor", "monitor:write": "Cancel monitor executions", "monitor:delete": "Delete monitor executions", diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index 2eea54f9..d630c3a2 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -122,8 +122,6 @@ func permissionForRequest(method, fullPath string) string { return "dashboard:read" case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"): return crudPermission(method, "chat") - case strings.HasPrefix(path, "/groups"): - return crudPermission(method, "group") case strings.HasPrefix(path, "/monitor"): return crudPermission(method, "monitor") case strings.HasPrefix(path, "/notifications"): diff --git a/web/static/css/style.css b/web/static/css/style.css index 1cfc7bb6..13562641 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3032,10 +3032,6 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { margin-bottom: 10px; } -.conversation-sidebar .conversation-groups-section { - margin-bottom: 12px; -} - .conversation-sidebar .recent-conversations-section { margin-bottom: 12px; } @@ -3401,29 +3397,6 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { 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; @@ -4093,7 +4066,6 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { line-height: 1.5; } - .message-bubble table tbody tr:hover { background: var(--bg-secondary); } @@ -5260,7 +5232,6 @@ html[data-theme="dark"] .conversation-reasoning-card:not(.conversation-reasoning color: var(--error-color, #e53e3e); } - .ai-channel-editor-form { max-width: none; } @@ -9869,7 +9840,6 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { background: var(--accent-hover); } - #settings-section-audit .audit-log-list { margin-bottom: 0; } @@ -13861,7 +13831,6 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { min-width: 0; } - .rbac-assignment-controls { flex: 0 0 auto; display: flex; @@ -13869,7 +13838,6 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { gap: 8px; } - .rbac-assignment-help { flex: 0 0 auto; margin: 0; @@ -16701,7 +16669,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { transition: background 0.2s ease, transform 0.2s ease; } - .legend-item:hover { background: rgba(99, 102, 241, 0.06); transform: translateX(2px); @@ -18239,14 +18206,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { } } -/* 对话分组和最近对话样式 */ -.conversation-groups-section, +/* 最近对话样式 */ .recent-conversations-section { margin-bottom: 24px; min-width: 0; } -.conversation-groups-section:last-child, .recent-conversations-section:last-child { margin-bottom: 0; } @@ -18430,93 +18395,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { 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, -.group-item:focus-within .group-item-menu { - opacity: 1; -} - -.group-item-menu:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - .conversation-item { position: relative; } @@ -18548,8 +18426,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { line-height: 1; } -.conversation-item:hover .conversation-item-menu, -.group-conversation-item:hover .conversation-item-menu { +.conversation-item:hover .conversation-item-menu { opacity: 1; } @@ -18559,240 +18436,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { opacity: 1; } -/* 分组详情页面 */ -.group-detail-page { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - background: var(--bg-primary); - overflow: hidden; - height: 100%; -} - -.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-search-container { - padding: 12px 24px; - border-bottom: 1px solid var(--border-color); - background: var(--bg-primary); - flex-shrink: 0; -} - -.group-search-input-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.group-search-input { - width: 100%; - padding: 8px 36px 8px 12px; - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 0.875rem; - color: var(--text-primary); - background: var(--bg-secondary); - transition: all 0.2s ease; -} - -.group-search-input:focus { - outline: none; - border-color: var(--accent-color); - background: var(--bg-primary); - box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1); -} - -.group-search-input::placeholder { - color: var(--text-muted); -} - -.group-search-clear-btn { - position: absolute; - right: 8px; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--text-muted); - cursor: pointer; - border-radius: 4px; - transition: all 0.2s ease; - padding: 0; -} - -.group-search-clear-btn:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.group-detail-content { - flex: 1; - overflow-y: auto; - padding: 24px; - background: #f5f7fa; -} - -.group-conversations-list { - display: flex; - flex-direction: column; - gap: 8px; - max-width: 100%; -} - -.group-conversation-item { - padding: 12px 16px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s ease; - position: relative; - display: flex; - align-items: flex-start; - gap: 12px; -} - -.group-conversation-item:hover { - background: var(--bg-tertiary); - border-color: var(--accent-color); -} - -.group-conversation-item.active { - background: rgba(0, 102, 255, 0.08); - border-color: var(--accent-color); -} - -.group-conversation-item:hover .conversation-item-menu { - opacity: 1; -} - -.group-conversation-content-wrapper { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -.group-conversation-item .conversation-item-menu { - position: absolute; - top: 8px; - right: 8px; - opacity: 0.6; - flex-shrink: 0; -} - -.group-conversation-title { - font-size: 0.9375rem; - font-weight: 500; - color: var(--text-primary); - line-height: 1.4; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; - line-clamp: 1; - -webkit-box-orient: vertical; -} - -.group-conversation-time { - font-size: 0.75rem; - color: var(--text-muted); - line-height: 1.4; -} - -.group-conversation-content { - margin-top: 4px; - padding: 8px 12px; - background: var(--bg-secondary); - border-radius: 6px; - font-size: 0.8125rem; - color: var(--text-secondary); - line-height: 1.5; - max-height: 60px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - word-break: break-word; -} - /* 批量管理模态框 */ .batch-manage-modal-content { max-width: 1040px; @@ -18865,7 +18508,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .batch-table-header { display: grid; - grid-template-columns: 40px minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(0, 0.75fr) 160px 72px; + grid-template-columns: 40px minmax(0, 1.4fr) minmax(0, 0.85fr) 160px 72px; gap: 12px; padding: 12px 16px; background: var(--bg-secondary); @@ -18884,7 +18527,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .batch-conversation-row { display: grid; - grid-template-columns: 40px minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(0, 0.75fr) 160px 72px; + grid-template-columns: 40px minmax(0, 1.4fr) minmax(0, 0.85fr) 160px 72px; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-color); @@ -18926,20 +18569,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { opacity: 0.85; } -.batch-table-col-group { - font-size: 0.8125rem; - color: var(--text-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.batch-table-col-group.is-unbound { - color: var(--text-muted); - font-style: italic; - opacity: 0.85; -} - .batch-table-col-time { font-size: 0.875rem; color: var(--text-muted); @@ -18996,45 +18625,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { flex-shrink: 0; } -.batch-footer-move { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.batch-footer-move .conversation-project-filter-ui { - width: 180px; - min-width: 140px; - flex-shrink: 0; -} - -.batch-footer-move .conversation-project-filter-trigger { - font-size: 0.8125rem; - padding: 8px 10px; -} - -.batch-manage-modal-content .batch-footer-move .conversation-project-filter-ui.open { - z-index: 400; -} - -.batch-footer-move .conversation-project-filter-dropdown { - top: auto; - bottom: calc(100% + 4px); -} - -.batch-move-group-select { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - .batch-table-col-checkbox input[type="checkbox"] { cursor: pointer; } @@ -19048,268 +18638,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { gap: 12px; } -/* 创建分组模态框 */ -.create-group-modal-content { - max-width: 640px; - width: 60vw; - margin: 8% auto; -} - -.create-group-modal-content .modal-header { - padding: 12px 20px; - border-bottom: 1px solid #f0f0f0; - background: #ffffff; -} - -.create-group-modal-content .modal-header h2 { - font-size: 1rem; - font-weight: 600; - color: #1a1a1a; - background: none; - -webkit-background-clip: unset; - -webkit-text-fill-color: #1a1a1a; - background-clip: unset; - margin: 0; -} - - -.create-group-modal-content .modal-footer { - padding: 10px 20px; - border-top: 1px solid #f0f0f0; - background: #fafafa; -} - -.create-group-body { - padding: 16px 20px; -} - -.create-group-description { - font-size: 0.8125rem; - color: #666; - line-height: 1.3; - margin-bottom: 12px; - margin-top: 0; - padding: 0; -} - -.create-group-input-wrapper { - position: relative; - display: flex; - align-items: center; - margin-bottom: 0; - width: 100%; -} - -.group-icon-input { - position: absolute; - left: 8px; - top: 50%; - transform: translateY(-50%); - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - background: #f5f5f5; - border: 1px solid #e0e0e0; - border-radius: 6px; - font-size: 1rem; - cursor: pointer; - z-index: 2; - box-shadow: none; - line-height: 1; - transition: all 0.2s ease; -} - -.group-icon-input:hover { - background: #e8e8e8; - border-color: #d0d0d0; - transform: translateY(-50%) scale(1.05); -} - -.group-icon-input:active { - transform: translateY(-50%) scale(0.98); - background: #ddd; -} - -/* 图标选择器面板 */ -.group-icon-picker { - position: absolute; - top: calc(100% + 8px); - left: 0; - width: 280px; - background: #ffffff; - border: 1px solid #e0e0e0; - border-radius: 12px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - z-index: 100; - overflow: hidden; -} - -.icon-picker-header { - padding: 10px 14px; - font-size: 0.8125rem; - font-weight: 600; - color: #666; - background: #fafafa; - border-bottom: 1px solid #f0f0f0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.icon-picker-header > span { - flex-shrink: 0; -} - -.icon-picker-custom { - display: flex; - align-items: center; - gap: 6px; -} - -.custom-icon-input { - width: 60px; - padding: 4px 8px; - border: 1px solid #e0e0e0; - border-radius: 6px; - font-size: 0.875rem; - text-align: center; - background: #ffffff; - transition: all 0.2s ease; -} - -.custom-icon-input:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); -} - -.custom-icon-input::placeholder { - color: #bbb; - font-size: 0.75rem; -} - -.custom-icon-btn { - padding: 4px 10px; - font-size: 0.75rem; - font-weight: 500; - color: #fff; - background: #667eea; - border: none; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; -} - -.custom-icon-btn:hover { - background: #5a6fd6; -} - -.custom-icon-btn:active { - transform: scale(0.96); -} - -.icon-picker-grid { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 4px; - padding: 12px; - max-height: 180px; - overflow-y: auto; -} - -.icon-option { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - cursor: pointer; - border-radius: 8px; - transition: all 0.15s ease; - user-select: none; -} - -.icon-option:hover { - background: #f0f0f0; - transform: scale(1.15); -} - -.icon-option:active { - transform: scale(1); - background: #e0e0e0; -} - -#create-group-name-input { - width: 100%; - padding: 8px 12px 8px 40px; - border: 1.5px solid #e0e0e0; - border-radius: 8px; - font-size: 0.875rem; - background: #fafafa; - color: var(--text-primary); - transition: all 0.3s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); - height: 36px; - box-sizing: border-box; - line-height: 1.2; -} - -#create-group-name-input:hover { - border-color: #b0b0b0; - background: #ffffff; -} - -#create-group-name-input:focus { - outline: none; - border-color: #667eea; - background: #ffffff; - box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1), 0 2px 8px rgba(0, 0, 0, 0.08); -} - -#create-group-name-input::placeholder { - color: #999; -} - -.create-group-suggestions { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; -} - -.suggestion-tag { - display: inline-flex; - align-items: center; - padding: 5px 12px; - background: #f5f5f5; - border: 1px solid #e0e0e0; - border-radius: 14px; - font-size: 0.8125rem; - color: #666; - cursor: pointer; - transition: all 0.2s ease; - user-select: none; - height: 26px; - box-sizing: border-box; -} - -.suggestion-tag:hover { - background: #e8e8e8; - border-color: #d0d0d0; - color: #333; - transform: translateY(-1px); -} - -.suggestion-tag:active { - transform: translateY(0); - background: #ddd; -} - /* 上下文菜单 */ .context-menu { position: fixed; @@ -19419,15 +18747,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { 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); -} - /* 任务管理页面样式 */ .tasks-stats-bar { display: flex; @@ -28925,7 +28244,6 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value { transform: translate(0, -50%) rotate(45deg); } - /* 选项内勾选:未选中时隐藏(与角色列表一致) */ .agent-mode-option .agent-mode-check { display: none !important; @@ -28988,7 +28306,6 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value { letter-spacing: -0.01em; } - .role-selection-list-main { display: flex; flex-direction: column; @@ -32668,7 +31985,6 @@ button.chat-files-dropdown-item:hover:not(:disabled) { display: none !important; } - /* 新建文件夹弹窗:层次清晰、留白舒适,无强装饰 */ .chat-files-mkdir-modal-content { max-width: 420px; @@ -38080,27 +37396,22 @@ html[data-theme="dark"] .main-sidebar .nav-item.expanded > .nav-item-content { /* Conversation list items have an inner .conversation-content wrapper; keep it transparent so it does not inherit the main chat panel background. */ -html[data-theme="dark"] .conversation-item .conversation-content, -html[data-theme="dark"] .group-conversation-item .group-conversation-content, -html[data-theme="dark"] .group-conversation-item .group-conversation-content-wrapper { +html[data-theme="dark"] .conversation-item .conversation-content { background: transparent !important; border-color: transparent !important; box-shadow: none !important; } html[data-theme="dark"] .conversation-item .conversation-title, -html[data-theme="dark"] .conversation-item .conversation-time, -html[data-theme="dark"] .group-conversation-item .group-conversation-title { +html[data-theme="dark"] .conversation-item .conversation-time { background: transparent !important; } -html[data-theme="dark"] .conversation-item:hover, -html[data-theme="dark"] .group-conversation-item:hover { +html[data-theme="dark"] .conversation-item:hover { background: rgba(96, 165, 250, 0.10) !important; } -html[data-theme="dark"] .conversation-item.active, -html[data-theme="dark"] .group-conversation-item.active { +html[data-theme="dark"] .conversation-item.active { background: rgba(96, 165, 250, 0.16) !important; border-color: rgba(96, 165, 250, 0.34) !important; } @@ -39535,169 +38846,6 @@ html[data-theme="dark"] #page-vulnerabilities .stat-stacked-bar { background: rgba(148, 163, 184, 0.16) !important; } -/* Conversation group pages and dialogs dark theme. */ -html[data-theme="dark"] .group-detail-page, -html[data-theme="dark"] .group-detail-header, -html[data-theme="dark"] .group-search-container, -html[data-theme="dark"] .group-detail-content { - background: var(--bg-primary) !important; - color: var(--text-primary) !important; - border-color: var(--border-color) !important; -} - -html[data-theme="dark"] .group-detail-content { - background: #0f172a !important; -} - -html[data-theme="dark"] .group-action-btn, -html[data-theme="dark"] .group-search-input, -html[data-theme="dark"] .batch-search-box input { - background: #0f172a !important; - color: var(--text-primary) !important; - border-color: #2b374b !important; -} - -html[data-theme="dark"] .group-action-btn:hover, -html[data-theme="dark"] .group-search-clear-btn:hover { - background: rgba(96, 165, 250, 0.12) !important; - color: var(--accent-hover) !important; -} - -html[data-theme="dark"] .group-search-input::placeholder, -html[data-theme="dark"] .batch-search-box input::placeholder { - color: var(--text-muted) !important; -} - -html[data-theme="dark"] .batch-footer-move .conversation-project-filter-trigger, -html[data-theme="dark"] .batch-footer-move .conversation-project-filter-dropdown { - background: #0f172a !important; - color: var(--text-primary) !important; - border-color: #263244 !important; -} - -html[data-theme="dark"] .batch-footer-move .conversation-project-filter-option:hover { - background: #1e293b !important; -} - -html[data-theme="dark"] .batch-footer-move .conversation-project-filter-option.is-selected { - background: rgba(59, 130, 246, 0.15) !important; - color: #93c5fd !important; -} - -html[data-theme="dark"] .batch-manage-modal-content, -html[data-theme="dark"] .batch-manage-modal-content .modal-header, -html[data-theme="dark"] .batch-manage-body, -html[data-theme="dark"] .batch-manage-footer { - background: #111827 !important; - color: var(--text-primary) !important; - border-color: #263244 !important; -} - -html[data-theme="dark"] #batch-manage-modal .modal-header { - background: linear-gradient(180deg, #111827 0%, #0f172a 100%) !important; - border-bottom-color: #263244 !important; - box-shadow: none !important; -} - -html[data-theme="dark"] #batch-manage-modal .modal-header h2, -html[data-theme="dark"] #batch-manage-title, -html[data-theme="dark"] #batch-manage-title span { - background: none !important; - color: var(--text-primary) !important; - -webkit-text-fill-color: var(--text-primary) !important; -} - -html[data-theme="dark"] .batch-table-header { - background: #0f172a !important; - color: var(--text-secondary) !important; - border-color: #263244 !important; -} - -html[data-theme="dark"] .batch-conversation-row { - background: #111827 !important; - color: var(--text-primary) !important; - border-color: #263244 !important; -} - -html[data-theme="dark"] .batch-conversation-row:hover { - background: rgba(96, 165, 250, 0.10) !important; -} - -html[data-theme="dark"] .batch-table-col-name { - color: var(--text-primary) !important; -} - -html[data-theme="dark"] .batch-table-col-project, -html[data-theme="dark"] .batch-table-col-time { - color: var(--text-secondary) !important; -} - -html[data-theme="dark"] .create-group-modal-content, -html[data-theme="dark"] .create-group-modal-content .modal-header, -html[data-theme="dark"] .create-group-modal-content .modal-footer, -html[data-theme="dark"] .create-group-body { - background: #111827 !important; - color: var(--text-primary) !important; - border-color: #263244 !important; -} - -html[data-theme="dark"] .create-group-modal-content .modal-header h2 { - color: var(--text-primary) !important; - -webkit-text-fill-color: var(--text-primary) !important; -} - -html[data-theme="dark"] .create-group-description { - color: var(--text-secondary) !important; -} - -html[data-theme="dark"] .group-icon-input, -html[data-theme="dark"] #create-group-name-input, -html[data-theme="dark"] .custom-icon-input { - background: #0f172a !important; - color: var(--text-primary) !important; - border-color: #2b374b !important; -} - -html[data-theme="dark"] .group-icon-input:hover, -html[data-theme="dark"] #create-group-name-input:hover, -html[data-theme="dark"] #create-group-name-input:focus { - background: #111c2f !important; - border-color: rgba(96, 165, 250, 0.58) !important; -} - -html[data-theme="dark"] #create-group-name-input::placeholder, -html[data-theme="dark"] .custom-icon-input::placeholder { - color: var(--text-muted) !important; -} - -html[data-theme="dark"] .suggestion-tag { - background: rgba(148, 163, 184, 0.12) !important; - color: var(--text-secondary) !important; - border-color: rgba(148, 163, 184, 0.22) !important; -} - -html[data-theme="dark"] .suggestion-tag:hover { - background: rgba(96, 165, 250, 0.16) !important; - color: #dbeafe !important; - border-color: rgba(96, 165, 250, 0.34) !important; -} - -html[data-theme="dark"] .group-icon-picker { - background: #111827 !important; - border-color: #263244 !important; - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42) !important; -} - -html[data-theme="dark"] .icon-picker-header { - background: #0f172a !important; - color: var(--text-secondary) !important; - border-color: #263244 !important; -} - -html[data-theme="dark"] .icon-option:hover { - background: rgba(96, 165, 250, 0.16) !important; -} - /* Batch queue detail modal dark theme */ html[data-theme="dark"] #batch-queue-detail-modal .modal-content, html[data-theme="dark"] #batch-queue-detail-modal .modal-header, @@ -45329,26 +44477,9 @@ html[data-theme="dark"] .project-folders-load-more-count { text-align: center; } -.conversation-sidebar .conversation-groups-section, .conversation-sidebar .recent-conversations-section { margin: 0; - padding: 13px 0; - border-top: 1px solid #eceff3; -} - -.conversation-sidebar .conversation-groups-section .section-header { - margin-bottom: 5px; -} - -.conversation-sidebar .group-item { - min-height: 38px; - padding: 7px 8px; - border: 0; - border-radius: 8px; -} - -.conversation-sidebar .group-item.active { - background: #edf4ff; + padding: 8px 0 13px; } .recent-conversations-toggle { @@ -45458,16 +44589,6 @@ html[data-theme="dark"] .project-conversation-row:hover .project-conversation-it background: rgba(148, 163, 184, 0.19); } -html[data-theme="dark"] .conversation-sidebar .group-item.active { - background: rgba(59, 130, 246, 0.16); - color: #93c5fd; -} - -html[data-theme="dark"] .conversation-sidebar .conversation-groups-section, -html[data-theme="dark"] .conversation-sidebar .recent-conversations-section { - border-color: var(--border-color); -} - @media (max-width: 900px) { .conversation-sidebar { width: 260px; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ba8cfea8..156222c7 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -10,6 +10,7 @@ "close": "Close", "edit": "Edit", "delete": "Delete", + "remove": "Remove", "save": "Save", "loading": "Loading…", "search": "Search", @@ -554,10 +555,10 @@ "projectPreviewNoDescription": "No project description", "projectPreviewScope": "Test scope: {{scope}}", "projectPreviewEdit": "Edit project", - "conversationPreviewJustNow": "Now", + "conversationPreviewJustNow": "Just now", "conversationPreviewMinutes": "{{count}} min", - "conversationPreviewHours": "{{count}}h", - "conversationPreviewDays": "{{count}}d", + "conversationPreviewHours": "{{count}} hr", + "conversationPreviewDays": "{{count}} days", "conversationPreviewDateTime": "{{year}}-{{month}}-{{day}} {{hour}}:{{minute}}", "conversationPreviewNoProject": "No project", "conversationPreviewDefaultMode": "Default", @@ -579,8 +580,6 @@ "renameConversationSubtitle": "The name will update in project folders and recent conversations", "conversationTitleLabel": "Conversation name", "conversationTitlePlaceholder": "Enter a conversation name", - "conversationGroups": "Conversation groups", - "addGroup": "New group", "recentConversations": "Recent conversations", "toggleRecentConversations": "Expand/collapse recent conversations", "filterByProject": "Filter by project", @@ -622,7 +621,6 @@ "attachmentUploadFailed": "Failed", "attachmentUploadAlert": "Upload failed: {{name}}", "send": "Send", - "searchInGroup": "Search in group...", "loadingTools": "Loading tools...", "noMatchTools": "No matching tools", "penetrationTestDetail": "Task execution details", @@ -656,11 +654,7 @@ "deleteTurnTitle": "Delete this turn", "deleteTurnConfirm": "Delete this entire turn (user message and assistant reply)? This cannot be undone. The next reply will use only the remaining messages; saved context snapshots will be cleared.", "deleteTurnFailed": "Failed to delete turn", - "emptyGroupConversations": "This group has no conversations yet.", - "noMatchingConversationsInGroup": "No matching conversations found.", "noHistoryConversations": "No conversation history yet", - "renameGroupPrompt": "Please enter new name:", - "deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.", "deleteConversationConfirm": "Delete this conversation? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.", "renameFailed": "Rename failed", "downloadConversationFailed": "Failed to download conversation", @@ -675,7 +669,6 @@ "projectWelcomeTitleSuffix": "?", "noProjectWelcomeTitle": "What should be tested?", "welcomeSubtitle": "Enter your test requirements and the system will automatically run the corresponding security tests.", - "addNewGroup": "+ New group", "callNumber": "Call #{{n}}", "iterationRound": "Iteration {{n}}", "einoOrchestratorRound": "Orchestrator · round {{n}}", @@ -747,10 +740,6 @@ "historyGroupToday": "Today", "historyGroupLast7Days": "Past 7 days", "historyGroupEarlier": "Older", - "conversationPreviewJustNow": "Just now", - "conversationPreviewMinutes": "{{count}} min", - "conversationPreviewHours": "{{count}} hr", - "conversationPreviewDays": "{{count}} days", "agentModeSelectAria": "Choose conversation execution mode", "agentModePanelTitle": "Conversation mode", "agentModeEinoSingle": "Eino single (ADK)", @@ -829,7 +818,8 @@ "hitlTimeoutTenMinutes": "10 minutes", "hitlTimeoutUnlimited": "No limit", "hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.", - "hitlStatusOff": "Human-in-the-loop: Off" + "hitlStatusOff": "Human-in-the-loop: Off", + "rolePanelTitle": "Select role" }, "hitl": { "pageTitle": "HITL approvals", @@ -870,7 +860,7 @@ "viewEditedArgs": "View edited parameters", "reviewArgs": "Review parameters (JSON)", "commentOptional": "Comment (optional)", - "commentPlaceholder": "For example: read-only operations only", + "commentPlaceholder": "e.g. allow read-only command", "reject": "Reject", "allowOnce": "Allow once", "saveEditedAndAllow": "Save edits and allow", @@ -971,8 +961,6 @@ "reviewEditHelp": "Review & edit mode: provide a JSON object to override tool arguments. Example: {\"command\":\"ls -la\"}", "approvalHelp": "Approval mode: only approve/reject, argument editing is disabled.", "commentHelp": "Comment (optional): briefly note the approval reason.", - "commentPlaceholder": "e.g. allow read-only command", - "reject": "Reject", "approve": "Approve", "loadFailed": "Failed to load", "invalidJson": "Invalid JSON arguments", @@ -2019,7 +2007,6 @@ "conversationManagement": "Conversation Management", "conversationInteraction": "Conversation Interaction", "batchTasks": "Batch Tasks", - "conversationGroups": "Conversation Groups", "vulnerabilityManagement": "Vulnerability Management", "roleManagement": "Role Management", "skillsManagement": "Skills Management", @@ -2065,14 +2052,6 @@ "portScan": "Port scan", "updateBatchTask": "Update batch task", "deleteBatchTask": "Delete batch task", - "createGroup": "Create group", - "listGroups": "List groups", - "getGroup": "Get group", - "updateGroup": "Update group", - "deleteGroup": "Delete group", - "getGroupConversations": "Get conversations in group", - "addConversationToGroup": "Add conversation to group", - "removeConversationFromGroup": "Remove conversation from group", "listVulnerabilities": "List vulnerabilities", "createVulnerability": "Create vulnerability", "getVulnerabilityStats": "Get vulnerability statistics", @@ -2115,8 +2094,6 @@ "getAttackChain": "Get attack chain", "regenerateAttackChain": "Regenerate attack chain", "pinConversation": "Pin conversation", - "pinGroup": "Pin group", - "pinGroupConversation": "Pin conversation in group", "getCategories": "Get categories", "listKnowledgeItems": "List knowledge items", "createKnowledgeItem": "Create knowledge item", @@ -2143,7 +2120,6 @@ "updateBatchQueueMetadata": "Update queue metadata", "updateBatchQueueSchedule": "Update queue schedule", "setBatchQueueScheduleEnabled": "Toggle cron auto-schedule", - "getAllGroupMappings": "Get all group mappings", "fofaSearch": "FOFA search", "fofaParse": "Parse natural language to FOFA syntax", "testOpenAI": "Test OpenAI API connection", @@ -2207,8 +2183,6 @@ "conversationNotFound": "Conversation not found", "conversationOrResultNotFound": "Conversation or result not found", "badRequestTaskEmpty": "Bad request (e.g. task is empty)", - "badRequestGroupNameExists": "Bad request or group name already exists", - "groupNotFound": "Group not found", "badRequestConfig": "Bad request (e.g. invalid config or missing required fields)", "badRequestQueryEmpty": "Bad request (e.g. query is empty)", "methodNotAllowed": "Method not allowed (POST only)", @@ -2227,7 +2201,6 @@ "pauseSuccess": "Paused successfully", "addSuccess": "Added successfully", "taskNotFound": "Task not found", - "conversationOrGroupNotFound": "Conversation or group not found", "cancelSubmitted": "Cancel request submitted", "noRunningTask": "No running task found", "messageSent": "Message sent, AI reply returned", @@ -2268,23 +2241,6 @@ "assetImportTransactionFailed": "Import transaction failed" } }, - "chatGroup": { - "search": "Search", - "edit": "Edit", - "delete": "Delete", - "clearSearch": "Clear search", - "searchInGroupPlaceholder": "Search in group...", - "attackChain": "Attack chain", - "viewAttackChain": "View attack chain", - "selectRole": "Select role", - "close": "Close", - "selectFile": "Select file", - "uploadFile": "Upload file (multi-select or drag & drop)", - "send": "Send", - "rolePanelTitle": "Select role", - "copyMessage": "Copy message", - "remove": "Remove" - }, "mcpMonitor": { "deselectAll": "Deselect all", "statusPending": "Pending", @@ -2579,16 +2535,16 @@ "errorGeneric": "Something went wrong. Please try again." }, "vulnerabilityPage": { - "alertTitle": "Robot vulnerability alerts", - "alertDescription": "Push newly discovered vulnerabilities to robots bound to this account, filtered by severity.", - "alertHint": "Send newly discovered vulnerabilities at or above this severity to your bound robot accounts.", - "alertConfiguredNotBound": "{{platforms}} is enabled, but this Web account is not bound to a recipient identity.", - "alertNoBinding": "No proactive robot is both enabled and bound. Your settings are still saved.", - "alertBindAction": "Bind recipient account", - "alertMinimum": "Minimum severity", - "alertEnabled": "Enable alerts", - "alertSaved": "Vulnerability alert settings saved", - "alertSaveFailed": "Failed to save vulnerability alert settings", + "alertTitle": "Robot vulnerability alerts", + "alertDescription": "Push newly discovered vulnerabilities to robots bound to this account, filtered by severity.", + "alertHint": "Send newly discovered vulnerabilities at or above this severity to your bound robot accounts.", + "alertConfiguredNotBound": "{{platforms}} is enabled, but this Web account is not bound to a recipient identity.", + "alertNoBinding": "No proactive robot is both enabled and bound. Your settings are still saved.", + "alertBindAction": "Bind recipient account", + "alertMinimum": "Minimum severity", + "alertEnabled": "Enable alerts", + "alertSaved": "Vulnerability alert settings saved", + "alertSaveFailed": "Failed to save vulnerability alert settings", "statTotal": "Total", "statClickAll": "View all (clear severity filter)", "statClickFilter": "Click to filter by this severity; click again to clear", @@ -3376,50 +3332,18 @@ "searchPlaceholder": "Search history", "conversationName": "Conversation name", "project": "Project", - "group": "Group", "noProject": "No project", "unknownProject": "Unknown project", - "noGroup": "Ungrouped", - "unknownGroup": "Unknown group", "filterByProject": "Filter by project", - "filterByGroup": "Filter by group", - "filterAllGroups": "All groups", - "filterUngrouped": "Ungrouped", "lastTime": "Last activity", "action": "Action", "selectAll": "Select all", - "setGroup": "Set group", - "noGroupOption": "Ungrouped", "deleteSelected": "Delete selected", "confirmDeleteNone": "Please select at least one conversation to delete", "confirmDeleteN": "Delete {{count}} selected conversation(s)? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.", - "confirmGroupChangeNone": "Please select at least one conversation", - "confirmMoveN": "Move {{count}} selected conversation(s) to \"{{group}}\"?", - "confirmRemoveNoGroup": "None of the selected conversations belong to a group", - "confirmRemoveN": "Remove {{count}} selected conversation(s) from their group(s)?", - "removeFailed": "Remove failed", - "moveFailed": "Move failed", "deleteFailed": "Delete failed", "unnamedConversation": "Unnamed conversation" }, - "createGroupModal": { - "title": "Create group", - "description": "Group conversations for easier management.", - "selectIcon": "Click to choose icon", - "groupNamePlaceholder": "Enter group name", - "pickIcon": "Pick icon", - "customIcon": "Custom", - "confirmIcon": "OK", - "create": "Create", - "cancel": "Cancel", - "suggestionPenetrationTest": "Penetration Testing", - "suggestionCtf": "CTF", - "suggestionRedTeam": "Red Team", - "suggestionVulnerabilityMining": "Vulnerability Mining", - "nameExists": "Group name already exists, please use another name.", - "createFailed": "Create failed", - "unknownError": "Unknown error" - }, "contextMenu": { "viewAttackChain": "View attack chain", "viewVulnerabilities": "View vulnerabilities", @@ -3430,11 +3354,7 @@ "pinConversation": "Pin conversation", "unpinConversation": "Unpin", "batchManage": "Batch manage", - "moveToGroup": "Move to group", - "deleteConversation": "Delete conversation", - "pinGroup": "Pin group", - "unpinGroup": "Unpin", - "deleteGroup": "Delete group" + "deleteConversation": "Delete conversation" }, "batchImportModal": { "title": "New task", @@ -4565,10 +4485,22 @@ } }, "systemRoles": { - "admin": { "name": "Administrator", "description": "Full platform administration access" }, - "operator": { "name": "Operator", "description": "Run daily security workflows without account or core configuration management" }, - "auditor": { "name": "Auditor", "description": "Read-only access to audits, monitoring, and assets" }, - "viewer": { "name": "Read-only User", "description": "Read-only access to explicitly granted resources" } + "admin": { + "name": "Administrator", + "description": "Full platform administration access" + }, + "operator": { + "name": "Operator", + "description": "Run daily security workflows without account or core configuration management" + }, + "auditor": { + "name": "Auditor", + "description": "Read-only access to audits, monitoring, and assets" + }, + "viewer": { + "name": "Read-only User", + "description": "Read-only access to explicitly granted resources" + } }, "empty": { "noMatchingUsers": "No matching members", @@ -4624,7 +4556,6 @@ "dashboard": "Dashboard", "files": "Files", "fofa": "FOFA", - "group": "Conversation Groups", "hitl": "Human-in-the-loop", "knowledge": "Knowledge Base", "mcp": "MCP", @@ -4643,14 +4574,20 @@ "workflow": "Workflows" }, "permissionDescriptions": { - "auth": { "self": "Manage own session and password" }, - "dashboard": { "read": "View dashboard summaries" }, + "auth": { + "self": "Manage own session and password" + }, + "dashboard": { + "read": "View dashboard summaries" + }, "chat": { "read": "View conversations", "write": "Create and update conversations", "delete": "Delete conversations and turns" }, - "agent": { "execute": "Run AI agents and workflows" }, + "agent": { + "execute": "Run AI agents and workflows" + }, "hitl": { "read": "View human-in-the-loop queues and logs", "write": "Approve, dismiss, and configure human-in-the-loop requests" @@ -4713,7 +4650,9 @@ "read": "View system configuration", "write": "Update and apply system configuration" }, - "terminal": { "execute": "Execute terminal commands" }, + "terminal": { + "execute": "Execute terminal commands" + }, "audit": { "read": "View and export audit logs", "delete": "Delete audit logs" @@ -4739,12 +4678,11 @@ "read": "View attack chains", "write": "Regenerate attack chains" }, - "fofa": { "execute": "Run FOFA searches and parse queries" }, - "openapi": { "read": "Read OpenAPI aggregation results" }, - "group": { - "read": "View conversation groups", - "write": "Create and update conversation groups", - "delete": "Delete conversation groups" + "fofa": { + "execute": "Run FOFA searches and parse queries" + }, + "openapi": { + "read": "Read OpenAPI aggregation results" }, "monitor": { "read": "View the execution monitor", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6c9db80e..aff971f1 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -10,6 +10,7 @@ "close": "关闭", "edit": "编辑", "delete": "删除", + "remove": "移除", "save": "保存", "loading": "加载中…", "search": "搜索", @@ -567,8 +568,6 @@ "renameConversationSubtitle": "修改后会同步更新项目文件夹和最近对话中的名称", "conversationTitleLabel": "对话名称", "conversationTitlePlaceholder": "请输入对话名称", - "conversationGroups": "对话分组", - "addGroup": "新建分组", "recentConversations": "最近对话", "toggleRecentConversations": "展开/折叠最近对话", "filterByProject": "按项目筛选", @@ -610,7 +609,6 @@ "attachmentUploadFailed": "失败", "attachmentUploadAlert": "上传失败:{{name}}", "send": "发送", - "searchInGroup": "搜索分组中的对话...", "loadingTools": "正在加载工具...", "noMatchTools": "没有匹配的工具", "penetrationTestDetail": "任务执行详情", @@ -644,11 +642,7 @@ "deleteTurnTitle": "删除本轮对话", "deleteTurnConfirm": "确定删除本轮对话?将同时删除该轮用户消息与助手回复,且无法恢复;下次模型回复将仅基于剩余消息(已保存的上下文快照会清空并按剩余内容重建)。", "deleteTurnFailed": "删除本轮失败", - "emptyGroupConversations": "该分组暂无对话", - "noMatchingConversationsInGroup": "未找到匹配的对话", "noHistoryConversations": "暂无历史对话", - "renameGroupPrompt": "请输入新名称:", - "deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。", "deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。", "renameFailed": "重命名失败", "downloadConversationFailed": "下载对话失败", @@ -663,7 +657,6 @@ "projectWelcomeTitleSuffix": " 项目中测试什么?", "noProjectWelcomeTitle": "要测试什么?", "welcomeSubtitle": "请输入您的测试需求,系统将自动执行相应的安全测试。", - "addNewGroup": "+ 新增分组", "callNumber": "调用 #{{n}}", "iterationRound": "第 {{n}} 轮迭代", "einoOrchestratorRound": "主代理 · 第 {{n}} 轮", @@ -735,10 +728,6 @@ "historyGroupToday": "今天", "historyGroupLast7Days": "过去七天", "historyGroupEarlier": "更早", - "conversationPreviewJustNow": "刚刚", - "conversationPreviewMinutes": "{{count}} 分钟", - "conversationPreviewHours": "{{count}} 小时", - "conversationPreviewDays": "{{count}} 天", "agentModeSelectAria": "选择对话执行模式", "agentModePanelTitle": "对话模式", "agentModeEinoSingle": "Eino 单代理(ADK)", @@ -817,7 +806,8 @@ "hitlTimeoutTenMinutes": "10 分钟", "hitlTimeoutUnlimited": "不限制", "hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。", - "hitlStatusOff": "人机协同:关闭" + "hitlStatusOff": "人机协同:关闭", + "rolePanelTitle": "选择角色" }, "hitl": { "pageTitle": "人机协同审批", @@ -858,7 +848,7 @@ "viewEditedArgs": "查看修改后的参数", "reviewArgs": "审查参数(JSON)", "commentOptional": "备注(可选)", - "commentPlaceholder": "例如:仅允许只读操作", + "commentPlaceholder": "例如:允许只读命令", "reject": "拒绝", "allowOnce": "允许一次", "saveEditedAndAllow": "保存修改并允许", @@ -959,8 +949,6 @@ "reviewEditHelp": "审查编辑模式:可填写 JSON 对象覆盖参数。示例:{\"command\":\"ls -la\"}", "approvalHelp": "审批模式:仅通过/拒绝,不支持改参。", "commentHelp": "备注(可选):建议写审批依据。", - "commentPlaceholder": "例如:允许只读命令", - "reject": "拒绝", "approve": "通过", "loadFailed": "加载失败", "invalidJson": "JSON 参数格式错误", @@ -2007,7 +1995,6 @@ "conversationManagement": "对话管理", "conversationInteraction": "对话交互", "batchTasks": "批量任务", - "conversationGroups": "对话分组", "vulnerabilityManagement": "漏洞管理", "roleManagement": "角色管理", "skillsManagement": "Skills管理", @@ -2053,14 +2040,6 @@ "portScan": "端口扫描", "updateBatchTask": "更新批量任务", "deleteBatchTask": "删除批量任务", - "createGroup": "创建分组", - "listGroups": "列出分组", - "getGroup": "获取分组", - "updateGroup": "更新分组", - "deleteGroup": "删除分组", - "getGroupConversations": "获取分组中的对话", - "addConversationToGroup": "添加对话到分组", - "removeConversationFromGroup": "从分组移除对话", "listVulnerabilities": "列出漏洞", "createVulnerability": "创建漏洞", "getVulnerabilityStats": "获取漏洞统计", @@ -2103,8 +2082,6 @@ "getAttackChain": "获取攻击链", "regenerateAttackChain": "重新生成攻击链", "pinConversation": "设置对话置顶", - "pinGroup": "设置分组置顶", - "pinGroupConversation": "设置分组中对话的置顶", "getCategories": "获取分类", "listKnowledgeItems": "列出知识项", "createKnowledgeItem": "创建知识项", @@ -2131,7 +2108,6 @@ "updateBatchQueueMetadata": "修改队列元数据", "updateBatchQueueSchedule": "修改队列调度配置", "setBatchQueueScheduleEnabled": "开关Cron自动调度", - "getAllGroupMappings": "获取所有分组映射", "fofaSearch": "FOFA搜索", "fofaParse": "自然语言解析为FOFA语法", "testOpenAI": "测试OpenAI API连接", @@ -2195,8 +2171,6 @@ "conversationNotFound": "对话不存在", "conversationOrResultNotFound": "对话不存在或结果不存在", "badRequestTaskEmpty": "请求参数错误(如task为空)", - "badRequestGroupNameExists": "请求参数错误或分组名称已存在", - "groupNotFound": "分组不存在", "badRequestConfig": "请求参数错误(如配置格式不正确、缺少必需字段等)", "badRequestQueryEmpty": "请求参数错误(如query为空)", "methodNotAllowed": "方法不允许(仅支持POST请求)", @@ -2215,7 +2189,6 @@ "pauseSuccess": "暂停成功", "addSuccess": "添加成功", "taskNotFound": "任务不存在", - "conversationOrGroupNotFound": "对话或分组不存在", "cancelSubmitted": "取消请求已提交", "noRunningTask": "未找到正在执行的任务", "messageSent": "消息发送成功,返回AI回复", @@ -2256,23 +2229,6 @@ "assetImportTransactionFailed": "导入事务失败" } }, - "chatGroup": { - "search": "搜索", - "edit": "编辑", - "delete": "删除", - "clearSearch": "清除搜索", - "searchInGroupPlaceholder": "搜索分组中的对话...", - "attackChain": "攻击链", - "viewAttackChain": "查看攻击链", - "selectRole": "选择角色", - "close": "关闭", - "selectFile": "选择文件", - "uploadFile": "上传文件(可多选或拖拽到此处)", - "send": "发送", - "rolePanelTitle": "选择角色", - "copyMessage": "复制消息内容", - "remove": "移除" - }, "mcpMonitor": { "deselectAll": "取消全选", "statusPending": "等待中", @@ -2567,16 +2523,16 @@ "errorGeneric": "操作失败,请稍后重试。" }, "vulnerabilityPage": { - "alertTitle": "机器人漏洞提醒", - "alertDescription": "按严重级别将新发现的漏洞推送至当前账号绑定的机器人。", - "alertHint": "发现符合级别的新漏洞后,通过已绑定机器人账号推送。", - "alertConfiguredNotBound": "{{platforms}}已启用,但当前 Web 账号尚未绑定对应的接收身份。", - "alertNoBinding": "尚未启用并绑定支持主动推送的机器人。设置仍会保存。", - "alertBindAction": "绑定接收账号", - "alertMinimum": "最低级别", - "alertEnabled": "启用提醒", - "alertSaved": "漏洞提醒设置已保存", - "alertSaveFailed": "保存漏洞提醒设置失败", + "alertTitle": "机器人漏洞提醒", + "alertDescription": "按严重级别将新发现的漏洞推送至当前账号绑定的机器人。", + "alertHint": "发现符合级别的新漏洞后,通过已绑定机器人账号推送。", + "alertConfiguredNotBound": "{{platforms}}已启用,但当前 Web 账号尚未绑定对应的接收身份。", + "alertNoBinding": "尚未启用并绑定支持主动推送的机器人。设置仍会保存。", + "alertBindAction": "绑定接收账号", + "alertMinimum": "最低级别", + "alertEnabled": "启用提醒", + "alertSaved": "漏洞提醒设置已保存", + "alertSaveFailed": "保存漏洞提醒设置失败", "statTotal": "总漏洞数", "statClickAll": "查看全部(清除严重度筛选)", "statClickFilter": "点击按此严重度筛选;再次点击清除", @@ -3364,50 +3320,18 @@ "searchPlaceholder": "搜索历史记录", "conversationName": "对话名称", "project": "项目", - "group": "对话分组", "noProject": "无项目", "unknownProject": "未知项目", - "noGroup": "无分组", - "unknownGroup": "未知分组", "filterByProject": "按项目筛选", - "filterByGroup": "按分组筛选", - "filterAllGroups": "全部分组", - "filterUngrouped": "无分组", "lastTime": "最近一次对话时间", "action": "操作", "selectAll": "全选", - "setGroup": "设置分组", - "noGroupOption": "无分组", "deleteSelected": "删除所选", "confirmDeleteNone": "请先选择要删除的对话", "confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。", - "confirmGroupChangeNone": "请先选择要操作的对话", - "confirmMoveN": "确定将选中的 {{count}} 条对话移动到「{{group}}」吗?", - "confirmRemoveNoGroup": "所选对话均未归属分组", - "confirmRemoveN": "确定将选中的 {{count}} 条对话移出分组吗?", - "removeFailed": "移出失败", - "moveFailed": "移动失败", "deleteFailed": "删除失败", "unnamedConversation": "未命名对话" }, - "createGroupModal": { - "title": "创建分组", - "description": "分组功能可将对话集中归类管理,让对话更加井然有序。", - "selectIcon": "点击选择图标", - "groupNamePlaceholder": "请输入分组名称", - "pickIcon": "选择图标", - "customIcon": "自定义", - "confirmIcon": "确定", - "create": "创建", - "cancel": "取消", - "suggestionPenetrationTest": "渗透测试", - "suggestionCtf": "CTF", - "suggestionRedTeam": "红队", - "suggestionVulnerabilityMining": "漏洞挖掘", - "nameExists": "分组名称已存在,请使用其他名称", - "createFailed": "创建失败", - "unknownError": "未知错误" - }, "contextMenu": { "viewAttackChain": "查看攻击链", "viewVulnerabilities": "查看漏洞", @@ -3418,11 +3342,7 @@ "pinConversation": "置顶此对话", "unpinConversation": "取消置顶", "batchManage": "批量管理", - "moveToGroup": "移动到分组", - "deleteConversation": "删除此对话", - "pinGroup": "置顶此分组", - "unpinGroup": "取消置顶", - "deleteGroup": "删除此分组" + "deleteConversation": "删除此对话" }, "batchImportModal": { "title": "新建任务", @@ -4553,10 +4473,22 @@ } }, "systemRoles": { - "admin": { "name": "管理员", "description": "全局管理权限" }, - "operator": { "name": "操作员", "description": "可执行日常安全工作流,不能管理账号与核心配置" }, - "auditor": { "name": "审计员", "description": "只读查看审计、监控与资产" }, - "viewer": { "name": "只读用户", "description": "只读查看被授权资源" } + "admin": { + "name": "管理员", + "description": "全局管理权限" + }, + "operator": { + "name": "操作员", + "description": "可执行日常安全工作流,不能管理账号与核心配置" + }, + "auditor": { + "name": "审计员", + "description": "只读查看审计、监控与资产" + }, + "viewer": { + "name": "只读用户", + "description": "只读查看被授权资源" + } }, "empty": { "noMatchingUsers": "没有匹配的成员", @@ -4612,7 +4544,6 @@ "dashboard": "仪表盘", "files": "文件", "fofa": "FOFA", - "group": "对话分组", "hitl": "人机协同", "knowledge": "知识库", "mcp": "MCP", @@ -4631,14 +4562,20 @@ "workflow": "工作流" }, "permissionDescriptions": { - "auth": { "self": "管理自己的会话和密码" }, - "dashboard": { "read": "查看仪表盘汇总" }, + "auth": { + "self": "管理自己的会话和密码" + }, + "dashboard": { + "read": "查看仪表盘汇总" + }, "chat": { "read": "查看对话", "write": "创建和更新对话", "delete": "删除对话和消息轮次" }, - "agent": { "execute": "运行 AI 智能体和工作流" }, + "agent": { + "execute": "运行 AI 智能体和工作流" + }, "hitl": { "read": "查看人机协同队列和日志", "write": "审批、驳回和配置人机协同" @@ -4701,7 +4638,9 @@ "read": "查看系统配置", "write": "更新并应用系统配置" }, - "terminal": { "execute": "执行终端命令" }, + "terminal": { + "execute": "执行终端命令" + }, "audit": { "read": "查看和导出审计日志", "delete": "删除审计日志" @@ -4727,12 +4666,11 @@ "read": "查看攻击链", "write": "重新生成攻击链" }, - "fofa": { "execute": "执行 FOFA 搜索和查询解析" }, - "openapi": { "read": "读取 OpenAPI 聚合结果" }, - "group": { - "read": "查看对话分组", - "write": "创建和更新对话分组", - "delete": "删除对话分组" + "fofa": { + "execute": "执行 FOFA 搜索和查询解析" + }, + "openapi": { + "read": "读取 OpenAPI 聚合结果" }, "monitor": { "read": "查看执行监控", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index e9fa93dc..b15da08d 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2121,7 +2121,7 @@ function saveChatDraftDebounced(content) { if (draftSaveTimer) { clearTimeout(draftSaveTimer); } - + // 设置新的定时器 draftSaveTimer = setTimeout(() => { saveChatDraft(content); @@ -2164,7 +2164,7 @@ function restoreChatDraft() { if (chatInput.value && chatInput.value.trim().length > 0) { return; } - + const draft = localStorage.getItem(DRAFT_STORAGE_KEY); const trimmedDraft = draft ? draft.trim() : ''; @@ -2195,17 +2195,17 @@ function clearChatDraft() { // 调整textarea高度以适应内容 function adjustTextareaHeight(textarea) { if (!textarea) return; - + // 先重置高度为auto,然后立即设置为固定值,确保能准确获取scrollHeight textarea.style.height = 'auto'; // 强制浏览器重新计算布局 void textarea.offsetHeight; - + // 计算新高度(最小40px,最大不超过300px) const scrollHeight = textarea.scrollHeight; const newHeight = Math.min(Math.max(scrollHeight, 40), 300); textarea.style.height = newHeight + 'px'; - + // 如果内容为空或只有很少内容,立即重置到最小高度 if (!textarea.value || textarea.value.trim().length === 0) { textarea.style.height = '40px'; @@ -2289,13 +2289,13 @@ async function sendMessage() { if (currentConversationId) { invalidateConversationLiteCache(currentConversationId); } - + // 清除防抖定时器,防止在清空输入框后重新保存草稿 if (draftSaveTimer) { clearTimeout(draftSaveTimer); draftSaveTimer = null; } - + // 立即清除草稿,防止页面刷新时恢复 clearChatDraft(); // 使用同步方式确保草稿被清除 @@ -2304,7 +2304,7 @@ async function sendMessage() { } catch (e) { // 忽略错误 } - + // 立即清空输入框并清除草稿(在发送请求之前) input.value = ''; // 强制重置输入框高度为初始高度(40px) @@ -2385,7 +2385,7 @@ async function sendMessage() { loadActiveTasks(); let assistantMessageId = null; let mcpExecutionIds = []; - + try { const modeSel = document.getElementById('agent-mode-select'); let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE; @@ -2403,7 +2403,7 @@ async function sendMessage() { body: JSON.stringify(body), signal: requestAbortController.signal, }); - + if (!response.ok) { throw new Error('请求失败: ' + response.status); } @@ -2515,7 +2515,7 @@ async function sendMessage() { } catch (e) { // 忽略错误 } - + } catch (error) { clearLiveChatStreamIfOwned(liveStreamState); if (liveStreamState.detached || !isStreamStillVisibleForRequest()) { @@ -2566,7 +2566,7 @@ function renderChatFileChips() { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'chat-file-chip-remove'; - remove.title = typeof window.t === 'function' ? window.t('chatGroup.remove') : '移除'; + remove.title = typeof window.t === 'function' ? window.t('common.remove') : '移除'; remove.innerHTML = '×'; remove.setAttribute('aria-label', '移除 ' + a.fileName); remove.addEventListener('click', () => removeChatAttachment(i)); @@ -2790,7 +2790,7 @@ function ensureMentionToolsLoaded() { mentionTools = []; delete window._mentionToolsRoleChanged; } - + if (mentionToolsLoaded) { return Promise.resolve(mentionTools); } @@ -2832,7 +2832,7 @@ async function fetchMentionTools() { externalMcpNames = Object.keys(mcpData.servers || {}).filter(name => { const server = mcpData.servers[name]; // 只包含已连接且已启用的MCP - return server.status === 'connected' && + return server.status === 'connected' && (server.config.external_mcp_enable || (server.config.enabled && !server.config.disabled)); }); } @@ -3016,7 +3016,7 @@ function updateMentionCandidates() { if (normalizedQuery) { // 检查是否精确匹配外部MCP名称 - const exactMatchedMcp = externalMcpNames.find(mcpName => + const exactMatchedMcp = externalMcpNames.find(mcpName => mcpName.toLowerCase() === normalizedQuery ); @@ -3027,21 +3027,21 @@ function updateMentionCandidates() { }); } else { // 检查是否部分匹配MCP名称 - const partialMatchedMcps = externalMcpNames.filter(mcpName => + const partialMatchedMcps = externalMcpNames.filter(mcpName => mcpName.toLowerCase().includes(normalizedQuery) ); - + // 正常匹配:按工具名称和描述过滤,同时也匹配MCP名称 filtered = mentionTools.filter(tool => { const nameMatch = tool.name.toLowerCase().includes(normalizedQuery); const descMatch = tool.description && tool.description.toLowerCase().includes(normalizedQuery); const mcpMatch = tool.externalMcp && tool.externalMcp.toLowerCase().includes(normalizedQuery); - + // 如果部分匹配到MCP名称,也包含该MCP下的所有工具 - const mcpPartialMatch = partialMatchedMcps.some(mcpName => + const mcpPartialMatch = partialMatchedMcps.some(mcpName => tool.externalMcp && tool.externalMcp.toLowerCase() === mcpName.toLowerCase() ); - + return nameMatch || descMatch || mcpMatch || mcpPartialMatch; }); } @@ -3064,7 +3064,7 @@ function updateMentionCandidates() { if (aMcpExact !== bMcpExact) { return aMcpExact ? -1 : 1; } - + const aStarts = a.name.toLowerCase().startsWith(normalizedQuery); const bStarts = b.name.toLowerCase().startsWith(normalizedQuery); if (aStarts !== bStarts) { @@ -3270,7 +3270,7 @@ function applyMentionSelection() { const newCaret = before.length + insertText.length; textarea.focus(); textarea.setSelectionRange(newCaret, newCaret); - + // 调整输入框高度并保存草稿 adjustTextareaHeight(textarea); saveChatDraftDebounced(textarea.value); @@ -3344,11 +3344,11 @@ function wrapTablesInBubble(bubble) { if (table.parentElement && table.parentElement.classList.contains('table-wrapper')) { return; } - + // 创建表格包装容器 const wrapper = document.createElement('div'); wrapper.className = 'table-wrapper'; - + // 将表格移动到包装容器中 table.parentNode.insertBefore(wrapper, table); wrapper.appendChild(table); @@ -3528,17 +3528,17 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr const id = 'msg-' + Date.now() + '-' + messageCounter + '-' + Math.random().toString(36).substr(2, 9); messageDiv.id = id; messageDiv.className = 'message ' + role; - + messagesDiv.querySelector('.chat-welcome-empty-state')?.remove(); // 创建消息内容容器 const contentWrapper = document.createElement('div'); contentWrapper.className = 'message-content'; - + // 创建消息气泡 const bubble = document.createElement('div'); bubble.className = 'message-bubble'; - + // 解析 Markdown 或 HTML 格式 let formattedContent; const escapeHtml = (text) => { @@ -3547,7 +3547,7 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr div.textContent = text; return div.innerHTML; }; - + // 助手消息中的已知中文错误前缀做国际化替换(后端固定返回中文) let displayContent = content; if (role === 'assistant' && typeof displayContent === 'string' && typeof window.t === 'function') { @@ -3571,7 +3571,7 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr const rawForEscape = role === 'assistant' ? displayContent : content; formattedContent = escapeHtml(rawForEscape).replace(/\n/g, ''); } - + bubble.innerHTML = formattedContent; // 刷新恢复运行中会话时,后端正文可能仍是持久化占位值“处理中...”。 @@ -3580,21 +3580,21 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr messageDiv.classList.add('assistant-placeholder-content'); bubble.hidden = true; } - + if (typeof window.csMarkdownSanitize !== 'undefined') { window.csMarkdownSanitize.stripSuspiciousImages(bubble); } - + // 为每个表格添加独立的滚动容器 wrapTablesInBubble(bubble); - + contentWrapper.appendChild(bubble); - + // 保存原始内容到消息元素,用于复制功能 if (role === 'assistant' || role === 'user') { messageDiv.dataset.originalContent = content; } - + // 添加时间戳 const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; @@ -3633,7 +3633,7 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr if (role === 'assistant' || role === 'user') { appendMessageCopyButton(messageDiv); } - + // 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建) if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) { if (options && options.deferMcpButtons) { @@ -3645,7 +3645,7 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr setMcpCallExecutionIds(messageDiv, mcpExecutionIds); } } - + // 标记「系统就绪」占位消息,便于切换语言后刷新文案 if (options && options.systemReadyMessage) { messageDiv.setAttribute('data-system-ready-message', '1'); @@ -3714,13 +3714,13 @@ function copyMessageToClipboard(messageDiv, button) { if (bubble) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = bubble.innerHTML; - + // 移除复制按钮本身(避免复制按钮文本) const copyBtnInTemp = tempDiv.querySelector('.message-copy-btn'); if (copyBtnInTemp) { copyBtnInTemp.remove(); } - + // 提取纯文本内容 let textContent = tempDiv.textContent || tempDiv.innerText || ''; textContent = textContent.replace(/\n{3,}/g, '\n\n').trim(); @@ -3729,7 +3729,7 @@ function copyMessageToClipboard(messageDiv, button) { } return; } - + // 使用原始Markdown内容 doCopy(originalContent); } catch (error) { @@ -4056,12 +4056,12 @@ function renderProcessDetails(messageId, processDetails, options) { pruneEmptyMcpCallSection(messageElement); return; } - + // 查找或创建 MCP 区域(工具栏 + 工具列表 + 迭代时间线 分区) const chrome = ensureMcpCallSectionChrome(messageElement, messageId); if (!chrome) return; const { mcpSection, toolbar: buttonsContainer } = chrome; - + // 添加过程详情按钮(如果还没有) let processDetailBtn = buttonsContainer.querySelector('.process-detail-btn'); if (!processDetailBtn) { @@ -4072,12 +4072,12 @@ function renderProcessDetails(messageId, processDetails, options) { buttonsContainer.appendChild(processDetailBtn); } syncMcpToolsToggleButton(messageElement); - + // 创建过程详情容器(放在工具列表之后) const detailsId = 'process-details-' + messageId; let detailsContainer = document.getElementById(detailsId); const toolListEl = chrome.toolList; - + if (!detailsContainer) { detailsContainer = document.createElement('div'); detailsContainer.id = detailsId; @@ -4090,26 +4090,26 @@ function renderProcessDetails(messageId, processDetails, options) { mcpSection.appendChild(detailsContainer); } } - + // 创建时间线(即使没有processDetails也要创建,以便展开详情按钮能正常工作) const timelineId = detailsId + '-timeline'; let timeline = document.getElementById(timelineId); - + if (!timeline) { const contentDiv = document.createElement('div'); contentDiv.className = 'process-details-content'; - + timeline = document.createElement('div'); timeline.id = timelineId; timeline.className = 'progress-timeline'; - + contentDiv.appendChild(timeline); detailsContainer.appendChild(contentDiv); } if (typeof window.ensureProcessDetailsReturnLatestControl === 'function') { window.ensureProcessDetailsReturnLatestControl(timeline); } - + // processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示 const isLazyNotLoaded = isLazyRequest; if (isLazyNotLoaded && !reasoningFromMessage) { @@ -4166,7 +4166,7 @@ function renderProcessDetails(messageId, processDetails, options) { } return; } - + const prependAnchor = prependMode ? timeline.firstChild : null; const prependScrollBox = prependMode ? document.getElementById('chat-messages') : null; const prependScrollHeight = prependScrollBox ? prependScrollBox.scrollHeight : 0; @@ -4176,8 +4176,8 @@ function renderProcessDetails(messageId, processDetails, options) { if (!appendMode && !prependMode) { timeline.innerHTML = ''; } - - + + function processDetailAgentPrefix(d) { if (!d || d.einoAgent == null) return ''; const s = String(d.einoAgent).trim(); @@ -4266,7 +4266,7 @@ function renderProcessDetails(messageId, processDetails, options) { const title = detail.message || ''; const data = detail.data || {}; const agPx = processDetailAgentPrefix(data); - + let itemTitle = title; if (eventType === 'workflow_start') { const name = data.workflowName || data.workflowId || ''; @@ -4397,7 +4397,7 @@ function renderProcessDetails(messageId, processDetails, options) { ? window.t('chat.userInterruptContinueTitle') : '⏸️ 用户中断并继续'; } - + if (eventType === 'hitl_interrupt' || eventType === 'hitl_audit_agent_started' || eventType === 'hitl_audit_agent' || eventType === 'hitl_resumed' || eventType === 'hitl_rejected') { const hitlTarget = typeof findToolCallItemForHitl === 'function' @@ -4508,10 +4508,10 @@ function finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoa timeline.appendChild(lazyHint); bindProcessDetailsLazyHint(lazyHint, messageElement.id); } - + const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt'); const hasPendingWorkflowHitl = processDetails.some(d => d && d.eventType === 'workflow_hitl_waiting'); - const hasErrorOrCancelled = processDetails.some(d => + const hasErrorOrCancelled = processDetails.some(d => d.eventType === 'error' || d.eventType === 'cancelled' ); const userExpanded = isProcessDetailsUserExpanded(messageElement.id); @@ -5870,17 +5870,6 @@ async function startNewConversation(options = {}) { if (typeof window.clearChatHitlApprovalDock === 'function') { window.clearChatHitlApprovalDock(); } - // 如果当前在分组详情页面,先退出分组详情 - if (currentGroupId) { - const groupDetailPage = document.getElementById('group-detail-page'); - const chatContainer = document.querySelector('.chat-container'); - if (groupDetailPage) groupDetailPage.style.display = 'none'; - if (chatContainer) chatContainer.style.display = 'flex'; - currentGroupId = null; - // 刷新对话列表 - loadConversationsWithGroups(); - } - currentConversationId = null; window._loadedConversationProjectId = ''; try { @@ -5888,7 +5877,6 @@ async function startNewConversation(options = {}) { } catch (e) { /* ignore */ } window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId: '' } })); updateChatPrimaryActionState(); - currentConversationGroupId = null; // 新对话不属于任何分组 // 顶部“新任务”继承当前文件夹;文件夹内的“+”仍可显式指定(包括无项目)。 if (typeof setActiveProjectId === 'function') setActiveProjectId(requestedProjectId); if (typeof refreshChatProjectSelector === 'function') { @@ -5899,10 +5887,8 @@ async function startNewConversation(options = {}) { renderChatWelcomeEmptyState(); addAttackChainButton(null); updateActiveConversation(); - // 刷新分组列表,清除分组高亮 - await loadGroups(); // 刷新对话列表,确保显示最新的历史对话 - loadConversationsWithGroups(); + loadConversations(); // 清除防抖定时器,防止恢复草稿时触发保存 if (draftSaveTimer) { clearTimeout(draftSaveTimer); @@ -5919,11 +5905,6 @@ async function startNewConversation(options = {}) { refreshHitlConfigByCurrentConversation(); } -// 与 loadConversationsWithGroups 合并实现,避免并发加载时重复追加列表项 -async function loadConversations(searchQuery = '') { - return loadConversationsWithGroups(searchQuery); -} - function createConversationListItem(conversation) { const item = document.createElement('div'); item.className = 'conversation-item'; @@ -5965,7 +5946,7 @@ function createConversationListItem(conversation) { deleteBtn.className = 'conversation-delete-btn'; deleteBtn.innerHTML = ` - `; @@ -5994,10 +5975,10 @@ function handleConversationSearch(query) { if (conversationSearchTimer) { clearTimeout(conversationSearchTimer); } - + const searchInput = document.getElementById('conversation-search-input'); const clearBtn = document.getElementById('conversation-search-clear'); - + if (clearBtn) { if (query && query.trim()) { clearBtn.style.display = 'block'; @@ -6005,7 +5986,7 @@ function handleConversationSearch(query) { clearBtn.style.display = 'none'; } } - + conversationSearchTimer = setTimeout(() => { loadConversations(query); }, 300); // 300ms防抖延迟 @@ -6015,14 +5996,14 @@ function handleConversationSearch(query) { function clearConversationSearch() { const searchInput = document.getElementById('conversation-search-input'); const clearBtn = document.getElementById('conversation-search-clear'); - + if (searchInput) { searchInput.value = ''; } if (clearBtn) { clearBtn.style.display = 'none'; } - + commitConversationsPage(1, { bumpNavigateGen: true }); conversationsSearchQuery = ''; loadConversations(''); @@ -6404,42 +6385,7 @@ async function loadConversation(conversationId) { if (seq !== loadConversationRequestSeq) { return; } - - // 如果当前在分组详情页面,切换到对话界面 - // 退出分组详情模式,显示所有最近对话,提供更好的用户体验 - if (currentGroupId) { - const sidebar = document.querySelector('.conversation-sidebar'); - const groupDetailPage = document.getElementById('group-detail-page'); - const chatContainer = document.querySelector('.chat-container'); - - // 确保侧边栏始终可见 - if (sidebar) sidebar.style.display = 'flex'; - // 隐藏分组详情页,显示对话界面 - if (groupDetailPage) groupDetailPage.style.display = 'none'; - if (chatContainer) chatContainer.style.display = 'flex'; - - // 退出分组详情模式,这样最近对话列表会显示所有对话 - // 用户可以在侧边栏看到所有对话,方便切换 - const previousGroupId = currentGroupId; - currentGroupId = null; - - // 刷新最近对话列表,显示所有对话(包括分组中的) - loadConversationsWithGroups(); - } - - // 获取当前对话所属的分组ID(用于高亮显示) - // 确保分组映射已加载(使用缓存避免重复请求) - if (Object.keys(conversationGroupMappingCache).length === 0) { - await loadConversationGroupMapping(); - } - if (seq !== loadConversationRequestSeq) { - return; - } - currentConversationGroupId = conversationGroupMappingCache[conversationId] || null; - // 异步刷新分组列表高亮状态(不阻塞消息渲染) - loadGroups(); - // 更新当前对话ID currentConversationId = conversationId; window._loadedConversationProjectId = conversation.projectId || conversation.project_id || ''; @@ -6471,7 +6417,7 @@ async function loadConversation(conversationId) { return; } updateActiveConversation(); - + // 如果攻击链模态框打开且显示的不是当前对话,关闭它 const attackChainModal = document.getElementById('attack-chain-modal'); if (attackChainModal && isAppModalOpen('attack-chain-modal')) { @@ -6479,14 +6425,14 @@ async function loadConversation(conversationId) { closeAttackChainModal(); } } - + // 清空消息区域 const messagesDiv = document.getElementById('chat-messages'); if (seq !== loadConversationRequestSeq) { return; } messagesDiv.innerHTML = ''; - + // 检查对话中是否有最近的消息,如果有,清除草稿(避免恢复已发送的消息) let hasRecentUserMessage = false; if (conversation.messages && conversation.messages.length > 0) { @@ -6510,7 +6456,7 @@ async function loadConversation(conversationId) { adjustTextareaHeight(chatInput); } } - + // 加载消息 — 分批渲染避免长时间阻塞主线程 if (conversation.messages && conversation.messages.length > 0) { const FIRST_BATCH = 20; // 首批同步渲染(用户可见区域) @@ -6765,9 +6711,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) { } invalidateConversationLiteCache(currentConversationId); await loadConversation(currentConversationId); - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); - } else if (typeof loadConversations === 'function') { + if (typeof loadConversations === 'function') { loadConversations(); } } catch (error) { @@ -6785,17 +6729,17 @@ async function deleteConversation(conversationId, skipConfirm = false) { return; } } - + try { const response = await apiFetch(`/api/conversations/${conversationId}`, { method: 'DELETE' }); - + if (!response.ok) { const error = await response.json(); throw new Error(error.error || '删除失败'); } - + // 如果删除的是当前对话,清空对话界面 if (conversationId === currentConversationId) { currentConversationId = null; @@ -6806,28 +6750,17 @@ async function deleteConversation(conversationId, skipConfirm = false) { renderChatWelcomeEmptyState(); addAttackChainButton(null); } - - // 更新缓存 - 立即删除,确保后续加载时能正确识别 - delete conversationGroupMappingCache[conversationId]; + invalidateConversationLiteCache(conversationId); - // 同时从待保留映射中移除 - delete pendingGroupMappings[conversationId]; // 先同步所有侧栏的本地状态,再执行网络刷新。项目文件夹使用独立的 // conversation cache;如果只刷新“最近对话”,删除项会一直残留到整页刷新。 try { document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } })); } catch (e) { /* ignore */ } - - // 如果当前在分组详情页面,重新加载分组对话 - if (currentGroupId) { - await loadGroupConversations(currentGroupId); - } - - // 刷新对话列表(使用分组接口以与其他入口一致) - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); - } else if (typeof loadConversations === 'function') { + + // 刷新对话列表 + if (typeof loadConversations === 'function') { loadConversations(); } @@ -6934,14 +6867,14 @@ async function showAttackChain(conversationId) { return; } } - + currentAttackChainConversationId = conversationId; const modal = document.getElementById('attack-chain-modal'); if (!modal) { console.error('攻击链模态框未找到'); return; } - + openAppModal('attack-chain-modal', { focus: false }); updateAttackChainStats({ nodes: [], edges: [] }); @@ -6950,13 +6883,13 @@ async function showAttackChain(conversationId) { if (container) { container.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.loading') : '加载中...') + ''; } - + // 隐藏详情面板 const detailsPanel = document.getElementById('attack-chain-details'); if (detailsPanel) { detailsPanel.style.display = 'none'; } - + // 禁用重新生成按钮 const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]'); if (regenerateBtn) { @@ -6964,7 +6897,7 @@ async function showAttackChain(conversationId) { regenerateBtn.style.opacity = '0.5'; regenerateBtn.style.cursor = 'not-allowed'; } - + // 加载攻击链数据 await loadAttackChain(conversationId); } @@ -6974,12 +6907,12 @@ async function loadAttackChain(conversationId) { if (isAttackChainLoading(conversationId)) { return; // 防止重复调用 } - + setAttackChainLoading(conversationId, true); - + try { const response = await apiFetch(`/api/attack-chain/${conversationId}`); - + if (!response.ok) { // 处理 409 Conflict(正在生成中) if (response.status === 409) { @@ -7018,13 +6951,13 @@ async function loadAttackChain(conversationId) { } return; // 提前返回,不执行 finally 块中的 setAttackChainLoading(conversationId, false) } - + const error = await response.json(); throw new Error(error.error || '加载攻击链失败'); } - + const chainData = await response.json(); - + // 检查当前显示的对话ID是否匹配,防止串台 if (currentAttackChainConversationId !== conversationId) { console.log('攻击链数据已返回,但当前显示的对话已切换,忽略此次渲染', { @@ -7034,16 +6967,16 @@ async function loadAttackChain(conversationId) { setAttackChainLoading(conversationId, false); return; } - + // 渲染攻击链 renderAttackChain(chainData); - + // 更新统计信息 updateAttackChainStats(chainData); - + // 成功加载后,重置加载状态 setAttackChainLoading(conversationId, false); - + } catch (error) { console.error('加载攻击链失败:', error); const container = document.getElementById('attack-chain-container'); @@ -7069,21 +7002,21 @@ function renderAttackChain(chainData) { if (!container) { return; } - + // 清空容器 container.innerHTML = ''; - + if (!chainData.nodes || chainData.nodes.length === 0) { container.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.noAttackChainData') : '暂无攻击链数据') + ''; return; } - + // 计算图的复杂度(用于动态调整布局和样式) const nodeCount = chainData.nodes.length; const edgeCount = chainData.edges.length; const isComplexGraph = nodeCount > 15 || edgeCount > 25; const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark'; - + // 优化节点标签:智能截断和换行 chainData.nodes.forEach(node => { if (node.label) { @@ -7106,10 +7039,10 @@ function renderAttackChain(chainData) { } } }); - + // 准备Cytoscape数据 const elements = []; - + // 添加节点,并预计算样式信息(与导出保持一致的主题色) chainData.nodes.forEach(node => { const riskScore = node.risk_score || 0; @@ -7250,10 +7183,10 @@ function renderAttackChain(chainData) { } }); }); - + // 添加边(只添加源节点和目标节点都存在的边) const nodeIds = new Set(chainData.nodes.map(node => node.id)); - + // 保存有效的边用于ELK布局 const validEdges = []; chainData.edges.forEach(edge => { @@ -7279,7 +7212,7 @@ function renderAttackChain(chainData) { }); } }); - + // 初始化Cytoscape - 现代卡片式节点设计(图标 + 文字 + 徽章) attackChainCytoscape = cytoscape({ container: container, @@ -7450,7 +7383,7 @@ function renderAttackChain(chainData) { minZoom: 0.2, maxZoom: 3 }); - + // 使用ELK布局(高质量DAG布局,减少边交叉) let layoutOptions = { name: 'breadthfirst', @@ -7458,7 +7391,7 @@ function renderAttackChain(chainData) { spacingFactor: isComplexGraph ? 3.0 : 2.5, padding: 40 }; - + // 使用ELK.js进行布局计算 // elk.bundled.js会暴露ELK对象,可以直接使用new ELK() let elkInstance = null; @@ -7469,10 +7402,10 @@ function renderAttackChain(chainData) { console.warn('ELK初始化失败:', e); } } - + if (elkInstance) { try { - + // === 布局参数(始终使用 DOWN 纵向布局)=== const isSmallGraph = chainData.nodes.length <= 8 && validEdges.length <= 12; // 同层节点间距(横向分散) @@ -7522,7 +7455,7 @@ function renderAttackChain(chainData) { targets: [edge.target] })) }; - + // 使用ELK计算布局 elkInstance.layout(elkGraph).then(laidOutGraph => { // 应用ELK计算的布局到Cytoscape节点 @@ -7536,7 +7469,7 @@ function renderAttackChain(chainData) { }); } }); - + // 布局完成后,居中显示图 setTimeout(() => { centerAttackChain(); @@ -7577,7 +7510,7 @@ function renderAttackChain(chainData) { }); layout.run(); } - + // 居中攻击链的函数:始终让所有节点完整可见 function centerAttackChain() { try { @@ -7630,7 +7563,7 @@ function renderAttackChain(chainData) { console.warn('居中图表时出错:', error); } } - + // 添加点击事件 attackChainCytoscape.on('tap', 'node', function(evt) { const node = evt.target; @@ -7686,12 +7619,12 @@ function getEdgeNodes(edge) { try { const source = edge.source(); const target = edge.target(); - + // 检查源节点和目标节点是否存在 if (!source || !target || source.length === 0 || target.length === 0) { return { source: null, target: null, valid: false }; } - + return { source: source, target: target, valid: true }; } catch (error) { console.warn('获取边的节点时出错:', error, edge.id()); @@ -7704,7 +7637,7 @@ function filterAttackChainNodes(searchText) { if (!attackChainCytoscape || !window.attackChainOriginalData) { return; } - + const searchLower = searchText.toLowerCase().trim(); if (searchLower === '') { // 重置所有节点可见性 @@ -7714,7 +7647,7 @@ function filterAttackChainNodes(searchText) { attackChainCytoscape.nodes().style('border-width', 2); return; } - + // 过滤节点 attackChainCytoscape.nodes().forEach(node => { // 使用原始标签进行搜索,不包含类型标签 @@ -7722,7 +7655,7 @@ function filterAttackChainNodes(searchText) { const label = originalLabel.toLowerCase(); const type = (node.data('type') || '').toLowerCase(); const matches = label.includes(searchLower) || type.includes(searchLower); - + if (matches) { node.style('display', 'element'); // 高亮匹配的节点 @@ -7732,7 +7665,7 @@ function filterAttackChainNodes(searchText) { node.style('display', 'none'); } }); - + // 隐藏没有可见源节点或目标节点的边 attackChainCytoscape.edges().forEach(edge => { const { source, target, valid } = getEdgeNodes(edge); @@ -7740,7 +7673,7 @@ function filterAttackChainNodes(searchText) { edge.style('display', 'none'); return; } - + const sourceVisible = source.style('display') !== 'none'; const targetVisible = target.style('display') !== 'none'; if (sourceVisible && targetVisible) { @@ -7749,7 +7682,7 @@ function filterAttackChainNodes(searchText) { edge.style('display', 'none'); } }); - + // 重新调整视图 attackChainCytoscape.fit(undefined, 60); } @@ -7759,7 +7692,7 @@ function filterAttackChainByType(type) { if (!attackChainCytoscape || !window.attackChainOriginalData) { return; } - + if (type === 'all') { attackChainCytoscape.nodes().style('display', 'element'); attackChainCytoscape.edges().style('display', 'element'); @@ -7767,7 +7700,7 @@ function filterAttackChainByType(type) { attackChainCytoscape.fit(undefined, 60); return; } - + // 过滤节点 attackChainCytoscape.nodes().forEach(node => { const nodeType = node.data('type') || ''; @@ -7777,7 +7710,7 @@ function filterAttackChainByType(type) { node.style('display', 'none'); } }); - + // 隐藏没有可见源节点或目标节点的边 attackChainCytoscape.edges().forEach(edge => { const { source, target, valid } = getEdgeNodes(edge); @@ -7785,7 +7718,7 @@ function filterAttackChainByType(type) { edge.style('display', 'none'); return; } - + const sourceVisible = source.style('display') !== 'none'; const targetVisible = target.style('display') !== 'none'; if (sourceVisible && targetVisible) { @@ -7794,7 +7727,7 @@ function filterAttackChainByType(type) { edge.style('display', 'none'); } }); - + // 重新调整视图 attackChainCytoscape.fit(undefined, 60); } @@ -7804,7 +7737,7 @@ function filterAttackChainByRisk(riskLevel) { if (!attackChainCytoscape || !window.attackChainOriginalData) { return; } - + if (riskLevel === 'all') { attackChainCytoscape.nodes().style('display', 'element'); attackChainCytoscape.edges().style('display', 'element'); @@ -7812,7 +7745,7 @@ function filterAttackChainByRisk(riskLevel) { attackChainCytoscape.fit(undefined, 60); return; } - + // 定义风险范围 const riskRanges = { 'high': [80, 100], @@ -7820,9 +7753,9 @@ function filterAttackChainByRisk(riskLevel) { 'medium': [40, 59], 'low': [0, 39] }; - + const [minRisk, maxRisk] = riskRanges[riskLevel] || [0, 100]; - + // 过滤节点 attackChainCytoscape.nodes().forEach(node => { const riskScore = node.data('riskScore') || 0; @@ -7832,7 +7765,7 @@ function filterAttackChainByRisk(riskLevel) { node.style('display', 'none'); } }); - + // 隐藏没有可见源节点或目标节点的边 attackChainCytoscape.edges().forEach(edge => { const { source, target, valid } = getEdgeNodes(edge); @@ -7840,7 +7773,7 @@ function filterAttackChainByRisk(riskLevel) { edge.style('display', 'none'); return; } - + const sourceVisible = source.style('display') !== 'none'; const targetVisible = target.style('display') !== 'none'; if (sourceVisible && targetVisible) { @@ -7849,7 +7782,7 @@ function filterAttackChainByRisk(riskLevel) { edge.style('display', 'none'); } }); - + // 重新调整视图 attackChainCytoscape.fit(undefined, 60); } @@ -7861,19 +7794,19 @@ function resetAttackChainFilters() { if (searchInput) { searchInput.value = ''; } - + // 重置类型筛选 const typeFilter = document.getElementById('attack-chain-type-filter'); if (typeFilter) { typeFilter.value = 'all'; } - + // 重置风险筛选 const riskFilter = document.getElementById('attack-chain-risk-filter'); if (riskFilter) { riskFilter.value = 'all'; } - + // 重置所有节点可见性 if (attackChainCytoscape) { attackChainCytoscape.nodes().forEach(node => { @@ -7889,7 +7822,7 @@ function resetAttackChainFilters() { function showNodeDetails(nodeData) { const detailsPanel = document.getElementById('attack-chain-details'); const detailsContent = document.getElementById('attack-chain-details-content'); - + if (!detailsPanel || !detailsContent) { return; } @@ -7905,7 +7838,7 @@ function showNodeDetails(nodeData) { detailsPanel.style.opacity = '1'; }); }); - + let html = ` 节点ID: ${nodeData.id} @@ -7920,7 +7853,7 @@ function showNodeDetails(nodeData) { 风险评分: ${nodeData.riskScore}/100 `; - + // 显示action节点信息(工具执行 + AI分析) if (nodeData.type === 'action' && nodeData.metadata) { if (nodeData.metadata.tool_name) { @@ -7962,7 +7895,7 @@ function showNodeDetails(nodeData) { `; } } - + // 显示目标信息(如果是目标节点) if (nodeData.type === 'target' && nodeData.metadata && nodeData.metadata.target) { html += ` @@ -7971,7 +7904,7 @@ function showNodeDetails(nodeData) { `; } - + // 显示漏洞信息(如果是漏洞节点) if (nodeData.type === 'vulnerability' && nodeData.metadata) { if (nodeData.metadata.vulnerability_type) { @@ -8003,7 +7936,7 @@ function showNodeDetails(nodeData) { `; } } - + if (nodeData.toolExecutionId) { html += ` @@ -8011,7 +7944,7 @@ function showNodeDetails(nodeData) { `; } - + // 详情占满 sidebar 后,内容区滚动由自身处理,重置到顶部 if (detailsContent) { detailsContent.scrollTop = 0; @@ -8102,16 +8035,16 @@ function closeNodeDetails() { // 关闭攻击链模态框 function closeAttackChainModal() { closeAppModal('attack-chain-modal'); - + // 关闭节点详情 closeNodeDetails(); - + // 清理Cytoscape实例 if (attackChainCytoscape) { attackChainCytoscape.destroy(); attackChainCytoscape = null; } - + currentAttackChainConversationId = null; } @@ -8139,22 +8072,22 @@ async function regenerateAttackChain() { if (!currentAttackChainConversationId) { return; } - + // 防止重复点击(只检查当前对话的加载状态) if (isAttackChainLoading(currentAttackChainConversationId)) { console.log('攻击链正在生成中,请稍候...'); return; } - + // 保存请求时的对话ID,防止串台 const savedConversationId = currentAttackChainConversationId; setAttackChainLoading(savedConversationId, true); - + const container = document.getElementById('attack-chain-container'); if (container) { container.innerHTML = '重新生成中...'; } - + // 禁用重新生成按钮 const regenerateBtn = document.querySelector('button[onclick="regenerateAttackChain()"]'); if (regenerateBtn) { @@ -8162,13 +8095,13 @@ async function regenerateAttackChain() { regenerateBtn.style.opacity = '0.5'; regenerateBtn.style.cursor = 'not-allowed'; } - + try { // 调用重新生成接口 const response = await apiFetch(`/api/attack-chain/${savedConversationId}/regenerate`, { method: 'POST' }); - + if (!response.ok) { // 处理 409 Conflict(正在生成中) if (response.status === 409) { @@ -8190,20 +8123,20 @@ async function regenerateAttackChain() { // savedConversationId 已在函数开始处定义 setTimeout(() => { // 检查当前显示的对话ID是否匹配,且仍在加载中 - if (currentAttackChainConversationId === savedConversationId && + if (currentAttackChainConversationId === savedConversationId && isAttackChainLoading(savedConversationId)) { refreshAttackChain(); } }, 5000); return; } - + const error = await response.json(); throw new Error(error.error || '重新生成攻击链失败'); } - + const chainData = await response.json(); - + // 检查当前显示的对话ID是否匹配,防止串台 if (currentAttackChainConversationId !== savedConversationId) { console.log('攻击链数据已返回,但当前显示的对话已切换,忽略此次渲染', { @@ -8213,13 +8146,13 @@ async function regenerateAttackChain() { setAttackChainLoading(savedConversationId, false); return; } - + // 渲染攻击链 renderAttackChain(chainData); - + // 更新统计信息 updateAttackChainStats(chainData); - + } catch (error) { console.error('重新生成攻击链失败:', error); if (container) { @@ -8227,7 +8160,7 @@ async function regenerateAttackChain() { } } finally { setAttackChainLoading(savedConversationId, false); - + // 恢复重新生成按钮 if (regenerateBtn) { regenerateBtn.disabled = false; @@ -9045,18 +8978,11 @@ function exportAttackChain(format) { } // ============================================ -// 对话分组和批量管理功能 +// 对话批量管理功能 // ============================================ -// 分组数据管理(使用API) -let currentGroupId = null; // 当前正在查看的分组详情页面 -let currentConversationGroupId = null; // 当前对话所属的分组ID(用于高亮显示) let contextMenuConversationId = null; let contextMenuConversationTitle = ''; -let contextMenuGroupId = null; -let groupsCache = []; -let conversationGroupMappingCache = {}; -let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况) let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染 let conversationsListNavigateGen = 0; // 用户主动翻页代数,防止后台刷新覆盖翻页结果 const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size'; @@ -9066,9 +8992,6 @@ const CONVERSATION_PROJECT_FILTER_NONE = '__none__'; const CONVERSATION_PROJECT_FILTER_SELECT_ID = 'conversation-project-filter'; const CONVERSATION_PROJECT_FILTER_CARET = ''; const BATCH_PROJECT_FILTER_SELECT_ID = 'batch-project-filter'; -const BATCH_GROUP_FILTER_SELECT_ID = 'batch-move-group-select'; -const BATCH_GROUP_HEADER_FILTER_SELECT_ID = 'batch-group-filter'; -const BATCH_GROUP_NONE = '__none__'; const projectFilterCustomSelectRegistry = {}; let projectFilterCustomSelectDocBound = false; @@ -9360,14 +9283,6 @@ function initSimpleCustomSelect(selectId) { syncSimpleCustomSelect(selectId); } -function initBatchGroupCustomSelect() { - initSimpleCustomSelect(BATCH_GROUP_FILTER_SELECT_ID); -} - -function initBatchGroupHeaderFilterCustomSelect() { - initSimpleCustomSelect(BATCH_GROUP_HEADER_FILTER_SELECT_ID); -} - function initProjectFilterCustomSelect(selectId) { const select = document.getElementById(selectId); if (!select) return; @@ -9538,17 +9453,13 @@ async function refreshConversationProjectFilter() { function onConversationProjectFilterChange(projectId) { setConversationProjectFilter(projectId || ''); commitConversationsPage(1, { bumpNavigateGen: true }); - loadConversationsWithGroups(conversationsSearchQuery); + loadConversations(conversationsSearchQuery); } function updateConversationSidebarFilterUI() { - const groupsSection = document.querySelector('.conversation-groups-section'); const titleEl = document.querySelector('.recent-conversations-section .section-title'); const filter = getConversationProjectFilter(); const hasSearch = !!(conversationsSearchQuery && conversationsSearchQuery.trim()); - if (groupsSection) { - groupsSection.hidden = !!filter || hasSearch; - } if (!titleEl) return; const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; if (filter && filter !== CONVERSATION_PROJECT_FILTER_NONE) { @@ -9573,7 +9484,7 @@ function updateConversationSidebarFilterUI() { } window.onConversationProjectBindingChanged = function onConversationProjectBindingChanged() { - loadConversationsWithGroups(conversationsSearchQuery); + loadConversations(conversationsSearchQuery); }; function getConversationSortBy() { @@ -9645,7 +9556,7 @@ function setConversationSortBy(sortBy) { updateConversationSortMenuUI(); closeConversationSortMenu(); commitConversationsPage(1, { bumpNavigateGen: true }); - loadConversationsWithGroups(conversationsSearchQuery); + loadConversations(conversationsSearchQuery); } if (!window.__conversationSortMenuBound) { @@ -9689,7 +9600,7 @@ function getConversationsTotalPages() { /** * 分页状态约定: * - conversationsPagination.page 仅在此处(用户操作 / reconcile 钳制 / clamp)写入 - * - loadConversationsWithGroups 只读页码,用 intentPage 或当前 page 计算 offset + * - loadConversations 只读页码,用 intentPage 或当前 page 计算 offset * - isStaleConversationListLoad 丢弃页码或 navigateGen 已变的在途请求 */ function commitConversationsPage(page, { bumpNavigateGen = false } = {}) { @@ -9923,7 +9834,7 @@ function goConversationsPage(page) { const requestedPage = Math.max(1, parseInt(page, 10) || 1); const scrollToTop = requestedPage !== conversationsPagination.page; commitConversationsPage(requestedPage, { bumpNavigateGen: true }); - loadConversationsWithGroups(conversationsSearchQuery, { + loadConversations(conversationsSearchQuery, { refreshMeta: false, scrollToTop, intentPage: requestedPage, @@ -9941,108 +9852,14 @@ function changeConversationsPageSize() { } catch (e) { /* ignore */ } conversationsPagination.pageSize = newSize; commitConversationsPage(1, { bumpNavigateGen: true }); - loadConversationsWithGroups(conversationsSearchQuery); + loadConversations(conversationsSearchQuery); } window.goConversationsPage = goConversationsPage; window.changeConversationsPageSize = changeConversationsPageSize; -// 加载分组列表 -async function loadGroups() { - try { - const response = await apiFetch('/api/groups'); - if (!response.ok) { - groupsCache = []; - return; - } - const data = await response.json(); - // 确保groupsCache是有效数组 - if (Array.isArray(data)) { - groupsCache = data; - } else { - // 如果返回的不是数组,使用空数组(不打印警告,因为可能后端返回了错误格式但我们要优雅处理) - groupsCache = []; - } - - 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'; - // 高亮逻辑: - // 1. 如果当前在分组详情页面,只高亮当前分组(currentGroupId) - // 2. 如果不在分组详情页面,高亮当前对话所属的分组(currentConversationGroupId) - const shouldHighlight = currentGroupId - ? (currentGroupId === group.id) - : (currentConversationGroupId === group.id); - if (shouldHighlight) { - 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.type = 'button'; - menuBtn.className = 'group-item-menu'; - menuBtn.innerHTML = '⋯'; - menuBtn.title = typeof window.t === 'function' ? window.t('common.actions') : '操作'; - menuBtn.setAttribute('aria-label', menuBtn.title); - 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 = '', options = {}) { +// 加载对话列表(支持置顶) +async function loadConversations(searchQuery = '', options = {}) { const refreshMeta = options.refreshMeta !== false; const scrollToTop = options.scrollToTop === true; const intentPage = Number.isFinite(options.intentPage) ? options.intentPage : null; @@ -10064,17 +9881,10 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { } if (searchQuery && searchQuery.trim()) { convParams.set('search', searchQuery.trim()); - } else if (!projectFilter) { - convParams.set('exclude_grouped', 'true'); } updateConversationSidebarFilterUI(); const url = `/api/conversations?${convParams}`; - const fetchTasks = [apiFetch(url)]; - if (refreshMeta) { - fetchTasks.unshift(loadGroups(), loadConversationGroupMapping()); - } - const results = await Promise.all(fetchTasks); - const response = results[results.length - 1]; + const response = await apiFetch(url); if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return; const listContainer = document.getElementById('conversations-list'); @@ -10116,7 +9926,7 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { if (intentPage != null) { commitConversationsPage(pageCheck.clampedPage, { bumpNavigateGen: true }); } - loadConversationsWithGroups(searchQuery, { + loadConversations(searchQuery, { ...options, intentPage: pageCheck.clampedPage, scrollToTop: options.scrollToTop === true || activePage !== pageCheck.clampedPage, @@ -10125,7 +9935,7 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { } if (intentPage == null && clampConversationsPageToTotal()) { if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return; - loadConversationsWithGroups(searchQuery, options); + loadConversations(searchQuery, options); return; } @@ -10140,11 +9950,6 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { uniqueConversations.push(conv); }); - const hasSearchQuery = searchQuery && searchQuery.trim(); - const hasProjectFilter = !!getConversationProjectFilter(); - // 与请求参数 exclude_grouped 一致:后端已排除分组内对话,勿再用 mapping 缓存二次过滤(易导致 2→1 等页被滤空) - const listUsesUngroupedApi = !hasSearchQuery && !hasProjectFilter; - if (uniqueConversations.length === 0) { listContainer.innerHTML = emptyStateHtml; if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); @@ -10157,32 +9962,6 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { const normalConvs = []; uniqueConversations.forEach(conv => { - // 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的) - if (hasSearchQuery) { - // 搜索时显示所有匹配的对话,不管是否在分组中 - if (conv.pinned) { - pinnedConvs.push(conv); - } else { - normalConvs.push(conv); - } - return; - } - - // 按项目筛选时展示该项目下全部对话(含分组内) - if (hasProjectFilter) { - if (conv.pinned) { - pinnedConvs.push(conv); - } else { - normalConvs.push(conv); - } - return; - } - - // 未走 exclude_grouped 接口时,才用 mapping 缓存过滤分组内对话 - if (!listUsesUngroupedApi && conversationGroupMappingCache[conv.id]) { - return; - } - if (conv.pinned) { pinnedConvs.push(conv); } else { @@ -10275,7 +10054,7 @@ async function loadConversationsWithGroups(searchQuery = '', options = {}) { listContainer.appendChild(fragment); updateActiveConversation(); renderConversationsPagination(visibleCount); - + // 翻页时回到列表顶部;后台刷新保留滚动位置 if (sidebarContent) { requestAnimationFrame(() => { @@ -10338,26 +10117,6 @@ function createConversationListItemWithMenu(conversation, isPinned) { time.textContent = conversation._timeText || 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'; - const groupTagIcon = document.createElement('span'); - groupTagIcon.className = 'group-tag-icon'; - groupTagIcon.textContent = group.icon || '📁'; - const groupTagName = document.createElement('span'); - groupTagName.className = 'group-tag-name'; - groupTagName.textContent = group.name; - groupTag.appendChild(groupTagIcon); - groupTag.appendChild(groupTagName); - groupTag.title = `分组: ${group.name}`; - contentWrapper.appendChild(groupTag); - } - } - item.appendChild(contentWrapper); const menuBtn = document.createElement('button'); @@ -10369,9 +10128,6 @@ function createConversationListItemWithMenu(conversation, isPinned) { item.onclick = (e) => { e.preventDefault(); e.stopPropagation(); - if (currentGroupId) { - exitGroupDetail(); - } const targetConversationId = String(item.dataset.conversationId || '').trim(); if (targetConversationId) loadConversation(targetConversationId); }; @@ -10387,29 +10143,75 @@ function openConversationContextMenuForId(event, conversationId, conversationTit return showConversationContextMenu(event); } +let downloadMarkdownSubmenuHideTimer = null; + +function clearDownloadMarkdownSubmenuHideTimeout() { + if (!downloadMarkdownSubmenuHideTimer) return; + clearTimeout(downloadMarkdownSubmenuHideTimer); + downloadMarkdownSubmenuHideTimer = null; +} + +function hideDownloadMarkdownSubmenu() { + clearDownloadMarkdownSubmenuHideTimeout(); + downloadMarkdownSubmenuHideTimer = setTimeout(() => { + const submenu = document.getElementById('download-markdown-submenu'); + if (submenu) submenu.style.display = 'none'; + downloadMarkdownSubmenuHideTimer = null; + }, 120); +} + +function handleDownloadMarkdownSubmenuEnter() { + clearDownloadMarkdownSubmenuHideTimeout(); + const submenu = document.getElementById('download-markdown-submenu'); + if (submenu) submenu.style.display = 'block'; +} + +function handleDownloadMarkdownSubmenuLeave(event) { + const submenu = document.getElementById('download-markdown-submenu'); + if (submenu && event?.relatedTarget && submenu.contains(event.relatedTarget)) return; + hideDownloadMarkdownSubmenu(); +} + +function updateConversationContextPinText(isPinned) { + const pinMenuText = document.getElementById('pin-conversation-menu-text'); + if (!pinMenuText) return; + if (typeof window.t === 'function') { + pinMenuText.textContent = isPinned ? window.t('contextMenu.unpinConversation') : window.t('contextMenu.pinConversation'); + } else { + pinMenuText.textContent = isPinned ? '取消置顶' : '置顶此对话'; + } +} + +async function refreshConversationContextPinText(convId) { + if (!convId) { + updateConversationContextPinText(false); + return; + } + try { + const response = await apiFetch(`/api/conversations/${convId}`); + if (!response.ok) return; + const conv = await response.json(); + updateConversationContextPinText(!!conv.pinned); + } catch (error) { + console.error('获取对话置顶状态失败:', error); + } +} + // 显示对话上下文菜单 async function showConversationContextMenu(event) { const menu = document.getElementById('conversation-context-menu'); if (!menu) return; - // 先隐藏子菜单,确保每次打开菜单时子菜单都是关闭状态 - const submenu = document.getElementById('move-to-group-submenu'); - if (submenu) { - submenu.style.display = 'none'; - submenuVisible = false; - } const downloadSubmenu = document.getElementById('download-markdown-submenu'); if (downloadSubmenu) { downloadSubmenu.style.display = 'none'; } // 清除所有定时器 - clearSubmenuHideTimeout(); - clearSubmenuShowTimeout(); clearDownloadMarkdownSubmenuHideTimeout(); - submenuLoading = false; const convId = contextMenuConversationId; - + updateConversationContextPinText(false); + // 更新攻击链菜单项的启用状态 const attackChainMenuItem = document.getElementById('attack-chain-menu-item'); if (attackChainMenuItem) { @@ -10435,80 +10237,26 @@ async function showConversationContextMenu(event) { attackChainMenuItem.title = (typeof window.t === 'function' ? window.t('chat.viewAttackChainSelectConv') : '请选择一个对话以查看攻击链'); } } - - // 先获取对话的置顶状态并更新菜单文本(在显示菜单之前) - if (convId) { - try { - let isPinned = false; - // 检查对话是否真的在当前分组中 - const conversationGroupId = conversationGroupMappingCache[convId]; - const isInCurrentGroup = currentGroupId && conversationGroupId === currentGroupId; - - if (isInCurrentGroup) { - // 对话在当前分组中,获取分组内置顶状态 - const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); - if (response.ok) { - const groupConvs = await response.json(); - const conv = groupConvs.find(c => c.id === convId); - if (conv) { - isPinned = conv.groupPinned || false; - } - } - } else { - // 不在分组详情页面,或者对话不在当前分组中,获取全局置顶状态 - const response = await apiFetch(`/api/conversations/${convId}`); - if (response.ok) { - const conv = await response.json(); - isPinned = conv.pinned || false; - } - } - - // 更新菜单文本 - const pinMenuText = document.getElementById('pin-conversation-menu-text'); - if (pinMenuText && typeof window.t === 'function') { - pinMenuText.textContent = isPinned ? window.t('contextMenu.unpinConversation') : window.t('contextMenu.pinConversation'); - } else if (pinMenuText) { - pinMenuText.textContent = isPinned ? '取消置顶' : '置顶此对话'; - } - } catch (error) { - console.error('获取对话置顶状态失败:', error); - const pinMenuText = document.getElementById('pin-conversation-menu-text'); - if (pinMenuText && typeof window.t === 'function') { - pinMenuText.textContent = window.t('contextMenu.pinConversation'); - } else if (pinMenuText) { - pinMenuText.textContent = '置顶此对话'; - } - } - } else { - const pinMenuText = document.getElementById('pin-conversation-menu-text'); - if (pinMenuText && typeof window.t === 'function') { - pinMenuText.textContent = window.t('contextMenu.pinConversation'); - } else if (pinMenuText) { - pinMenuText.textContent = '置顶此对话'; - } - } - // 在状态获取完成后再显示菜单 + // 先显示菜单,置顶状态随后异步刷新,避免接口慢时点击没有任何反馈。 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; - - // 获取子菜单的宽度(如果存在,重用之前获取的submenu变量) - const submenuWidth = submenu ? 180 : 0; // 子菜单宽度 + 间距 - + + const submenuWidth = 0; + let left = event.clientX; let top = event.clientY; - + // 如果菜单会超出右边界,调整到左侧 - // 考虑子菜单的宽度 if (left + menuRect.width + submenuWidth > viewportWidth) { left = event.clientX - menuRect.width; // 如果调整后仍然超出,则放在按钮左侧 @@ -10516,33 +10264,27 @@ async function showConversationContextMenu(event) { left = Math.max(8, event.clientX - menuRect.width - submenuWidth); } } - + // 如果菜单会超出下边界,调整到上方 if (top + menuRect.height > viewportHeight) { top = Math.max(8, event.clientY - menuRect.height); } - + // 确保不超出左边界 if (left < 0) { left = 8; } - + // 确保不超出上边界 if (top < 0) { top = 8; } - + menu.style.left = left + 'px'; menu.style.top = top + 'px'; - + // 如果菜单在右侧,子菜单应该在左侧显示 if (left < event.clientX) { - if (submenu) { - submenu.style.left = 'auto'; - submenu.style.right = '100%'; - submenu.style.marginLeft = '0'; - submenu.style.marginRight = '4px'; - } if (downloadSubmenu) { downloadSubmenu.style.left = 'auto'; downloadSubmenu.style.right = '100%'; @@ -10550,12 +10292,6 @@ async function showConversationContextMenu(event) { downloadSubmenu.style.marginRight = '4px'; } } else { - if (submenu) { - submenu.style.left = '100%'; - submenu.style.right = 'auto'; - submenu.style.marginLeft = '4px'; - submenu.style.marginRight = '0'; - } if (downloadSubmenu) { downloadSubmenu.style.left = '100%'; downloadSubmenu.style.right = 'auto'; @@ -10567,14 +10303,11 @@ async function showConversationContextMenu(event) { // 点击外部关闭菜单 const closeMenu = (e) => { // 检查点击是否在主菜单或子菜单内 - const moveToGroupSubmenuEl = document.getElementById('move-to-group-submenu'); const downloadMarkdownSubmenuEl = document.getElementById('download-markdown-submenu'); const clickedInMenu = menu.contains(e.target); - const clickedInSubmenu = moveToGroupSubmenuEl && moveToGroupSubmenuEl.contains(e.target); const clickedInDownloadSubmenu = downloadMarkdownSubmenuEl && downloadMarkdownSubmenuEl.contains(e.target); - - if (!clickedInMenu && !clickedInSubmenu && !clickedInDownloadSubmenu) { - // 使用 closeContextMenu 确保同时关闭主菜单和子菜单 + + if (!clickedInMenu && !clickedInDownloadSubmenu) { closeContextMenu(); document.removeEventListener('click', closeMenu); } @@ -10582,98 +10315,8 @@ async function showConversationContextMenu(event) { setTimeout(() => { document.addEventListener('click', closeMenu); }, 0); -} -// 显示分组上下文菜单 -async function showGroupContextMenu(event, groupId) { - const menu = document.getElementById('group-context-menu'); - if (!menu) return; - - contextMenuGroupId = groupId; - - // 先获取分组的置顶状态并更新菜单文本(在显示菜单之前) - try { - // 先从缓存中查找 - let group = groupsCache.find(g => g.id === groupId); - let isPinned = false; - - if (group) { - isPinned = group.pinned || false; - } else { - // 如果缓存中没有,从API获取 - const response = await apiFetch(`/api/groups/${groupId}`); - if (response.ok) { - group = await response.json(); - isPinned = group.pinned || false; - } - } - - // 更新菜单文本 - const pinMenuText = document.getElementById('pin-group-menu-text'); - if (pinMenuText && typeof window.t === 'function') { - pinMenuText.textContent = isPinned ? window.t('contextMenu.unpinGroup') : window.t('contextMenu.pinGroup'); - } else if (pinMenuText) { - pinMenuText.textContent = isPinned ? '取消置顶' : '置顶此分组'; - } - } catch (error) { - console.error('获取分组置顶状态失败:', error); - const pinMenuText = document.getElementById('pin-group-menu-text'); - if (pinMenuText && typeof window.t === 'function') { - pinMenuText.textContent = window.t('contextMenu.pinGroup'); - } else if (pinMenuText) { - pinMenuText.textContent = '置顶此分组'; - } - } - - // 在状态获取完成后再显示菜单 - 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); + refreshConversationContextPinText(convId); } let renameConversationTargetId = null; @@ -10780,29 +10423,20 @@ async function saveConversationRename() { // 更新前端显示 document.querySelectorAll('[data-conversation-id]').forEach((item) => { if (item.dataset.conversationId !== convId) return; - item.querySelectorAll('.conversation-title, .group-conversation-title, .project-conversation-title') + item.querySelectorAll('.conversation-title, .project-conversation-title') .forEach((titleEl) => { titleEl.textContent = newTitle.trim(); titleEl.title = 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(); - } - } - // 同步更新顶栏正在运行的任务名称 if (typeof updateActiveTaskConversationTitle === 'function') { updateActiveTaskConversationTitle(convId, newTitle.trim()); } // 重新加载对话列表 - await loadConversationsWithGroups(); + await loadConversations(); if (typeof window.refreshChatProjectFolders === 'function') { await window.refreshChatProjectFolders(); } @@ -10810,7 +10444,7 @@ async function saveConversationRename() { } catch (error) { console.error('重命名对话失败:', error); const failedLabel = typeof window.t === 'function' ? window.t('chat.renameFailed') : '重命名失败'; - const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; + const unknownErr = '未知错误'; alert(failedLabel + ': ' + (error.message || unknownErr)); } finally { if (submitButton) submitButton.disabled = false; @@ -10843,58 +10477,22 @@ async function pinConversation() { closeContextMenu(); try { - // 检查对话是否真的在当前分组中 - // 如果对话已经从分组移出,conversationGroupMappingCache 中不会有该对话的映射 - // 或者映射的分组ID不等于当前分组ID - const conversationGroupId = conversationGroupMappingCache[convId]; - const isInCurrentGroup = currentGroupId && conversationGroupId === currentGroupId; - - // 如果当前在分组详情页面,且对话确实在当前分组中,使用分组内置顶 - if (isInCurrentGroup) { - // 获取当前对话在分组中的置顶状态 - const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); - await assertConversationActionResponse(response, '获取分组对话失败'); - const groupConvs = await response.json(); - const conv = groupConvs.find(c => c.id === convId); - - // 如果找不到对话,说明可能有问题,使用默认值 - const currentPinned = conv && conv.groupPinned !== undefined ? conv.groupPinned : false; - const newPinned = !currentPinned; + const response = await apiFetch(`/api/conversations/${convId}`); + await assertConversationActionResponse(response, '获取对话失败'); + const conv = await response.json(); + const newPinned = !conv.pinned; - // 更新分组内置顶状态 - const updateResponse = await apiFetch(`/api/groups/${currentGroupId}/conversations/${convId}/pinned`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ pinned: newPinned }), - }); - await assertConversationActionResponse(updateResponse, '更新分组内置顶状态失败'); + const updateResponse = await apiFetch(`/api/conversations/${convId}/pinned`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pinned: newPinned }), + }); + await assertConversationActionResponse(updateResponse, '更新置顶状态失败'); - // 重新加载分组对话 - await loadGroupConversations(currentGroupId); - } else { - // 不在分组详情页面,或者对话不在当前分组中,使用全局置顶 - const response = await apiFetch(`/api/conversations/${convId}`); - await assertConversationActionResponse(response, '获取对话失败'); - const conv = await response.json(); - const newPinned = !conv.pinned; - - // 更新全局置顶状态 - const updateResponse = await apiFetch(`/api/conversations/${convId}/pinned`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ pinned: newPinned }), - }); - await assertConversationActionResponse(updateResponse, '更新置顶状态失败'); - - // 项目文件夹侧栏与“最近对话”使用不同缓存;先发事件做即时更新, - // projects.js 再后台拉取服务端数据校准。 - notifyConversationPinnedChanged(convId, newPinned); - loadConversationsWithGroups(); - } + notifyConversationPinnedChanged(convId, newPinned); + loadConversations(); } catch (error) { console.error('置顶对话失败:', error); alert('置顶失败: ' + (error.message || '未知错误')); @@ -10902,438 +10500,11 @@ async function pinConversation() { } -// 显示移动到分组子菜单 -async function showMoveToGroupSubmenu() { - const submenu = document.getElementById('move-to-group-submenu'); - if (!submenu) return; - - // 如果子菜单已经显示,不需要重复渲染 - if (submenuVisible && submenu.style.display === 'block') { - return; - } - - // 如果正在加载中,避免重复调用 - if (submenuLoading) { - return; - } - - // 清除隐藏定时器 - clearSubmenuHideTimeout(); - - // 标记为加载中 - submenuLoading = true; - submenu.innerHTML = ''; - - // 确保分组列表已加载 - 强制重新加载以确保数据是最新的 - try { - // 如果缓存为空,强制加载 - if (!Array.isArray(groupsCache) || groupsCache.length === 0) { - await loadGroups(); - } else { - // 即使缓存不为空,也尝试刷新一次,确保数据是最新的 - // 但使用静默方式,不显示错误 - try { - const response = await apiFetch('/api/groups'); - if (response.ok) { - const freshGroups = await response.json(); - if (Array.isArray(freshGroups)) { - groupsCache = freshGroups; - } - } - } catch (err) { - // 如果刷新失败,使用缓存的数据 - console.warn('刷新分组列表失败,使用缓存数据:', err); - } - } - - // 再次验证缓存 - if (!Array.isArray(groupsCache)) { - console.warn('groupsCache 不是有效数组,重置为空数组'); - groupsCache = []; - // 如果仍然无效,尝试重新加载 - if (groupsCache.length === 0) { - await loadGroups(); - } - } - } catch (error) { - console.error('加载分组列表失败:', error); - // 即使加载失败,也继续显示菜单,使用现有缓存 - } - - // 如果当前在分组详情页面,显示"移出本组"选项 - if (currentGroupId && contextMenuConversationId) { - // 检查对话是否在当前分组中 - const convInGroup = conversationGroupMappingCache[contextMenuConversationId] === currentGroupId; - if (convInGroup) { - const removeItem = document.createElement('div'); - removeItem.className = 'context-submenu-item'; - removeItem.innerHTML = ` - - - - - 移出本组 - `; - removeItem.onclick = () => { - removeConversationFromGroup(contextMenuConversationId, currentGroupId); - }; - submenu.appendChild(removeItem); - - // 添加分隔线 - const divider = document.createElement('div'); - divider.className = 'context-menu-divider'; - submenu.appendChild(divider); - } - } - - // 验证 groupsCache 是否为有效数组 - if (!Array.isArray(groupsCache)) { - console.warn('groupsCache 不是有效数组,重置为空数组'); - groupsCache = []; - } - - // 如果有分组,显示所有分组(排除对话已所在的分组) - if (groupsCache.length > 0) { - // 检查对话当前所在的分组ID - const conversationCurrentGroupId = contextMenuConversationId - ? conversationGroupMappingCache[contextMenuConversationId] - : null; - - groupsCache.forEach(group => { - // 验证分组对象是否有效 - if (!group || !group.id || !group.name) { - console.warn('无效的分组对象:', group); - return; - } - - // 如果对话已经在当前分组中,不显示该分组(因为已经在里面了) - if (conversationCurrentGroupId && group.id === conversationCurrentGroupId) { - return; - } - - const item = document.createElement('div'); - item.className = 'context-submenu-item'; - const folderIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - folderIcon.setAttribute('width', '16'); - folderIcon.setAttribute('height', '16'); - folderIcon.setAttribute('viewBox', '0 0 24 24'); - folderIcon.setAttribute('fill', 'none'); - folderIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - const folderPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - folderPath.setAttribute('d', 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z'); - folderPath.setAttribute('stroke', 'currentColor'); - folderPath.setAttribute('stroke-width', '2'); - folderPath.setAttribute('stroke-linecap', 'round'); - folderPath.setAttribute('stroke-linejoin', 'round'); - folderIcon.appendChild(folderPath); - const label = document.createElement('span'); - label.textContent = group.name; - item.appendChild(folderIcon); - item.appendChild(label); - item.onclick = () => { - moveConversationToGroup(contextMenuConversationId, group.id); - }; - submenu.appendChild(item); - }); - } else { - // 如果仍然没有分组,记录日志以便调试 - console.warn('showMoveToGroupSubmenu: groupsCache 为空,无法显示分组列表'); - } - - // 始终显示"创建分组"选项 - const addGroupLabel = typeof window.t === 'function' ? window.t('chat.addNewGroup') : '+ 新增分组'; - const addItem = document.createElement('div'); - addItem.className = 'context-submenu-item add-group-item'; - addItem.innerHTML = ` - - - - ${addGroupLabel} - `; - addItem.onclick = () => { - showCreateGroupModal(true); - }; - submenu.appendChild(addItem); - - submenu.style.display = 'block'; - submenuVisible = true; - submenuLoading = false; - - // 计算子菜单位置,防止溢出 - setTimeout(() => { - const submenuRect = submenu.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - // 如果子菜单超出右边界,调整到左侧 - if (submenuRect.right > viewportWidth) { - submenu.style.left = 'auto'; - submenu.style.right = '100%'; - submenu.style.marginLeft = '0'; - submenu.style.marginRight = '4px'; - } - - // 如果子菜单超出下边界,调整位置 - if (submenuRect.bottom > viewportHeight) { - const overflow = submenuRect.bottom - viewportHeight; - const currentTop = parseInt(submenu.style.top) || 0; - submenu.style.top = (currentTop - overflow - 8) + 'px'; - } - }, 0); -} - -// 隐藏移动到分组子菜单的定时器 -let submenuHideTimeout = null; -// 显示子菜单的防抖定时器 -let submenuShowTimeout = null; -// 子菜单是否正在加载中 -let submenuLoading = false; -// 子菜单是否已显示 -let submenuVisible = false; -// 下载Markdown子菜单隐藏定时器 -let downloadMarkdownSubmenuHideTimeout = null; - -// 隐藏移动到分组子菜单 -function hideMoveToGroupSubmenu() { - const submenu = document.getElementById('move-to-group-submenu'); - if (submenu) { - submenu.style.display = 'none'; - submenuVisible = false; - } -} - -// 清除隐藏子菜单的定时器 -function clearSubmenuHideTimeout() { - if (submenuHideTimeout) { - clearTimeout(submenuHideTimeout); - submenuHideTimeout = null; - } -} - -// 清除显示子菜单的定时器 -function clearSubmenuShowTimeout() { - if (submenuShowTimeout) { - clearTimeout(submenuShowTimeout); - submenuShowTimeout = null; - } -} - -function clearDownloadMarkdownSubmenuHideTimeout() { - if (downloadMarkdownSubmenuHideTimeout) { - clearTimeout(downloadMarkdownSubmenuHideTimeout); - downloadMarkdownSubmenuHideTimeout = null; - } -} - -function showDownloadMarkdownSubmenu() { - const submenu = document.getElementById('download-markdown-submenu'); - if (!submenu) return; - clearDownloadMarkdownSubmenuHideTimeout(); - submenu.style.display = 'block'; -} - -function hideDownloadMarkdownSubmenu() { - const submenu = document.getElementById('download-markdown-submenu'); - if (!submenu) return; - submenu.style.display = 'none'; -} - -function handleDownloadMarkdownSubmenuEnter() { - clearDownloadMarkdownSubmenuHideTimeout(); - showDownloadMarkdownSubmenu(); -} - -function handleDownloadMarkdownSubmenuLeave(event) { - const submenu = document.getElementById('download-markdown-submenu'); - if (!submenu) return; - const relatedTarget = event.relatedTarget; - if (relatedTarget && submenu.contains(relatedTarget)) { - return; - } - clearDownloadMarkdownSubmenuHideTimeout(); - downloadMarkdownSubmenuHideTimeout = setTimeout(() => { - hideDownloadMarkdownSubmenu(); - downloadMarkdownSubmenuHideTimeout = null; - }, 200); -} - -// 处理鼠标进入"移动到分组"菜单项(带防抖) -function handleMoveToGroupSubmenuEnter() { - // 清除隐藏定时器 - clearSubmenuHideTimeout(); - - // 如果子菜单已经显示,不需要重复调用 - const submenu = document.getElementById('move-to-group-submenu'); - if (submenu && submenuVisible && submenu.style.display === 'block') { - return; - } - - // 清除之前的显示定时器 - clearSubmenuShowTimeout(); - - // 使用防抖延迟显示,避免频繁触发 - submenuShowTimeout = setTimeout(() => { - showMoveToGroupSubmenu(); - submenuShowTimeout = null; - }, 100); -} - -// 处理鼠标离开"移动到分组"菜单项 -function handleMoveToGroupSubmenuLeave(event) { - const submenu = document.getElementById('move-to-group-submenu'); - if (!submenu) return; - - // 清除显示定时器 - clearSubmenuShowTimeout(); - - // 检查鼠标是否移动到子菜单 - const relatedTarget = event.relatedTarget; - if (relatedTarget && submenu.contains(relatedTarget)) { - // 鼠标移动到子菜单,不清除 - return; - } - - // 清除之前的隐藏定时器 - clearSubmenuHideTimeout(); - - // 延迟隐藏,给用户时间移动到子菜单 - submenuHideTimeout = setTimeout(() => { - hideMoveToGroupSubmenu(); - submenuHideTimeout = null; - }, 200); -} - -// 移动对话到分组 -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, - }), - }); - - // 更新缓存 - const oldGroupId = conversationGroupMappingCache[convId]; - conversationGroupMappingCache[convId] = groupId; - - // 将新移动的对话添加到待保留映射中,防止后端API延迟导致映射丢失 - pendingGroupMappings[convId] = groupId; - - // 如果移动的是当前对话,更新 currentConversationGroupId - if (currentConversationId === convId) { - currentConversationGroupId = groupId; - } - - // 如果当前在分组详情页面,重新加载分组对话 - if (currentGroupId) { - // 如果从当前分组移出,或者移动到当前分组,都需要重新加载 - if (currentGroupId === oldGroupId || currentGroupId === groupId) { - await loadGroupConversations(currentGroupId); - } - } - - // 无论是否在分组详情页面,都需要刷新最近对话列表 - // 因为最近对话列表会根据分组映射缓存来过滤显示,需要立即更新 - // loadConversationsWithGroups 内部会调用 loadConversationGroupMapping, - // loadConversationGroupMapping 会保留 pendingGroupMappings 中的映射 - await loadConversationsWithGroups(); - - // 注意:pendingGroupMappings 中的映射会在下次 loadConversationGroupMapping - // 成功从后端加载时自动清理(在 loadConversationGroupMapping 中处理) - - // 刷新分组列表,更新高亮状态 - await loadGroups(); - } catch (error) { - console.error('移动对话到分组失败:', error); - alert('移动失败: ' + (error.message || '未知错误')); - } - - closeContextMenu(); -} - -// 从分组中移除对话 -async function removeConversationFromGroup(convId, groupId) { - try { - await apiFetch(`/api/groups/${groupId}/conversations/${convId}`, { - method: 'DELETE', - }); - - // 更新缓存 - 立即删除,确保后续加载时能正确识别 - delete conversationGroupMappingCache[convId]; - // 同时从待保留映射中移除 - delete pendingGroupMappings[convId]; - - // 如果移除的是当前对话,清除 currentConversationGroupId - if (currentConversationId === convId) { - currentConversationGroupId = null; - } - - // 如果当前在分组详情页面,重新加载分组对话 - if (currentGroupId === groupId) { - await loadGroupConversations(groupId); - } - - // 重新加载分组映射,确保缓存是最新的 - await loadConversationGroupMapping(); - - // 刷新分组列表,更新高亮状态 - await loadGroups(); - - // 刷新最近对话列表,让移出的对话立即显示 - // 使用临时变量保存 currentGroupId,然后临时设置为 null,确保显示所有不在分组的对话 - const savedGroupId = currentGroupId; - currentGroupId = null; - await loadConversationsWithGroups(); - currentGroupId = savedGroupId; - } catch (error) { - console.error('从分组中移除对话失败:', error); - alert('移除失败: ' + (error.message || '未知错误')); - } - - closeContextMenu(); -} - -// 加载对话分组映射 -async function loadConversationGroupMapping() { - try { - // 使用批量 API 一次性获取所有映射(消除 N+1 串行请求) - const response = await apiFetch('/api/groups/mappings'); - - // 保存待保留的映射 - const preservedMappings = { ...pendingGroupMappings }; - - conversationGroupMappingCache = {}; - - if (response.ok) { - const mappings = await response.json(); - if (Array.isArray(mappings)) { - mappings.forEach(m => { - conversationGroupMappingCache[m.conversationId] = m.groupId; - // 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了) - if (preservedMappings[m.conversationId] === m.groupId) { - delete pendingGroupMappings[m.conversationId]; - } - }); - } - } - - // 恢复待保留的映射(这些是后端API尚未同步的映射) - Object.assign(conversationGroupMappingCache, preservedMappings); - } catch (error) { - console.error('加载对话分组映射失败:', error); - } -} - // 从上下文菜单查看攻击链 function showAttackChainFromContext() { const convId = contextMenuConversationId; if (!convId) return; - + closeContextMenu(); showAttackChain(convId); } @@ -11512,20 +10683,12 @@ function closeContextMenu() { if (menu) { menu.style.display = 'none'; } - const submenu = document.getElementById('move-to-group-submenu'); - if (submenu) { - submenu.style.display = 'none'; - submenuVisible = false; - } const downloadSubmenu = document.getElementById('download-markdown-submenu'); if (downloadSubmenu) { downloadSubmenu.style.display = 'none'; } // 清除所有定时器 - clearSubmenuHideTimeout(); - clearSubmenuShowTimeout(); clearDownloadMarkdownSubmenuHideTimeout(); - submenuLoading = false; contextMenuConversationId = null; contextMenuConversationTitle = ''; } @@ -11547,22 +10710,6 @@ function getConversationProjectLabel(conv) { return typeof window.t === 'function' ? window.t('batchManageModal.unknownProject') : '未知项目'; } -function getConversationGroupId(conv) { - return (conv && conversationGroupMappingCache[conv.id]) || ''; -} - -function getConversationGroupLabel(conv) { - const groupId = getConversationGroupId(conv); - if (!groupId) { - return typeof window.t === 'function' ? window.t('batchManageModal.noGroup') : '无分组'; - } - const group = Array.isArray(groupsCache) ? groupsCache.find(g => g.id === groupId) : null; - if (group) { - return `${group.icon || '📁'} ${group.name}`; - } - return typeof window.t === 'function' ? window.t('batchManageModal.unknownGroup') : '未知分组'; -} - async function prefetchProjectNamesForConversations(conversations) { const missing = new Set(); for (const conv of conversations || []) { @@ -11592,83 +10739,9 @@ async function refreshBatchProjectFilter() { syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID); } -async function refreshBatchGroupSelect() { - const sel = document.getElementById('batch-move-group-select'); - if (!sel) return; - - if (!Array.isArray(groupsCache) || groupsCache.length === 0) { - await loadGroups(); - } - - const saved = sel.value || BATCH_GROUP_NONE; - sel.innerHTML = ''; - - const noneOpt = document.createElement('option'); - noneOpt.value = BATCH_GROUP_NONE; - noneOpt.textContent = projectFilterT('batchManageModal.noGroupOption', '无分组'); - sel.appendChild(noneOpt); - - (groupsCache || []).forEach((group) => { - const opt = document.createElement('option'); - opt.value = group.id; - opt.textContent = `${group.icon || '📁'} ${group.name}`; - sel.appendChild(opt); - }); - - if (saved === BATCH_GROUP_NONE || (groupsCache || []).some((group) => group.id === saved)) { - sel.value = saved; - } else { - sel.value = BATCH_GROUP_NONE; - } - syncSimpleCustomSelect(BATCH_GROUP_FILTER_SELECT_ID); -} - -function appendBatchGroupHeaderFilterNativeOptions(sel) { - const allLabel = projectFilterT('batchManageModal.filterAllGroups', '全部分组'); - const ungroupedLabel = projectFilterT('batchManageModal.filterUngrouped', '无分组'); - sel.innerHTML = ''; - const allOpt = document.createElement('option'); - allOpt.value = ''; - allOpt.textContent = allLabel; - allOpt.setAttribute('data-i18n', 'batchManageModal.filterAllGroups'); - sel.appendChild(allOpt); - const ungroupedOpt = document.createElement('option'); - ungroupedOpt.value = BATCH_GROUP_NONE; - ungroupedOpt.textContent = ungroupedLabel; - ungroupedOpt.setAttribute('data-i18n', 'batchManageModal.filterUngrouped'); - sel.appendChild(ungroupedOpt); -} - -async function refreshBatchGroupHeaderFilter() { - const sel = document.getElementById(BATCH_GROUP_HEADER_FILTER_SELECT_ID); - if (!sel) return; - - if (!Array.isArray(groupsCache) || groupsCache.length === 0) { - await loadGroups(); - } - - const saved = sel.value || ''; - appendBatchGroupHeaderFilterNativeOptions(sel); - - (groupsCache || []).forEach((group) => { - const opt = document.createElement('option'); - opt.value = group.id; - opt.textContent = `${group.icon || '📁'} ${group.name}`; - sel.appendChild(opt); - }); - - if (saved === '' || saved === BATCH_GROUP_NONE || (groupsCache || []).some((group) => group.id === saved)) { - sel.value = saved; - } else { - sel.value = ''; - } - syncSimpleCustomSelect(BATCH_GROUP_HEADER_FILTER_SELECT_ID); -} - function getBatchFilteredConversations() { const query = (document.getElementById('batch-search-input')?.value || '').trim().toLowerCase(); const projectFilter = (document.getElementById('batch-project-filter')?.value || '').trim(); - const groupFilter = (document.getElementById(BATCH_GROUP_HEADER_FILTER_SELECT_ID)?.value || '').trim(); return allConversationsForBatch.filter((conv) => { const pid = getConversationProjectId(conv); if (projectFilter) { @@ -11678,19 +10751,10 @@ function getBatchFilteredConversations() { return false; } } - const gid = getConversationGroupId(conv); - if (groupFilter) { - if (groupFilter === BATCH_GROUP_NONE) { - if (gid) return false; - } else if (gid !== groupFilter) { - return false; - } - } if (!query) return true; const title = (conv.title || '').toLowerCase(); const projectName = getConversationProjectLabel(conv).toLowerCase(); - const groupName = getConversationGroupLabel(conv).toLowerCase(); - return title.includes(query) || projectName.includes(query) || groupName.includes(query); + return title.includes(query) || projectName.includes(query); }); } @@ -11713,16 +10777,8 @@ async function showBatchManageModal() { try { initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID); allConversationsForBatch = await fetchAllConversations(''); - await Promise.all([ - prefetchProjectNamesForConversations(allConversationsForBatch), - loadGroups(), - loadConversationGroupMapping(), - ]); + await prefetchProjectNamesForConversations(allConversationsForBatch); await refreshBatchProjectFilter(); - initBatchGroupHeaderFilterCustomSelect(); - await refreshBatchGroupHeaderFilter(); - initBatchGroupCustomSelect(); - await refreshBatchGroupSelect(); const sidebarFilter = getConversationProjectFilter(); const batchSel = document.getElementById('batch-project-filter'); if (batchSel && sidebarFilter && ( @@ -11738,10 +10794,8 @@ async function showBatchManageModal() { } catch (error) { console.error('加载对话列表失败:', error); initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID); - initBatchGroupHeaderFilterCustomSelect(); - initBatchGroupCustomSelect(); allConversationsForBatch = []; - await Promise.all([refreshBatchProjectFilter(), refreshBatchGroupHeaderFilter(), refreshBatchGroupSelect()]); + await refreshBatchProjectFilter(); applyBatchConversationFilters(); openAppModal('batch-manage-modal', { focus: false }); } @@ -11752,36 +10806,36 @@ function safeTruncateText(text, maxLength = 50) { if (!text || typeof text !== 'string') { return text || ''; } - + // 使用 Array.from 将字符串转换为字符数组(正确处理 Unicode 代理对) const chars = Array.from(text); - + // 如果文本长度未超过限制,直接返回 if (chars.length <= maxLength) { return text; } - + // 截断到最大长度(基于字符数,而不是代码单元) let truncatedChars = chars.slice(0, maxLength); - + // 尝试在标点符号或空格处截断,使截断更自然 // 在截断点往前查找合适的断点(不超过20%的长度) const searchRange = Math.floor(maxLength * 0.2); const breakChars = [',', '。', '、', ' ', ',', '.', ';', ':', '!', '?', '!', '?', '/', '\\', '-', '_']; let bestBreakPos = truncatedChars.length; - + for (let i = truncatedChars.length - 1; i >= truncatedChars.length - searchRange && i >= 0; i--) { if (breakChars.includes(truncatedChars[i])) { bestBreakPos = i + 1; // 在标点符号后断开 break; } } - + // 如果找到合适的断点,使用它;否则使用原截断位置 if (bestBreakPos < truncatedChars.length) { truncatedChars = truncatedChars.slice(0, bestBreakPos); } - + // 将字符数组转换回字符串,并添加省略号 return truncatedChars.join('') + '...'; } @@ -11826,16 +10880,6 @@ function renderBatchConversations(filtered = null) { project.classList.add('is-unbound'); } - const group = document.createElement('div'); - group.className = 'batch-table-col-group'; - const groupLabel = getConversationGroupLabel(conv); - const truncatedGroup = safeTruncateText(groupLabel, 24); - group.textContent = truncatedGroup; - group.title = groupLabel; - if (!getConversationGroupId(conv)) { - group.classList.add('is-unbound'); - } - const time = document.createElement('div'); time.className = 'batch-table-col-time'; const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); @@ -11871,7 +10915,6 @@ function renderBatchConversations(filtered = null) { row.appendChild(checkboxCol); row.appendChild(name); row.appendChild(project); - row.appendChild(group); row.appendChild(time); row.appendChild(action); @@ -11919,133 +10962,6 @@ function syncSelectAllBatchCheckbox() { } } -// 批量设置对话分组(选「无分组」即移出) -async function applyBatchGroupChange() { - const checkboxes = document.querySelectorAll('.batch-conversation-checkbox:checked'); - if (checkboxes.length === 0) { - alert(typeof window.t === 'function' ? window.t('batchManageModal.confirmGroupChangeNone') : '请先选择要操作的对话'); - return; - } - - const groupSelect = document.getElementById('batch-move-group-select'); - const groupId = (groupSelect?.value || BATCH_GROUP_NONE).trim(); - - if (groupId === BATCH_GROUP_NONE) { - const items = Array.from(checkboxes).map((cb) => ({ - id: cb.dataset.conversationId, - groupId: conversationGroupMappingCache[cb.dataset.conversationId] || '', - })).filter((item) => item.groupId); - - if (items.length === 0) { - alert(typeof window.t === 'function' ? window.t('batchManageModal.confirmRemoveNoGroup') : '所选对话均未归属分组'); - return; - } - - const confirmMsg = typeof window.t === 'function' - ? window.t('batchManageModal.confirmRemoveN', { count: items.length }) - : `确定将选中的 ${items.length} 条对话移出分组吗?`; - if (!confirm(confirmMsg)) { - return; - } - - try { - for (const item of items) { - await apiFetch(`/api/groups/${item.groupId}/conversations/${item.id}`, { - method: 'DELETE', - }); - - delete conversationGroupMappingCache[item.id]; - delete pendingGroupMappings[item.id]; - - if (currentConversationId === item.id) { - currentConversationGroupId = null; - } - } - - await finishBatchGroupChangeAfterRemove(); - } catch (error) { - console.error('批量移出分组失败:', error); - await loadConversationGroupMapping(); - applyBatchConversationFilters(); - const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.removeFailed') : '移出失败'; - const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; - alert(failedMsg + ': ' + (error.message || unknownErr)); - } - return; - } - - const targetGroup = (groupsCache || []).find((group) => group.id === groupId); - const groupName = targetGroup ? targetGroup.name : groupId; - const confirmMsg = typeof window.t === 'function' - ? window.t('batchManageModal.confirmMoveN', { count: checkboxes.length, group: groupName }) - : `确定将选中的 ${checkboxes.length} 条对话移动到「${groupName}」吗?`; - if (!confirm(confirmMsg)) { - return; - } - - const ids = Array.from(checkboxes).map((cb) => cb.dataset.conversationId); - - try { - for (const id of ids) { - await apiFetch('/api/groups/conversations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - conversationId: id, - groupId, - }), - }); - - conversationGroupMappingCache[id] = groupId; - pendingGroupMappings[id] = groupId; - - if (currentConversationId === id) { - currentConversationGroupId = groupId; - } - } - - if (currentGroupId) { - await loadGroupConversations(currentGroupId); - } - await loadConversationsWithGroups(); - await loadGroups(); - resetBatchSelectionAfterGroupChange(); - } catch (error) { - console.error('批量移动对话失败:', error); - await loadConversationGroupMapping(); - applyBatchConversationFilters(); - const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.moveFailed') : '移动失败'; - const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; - alert(failedMsg + ': ' + (error.message || unknownErr)); - } -} - -function resetBatchSelectionAfterGroupChange() { - applyBatchConversationFilters(); - const selectAll = document.getElementById('batch-select-all'); - if (selectAll) { - selectAll.checked = false; - selectAll.indeterminate = false; - } -} - -async function finishBatchGroupChangeAfterRemove() { - if (currentGroupId) { - await loadGroupConversations(currentGroupId); - } - await loadConversationGroupMapping(); - await loadGroups(); - - const savedGroupId = currentGroupId; - currentGroupId = null; - await loadConversationsWithGroups(); - currentGroupId = savedGroupId; - - resetBatchSelectionAfterGroupChange(); -} - // 删除选中的对话 async function deleteSelectedConversations() { if (typeof requirePermission === 'function' && !requirePermission('chat:delete')) return; @@ -12061,7 +10977,7 @@ async function deleteSelectedConversations() { } const ids = Array.from(checkboxes).map(cb => cb.dataset.conversationId); - + try { for (const id of ids) { await deleteConversation(id, true); // 跳过内部确认,因为批量删除时已经确认过了 @@ -12075,7 +10991,7 @@ async function deleteSelectedConversations() { } catch (error) { console.error('删除失败:', error); const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.deleteFailed') : '删除失败'; - const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; + const unknownErr = '未知错误'; alert(failedMsg + ': ' + (error.message || unknownErr)); } } @@ -12093,12 +11009,6 @@ function closeBatchManageModal() { if (searchInput) searchInput.value = ''; const batchProj = document.getElementById('batch-project-filter'); if (batchProj) batchProj.value = ''; - const batchGroupFilter = document.getElementById(BATCH_GROUP_HEADER_FILTER_SELECT_ID); - if (batchGroupFilter) batchGroupFilter.value = ''; - syncSimpleCustomSelect(BATCH_GROUP_HEADER_FILTER_SELECT_ID); - const batchGroup = document.getElementById('batch-move-group-select'); - if (batchGroup) batchGroup.value = BATCH_GROUP_NONE; - syncSimpleCustomSelect(BATCH_GROUP_FILTER_SELECT_ID); allConversationsForBatch = []; } @@ -12183,927 +11093,13 @@ document.addEventListener('languagechange', function () { } }); } - if (typeof refreshBatchGroupSelect === 'function') { - refreshBatchGroupSelect().then(() => syncSimpleCustomSelect(BATCH_GROUP_FILTER_SELECT_ID)); - } - if (typeof refreshBatchGroupHeaderFilter === 'function') { - refreshBatchGroupHeaderFilter(); - } // 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式 - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); - } else if (typeof loadConversations === 'function') { + if (typeof loadConversations === 'function') { loadConversations(); } }); -// 显示创建分组模态框 -function showCreateGroupModal(andMoveConversation = false) { - const modal = document.getElementById('create-group-modal'); - const input = document.getElementById('create-group-name-input'); - const iconBtn = document.getElementById('create-group-icon-btn'); - const iconPicker = document.getElementById('group-icon-picker'); - const customInput = document.getElementById('custom-icon-input'); - - if (input) { - input.value = ''; - } - // 重置图标为默认值 - if (iconBtn) { - iconBtn.textContent = '📁'; - } - // 清空自定义图标输入框 - if (customInput) { - customInput.value = ''; - } - // 关闭图标选择器 - if (iconPicker) { - iconPicker.style.display = 'none'; - } - if (modal) { - openAppModal('create-group-modal', { focusEl: input }); - modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false'; - } -} - -// 关闭创建分组模态框 -function closeCreateGroupModal() { - closeAppModal('create-group-modal'); - const input = document.getElementById('create-group-name-input'); - if (input) { - input.value = ''; - } - // 重置图标为默认值 - const iconBtn = document.getElementById('create-group-icon-btn'); - if (iconBtn) { - iconBtn.textContent = '📁'; - } - // 清空自定义图标输入框 - const customInput = document.getElementById('custom-icon-input'); - if (customInput) { - customInput.value = ''; - } - // 关闭图标选择器 - const iconPicker = document.getElementById('group-icon-picker'); - if (iconPicker) { - iconPicker.style.display = 'none'; - } -} - -// 选择建议标签 -function selectSuggestion(name) { - const input = document.getElementById('create-group-name-input'); - if (input) { - input.value = name; - input.focus(); - } -} - -// 按 i18n key 选择建议标签(用于国际化下填充当前语言的文案) -function selectSuggestionByKey(i18nKey) { - const input = document.getElementById('create-group-name-input'); - if (input && typeof window.t === 'function') { - input.value = window.t(i18nKey); - input.focus(); - } -} - -// 切换图标选择器显示状态 -function toggleGroupIconPicker() { - const picker = document.getElementById('group-icon-picker'); - if (picker) { - const isVisible = picker.style.display !== 'none'; - picker.style.display = isVisible ? 'none' : 'block'; - } -} - -// 选择分组图标 -function selectGroupIcon(icon) { - const iconBtn = document.getElementById('create-group-icon-btn'); - if (iconBtn) { - iconBtn.textContent = icon; - } - // 清空自定义输入框 - const customInput = document.getElementById('custom-icon-input'); - if (customInput) { - customInput.value = ''; - } - // 关闭选择器 - const picker = document.getElementById('group-icon-picker'); - if (picker) { - picker.style.display = 'none'; - } -} - -// 应用自定义图标 -function applyCustomIcon() { - const customInput = document.getElementById('custom-icon-input'); - if (!customInput) return; - - const customIcon = customInput.value.trim(); - if (!customIcon) { - return; - } - - const iconBtn = document.getElementById('create-group-icon-btn'); - if (iconBtn) { - iconBtn.textContent = customIcon; - } - - // 清空输入框并关闭选择器 - customInput.value = ''; - const picker = document.getElementById('group-icon-picker'); - if (picker) { - picker.style.display = 'none'; - } -} - -// 自定义图标输入框回车键处理 -document.addEventListener('DOMContentLoaded', function() { - mountChatSessionSettingsPopover(); - initSessionSettingsSelects(); - initChatReasoningBarHeightSync(); - initChatPrimaryActionButton(); - const customInput = document.getElementById('custom-icon-input'); - if (customInput) { - customInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - applyCustomIcon(); - } - }); - } - initChatAgentModeFromConfig() - .then(function () { - refreshHitlConfigByCurrentConversation(); - }) - .catch(function () { - refreshHitlConfigByCurrentConversation(); - }); -}); - -document.addEventListener('languagechange', function () { - refreshHitlConfigByCurrentConversation(); - updateChatPrimaryActionState(); -}); - -document.addEventListener('keydown', function (event) { - if (event.key === 'Escape') closeChatSystemModelPicker(); -}); - -// 点击外部关闭图标选择器、对话模式面板、侧栏折叠卡片 -document.addEventListener('click', function(event) { - const picker = document.getElementById('group-icon-picker'); - const iconBtn = document.getElementById('create-group-icon-btn'); - if (picker && iconBtn) { - // 如果点击的不是图标按钮和选择器本身,则关闭选择器 - if (!picker.contains(event.target) && !iconBtn.contains(event.target)) { - picker.style.display = 'none'; - } - } - - const agentWrap = document.getElementById('agent-mode-wrapper'); - const agentPanel = document.getElementById('agent-mode-panel'); - if (agentWrap && agentPanel && agentPanel.style.display === 'flex') { - if (!agentWrap.contains(event.target)) { - closeAgentModePanel(); - } - } - - const modelWrap = document.getElementById('chat-model-shortcut-wrap'); - const modelMenu = document.getElementById('chat-system-model-menu'); - if (modelWrap && modelMenu && !modelMenu.hidden && !modelWrap.contains(event.target)) { - closeChatSystemModelPicker(); - } - - const reasoningWrap = document.getElementById('chat-reasoning-wrapper'); - if (reasoningWrap && reasoningWrap.style.display !== 'none' && - !reasoningWrap.classList.contains('conversation-reasoning-collapsed')) { - if (!reasoningWrap.contains(event.target)) { - closeChatReasoningPanel(); - } - } - - const hitlCard = document.getElementById('hitl-sidebar-card'); - if (hitlCard && !hitlCard.classList.contains('hitl-sidebar-collapsed')) { - if (!hitlCard.contains(event.target)) { - closeHitlSidebarCard(); - } - } -}); - -// 创建分组 -async function createGroup(event) { - if (typeof requirePermission === 'function' && !requirePermission('group:write')) return; - // 阻止事件冒泡 - 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(typeof window.t === 'function' ? window.t('createGroupModal.groupNamePlaceholder') : '请输入分组名称'); - return; - } - - // 前端校验:检查名称是否已存在 - try { - let groups; - if (Array.isArray(groupsCache) && groupsCache.length > 0) { - groups = groupsCache; - } else { - const response = await apiFetch('/api/groups'); - groups = await response.json(); - } - - // 确保groups是有效数组 - if (!Array.isArray(groups)) { - groups = []; - } - - const nameExists = groups.some(g => g.name === name); - if (nameExists) { - alert(typeof window.t === 'function' ? window.t('createGroupModal.nameExists') : '分组名称已存在,请使用其他名称'); - return; - } - } catch (error) { - console.error('检查分组名称失败:', error); - } - - // 获取选中的图标 - const iconBtn = document.getElementById('create-group-icon-btn'); - const selectedIcon = iconBtn ? iconBtn.textContent.trim() : '📁'; - - try { - const response = await apiFetch('/api/groups', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: name, - icon: selectedIcon, - }), - }); - - if (!response.ok) { - const error = await response.json(); - const nameExistsMsg = typeof window.t === 'function' ? window.t('createGroupModal.nameExists') : '分组名称已存在,请使用其他名称'; - if (error.error && error.error.includes('已存在')) { - alert(nameExistsMsg); - return; - } - const createFailedMsg = typeof window.t === 'function' ? window.t('createGroupModal.createFailed') : '创建失败'; - throw new Error(error.error || createFailedMsg); - } - - const newGroup = await response.json(); - - // 检查"移动到分组"子菜单是否打开 - const submenu = document.getElementById('move-to-group-submenu'); - const isSubmenuOpen = submenu && submenu.style.display !== 'none'; - - await loadGroups(); - - const modal = document.getElementById('create-group-modal'); - const shouldMove = modal && modal.dataset.moveConversation === 'true'; - - closeCreateGroupModal(); - - if (shouldMove && contextMenuConversationId) { - moveConversationToGroup(contextMenuConversationId, newGroup.id); - } - - // 如果子菜单是打开的,刷新它,让新创建的分组立即显示 - if (isSubmenuOpen) { - await showMoveToGroupSubmenu(); - } - } catch (error) { - console.error('创建分组失败:', error); - const createFailedMsg = typeof window.t === 'function' ? window.t('createGroupModal.createFailed') : '创建失败'; - const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; - alert(createFailedMsg + ': ' + (error.message || unknownErr)); - } -} - -// 进入分组详情 -async function enterGroupDetail(groupId) { - currentGroupId = groupId; - // 进入分组详情页面时,清除当前对话所属的分组ID,避免高亮冲突 - // 因为此时用户是在查看分组详情,而不是在查看分组中的某个对话 - currentConversationGroupId = null; - - 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 chatContainer = document.querySelector('.chat-container'); - const titleEl = document.getElementById('group-detail-title'); - - // 保持侧边栏可见 - if (sidebar) sidebar.style.display = 'flex'; - // 隐藏对话界面,显示分组详情页 - if (chatContainer) chatContainer.style.display = 'none'; - if (groupDetailPage) groupDetailPage.style.display = 'flex'; - if (titleEl) titleEl.textContent = group.name; - - // 刷新分组列表,确保当前分组高亮显示 - await loadGroups(); - - // 加载分组对话(如果有搜索查询则使用搜索查询) - loadGroupConversations(groupId, currentGroupSearchQuery); - } catch (error) { - console.error('加载分组失败:', error); - currentGroupId = null; - } -} - -// 退出分组详情 -function exitGroupDetail() { - currentGroupId = null; - currentGroupSearchQuery = ''; // 清除搜索状态 - - // 隐藏搜索框并清除搜索内容 - const searchContainer = document.getElementById('group-search-container'); - const searchInput = document.getElementById('group-search-input'); - if (searchContainer) searchContainer.style.display = 'none'; - if (searchInput) searchInput.value = ''; - - const sidebar = document.querySelector('.conversation-sidebar'); - const groupDetailPage = document.getElementById('group-detail-page'); - const chatContainer = document.querySelector('.chat-container'); - - // 保持侧边栏可见 - if (sidebar) sidebar.style.display = 'flex'; - // 隐藏分组详情页,显示对话界面 - if (groupDetailPage) groupDetailPage.style.display = 'none'; - if (chatContainer) chatContainer.style.display = 'flex'; - - loadConversationsWithGroups(); -} - -// 加载分组中的对话 -async function loadGroupConversations(groupId, searchQuery = '') { - try { - if (!groupId) { - console.error('loadGroupConversations: groupId is null or undefined'); - return; - } - - // 确保分组映射已加载 - if (Object.keys(conversationGroupMappingCache).length === 0) { - await loadConversationGroupMapping(); - } - - // 先清空列表,避免显示旧数据 - const list = document.getElementById('group-conversations-list'); - if (!list) { - console.error('group-conversations-list element not found'); - return; - } - - // 显示加载状态 - if (searchQuery) { - list.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.searching') : '搜索中...') + ''; - } else { - list.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.loading') : '加载中...') + ''; - } - - // 构建URL,如果有搜索关键词则添加search参数 - let url = `/api/groups/${groupId}/conversations`; - if (searchQuery && searchQuery.trim()) { - url += '?search=' + encodeURIComponent(searchQuery.trim()); - } - - const response = await apiFetch(url); - if (!response.ok) { - console.error(`Failed to load conversations for group ${groupId}:`, response.statusText); - list.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.loadFailedRetry') : '加载失败,请重试') + ''; - return; - } - - let groupConvs = await response.json(); - - // 处理 null 或 undefined 的情况,将其视为空数组 - if (!groupConvs) { - groupConvs = []; - } - - // 验证返回的数据类型 - if (!Array.isArray(groupConvs)) { - console.error(`Invalid response for group ${groupId}:`, groupConvs); - list.innerHTML = '' + (typeof window.t === 'function' ? window.t('chat.dataFormatError') : '数据格式错误') + ''; - return; - } - - // 更新分组映射缓存(只更新当前分组的对话) - // 先清理该分组之前的映射(如果有对话被移出) - Object.keys(conversationGroupMappingCache).forEach(convId => { - if (conversationGroupMappingCache[convId] === groupId) { - // 如果这个对话不在新的列表中,说明已被移出 - if (!groupConvs.find(c => c.id === convId)) { - delete conversationGroupMappingCache[convId]; - } - } - }); - - // 更新当前分组的对话映射 - groupConvs.forEach(conv => { - conversationGroupMappingCache[conv.id] = groupId; - }); - - // 再次清空列表(清除"加载中"提示) - list.innerHTML = ''; - - if (groupConvs.length === 0) { - const emptyMsg = typeof window.t === 'function' ? window.t('chat.emptyGroupConversations') : '该分组暂无对话'; - const noMatchMsg = typeof window.t === 'function' ? window.t('chat.noMatchingConversationsInGroup') : '未找到匹配的对话'; - if (searchQuery && searchQuery.trim()) { - list.innerHTML = '' + (noMatchMsg || '未找到匹配的对话') + ''; - } else { - list.innerHTML = '' + (emptyMsg || '该分组暂无对话') + ''; - } - return; - } - - // 加载每个对话的详细信息以获取消息 - for (const conv of groupConvs) { - try { - // 验证对话ID存在 - if (!conv.id) { - console.warn('Conversation missing id:', conv); - continue; - } - - const convResponse = await apiFetch(`/api/conversations/${conv.id}`); - if (!convResponse.ok) { - console.error(`Failed to load conversation ${conv.id}:`, convResponse.statusText); - continue; - } - - const fullConv = await convResponse.json(); - - const item = document.createElement('div'); - item.className = 'group-conversation-item'; - item.dataset.conversationId = conv.id; - // 只有在分组详情页面且对话ID匹配时才显示active状态 - // 如果不在分组详情页面,不应该显示active状态 - if (currentGroupId && conv.id === currentConversationId) { - item.classList.add('active'); - } else { - item.classList.remove('active'); - } - - // 创建内容包装器 - const contentWrapper = document.createElement('div'); - contentWrapper.className = 'group-conversation-content-wrapper'; - - const titleWrapper = document.createElement('div'); - titleWrapper.style.display = 'flex'; - titleWrapper.style.alignItems = 'center'; - titleWrapper.style.gap = '4px'; - - const title = document.createElement('div'); - title.className = 'group-conversation-title'; - const titleText = fullConv.title || conv.title || '未命名对话'; - title.textContent = safeTruncateText(titleText, 60); - title.title = titleText; // 设置完整标题以便悬停查看 - titleWrapper.appendChild(title); - - // 如果对话在分组中置顶,显示置顶图标 - if (conv.groupPinned) { - const pinIcon = document.createElement('span'); - pinIcon.className = 'conversation-item-pinned'; - pinIcon.innerHTML = '📌'; - pinIcon.title = '在分组中已置顶'; - titleWrapper.appendChild(pinIcon); - } - - contentWrapper.appendChild(titleWrapper); - - const timeWrapper = document.createElement('div'); - timeWrapper.className = 'group-conversation-time'; - const dateObj = fullConv.updatedAt ? new Date(fullConv.updatedAt) : new Date(); - const convListLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US'; - timeWrapper.textContent = dateObj.toLocaleString(convListLocale, { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - - contentWrapper.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; - contentWrapper.appendChild(content); - } - } - - item.appendChild(contentWrapper); - - // 添加三个点菜单按钮 - const menuBtn = document.createElement('button'); - menuBtn.className = 'conversation-item-menu'; - menuBtn.innerHTML = '⋯'; - menuBtn.onclick = (e) => openConversationContextMenuForId(e, conv.id, fullConv.title || conv.title || ''); - item.appendChild(menuBtn); - - item.onclick = (e) => { - e.preventDefault(); - e.stopPropagation(); - // 切换到对话界面,但保持分组详情状态 - const groupDetailPage = document.getElementById('group-detail-page'); - const chatContainer = document.querySelector('.chat-container'); - if (groupDetailPage) groupDetailPage.style.display = 'none'; - if (chatContainer) chatContainer.style.display = 'flex'; - loadConversation(conv.id); - }; - - 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 renamePrompt = typeof window.t === 'function' ? window.t('chat.renameGroupPrompt') : '请输入新名称:'; - const newName = prompt(renamePrompt, group.name); - if (newName === null || !newName.trim()) return; - - const trimmedName = newName.trim(); - - // 前端校验:检查名称是否已存在(排除当前分组) - let groups; - if (Array.isArray(groupsCache) && groupsCache.length > 0) { - groups = groupsCache; - } else { - const response = await apiFetch('/api/groups'); - groups = await response.json(); - } - - // 确保groups是有效数组 - if (!Array.isArray(groups)) { - groups = []; - } - - const nameExists = groups.some(g => g.name === trimmedName && g.id !== currentGroupId); - if (nameExists) { - alert(typeof window.t === 'function' ? window.t('createGroupModal.nameExists') : '分组名称已存在,请使用其他名称'); - 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 || '未知错误')); - } -} - -function removeConversationGroupFromLocalState(groupId) { - groupsCache = groupsCache.filter(group => group.id !== groupId); - Object.keys(conversationGroupMappingCache).forEach(convId => { - if (conversationGroupMappingCache[convId] === groupId) { - delete conversationGroupMappingCache[convId]; - } - }); - document.querySelectorAll('.group-item[data-group-id]').forEach(item => { - if (item.dataset.groupId === groupId) item.remove(); - }); -} - -async function deleteConversationGroupById(groupId, options = {}) { - if (typeof requirePermission === 'function' && !requirePermission('group:delete')) { - if (options.closeContextMenu) closeGroupContextMenu(); - return; - } - if (!groupId) return; - - const deleteConfirmMsg = typeof window.t === 'function' ? window.t('chat.deleteGroupConfirm') : '确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。'; - if (!confirm(deleteConfirmMsg)) { - if (options.closeContextMenu) closeGroupContextMenu(); - return; - } - - try { - const deleteResponse = await apiFetch(`/api/groups/${groupId}`, { - method: 'DELETE', - }); - await assertConversationActionResponse(deleteResponse, '删除分组失败'); - - // 删除成功后先同步本地界面,再做服务端列表校准。 - removeConversationGroupFromLocalState(groupId); - if (currentGroupId === groupId) exitGroupDetail(); - - // 如果"移动到分组"子菜单是打开的,刷新它 - const submenu = document.getElementById('move-to-group-submenu'); - await loadGroups(); - if (submenu && submenu.style.display !== 'none') { - await showMoveToGroupSubmenu(); - } - - // 刷新对话列表,确保之前被分组的对话能立即显示 - await loadConversationsWithGroups(); - } catch (error) { - console.error('删除分组失败:', error); - alert('删除失败: ' + (error.message || '未知错误')); - } finally { - if (options.closeContextMenu) closeGroupContextMenu(); - } -} - -// 删除当前分组详情中的分组 -async function deleteGroup() { - await deleteConversationGroupById(currentGroupId); -} - -// 从上下文菜单重命名分组 -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 renamePrompt = typeof window.t === 'function' ? window.t('chat.renameGroupPrompt') : '请输入新名称:'; - const newName = prompt(renamePrompt, group.name); - if (newName === null || !newName.trim()) { - closeGroupContextMenu(); - return; - } - - const trimmedName = newName.trim(); - - // 前端校验:检查名称是否已存在(排除当前分组) - let groups; - if (Array.isArray(groupsCache) && groupsCache.length > 0) { - groups = groupsCache; - } else { - const response = await apiFetch('/api/groups'); - groups = await response.json(); - } - - // 确保groups是有效数组 - if (!Array.isArray(groups)) { - groups = []; - } - - const nameExists = groups.some(g => g.name === trimmedName && g.id !== groupId); - if (nameExists) { - alert(typeof window.t === 'function' ? window.t('createGroupModal.nameExists') : '分组名称已存在,请使用其他名称'); - 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); - const failedLabel = typeof window.t === 'function' ? window.t('chat.renameFailed') : '重命名失败'; - const unknownErr = typeof window.t === 'function' ? window.t('createGroupModal.unknownError') : '未知错误'; - alert(failedLabel + ': ' + (error.message || unknownErr)); - } - - 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; - await deleteConversationGroupById(groupId, { closeContextMenu: true }); -} - -// 关闭分组上下文菜单 -function closeGroupContextMenu() { - const menu = document.getElementById('group-context-menu'); - if (menu) { - menu.style.display = 'none'; - } - contextMenuGroupId = null; -} - - -// 分组搜索相关变量 -let groupSearchTimer = null; -let currentGroupSearchQuery = ''; - -// 切换分组搜索框显示/隐藏 -function toggleGroupSearch() { - const searchContainer = document.getElementById('group-search-container'); - const searchInput = document.getElementById('group-search-input'); - - if (!searchContainer || !searchInput) return; - - if (searchContainer.style.display === 'none') { - searchContainer.style.display = 'block'; - searchInput.focus(); - } else { - searchContainer.style.display = 'none'; - clearGroupSearch(); - } -} - -// 处理分组搜索输入 -function handleGroupSearchInput(event) { - // 支持回车键搜索 - if (event.key === 'Enter') { - event.preventDefault(); - performGroupSearch(); - return; - } - - // 支持ESC键关闭搜索 - if (event.key === 'Escape') { - clearGroupSearch(); - toggleGroupSearch(); - return; - } - - const searchInput = document.getElementById('group-search-input'); - const clearBtn = document.getElementById('group-search-clear-btn'); - - if (!searchInput) return; - - const query = searchInput.value.trim(); - - // 显示/隐藏清除按钮 - if (clearBtn) { - clearBtn.style.display = query ? 'block' : 'none'; - } - - // 防抖搜索 - if (groupSearchTimer) { - clearTimeout(groupSearchTimer); - } - - groupSearchTimer = setTimeout(() => { - performGroupSearch(); - }, 300); // 300ms 防抖 -} - -// 执行分组搜索 -async function performGroupSearch() { - const searchInput = document.getElementById('group-search-input'); - if (!searchInput || !currentGroupId) return; - - const query = searchInput.value.trim(); - currentGroupSearchQuery = query; - - // 加载搜索结果 - await loadGroupConversations(currentGroupId, query); -} - -// 清除分组搜索 -function clearGroupSearch() { - const searchInput = document.getElementById('group-search-input'); - const clearBtn = document.getElementById('group-search-clear-btn'); - - if (searchInput) { - searchInput.value = ''; - } - if (clearBtn) { - clearBtn.style.display = 'none'; - } - - currentGroupSearchQuery = ''; - - // 重新加载分组对话(不搜索) - if (currentGroupId) { - loadGroupConversations(currentGroupId, ''); - } -} - -// 初始化时加载分组 +// 初始化时加载对话列表 document.addEventListener('DOMContentLoaded', async () => { ensureProjectSidebarStructure(); if (window.i18nReady) await window.i18nReady; @@ -13117,25 +11113,24 @@ document.addEventListener('DOMContentLoaded', async () => { initConversationProjectCustomSelect(); initConversationsPaginationEvents(); await refreshConversationProjectFilter(); - await loadGroups(); - await loadConversationsWithGroups(); - + await loadConversations(); + // 添加页面焦点时自动刷新对话列表的功能 // 这样当通过OpenAPI创建对话后,切换回页面时能自动看到新对话 let lastFocusTime = Date.now(); const CONVERSATION_REFRESH_INTERVAL = 30000; // 30秒内最多刷新一次,避免过于频繁 - + window.addEventListener('focus', () => { const now = Date.now(); // 如果距离上次刷新超过30秒,才刷新对话列表 if (now - lastFocusTime > CONVERSATION_REFRESH_INTERVAL) { lastFocusTime = now; - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); + if (typeof loadConversations === 'function') { + loadConversations(); } } }); - + // 监听页面可见性变化(当用户切换标签页回来时) document.addEventListener('visibilitychange', () => { if (!document.hidden) { @@ -13143,8 +11138,8 @@ document.addEventListener('DOMContentLoaded', async () => { const now = Date.now(); if (now - lastFocusTime > CONVERSATION_REFRESH_INTERVAL) { lastFocusTime = now; - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); + if (typeof loadConversations === 'function') { + loadConversations(); } } } @@ -13155,7 +11150,7 @@ document.addEventListener('DOMContentLoaded', async () => { const id = e.detail && e.detail.conversationId; if (!id) return; // API 已确认删除后立即移除可见列表项,网络刷新只负责校准分页和计数。 - document.querySelectorAll('.conversation-item[data-conversation-id], .group-conversation-item[data-conversation-id]') + document.querySelectorAll('.conversation-item[data-conversation-id]') .forEach((item) => { if (item.dataset.conversationId === id) item.remove(); }); @@ -13169,9 +11164,7 @@ document.addEventListener('DOMContentLoaded', async () => { renderChatWelcomeEmptyState(); addAttackChainButton(null); } - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); - } else if (typeof loadConversations === 'function') { + if (typeof loadConversations === 'function') { loadConversations(); } }); diff --git a/web/static/js/conversation-actions-sync.test.cjs b/web/static/js/conversation-actions-sync.test.cjs index afc66cd3..58aba2f7 100644 --- a/web/static/js/conversation-actions-sync.test.cjs +++ b/web/static/js/conversation-actions-sync.test.cjs @@ -8,18 +8,20 @@ const template = fs.readFileSync('web/templates/index.html', 'utf8'); function functionSource(source, name, nextName) { const start = source.indexOf(`function ${name}(`); - const end = source.indexOf(`function ${nextName}(`, start); + const end = nextName ? source.indexOf(`function ${nextName}(`, start) : source.length; assert.notEqual(start, -1, `${name} should exist`); - assert.notEqual(end, -1, `${nextName} should follow ${name}`); + if (nextName) { + assert.notEqual(end, -1, `${nextName} should follow ${name}`); + } return source.slice(start, end); } test('全局置顶检查接口结果并即时通知项目文件夹', () => { - const source = functionSource(chat, 'pinConversation', 'showMoveToGroupSubmenu'); + const source = functionSource(chat, 'pinConversation'); assert.match(source, /assertConversationActionResponse\(updateResponse, '更新置顶状态失败'\)/); assert.match(source, /notifyConversationPinnedChanged\(convId, newPinned\)/); - assert.match(source, /loadConversationsWithGroups\(\)/); + assert.match(source, /loadConversations\(\)/); }); test('项目文件夹内置顶对话优先排序并显示图钉', () => { @@ -58,17 +60,20 @@ test('项目文件夹菜单可以置顶并立即更新排序', () => { assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/); }); -test('对话侧栏不再显示对话分组区域', () => { +test('对话侧栏只保留最近对话区域', () => { assert.doesNotMatch(template, /class="conversation-groups-section"/); assert.doesNotMatch(template, /id="conversation-groups-list"/); }); -test('删除对话分组检查接口结果并先清理本地状态', () => { - const deleteSource = functionSource(chat, 'deleteConversationGroupById', 'deleteGroup'); - const contextSource = functionSource(chat, 'deleteGroupFromContext', 'closeGroupContextMenu'); +test('对话三点菜单仍绑定打开上下文菜单', () => { + const itemSource = functionSource(chat, 'createConversationListItemWithMenu', 'openConversationContextMenuForId'); + const menuSource = functionSource(chat, 'showConversationContextMenu', 'ensureConversationRenameModal'); - assert.match(deleteSource, /assertConversationActionResponse\(deleteResponse, '删除分组失败'\)/); - assert.match(deleteSource, /removeConversationGroupFromLocalState\(groupId\)/); - assert.match(deleteSource, /if \(currentGroupId === groupId\) exitGroupDetail\(\)/); - assert.match(contextSource, /deleteConversationGroupById\(groupId, \{ closeContextMenu: true \}\)/); + assert.match(itemSource, /menuBtn\.onclick = \(e\) => openConversationContextMenuForId\(e, conversation\.id, conversation\.title \|\| ''\)/); + assert.match(menuSource, /const menu = document\.getElementById\('conversation-context-menu'\)/); + assert.match(menuSource, /menu\.style\.display = 'block'/); + assert.match(chat, /function clearDownloadMarkdownSubmenuHideTimeout\(/); + assert.match(chat, /function handleDownloadMarkdownSubmenuEnter\(/); + assert.match(chat, /function handleDownloadMarkdownSubmenuLeave\(/); + assert.match(chat, /function hideDownloadMarkdownSubmenu\(/); }); diff --git a/web/static/js/modal.js b/web/static/js/modal.js index 2b119819..ed7596d8 100644 --- a/web/static/js/modal.js +++ b/web/static/js/modal.js @@ -12,7 +12,6 @@ 'skill-modal', 'agent-md-modal', 'batch-manage-modal', - 'create-group-modal', 'workflow-meta-modal', 'workflow-dry-run-modal', 'login-overlay', diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index f9bcbecc..c0fc0059 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -2999,11 +2999,9 @@ function handleStreamEvent(event, progressElement, progressId, loadActiveTasks(); // 延迟刷新对话列表,确保用户消息已保存,updated_at已更新 // 这样新对话才能正确显示在最近对话列表的顶部 - // 使用loadConversationsWithGroups确保分组映射缓存正确加载,无论是否有分组都能立即显示 + // 刷新最近对话列表 setTimeout(() => { - if (typeof loadConversationsWithGroups === 'function') { - loadConversationsWithGroups(); - } else if (typeof loadConversations === 'function') { + if (typeof loadConversations === 'function') { loadConversations(); } if (typeof window.refreshChatProjectFolders === 'function') { diff --git a/web/static/js/rbac-guards.js b/web/static/js/rbac-guards.js index 7b3bcb92..1e9a9929 100644 --- a/web/static/js/rbac-guards.js +++ b/web/static/js/rbac-guards.js @@ -6,7 +6,7 @@ 'use strict'; const GLOBAL_WRITE_HANDLER_PERMISSIONS = { - // 对话 / 分组 + // 对话 sendMessage: 'chat:write', startNewConversation: 'chat:write', deleteConversation: 'chat:delete', @@ -16,15 +16,6 @@ deleteSelectedConversations: 'chat:delete', renameConversation: 'chat:write', pinConversation: 'chat:write', - showCreateGroupModal: 'group:write', - createGroup: 'group:write', - editGroup: 'group:write', - deleteGroup: 'group:delete', - deleteGroupFromContext: 'group:delete', - pinGroupFromContext: 'group:write', - renameGroupFromContext: 'group:write', - applyBatchGroupChange: 'group:write', - applyCustomIcon: 'group:write', // 人机协同 applyHitlSidebarConfig: 'hitl:write', diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 03bbdb13..0ee7d71e 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -2557,7 +2557,7 @@ function selectWebshell(id, stateReady) { '' + '' + '' + - '' + (wsT('chatGroup.rolePanelTitle') || '选择角色') + '' + + '' + (wsT('chat.rolePanelTitle') || '选择角色') + '' + '' + '' + '' + diff --git a/web/templates/index.html b/web/templates/index.html index 5dbdce14..40cddd8a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1084,51 +1084,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1210,7 +1165,7 @@ - 选择角色 + 选择角色 @@ -5923,10 +5878,6 @@ 全部项目 未绑定项目 - - 全部分组 - 无分组 - @@ -5945,7 +5896,6 @@ 对话名称 项目 - 对话分组 最近一次对话时间 操作 @@ -5953,12 +5903,6 @@ - - - - - 创建分组 - × - - - 分组功能可将对话集中归类管理,让对话更加井然有序。 - - 📁 - - - - - 选择图标 - - - 确定 - - - - 📁 - 🔒 - 🛡️ - ⚔️ - 🎯 - 🔍 - 💻 - 🐛 - 🚀 - ⚡ - 🔥 - 💡 - 🎮 - 🏴☠️ - 🕵️ - 🔑 - 📡 - 🌐 - 📊 - 📝 - 🗂️ - 📌 - ⭐ - 💎 - - - - - 渗透测试 - CTF - 红队 - 漏洞挖掘 - - - - - - @@ -6088,16 +5969,6 @@ 批量管理 - - - - - 移动到分组 - - - - - @@ -6107,29 +5978,6 @@ - - - - - - - - 重命名 - - - - - - 置顶此分组 - - - - - - 删除此分组 - - - @@ -6443,7 +6291,7 @@ - 选择角色 + 选择角色 ×
${nodeData.id}
分组功能可将对话集中归类管理,让对话更加井然有序。