Remove conversation grouping feature

This commit is contained in:
temp
2026-08-26 15:00:09 +08:00
parent fbbe984005
commit c70da22de7
26 changed files with 467 additions and 5276 deletions
+1 -1
View File
@@ -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.
-1
View File
@@ -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:
-1
View File
@@ -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` |
特殊权限说明:
+1 -17
View File
@@ -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)
+16 -76
View File
@@ -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 {
-90
View File
@@ -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字段是否存在
-486
View File
@@ -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
}
-1
View File
@@ -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"},
} {
+1 -20
View File
@@ -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)
+32 -12
View File
@@ -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")
-438
View File
@@ -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)
}
-41
View File
@@ -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: `<img src=x onerror="alert(1)">`, icon: "📁"},
{name: "日常安全巡检", icon: `<svg onload=alert(1)>`},
{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")
}
})
}
}
-497
View File
@@ -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{}{
+6 -11
View File
@@ -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",
// 新增缺失端点响应
-3
View File
@@ -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",
-2
View File
@@ -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"):
File diff suppressed because it is too large Load Diff
+51 -113
View File
@@ -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",
+48 -110
View File
@@ -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": "查看执行监控",
+279 -2286
View File
File diff suppressed because it is too large Load Diff
@@ -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\(/);
});
-1
View File
@@ -12,7 +12,6 @@
'skill-modal',
'agent-md-modal',
'batch-manage-modal',
'create-group-modal',
'workflow-meta-modal',
'workflow-dry-run-modal',
'login-overlay',
+2 -4
View File
@@ -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') {
+1 -10
View File
@@ -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',
+1 -1
View File
@@ -2557,7 +2557,7 @@ function selectWebshell(id, stateReady) {
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
'</button>' +
'<div id="ws-role-selection-panel" class="role-selection-panel" style="display:none;">' +
'<div class="role-selection-panel-header"><h3 class="role-selection-panel-title">' + (wsT('chatGroup.rolePanelTitle') || '选择角色') + '</h3>' +
'<div class="role-selection-panel-header"><h3 class="role-selection-panel-title">' + (wsT('chat.rolePanelTitle') || '选择角色') + '</h3>' +
'<button type="button" class="role-selection-panel-close" onclick="wsCloseRolePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
'</div><div id="ws-role-selection-list" class="role-selection-list-main"></div></div>' +
'</div>' +
+2 -154
View File
@@ -1084,51 +1084,6 @@
</div>
</aside>
<!-- 分组详情页面 -->
<div id="group-detail-page" class="group-detail-page" style="display: none;">
<div class="group-detail-header">
<button class="back-btn" onclick="exitGroupDetail()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M12 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h2 id="group-detail-title" class="group-detail-title"></h2>
<div class="group-detail-actions">
<button class="group-action-btn" onclick="toggleGroupSearch()" data-i18n="chatGroup.search" data-i18n-attr="title" title="搜索" id="group-search-toggle-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
<path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button class="group-action-btn" data-require-permission="group:write" onclick="editGroup()" data-i18n="chatGroup.edit" data-i18n-attr="title" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="group-action-btn delete-btn" data-require-permission="group:delete" onclick="deleteGroup()" data-i18n="chatGroup.delete" data-i18n-attr="title" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<div id="group-search-container" class="group-search-container" style="display: none;">
<div class="group-search-input-wrapper">
<input type="text" id="group-search-input" class="group-search-input" data-i18n="chat.searchInGroup" data-i18n-attr="placeholder" placeholder="搜索分组中的对话..." onkeyup="handleGroupSearchInput(event)" oninput="handleGroupSearchInput(event)">
<button class="group-search-clear-btn" onclick="clearGroupSearch()" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索" id="group-search-clear-btn" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="m8 8 8 8M16 8l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
<div class="group-detail-content">
<div id="group-conversations-list" class="group-conversations-list"></div>
</div>
</div>
<!-- 对话界面 -->
<div class="chat-container">
<!-- 会话顶部栏(只在有会话选中时显示) -->
@@ -1210,7 +1165,7 @@
<!-- 角色选择下拉面板 -->
<div id="role-selection-panel" class="role-selection-panel" style="display: none;">
<div class="role-selection-panel-header">
<h3 class="role-selection-panel-title" data-i18n="chatGroup.rolePanelTitle">选择角色</h3>
<h3 class="role-selection-panel-title" data-i18n="chat.rolePanelTitle">选择角色</h3>
<button class="role-selection-panel-close" onclick="closeRoleSelectionPanel()" data-i18n="common.close" data-i18n-attr="title" title="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -5923,10 +5878,6 @@
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
<option value="__none__" data-i18n="chat.filterUnboundProjects">未绑定项目</option>
</select>
<select id="batch-group-filter" class="conversation-project-filter-native" onchange="applyBatchConversationFilters()" data-i18n="batchManageModal.filterByGroup" data-i18n-attr="title" title="按分组筛选">
<option value="" data-i18n="batchManageModal.filterAllGroups">全部分组</option>
<option value="__none__" data-i18n="batchManageModal.filterUngrouped">无分组</option>
</select>
<div class="batch-search-box">
<input type="text" id="batch-search-input" data-i18n="batchManageModal.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索历史记录" oninput="filterBatchConversations(this.value)" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -5945,7 +5896,6 @@
</div>
<div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div>
<div class="batch-table-col-project" data-i18n="batchManageModal.project">项目</div>
<div class="batch-table-col-group" data-i18n="batchManageModal.group">对话分组</div>
<div class="batch-table-col-time" data-i18n="batchManageModal.lastTime">最近一次对话时间</div>
<div class="batch-table-col-action" data-i18n="batchManageModal.action">操作</div>
</div>
@@ -5953,12 +5903,6 @@
</div>
</div>
<div class="modal-footer batch-manage-footer">
<div class="batch-footer-move">
<select id="batch-move-group-select" class="batch-move-group-select conversation-project-filter-native" data-i18n="batchManageModal.setGroup" data-i18n-attr="title" title="设置分组">
<option value="__none__" data-i18n="batchManageModal.noGroupOption">无分组</option>
</select>
<button type="button" class="btn-secondary" onclick="applyBatchGroupChange()" data-i18n="batchManageModal.setGroup">设置分组</button>
</div>
<div class="batch-footer-actions">
<button class="btn-secondary" onclick="closeBatchManageModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" data-require-permission="chat:delete" onclick="deleteSelectedConversations()" data-i18n="batchManageModal.deleteSelected">删除所选</button>
@@ -5967,69 +5911,6 @@
</div>
</div>
<!-- 创建分组模态框 -->
<div id="create-group-modal" class="modal">
<div class="modal-content create-group-modal-content">
<div class="modal-header">
<h2 data-i18n="createGroupModal.title">创建分组</h2>
<span class="modal-close" onclick="closeCreateGroupModal()">&times;</span>
</div>
<div class="modal-body create-group-body">
<p class="create-group-description" data-i18n="createGroupModal.description">分组功能可将对话集中归类管理,让对话更加井然有序。</p>
<div class="create-group-input-wrapper">
<button type="button" class="group-icon-input" id="create-group-icon-btn" onclick="toggleGroupIconPicker()" data-i18n="createGroupModal.selectIcon" data-i18n-attr="title" title="点击选择图标">📁</button>
<input type="text" id="create-group-name-input" data-i18n="createGroupModal.groupNamePlaceholder" data-i18n-attr="placeholder" placeholder="请输入分组名称" />
<!-- Emoji选择器面板 -->
<div id="group-icon-picker" class="group-icon-picker" style="display: none;">
<div class="icon-picker-header">
<span data-i18n="createGroupModal.pickIcon">选择图标</span>
<div class="icon-picker-custom">
<input type="text" id="custom-icon-input" class="custom-icon-input" data-i18n="createGroupModal.customIcon" data-i18n-attr="placeholder" placeholder="自定义" maxlength="2" />
<button type="button" class="custom-icon-btn" onclick="applyCustomIcon()" data-i18n="createGroupModal.confirmIcon">确定</button>
</div>
</div>
<div class="icon-picker-grid">
<span class="icon-option" onclick="selectGroupIcon('📁')">📁</span>
<span class="icon-option" onclick="selectGroupIcon('🔒')">🔒</span>
<span class="icon-option" onclick="selectGroupIcon('🛡️')">🛡️</span>
<span class="icon-option" onclick="selectGroupIcon('⚔️')">⚔️</span>
<span class="icon-option" onclick="selectGroupIcon('🎯')">🎯</span>
<span class="icon-option" onclick="selectGroupIcon('🔍')">🔍</span>
<span class="icon-option" onclick="selectGroupIcon('💻')">💻</span>
<span class="icon-option" onclick="selectGroupIcon('🐛')">🐛</span>
<span class="icon-option" onclick="selectGroupIcon('🚀')">🚀</span>
<span class="icon-option" onclick="selectGroupIcon('⚡')"></span>
<span class="icon-option" onclick="selectGroupIcon('🔥')">🔥</span>
<span class="icon-option" onclick="selectGroupIcon('💡')">💡</span>
<span class="icon-option" onclick="selectGroupIcon('🎮')">🎮</span>
<span class="icon-option" onclick="selectGroupIcon('🏴‍☠️')">🏴‍☠️</span>
<span class="icon-option" onclick="selectGroupIcon('🕵️')">🕵️</span>
<span class="icon-option" onclick="selectGroupIcon('🔑')">🔑</span>
<span class="icon-option" onclick="selectGroupIcon('📡')">📡</span>
<span class="icon-option" onclick="selectGroupIcon('🌐')">🌐</span>
<span class="icon-option" onclick="selectGroupIcon('📊')">📊</span>
<span class="icon-option" onclick="selectGroupIcon('📝')">📝</span>
<span class="icon-option" onclick="selectGroupIcon('🗂️')">🗂️</span>
<span class="icon-option" onclick="selectGroupIcon('📌')">📌</span>
<span class="icon-option" onclick="selectGroupIcon('⭐')"></span>
<span class="icon-option" onclick="selectGroupIcon('💎')">💎</span>
</div>
</div>
</div>
<div class="create-group-suggestions">
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionPenetrationTest" onclick="selectSuggestionByKey('createGroupModal.suggestionPenetrationTest')">渗透测试</div>
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionCtf" onclick="selectSuggestionByKey('createGroupModal.suggestionCtf')">CTF</div>
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionRedTeam" onclick="selectSuggestionByKey('createGroupModal.suggestionRedTeam')">红队</div>
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionVulnerabilityMining" onclick="selectSuggestionByKey('createGroupModal.suggestionVulnerabilityMining')">漏洞挖掘</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeCreateGroupModal()" data-i18n="createGroupModal.cancel">取消</button>
<button class="btn-primary" data-require-permission="group:write" onclick="createGroup(event)" data-i18n="createGroupModal.create">创建</button>
</div>
</div>
</div>
<!-- 上下文菜单 -->
<div id="conversation-context-menu" class="context-menu" style="display: none;">
<div id="attack-chain-menu-item" class="context-menu-item" onclick="showAttackChainFromContext()">
@@ -6088,16 +5969,6 @@
</svg>
<span data-i18n="contextMenu.batchManage">批量管理</span>
</div>
<div class="context-menu-item context-menu-item-has-submenu" onmouseenter="handleMoveToGroupSubmenuEnter()" onmouseleave="handleMoveToGroupSubmenuLeave(event)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path 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" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.moveToGroup">移动到分组</span>
<svg class="submenu-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div id="move-to-group-submenu" class="context-submenu" style="display: none;" onmouseenter="clearSubmenuHideTimeout()" onmouseleave="hideMoveToGroupSubmenu()"></div>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item context-menu-item-danger" data-require-permission="chat:delete" onclick="deleteConversationFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -6107,29 +5978,6 @@
</div>
</div>
<!-- 分组上下文菜单 -->
<div id="group-context-menu" class="context-menu" style="display: none;">
<div class="context-menu-item" onclick="renameGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.rename">重命名</span>
</div>
<div class="context-menu-item" onclick="pinGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 17v5M5 17h14l-1-7H6l-1 7zM9 10V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span id="pin-group-menu-text" data-i18n="contextMenu.pinGroup">置顶此分组</span>
</div>
<div class="context-menu-item context-menu-item-danger" data-require-permission="group:delete" onclick="deleteGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.deleteGroup">删除此分组</span>
</div>
</div>
<!-- 项目列表操作菜单 -->
<div id="projects-list-action-menu" class="context-menu" style="display: none;" role="menu">
<div id="projects-list-menu-edit" class="context-menu-item" data-require-permission="project:write" onclick="editProjectFromListMenu()">
@@ -6443,7 +6291,7 @@
<div id="role-select-modal" class="modal">
<div class="modal-content role-select-modal-content">
<div class="modal-header">
<h2 data-i18n="chatGroup.rolePanelTitle">选择角色</h2>
<h2 data-i18n="chat.rolePanelTitle">选择角色</h2>
<span class="modal-close" onclick="closeRoleSelectModal()">&times;</span>
</div>
<div class="modal-body role-select-body">