Compare commits

..

22 Commits

Author SHA1 Message Date
公明 1d4c1dfb11 Add files via upload 2026-01-12 19:55:27 +08:00
公明 747c4a4c01 Add files via upload 2026-01-12 19:33:31 +08:00
公明 3d9f600e73 Add files via upload 2026-01-12 19:28:25 +08:00
公明 81757948eb Add files via upload 2026-01-12 19:10:43 +08:00
公明 98d36f750b Delete tools/httpx.yaml 2026-01-12 19:02:26 +08:00
公明 d598c40570 Add files via upload 2026-01-12 18:55:56 +08:00
公明 2064e89356 Add files via upload 2026-01-12 18:55:12 +08:00
公明 4a7422cbc4 Add files via upload 2026-01-11 15:17:41 +08:00
公明 c5fc0fa2c1 Delete 效果.png 2026-01-11 15:08:01 +08:00
公明 a98bfa35fd Add files via upload 2026-01-11 15:06:21 +08:00
公明 bb05f6677f Update README_CN.md 2026-01-11 15:05:52 +08:00
公明 231ef57642 Update README.md 2026-01-11 15:05:14 +08:00
公明 12eecfe5d2 Add files via upload 2026-01-11 15:03:03 +08:00
公明 5fa25eacb5 Add files via upload 2026-01-11 14:37:21 +08:00
公明 885203358c Add files via upload 2026-01-11 14:36:45 +08:00
公明 6fdd2c88da Delete img/效果.png 2026-01-11 14:34:45 +08:00
公明 8581027bbe Add files via upload 2026-01-11 14:31:35 +08:00
公明 6084d2d84f Add files via upload 2026-01-11 14:14:51 +08:00
公明 9e7ef85510 Add files via upload 2026-01-11 13:57:16 +08:00
公明 89b4517a83 Add files via upload 2026-01-11 13:49:15 +08:00
公明 ae528843ff Add files via upload 2026-01-11 13:44:06 +08:00
公明 fc40b42d35 Add files via upload 2026-01-11 02:46:23 +08:00
17 changed files with 837 additions and 258 deletions
+3
View File
@@ -30,6 +30,9 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
### Task Management
<img src="./img/任务.png" alt="Task Management" width="560">
### Role Management
<img src="./img/角色管理.png" alt="Role Management" width="560">
## Highlights
- 🤖 AI decision engine with OpenAI-compatible models (GPT, Claude, DeepSeek, etc.)
+3
View File
@@ -29,6 +29,9 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
### 任务管理
<img src="./img/任务.png" alt="任务管理" width="560">
### 角色管理
<img src="./img/角色管理.png" alt="角色管理" width="560">
## 特性速览
- 🤖 兼容 OpenAI/DeepSeek/Claude 等模型的智能决策引擎
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

+10 -9
View File
@@ -12,6 +12,7 @@ import (
type BatchTaskQueueRow struct {
ID string
Title sql.NullString
Role sql.NullString
Status string
CreatedAt time.Time
StartedAt sql.NullTime
@@ -33,7 +34,7 @@ type BatchTaskRow struct {
}
// CreateBatchQueue 创建批量任务队列
func (db *DB) CreateBatchQueue(queueID string, title string, tasks []map[string]interface{}) error {
func (db *DB) CreateBatchQueue(queueID string, title string, role string, tasks []map[string]interface{}) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始事务失败: %w", err)
@@ -42,8 +43,8 @@ func (db *DB) CreateBatchQueue(queueID string, title string, tasks []map[string]
now := time.Now()
_, err = tx.Exec(
"INSERT INTO batch_task_queues (id, title, status, created_at, current_index) VALUES (?, ?, ?, ?, ?)",
queueID, title, "pending", now, 0,
"INSERT INTO batch_task_queues (id, title, role, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?)",
queueID, title, role, "pending", now, 0,
)
if err != nil {
return fmt.Errorf("创建批量任务队列失败: %w", err)
@@ -77,9 +78,9 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
var row BatchTaskQueueRow
var createdAt string
err := db.QueryRow(
"SELECT id, title, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
"SELECT id, title, role, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
queueID,
).Scan(&row.ID, &row.Title, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
).Scan(&row.ID, &row.Title, &row.Role, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -103,7 +104,7 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
// GetAllBatchQueues 获取所有批量任务队列
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
rows, err := db.Query(
"SELECT id, title, status, created_at, started_at, completed_at, current_index FROM batch_task_queues ORDER BY created_at DESC",
"SELECT id, title, role, status, created_at, started_at, completed_at, current_index FROM batch_task_queues ORDER BY created_at DESC",
)
if err != nil {
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
@@ -114,7 +115,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
for rows.Next() {
var row BatchTaskQueueRow
var createdAt string
if err := rows.Scan(&row.ID, &row.Title, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
@@ -134,7 +135,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueueRow, error) {
query := "SELECT id, title, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
query := "SELECT id, title, role, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
args := []interface{}{}
// 状态筛选
@@ -162,7 +163,7 @@ func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*Bat
for rows.Next() {
var row BatchTaskQueueRow
var createdAt string
if err := rows.Scan(&row.ID, &row.Title, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
+20 -1
View File
@@ -433,7 +433,7 @@ func (db *DB) migrateConversationGroupMappingsTable() error {
return nil
}
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,添加title字段
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,添加title和role字段
func (db *DB) migrateBatchTaskQueuesTable() error {
// 检查title字段是否存在
var count int
@@ -454,6 +454,25 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
}
}
// 检查role字段是否存在
var roleCount int
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='role'").Scan(&roleCount)
if err != nil {
// 如果查询失败,尝试添加字段
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN role TEXT"); addErr != nil {
// 如果字段已存在,忽略错误
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加role字段失败", zap.Error(addErr))
}
}
} else if roleCount == 0 {
// 字段不存在,添加它
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN role TEXT"); err != nil {
db.logger.Warn("添加role字段失败", zap.Error(err))
}
}
return nil
}
+27 -6
View File
@@ -811,6 +811,7 @@ func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
type BatchTaskRequest struct {
Title string `json:"title"` // 任务标题(可选)
Tasks []string `json:"tasks" binding:"required"` // 任务列表,每行一个任务
Role string `json:"role,omitempty"` // 角色名称(可选,空字符串表示默认角色)
}
// CreateBatchQueue 创建批量任务队列
@@ -839,7 +840,7 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
return
}
queue := h.batchTaskManager.CreateBatchQueue(req.Title, validTasks)
queue := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, validTasks)
c.JSON(http.StatusOK, gin.H{
"queueId": queue.ID,
"queue": queue,
@@ -1095,7 +1096,27 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 保存conversationId到任务中(即使是运行中状态也要保存,以便查看对话)
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "running", "", "", conversationID)
// 保存用户消息
// 应用角色用户提示词和工具配置
finalMessage := task.Message
var roleTools []string // 角色配置的工具列表
if queue.Role != "" && queue.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[queue.Role]; exists && role.Enabled {
// 应用用户提示词
if role.UserPrompt != "" {
finalMessage = role.UserPrompt + "\n\n" + task.Message
h.logger.Info("应用角色用户提示词", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("role", queue.Role))
}
// 获取角色配置的工具列表(优先使用tools字段,向后兼容mcps字段)
if len(role.Tools) > 0 {
roleTools = role.Tools
h.logger.Info("使用角色配置的工具列表", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("role", queue.Role), zap.Int("toolCount", len(roleTools)))
}
}
}
}
// 保存用户消息(保存原始消息,不包含角色提示词)
_, err = h.db.AddMessage(conversationID, "user", task.Message, nil)
if err != nil {
h.logger.Error("保存用户消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
@@ -1116,14 +1137,14 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
}
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
// 执行任务
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("conversationId", conversationID))
// 执行任务(使用包含角色提示词的finalMessage和角色工具列表)
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
// 存储取消函数,以便在取消队列时能够取消当前任务
h.batchTaskManager.SetTaskCancel(queueID, cancel)
// 批量任务暂时不支持角色工具过滤,使用所有工具(传入nil
result, err := h.agent.AgentLoopWithProgress(ctx, task.Message, []agent.ChatMessage{}, conversationID, progressCallback, nil)
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
// 任务执行完成,清理取消函数
h.batchTaskManager.SetTaskCancel(queueID, nil)
cancel()
+10 -2
View File
@@ -29,6 +29,7 @@ type BatchTask struct {
type BatchTaskQueue struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Role string `json:"role,omitempty"` // 角色名称(空字符串表示默认角色)
Tasks []*BatchTask `json:"tasks"`
Status string `json:"status"` // pending, running, paused, completed, cancelled
CreatedAt time.Time `json:"createdAt"`
@@ -62,7 +63,7 @@ func (m *BatchTaskManager) SetDB(db *database.DB) {
}
// CreateBatchQueue 创建批量任务队列
func (m *BatchTaskManager) CreateBatchQueue(title string, tasks []string) *BatchTaskQueue {
func (m *BatchTaskManager) CreateBatchQueue(title, role string, tasks []string) *BatchTaskQueue {
m.mu.Lock()
defer m.mu.Unlock()
@@ -70,6 +71,7 @@ func (m *BatchTaskManager) CreateBatchQueue(title string, tasks []string) *Batch
queue := &BatchTaskQueue{
ID: queueID,
Title: title,
Role: role,
Tasks: make([]*BatchTask, 0, len(tasks)),
Status: "pending",
CreatedAt: time.Now(),
@@ -98,7 +100,7 @@ func (m *BatchTaskManager) CreateBatchQueue(title string, tasks []string) *Batch
// 保存到数据库
if m.db != nil {
if err := m.db.CreateBatchQueue(queueID, title, dbTasks); err != nil {
if err := m.db.CreateBatchQueue(queueID, title, role, dbTasks); err != nil {
// 如果数据库保存失败,记录错误但继续(使用内存缓存)
// 这里可以添加日志记录
}
@@ -158,6 +160,9 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
if queueRow.Title.Valid {
queue.Title = queueRow.Title.String
}
if queueRow.Role.Valid {
queue.Role = queueRow.Role.String
}
if queueRow.StartedAt.Valid {
queue.StartedAt = &queueRow.StartedAt.Time
}
@@ -351,6 +356,9 @@ func (m *BatchTaskManager) LoadFromDB() error {
if queueRow.Title.Valid {
queue.Title = queueRow.Title.String
}
if queueRow.Role.Valid {
queue.Role = queueRow.Role.String
}
if queueRow.StartedAt.Valid {
queue.StartedAt = &queueRow.StartedAt.Time
}
+8
View File
@@ -3,6 +3,7 @@ package security
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
@@ -616,6 +617,13 @@ func (e *Executor) formatParamValue(param config.ParameterConfig, value interfac
return strings.Join(strs, ",")
}
return fmt.Sprintf("%v", value)
case "object":
// 对象/字典:序列化为 JSON 字符串
if jsonBytes, err := json.Marshal(value); err == nil {
return string(jsonBytes)
}
// 如果 JSON 序列化失败,回退到默认格式化
return fmt.Sprintf("%v", value)
default:
formattedValue := fmt.Sprintf("%v", value)
// 特殊处理:对于 ports 参数(通常是 nmap 等工具的端口参数),清理空格
+27 -4
View File
@@ -445,7 +445,7 @@ args:
parser.add_argument("--url", required=True)
parser.add_argument("--method", default="GET")
parser.add_argument("--data", default="")
parser.add_argument("--headers", default="")
parser.add_argument("--headers", default="", type=str)
parser.add_argument("--cookies", default="")
parser.add_argument("--user-agent", dest="user_agent", default="")
parser.add_argument("--proxy", default="")
@@ -489,7 +489,30 @@ args:
prepared_url = smart_encode_url(args.url) if args.auto_encode_url else args.url
method = (args.method or "GET").upper()
headers = httpx.Headers(parse_headers(args.headers))
# 处理 headers:支持字典(JSON字符串)和字符串格式
# 框架会将 object 类型序列化为 JSON 字符串传递
headers_list = []
if args.headers:
headers_str = args.headers.strip()
# 优先尝试解析为 JSON(框架传递的字典会被序列化为 JSON)
if headers_str.startswith("{") or headers_str.startswith("["):
try:
parsed = json.loads(headers_str)
if isinstance(parsed, dict):
# 字典格式:直接转换为 (key, value) 元组列表
headers_list = [(str(k).strip(), str(v).strip()) for k, v in parsed.items()]
elif isinstance(parsed, list):
# 数组格式:使用原有的 parse_headers 函数处理
headers_list = parse_headers(headers_str)
else:
headers_list = parse_headers(headers_str)
except (json.JSONDecodeError, ValueError):
# JSON 解析失败,回退到原有的字符串解析逻辑
headers_list = parse_headers(headers_str)
else:
# 非 JSON 格式,使用原有的字符串解析逻辑(向后兼容)
headers_list = parse_headers(headers_str)
headers = httpx.Headers(headers_list)
if args.user_agent:
headers["User-Agent"] = args.user_agent
@@ -724,8 +747,8 @@ parameters:
required: false
flag: "--data"
- name: "headers"
type: "string"
description: "自定义请求头(JSON字典、行分隔或分号分隔的 Header: Value 格式"
type: "object"
description: "自定义请求头(字典格式,如 {\"X-Custom\": \"value\"}"
required: false
flag: "--headers"
- name: "cookies"
+36 -10
View File
@@ -17,20 +17,46 @@ args:
url = sys.argv[1]
method = (sys.argv[2] or "GET").upper()
location = (sys.argv[3] or "query").lower()
params_json = sys.argv[4] if len(sys.argv) > 4 else "{}"
params_input = sys.argv[4] if len(sys.argv) > 4 else "{}"
payloads_json = sys.argv[5] if len(sys.argv) > 5 else "[]"
max_requests = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
try:
params_template = json.loads(params_json) if params_json else {}
# 框架会将 object 类型序列化为 JSON 字符串传递
# sys.argv 中的参数都是字符串,需要解析 JSON
if params_input and params_input.strip():
params_template = json.loads(params_input)
if not isinstance(params_template, dict):
sys.stderr.write("参数模板必须是字典格式\n")
sys.exit(1)
else:
params_template = {}
except json.JSONDecodeError as exc:
sys.stderr.write(f"参数模板解析失败: {exc}\n")
sys.stderr.write(f"参数模板解析失败(需要 JSON 字典格式): {exc}\n")
sys.exit(1)
try:
payloads = json.loads(payloads_json)
except json.JSONDecodeError as exc:
sys.stderr.write(f"载荷解析失败: {exc}\n")
# 框架会将 array 类型转换为逗号分隔的字符串(见 formatParamValue
# 但为了兼容性,也支持 JSON 数组格式
if payloads_json and payloads_json.strip():
payloads_str = payloads_json.strip()
# 优先尝试解析为 JSON 数组
if payloads_str.startswith("["):
try:
payloads = json.loads(payloads_str)
except json.JSONDecodeError:
# JSON 解析失败,尝试逗号分隔格式
payloads = [item.strip() for item in payloads_str.split(",") if item.strip()]
else:
# 逗号分隔的字符串(框架的 array 类型默认格式)
payloads = [item.strip() for item in payloads_str.split(",") if item.strip()]
if not isinstance(payloads, list):
sys.stderr.write("载荷必须是数组格式\n")
sys.exit(1)
else:
payloads = []
except (json.JSONDecodeError, ValueError) as exc:
sys.stderr.write(f"载荷解析失败(需要 JSON 数组或逗号分隔格式): {exc}\n")
sys.exit(1)
if not isinstance(payloads, list) or not payloads:
@@ -110,14 +136,14 @@ parameters:
position: 2
format: "positional"
- name: "params"
type: "string"
description: "参数模板(JSON字典),指定要模糊的键及默认值"
type: "object"
description: "参数模板(字典格式),指定要模糊的键及默认值,如 {\"id\": \"1\", \"name\": \"test\"}"
required: true
position: 3
format: "positional"
- name: "payloads"
type: "string"
description: "载荷列表(JSON数组)"
type: "array"
description: "载荷列表(数组格式),如 [\"test1\", \"test2\", \"test3\"]"
required: true
position: 4
format: "positional"
-91
View File
@@ -1,91 +0,0 @@
name: "httpx"
command: "httpx"
enabled: true
short_description: "基于Python httpx库的HTTP客户端"
description: |
该工具包装的是 Python 社区版 httpx CLI`pip install httpx` 提供),可用于快速向 Web 目标发起请求、调试接口。
**提示:**
- 官方 CLI 的调用方式为 `httpx <URL> [OPTIONS]`
- 不支持 ProjectDiscovery 版本的 `-u/-l/-td` 等参数,请使用下方列出的原生选项或 additional_args 自行扩展
parameters:
- name: "url"
type: "string"
description: "目标URL(必填,作为位置参数传入)"
required: true
format: "positional"
- name: "method"
type: "string"
description: "HTTP方法,默认GET"
required: false
flag: "-m"
format: "flag"
- name: "content"
type: "string"
description: "原始请求体内容(对应 httpx CLI 的 --content"
required: false
flag: "-c"
format: "flag"
- name: "json"
type: "string"
description: "JSON 请求体(字符串形式)"
required: false
flag: "-j"
format: "flag"
- name: "proxy"
type: "string"
description: "代理地址(http(s):// 或 socks5://"
required: false
flag: "--proxy"
format: "flag"
- name: "timeout"
type: "string"
description: "网络超时时间(秒,可为小数)"
required: false
flag: "--timeout"
format: "flag"
- name: "follow_redirects"
type: "bool"
description: "是否自动跟随重定向"
required: false
flag: "--follow-redirects"
format: "flag"
default: false
- name: "no_verify"
type: "bool"
description: "关闭TLS证书校验(对应 --no-verify"
required: false
flag: "--no-verify"
format: "flag"
default: false
- name: "http2"
type: "bool"
description: "启用HTTP/2"
required: false
flag: "--http2"
format: "flag"
default: false
- name: "download"
type: "string"
description: "将响应内容保存至文件"
required: false
flag: "--download"
format: "flag"
- name: "verbose"
type: "bool"
description: "显示请求与响应的详细信息"
required: false
flag: "-v"
format: "flag"
default: false
- name: "additional_args"
type: "string"
description: |
额外 httpx CLI 选项,格式直接与官方命令保持一致。
**示例:**
- "--headers 'X-Test 1' 'X-Token secret'"
- "--cookies 'session abc123'"
- "--auth user pass"
required: false
format: "positional"
+251 -83
View File
@@ -1063,7 +1063,7 @@ header {
}
.message.system .message-content {
max-width: 90%;
max-width: 75%;
align-items: stretch;
margin: 0;
}
@@ -1208,12 +1208,42 @@ header {
}
/* Markdown 表格样式 */
/* 表格包装容器,为每个表格提供独立的横向滚动 */
.message-bubble .table-wrapper {
width: 100%;
overflow-x: auto;
overflow-y: visible;
margin: 1em 0;
-webkit-overflow-scrolling: touch;
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.message-bubble .table-wrapper::-webkit-scrollbar {
height: 8px;
}
.message-bubble .table-wrapper::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
.message-bubble .table-wrapper::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.message-bubble .table-wrapper::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 表格本身,允许根据内容自动扩展宽度 */
.message-bubble table {
width: auto;
min-width: 100%;
border-collapse: collapse;
margin: 1em 0;
margin: 0;
font-size: 0.9em;
display: table;
table-layout: auto;
@@ -1249,13 +1279,16 @@ header {
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
width: auto;
max-width: 200px;
min-width: 120px;
max-width: none;
vertical-align: top;
/* 强制不换行,内容会保持在一行,超出部分会溢出(由表格横向滚动处理) */
white-space: nowrap !important;
overflow: visible;
text-overflow: clip;
/* 允许文字换行,避免重叠和溢出 */
white-space: normal;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
line-height: 1.5;
}
@@ -1332,6 +1365,18 @@ header {
}
/* 用户消息中的表格样式 */
.message.user .message-bubble .table-wrapper {
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.message.user .message-bubble .table-wrapper::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
}
.message.user .message-bubble .table-wrapper::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
}
.message.user .message-bubble table {
color: rgba(255, 255, 255, 0.95);
}
@@ -2593,6 +2638,9 @@ header {
border-radius: 10px;
box-shadow: var(--shadow-sm);
color: var(--text-primary);
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
}
.active-task-item {
@@ -2604,8 +2652,8 @@ header {
border: 1px solid rgba(0, 102, 255, 0.2);
border-radius: 8px;
padding: 8px 12px;
flex: 1;
min-width: 0;
flex-shrink: 0;
min-width: 280px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.03);
}
@@ -3429,6 +3477,9 @@ header {
.monitor-sections {
display: grid;
gap: 24px;
width: 100%;
box-sizing: border-box;
min-width: 0;
}
.monitor-section {
@@ -3440,6 +3491,9 @@ header {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
}
.monitor-section .section-header {
@@ -3447,12 +3501,16 @@ header {
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
flex-wrap: wrap;
}
.monitor-section .section-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-primary);
flex-shrink: 0;
min-width: 0;
}
.monitor-section .section-actions {
@@ -3461,6 +3519,9 @@ header {
gap: 12px;
font-size: 0.875rem;
color: var(--text-secondary);
flex-wrap: wrap;
min-width: 0;
flex-shrink: 1;
}
.monitor-section .section-actions select {
@@ -3518,6 +3579,8 @@ header {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
width: 100%;
box-sizing: border-box;
}
.monitor-stat-card {
@@ -3529,35 +3592,55 @@ header {
flex-direction: column;
gap: 6px;
box-shadow: var(--shadow-xs);
min-width: 0;
overflow: hidden;
box-sizing: border-box;
}
.monitor-stat-card h4 {
margin: 0;
font-size: 0.95rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
max-width: 100%;
}
.monitor-stat-value {
font-size: 1.8rem;
font-weight: 600;
color: var(--text-primary);
word-break: break-word;
overflow-wrap: break-word;
}
.monitor-stat-meta {
font-size: 0.8rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
max-width: 100%;
}
.monitor-table-container {
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
width: 100%;
box-sizing: border-box;
overflow-x: auto;
}
.monitor-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
table-layout: auto;
min-width: 100%;
}
.monitor-table th,
@@ -3566,6 +3649,15 @@ header {
text-align: left;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
word-break: break-word;
overflow-wrap: break-word;
}
.monitor-table td:nth-child(2) {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.monitor-table thead {
@@ -6112,6 +6204,9 @@ header {
.batch-manage-modal-content {
max-width: 800px;
width: 90vw;
display: flex;
flex-direction: column;
max-height: 85vh;
}
.batch-manage-header-actions {
@@ -6146,8 +6241,12 @@ header {
}
.batch-manage-body {
max-height: 60vh;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0;
display: flex;
flex-direction: column;
}
.batch-conversations-table {
@@ -6241,6 +6340,7 @@ header {
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.select-all-checkbox {
@@ -7742,29 +7842,30 @@ header {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 320px;
width: 340px;
max-width: calc(100vw - 32px);
max-height: 60vh;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 16px;
padding: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
z-index: 1000;
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease-out;
animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
backdrop-filter: blur(20px);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0);
transform: translateY(0) scale(1);
}
}
@@ -7772,55 +7873,61 @@ header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 8px 4px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
padding: 10px 4px 12px 4px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
position: sticky;
top: 0;
background: var(--bg-primary);
background: #ffffff;
z-index: 10;
}
.role-selection-panel-title {
font-size: 0.875rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
color: #1a1a1a;
margin: 0;
letter-spacing: -0.01em;
}
.role-selection-panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 4px;
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
color: #666666;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
padding: 0;
}
.role-selection-panel-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
background: rgba(0, 0, 0, 0.06);
color: #1a1a1a;
transform: rotate(90deg);
}
.role-selection-panel-close:active {
transform: rotate(90deg) scale(0.95);
}
.role-selection-list-main {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
/* 限制显示8个角色:每个角色约70px高度 + gap8个角色约580px */
max-height: 580px;
overflow-y: auto;
padding-right: 4px;
padding-right: 6px;
flex: 1;
}
.role-selection-list-main::-webkit-scrollbar {
width: 6px;
width: 8px;
}
.role-selection-list-main::-webkit-scrollbar-track {
@@ -7828,81 +7935,102 @@ header {
}
.role-selection-list-main::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
background: rgba(0, 0, 0, 0.16);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.role-selection-list-main::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background: rgba(0, 0, 0, 0.24);
background-clip: padding-box;
}
.role-selection-item-main {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
gap: 12px;
padding: 12px;
border: 1.5px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
background: #ffffff;
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
.role-selection-item-main:hover {
background: var(--bg-secondary);
border-color: rgba(138, 43, 226, 0.3);
background: linear-gradient(135deg, rgba(138, 43, 226, 0.04) 0%, rgba(138, 43, 226, 0.02) 100%);
border-color: rgba(138, 43, 226, 0.2);
box-shadow: 0 2px 8px rgba(138, 43, 226, 0.08);
transform: translateY(-1px);
}
.role-selection-item-main.selected {
background: rgba(138, 43, 226, 0.1);
border-color: #8a2be2;
background: linear-gradient(135deg, rgba(138, 43, 226, 0.12) 0%, rgba(138, 43, 226, 0.06) 100%);
border-color: rgba(138, 43, 226, 0.4);
box-shadow: 0 4px 12px rgba(138, 43, 226, 0.15), 0 0 0 1px rgba(138, 43, 226, 0.1);
}
.role-selection-item-main:active {
transform: translateY(0) scale(0.98);
}
.role-selection-item-icon-main {
font-size: 1.125rem;
font-size: 1.25rem;
flex-shrink: 0;
width: 28px;
height: 28px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border-radius: 6px;
transition: all 0.2s;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.02) 100%);
border-radius: 10px;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
border: 1px solid rgba(0, 0, 0, 0.04);
}
.role-selection-item-main:hover .role-selection-item-icon-main {
background: linear-gradient(135deg, rgba(138, 43, 226, 0.1) 0%, rgba(138, 43, 226, 0.05) 100%);
border-color: rgba(138, 43, 226, 0.15);
transform: scale(1.05);
}
.role-selection-item-main.selected .role-selection-item-icon-main {
background: rgba(138, 43, 226, 0.15);
background: linear-gradient(135deg, rgba(138, 43, 226, 0.2) 0%, rgba(138, 43, 226, 0.12) 100%);
border-color: rgba(138, 43, 226, 0.3);
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
}
.role-selection-item-content-main {
display: flex;
flex-direction: column;
gap: 2px;
gap: 4px;
min-width: 0;
flex: 1;
padding-top: 2px;
}
.role-selection-item-name-main {
font-weight: 500;
color: var(--text-primary);
font-size: 0.8125rem;
line-height: 1.3;
color: #1a1a1a;
font-size: 0.875rem;
line-height: 1.4;
margin: 0;
transition: color 0.2s;
transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
letter-spacing: -0.01em;
}
.role-selection-item-main.selected .role-selection-item-name-main {
color: #8a2be2;
color: #6a1bb2;
font-weight: 600;
}
.role-selection-item-description-main {
font-size: 0.75rem;
color: var(--text-secondary);
line-height: 1.3;
color: #666666;
line-height: 1.4;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 1;
@@ -7910,23 +8038,44 @@ header {
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s;
}
.role-selection-item-main.selected .role-selection-item-description-main {
color: #8a5ab8;
}
.role-selection-checkmark-main {
position: absolute;
top: 6px;
right: 8px;
width: 16px;
height: 16px;
top: 10px;
right: 10px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #8a2be2;
background: linear-gradient(135deg, #8a2be2 0%, #6a1bb2 100%);
color: white;
border-radius: 50%;
font-size: 0.625rem;
font-size: 0.6875rem;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(138, 43, 226, 0.4);
animation: checkmarkPop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes checkmarkPop {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes highlight-flash {
@@ -8557,42 +8706,43 @@ header {
width: calc(100vw - 16px);
max-width: calc(100vw - 16px);
left: -8px;
padding: 8px;
border-radius: 8px;
padding: 10px;
border-radius: 14px;
max-height: 60vh;
}
.role-selection-panel-header {
margin-bottom: 8px;
padding: 6px 4px;
margin-bottom: 10px;
padding: 8px 4px 10px 4px;
}
.role-selection-panel-title {
font-size: 0.8125rem;
font-size: 0.875rem;
}
.role-selection-list-main {
/* 在移动设备上限制显示8个角色,每个约60px,总计约480px */
max-height: 480px;
gap: 4px;
gap: 5px;
}
.role-selection-item-main {
padding: 8px 10px;
padding: 10px;
gap: 10px;
}
.role-selection-item-icon-main {
width: 24px;
height: 24px;
font-size: 1rem;
width: 32px;
height: 32px;
font-size: 1.125rem;
}
.role-selection-item-name-main {
font-size: 0.75rem;
font-size: 0.8125rem;
}
.role-selection-item-description-main {
font-size: 0.6875rem;
font-size: 0.71875rem;
}
}
@@ -8601,16 +8751,34 @@ header {
width: calc(100vw - 8px);
max-width: calc(100vw - 8px);
left: -4px;
padding: 6px;
padding: 8px;
border-radius: 12px;
max-height: 50vh;
}
.role-selection-list-main {
/* 在小屏幕上限制显示8个角色,每个约55px,总计约450px */
max-height: 450px;
gap: 5px;
}
.role-selection-item-main {
padding: 6px 8px;
padding: 10px;
gap: 10px;
border-radius: 10px;
}
.role-selection-item-icon-main {
width: 30px;
height: 30px;
font-size: 1.0625rem;
}
.role-selection-checkmark-main {
width: 18px;
height: 18px;
font-size: 0.625rem;
top: 8px;
right: 8px;
}
}
+113 -4
View File
@@ -803,6 +803,25 @@ function initializeChatUI() {
// 消息计数器,确保ID唯一
let messageCounter = 0;
// 为消息气泡中的表格添加独立的滚动容器
function wrapTablesInBubble(bubble) {
const tables = bubble.querySelectorAll('table');
tables.forEach(table => {
// 检查表格是否已经有包装容器
if (table.parentElement && table.parentElement.classList.contains('table-wrapper')) {
return;
}
// 创建表格包装容器
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper';
// 将表格移动到包装容器中
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
});
}
// 添加消息
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null) {
const messagesDiv = document.getElementById('chat-messages');
@@ -878,6 +897,10 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
}
bubble.innerHTML = formattedContent;
// 为每个表格添加独立的滚动容器
wrapTablesInBubble(bubble);
contentWrapper.appendChild(bubble);
// 保存原始内容到消息元素,用于复制功能
@@ -943,6 +966,8 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
detailBtn.innerHTML = `<span>调用 #${index + 1}</span>`;
detailBtn.onclick = () => showMCPDetail(execId);
buttonsContainer.appendChild(detailBtn);
// 异步获取工具名称并更新按钮文本
updateButtonWithToolName(detailBtn, execId, index + 1);
});
}
@@ -1231,6 +1256,23 @@ window.addEventListener('beforeunload', () => {
}
});
// 异步获取工具名称并更新按钮文本
async function updateButtonWithToolName(button, executionId, index) {
try {
const response = await apiFetch(`/api/monitor/execution/${executionId}`);
if (response.ok) {
const exec = await response.json();
const toolName = exec.toolName || '未知工具';
// 格式化工具名称(如果是 name::toolName 格式,只显示 toolName 部分)
const displayToolName = toolName.includes('::') ? toolName.split('::')[1] : toolName;
button.querySelector('span').textContent = `${displayToolName} #${index}`;
}
} catch (error) {
// 如果获取失败,保持原有文本不变
console.error('获取工具名称失败:', error);
}
}
// 显示MCP调用详情
async function showMCPDetail(executionId) {
try {
@@ -1449,16 +1491,27 @@ async function loadConversations(searchQuery = '') {
url += '&search=' + encodeURIComponent(searchQuery.trim());
}
const response = await apiFetch(url);
const conversations = await response.json();
const listContainer = document.getElementById('conversations-list');
if (!listContainer) {
return;
}
// 保存滚动位置
const sidebarContent = listContainer.closest('.sidebar-content');
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
listContainer.innerHTML = '';
// 如果响应不是200,显示空状态(友好处理,不显示错误)
if (!response.ok) {
listContainer.innerHTML = emptyStateHtml;
return;
}
const conversations = await response.json();
if (!Array.isArray(conversations) || conversations.length === 0) {
listContainer.innerHTML = emptyStateHtml;
return;
@@ -1531,8 +1584,22 @@ async function loadConversations(searchQuery = '') {
listContainer.appendChild(fragment);
updateActiveConversation();
// 恢复滚动位置
if (sidebarContent) {
// 使用 requestAnimationFrame 确保 DOM 已经更新
requestAnimationFrame(() => {
sidebarContent.scrollTop = savedScrollTop;
});
}
} catch (error) {
console.error('加载对话列表失败:', error);
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list');
if (listContainer) {
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
listContainer.innerHTML = emptyStateHtml;
}
}
}
@@ -4056,16 +4123,27 @@ async function loadConversationsWithGroups(searchQuery = '') {
url += '&search=' + encodeURIComponent(searchQuery.trim());
}
const response = await apiFetch(url);
const conversations = await response.json();
const listContainer = document.getElementById('conversations-list');
if (!listContainer) {
return;
}
// 保存滚动位置
const sidebarContent = listContainer.closest('.sidebar-content');
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
listContainer.innerHTML = '';
// 如果响应不是200,显示空状态(友好处理,不显示错误)
if (!response.ok) {
listContainer.innerHTML = emptyStateHtml;
return;
}
const conversations = await response.json();
if (!Array.isArray(conversations) || conversations.length === 0) {
listContainer.innerHTML = emptyStateHtml;
return;
@@ -4134,8 +4212,22 @@ async function loadConversationsWithGroups(searchQuery = '') {
listContainer.appendChild(fragment);
updateActiveConversation();
// 恢复滚动位置
if (sidebarContent) {
// 使用 requestAnimationFrame 确保 DOM 已经更新
requestAnimationFrame(() => {
sidebarContent.scrollTop = savedScrollTop;
});
}
} catch (error) {
console.error('加载对话列表失败:', error);
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list');
if (listContainer) {
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
listContainer.innerHTML = emptyStateHtml;
}
}
}
@@ -4988,7 +5080,14 @@ let allConversationsForBatch = [];
async function showBatchManageModal() {
try {
const response = await apiFetch('/api/conversations?limit=1000');
allConversationsForBatch = await response.json();
// 如果响应不是200,使用空数组(友好处理,不显示错误)
if (!response.ok) {
allConversationsForBatch = [];
} else {
const data = await response.json();
allConversationsForBatch = Array.isArray(data) ? data : [];
}
const modal = document.getElementById('batch-manage-modal');
const countEl = document.getElementById('batch-manage-count');
@@ -5002,7 +5101,17 @@ async function showBatchManageModal() {
}
} catch (error) {
console.error('加载对话列表失败:', error);
alert('加载对话列表失败');
// 错误时使用空数组,不显示错误提示(更友好的用户体验)
allConversationsForBatch = [];
const modal = document.getElementById('batch-manage-modal');
const countEl = document.getElementById('batch-manage-count');
if (countEl) {
countEl.textContent = 0;
}
if (modal) {
renderBatchConversations();
modal.style.display = 'flex';
}
}
}
+162 -18
View File
@@ -428,8 +428,9 @@ async function loadRoleTools(page = 1, searchKeyword = '') {
});
} else {
// 工具已在映射中(可能是预先设置的选中工具或用户手动选择的),保留映射中的状态
// 但如果使用所有工具,且工具在MCP管理中已启用,确保标记为选中
// 注意:即使使用所有工具,也不要强制覆盖用户已取消的工具选择
const state = roleToolStateMap.get(toolKey);
// 如果使用所有工具,且工具在MCP管理中已启用,确保标记为选中
if (roleUsesAllTools && tool.enabled) {
// 使用所有工具时,确保所有已启用的工具都被选中
state.enabled = true;
@@ -670,11 +671,13 @@ function updateRoleToolsStats() {
if (roleUsesAllTools) {
// 使用从API响应中获取的已启用工具总数
const totalEnabled = totalEnabledToolsInMCP || 0;
// 当前页分母应该是当前页已启用的工具数,而不是所有工具数
const currentPageDenominator = currentPageEnabledInMCP > 0 ? currentPageEnabledInMCP : document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
// 当前页分母应该是当前页的总工具数(每页20个),而不是当前页已启用的工具数
const currentPageTotal = document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
// 总工具数(所有工具,包括已启用和未启用的)
const totalTools = roleToolsPagination.total || 0;
statsEl.innerHTML = `
<span title="当前页选中的工具数"> 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageDenominator}</span>
<span title="所有已启用工具中选中的工具总数(基于MCP管理)">📊 总计已选中: <strong>${totalEnabled}</strong> / ${totalEnabled} <em>(使用所有已启用工具)</em></span>
<span title="当前页选中的工具数"> 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
<span title="所有已启用工具中选中的工具总数(基于MCP管理)">📊 总计已选中: <strong>${totalEnabled}</strong> / ${totalTools} <em>(使用所有已启用工具)</em></span>
`;
return;
}
@@ -719,12 +722,14 @@ function updateRoleToolsStats() {
});
}
// 当前页分母应该是当前页已启用的工具数,而不是所有工具数
const currentPageDenominator = currentPageEnabledInMCP > 0 ? currentPageEnabledInMCP : document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
// 当前页分母应该是当前页的总工具数(每页20个),而不是当前页已启用的工具数
const currentPageTotal = document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
// 总工具数(所有工具,包括已启用和未启用的)
const totalTools = roleToolsPagination.total || 0;
statsEl.innerHTML = `
<span title="当前页选中的工具数(只统计已启用的工具)"> 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageDenominator}</span>
<span title="角色已关联的工具总数(基于角色实际配置)">📊 总计已选中: <strong>${totalSelected}</strong> / ${totalEnabledForRole}</span>
<span title="当前页选中的工具数(只统计已启用的工具)"> 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
<span title="角色已关联的工具总数(基于角色实际配置)">📊 总计已选中: <strong>${totalSelected}</strong> / ${totalTools}</span>
`;
}
@@ -823,6 +828,11 @@ async function showAddRoleModal() {
if (clearBtn) {
clearBtn.style.display = 'none';
}
// 清空工具列表 DOM,避免 loadRoleTools 中的 saveCurrentRolePageToolStates 读取旧状态
if (toolsList) {
toolsList.innerHTML = '';
}
// 加载并渲染工具列表
await loadRoleTools(1, '');
@@ -831,6 +841,9 @@ async function showAddRoleModal() {
if (toolsList) {
toolsList.style.display = 'block';
}
// 确保统计信息正确更新(显示0/108)
updateRoleToolsStats();
modal.style.display = 'flex';
}
@@ -965,11 +978,13 @@ async function editRole(roleName) {
if (toolKey) {
const state = roleToolStateMap.get(toolKey);
// 只选中在MCP管理中已启用的工具
const shouldEnable = state && state.mcpEnabled !== false;
// 如果状态存在,使用状态中的 mcpEnabled;否则假设已启用(因为 loadRoleTools 应该已经初始化了所有工具)
const shouldEnable = state ? (state.mcpEnabled !== false) : true;
checkbox.checked = shouldEnable;
if (state) {
state.enabled = shouldEnable;
} else {
// 如果状态不存在,创建新状态(这种情况不应该发生,因为 loadRoleTools 应该已经初始化了)
roleToolStateMap.set(toolKey, {
enabled: shouldEnable,
is_external: isExternal,
@@ -981,6 +996,8 @@ async function editRole(roleName) {
}
}
});
// 更新统计信息,确保显示正确的选中数量
updateRoleToolsStats();
} else if (selectedTools.length > 0) {
// 加载完成后,再次设置选中状态(确保当前页的工具也被正确设置)
setSelectedRoleTools(selectedTools);
@@ -1027,6 +1044,68 @@ function getDisabledTools(selectedTools) {
});
}
// 加载所有工具到状态映射中(用于从使用全部工具切换到部分工具时)
async function loadAllToolsToStateMap() {
try {
const pageSize = 100; // 使用较大的页面大小以减少请求次数
let page = 1;
let hasMore = true;
// 遍历所有页面获取所有工具
while (hasMore) {
const url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
const response = await apiFetch(url);
if (!response.ok) {
throw new Error('获取工具列表失败');
}
const result = await response.json();
// 将所有工具添加到状态映射中
result.tools.forEach(tool => {
const toolKey = getToolKey(tool);
if (!roleToolStateMap.has(toolKey)) {
// 工具不在映射中,根据当前模式初始化
let enabled = false;
if (roleUsesAllTools) {
// 如果使用所有工具,且工具在MCP管理中已启用,则标记为选中
enabled = tool.enabled ? true : false;
} else {
// 如果不使用所有工具,只有工具在角色配置的工具列表中才标记为选中
enabled = roleConfiguredTools.has(toolKey);
}
roleToolStateMap.set(toolKey, {
enabled: enabled,
is_external: tool.is_external || false,
external_mcp: tool.external_mcp || '',
name: tool.name,
mcpEnabled: tool.enabled // 保存MCP管理中的原始启用状态
});
} else {
// 工具已在映射中,更新其他属性但保留enabled状态
const state = roleToolStateMap.get(toolKey);
state.is_external = tool.is_external || false;
state.external_mcp = tool.external_mcp || '';
state.mcpEnabled = tool.enabled; // 更新MCP管理中的原始启用状态
if (!state.name || state.name === toolKey.split('::').pop()) {
state.name = tool.name; // 更新工具名称
}
}
});
// 检查是否还有更多页面
if (page >= result.total_pages) {
hasMore = false;
} else {
page++;
}
}
} catch (error) {
console.error('加载所有工具到状态映射失败:', error);
throw error;
}
}
// 保存角色
async function saveRole() {
const name = document.getElementById('role-name').value.trim();
@@ -1049,22 +1128,86 @@ async function saveRole() {
const userPrompt = document.getElementById('role-user-prompt').value.trim();
const enabled = document.getElementById('role-enabled').checked;
const isEdit = document.getElementById('role-name').disabled;
// 检查是否为默认角色
const isDefaultRole = name === '默认';
// 检查是否是首次添加角色(排除默认角色后,没有任何用户创建的角色)
const isFirstUserRole = !isEdit && !isDefaultRole && roles.filter(r => r.name !== '默认').length === 0;
// 默认角色不保存tools字段(使用所有工具)
// 非默认角色:如果使用所有工具(roleUsesAllTools为true),也不保存tools字段
let tools = [];
let disabledTools = []; // 存储未在MCP管理中启用的工具
if (!isDefaultRole && !roleUsesAllTools) {
if (!isDefaultRole) {
// 保存当前页的状态
saveCurrentRolePageToolStates();
// 收集所有选中的工具(包括未在MCP管理中启用的)
const allSelectedTools = getAllSelectedRoleTools();
let allSelectedTools = getAllSelectedRoleTools();
// 检查哪些工具未在MCP管理中启用
// 如果是首次添加角色且没有选择工具,默认使用全部工具
if (isFirstUserRole && allSelectedTools.length === 0) {
roleUsesAllTools = true;
showNotification('检测到这是首次添加角色且未选择工具,将默认使用全部工具', 'info');
} else if (roleUsesAllTools) {
// 如果当前使用所有工具,需要检查用户是否取消了一些工具
// 检查状态映射中是否有未选中的已启用工具
let hasUnselectedTools = false;
roleToolStateMap.forEach((state) => {
// 如果工具在MCP管理中已启用但未选中,说明用户取消了该工具
if (state.mcpEnabled !== false && !state.enabled) {
hasUnselectedTools = true;
}
});
// 如果用户取消了一些已启用的工具,切换到部分工具模式
if (hasUnselectedTools) {
// 在切换之前,需要加载所有工具到状态映射中
// 这样我们可以正确保存所有工具的状态(除了用户取消的那些)
await loadAllToolsToStateMap();
// 将所有已启用的工具标记为选中(除了用户已取消的那些)
// 用户已取消的工具在状态映射中enabled为false,保持不变
roleToolStateMap.forEach((state, toolKey) => {
// 如果工具在MCP管理中已启用,且状态映射中没有明确标记为未选中(即enabled不是false
// 则标记为选中
if (state.mcpEnabled !== false && state.enabled !== false) {
state.enabled = true;
}
});
roleUsesAllTools = false;
} else {
// 即使使用所有工具,也需要加载所有工具到状态映射中,以便检查是否有未启用的工具被选中
// 这样可以检测用户是否手动选择了一些未启用的工具
await loadAllToolsToStateMap();
// 检查是否有未启用的工具被手动选中(enabled为true但mcpEnabled为false
let hasDisabledToolsSelected = false;
roleToolStateMap.forEach((state) => {
if (state.enabled && state.mcpEnabled === false) {
hasDisabledToolsSelected = true;
}
});
// 如果没有未启用的工具被选中,将所有已启用的工具标记为选中(这是使用所有工具的默认行为)
if (!hasDisabledToolsSelected) {
roleToolStateMap.forEach((state) => {
if (state.mcpEnabled !== false) {
state.enabled = true;
}
});
}
// 更新 allSelectedTools,因为现在状态映射中包含了所有工具
allSelectedTools = getAllSelectedRoleTools();
}
}
// 检查哪些工具未在MCP管理中启用(无论是否使用所有工具都要检查)
disabledTools = getDisabledTools(allSelectedTools);
// 如果有未启用的工具,提示用户
@@ -1077,8 +1220,11 @@ async function saveRole() {
}
}
// 获取选中的工具列表(只包含在MCP管理中已启用的工具)
tools = await getSelectedRoleTools();
// 如果使用所有工具,不需要获取工具列表
if (!roleUsesAllTools) {
// 获取选中的工具列表(只包含在MCP管理中已启用的工具)
tools = await getSelectedRoleTools();
}
}
const roleData = {
@@ -1089,8 +1235,6 @@ async function saveRole() {
tools: tools, // 默认角色为空数组,表示使用所有工具
enabled: enabled
};
const isEdit = document.getElementById('role-name').disabled;
const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles';
const method = isEdit ? 'PUT' : 'POST';
@@ -1116,7 +1260,7 @@ async function saveRole() {
toolNames = toolNames.substring(0, 100) + '...';
}
showNotification(
`${isEdit ? '角色已更新' : '角色已创建'},但已过滤 ${disabledTools.length} 个未在MCP管理中启用的工具。请先在"MCP管理"中启用这些工具,然后再在角色中配置。`,
`${isEdit ? '角色已更新' : '角色已创建'},但已过滤 ${disabledTools.length} 个未在MCP管理中启用的工具${toolNames}。请先在"MCP管理"中启用这些工具,然后再在角色中配置。`,
'warning'
);
} else {
+134 -5
View File
@@ -716,23 +716,56 @@ const batchQueuesState = {
totalPages: 1
};
// 显示批量导入模态框
function showBatchImportModal() {
// 显示新建任务模态框
async function showBatchImportModal() {
const modal = document.getElementById('batch-import-modal');
const input = document.getElementById('batch-tasks-input');
const titleInput = document.getElementById('batch-queue-title');
const roleSelect = document.getElementById('batch-queue-role');
if (modal && input) {
input.value = '';
if (titleInput) {
titleInput.value = '';
}
// 重置角色选择为默认
if (roleSelect) {
roleSelect.value = '';
}
updateBatchImportStats('');
// 加载并填充角色列表
if (roleSelect && typeof loadRoles === 'function') {
try {
const loadedRoles = await loadRoles();
// 清空现有选项(除了默认选项)
roleSelect.innerHTML = '<option value="">默认</option>';
// 添加已启用的角色
const sortedRoles = loadedRoles.sort((a, b) => {
if (a.name === '默认') return -1;
if (b.name === '默认') return 1;
return (a.name || '').localeCompare(b.name || '', 'zh-CN');
});
sortedRoles.forEach(role => {
if (role.name !== '默认' && role.enabled !== false) {
const option = document.createElement('option');
option.value = role.name;
option.textContent = role.name;
roleSelect.appendChild(option);
}
});
} catch (error) {
console.error('加载角色列表失败:', error);
}
}
modal.style.display = 'block';
input.focus();
}
}
// 关闭批量导入模态框
// 关闭新建任务模态框
function closeBatchImportModal() {
const modal = document.getElementById('batch-import-modal');
if (modal) {
@@ -740,7 +773,7 @@ function closeBatchImportModal() {
}
}
// 更新批量导入统计
// 更新新建任务统计
function updateBatchImportStats(text) {
const statsEl = document.getElementById('batch-import-stats');
if (!statsEl) return;
@@ -770,6 +803,7 @@ document.addEventListener('DOMContentLoaded', function() {
async function createBatchQueue() {
const input = document.getElementById('batch-tasks-input');
const titleInput = document.getElementById('batch-queue-title');
const roleSelect = document.getElementById('batch-queue-role');
if (!input) return;
const text = input.value.trim();
@@ -788,13 +822,16 @@ async function createBatchQueue() {
// 获取标题(可选)
const title = titleInput ? titleInput.value.trim() : '';
// 获取角色(可选,空字符串表示默认角色)
const role = roleSelect ? roleSelect.value || '' : '';
try {
const response = await apiFetch('/api/batch-tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, tasks }),
body: JSON.stringify({ title, tasks, role }),
});
if (!response.ok) {
@@ -816,6 +853,34 @@ async function createBatchQueue() {
}
}
// 获取角色图标(辅助函数)
function getRoleIconForDisplay(roleName, rolesList) {
if (!roleName || roleName === '') {
return '🔵'; // 默认角色图标
}
if (Array.isArray(rolesList) && rolesList.length > 0) {
const role = rolesList.find(r => r.name === roleName);
if (role && role.icon) {
let icon = role.icon;
// 检查是否是 Unicode 转义格式(可能包含引号)
const unicodeMatch = icon.match(/^"?\\U([0-9A-F]{8})"?$/i);
if (unicodeMatch) {
try {
const codePoint = parseInt(unicodeMatch[1], 16);
icon = String.fromCodePoint(codePoint);
} catch (e) {
// 转换失败,使用默认图标
console.warn('转换 icon Unicode 转义失败:', icon, e);
return '👤';
}
}
return icon;
}
}
return '👤'; // 默认图标
}
// 加载批量任务队列列表
async function loadBatchQueues(page) {
const section = document.getElementById('batch-queues-section');
@@ -826,6 +891,17 @@ async function loadBatchQueues(page) {
batchQueuesState.currentPage = page;
}
// 加载角色列表(用于显示正确的角色图标)
let loadedRoles = [];
if (typeof loadRoles === 'function') {
try {
loadedRoles = await loadRoles();
} catch (error) {
console.warn('加载角色列表失败,将使用默认图标:', error);
}
}
batchQueuesState.loadedRoles = loadedRoles; // 保存到状态中供渲染使用
// 构建查询参数
const params = new URLSearchParams();
params.append('page', batchQueuesState.currentPage.toString());
@@ -933,11 +1009,18 @@ function renderBatchQueues() {
const titleDisplay = queue.title ? `<span class="batch-queue-title" style="font-weight: 600; color: var(--text-primary); margin-right: 8px;">${escapeHtml(queue.title)}</span>` : '';
// 显示角色信息(使用正确的角色图标)
const loadedRoles = batchQueuesState.loadedRoles || [];
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
const roleName = queue.role && queue.role !== '' ? queue.role : '默认';
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="角色: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
return `
<div class="batch-queue-item" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
<div class="batch-queue-header">
<div class="batch-queue-info" style="flex: 1;">
${titleDisplay}
${roleDisplay}
<span class="batch-queue-status ${status.class}">${status.text}</span>
<span class="batch-queue-id">队列ID: ${escapeHtml(queue.id)}</span>
<span class="batch-queue-time">创建时间: ${new Date(queue.createdAt).toLocaleString('zh-CN')}</span>
@@ -1110,6 +1193,16 @@ async function showBatchQueueDetail(queueId) {
if (!modal || !content) return;
try {
// 加载角色列表(如果还未加载)
let loadedRoles = [];
if (typeof loadRoles === 'function') {
try {
loadedRoles = await loadRoles();
} catch (error) {
console.warn('加载角色列表失败,将使用默认图标:', error);
}
}
const response = await apiFetch(`/api/batch-tasks/${queueId}`);
if (!response.ok) {
throw new Error('获取队列详情失败');
@@ -1164,12 +1257,48 @@ async function showBatchQueueDetail(queueId) {
'cancelled': { text: '已取消', class: 'batch-task-status-cancelled' }
};
// 获取角色信息(如果队列有角色配置)
let roleDisplay = '';
if (queue.role && queue.role !== '') {
// 如果有角色配置,尝试获取角色详细信息
let roleName = queue.role;
let roleIcon = '👤';
// 从已加载的角色列表中查找角色图标
if (Array.isArray(loadedRoles) && loadedRoles.length > 0) {
const role = loadedRoles.find(r => r.name === roleName);
if (role && role.icon) {
let icon = role.icon;
const unicodeMatch = icon.match(/^"?\\U([0-9A-F]{8})"?$/i);
if (unicodeMatch) {
try {
const codePoint = parseInt(unicodeMatch[1], 16);
icon = String.fromCodePoint(codePoint);
} catch (e) {
// 转换失败,使用默认图标
}
}
roleIcon = icon;
}
}
roleDisplay = `<div class="detail-item">
<span class="detail-label">角色</span>
<span class="detail-value">${roleIcon} ${escapeHtml(roleName)}</span>
</div>`;
} else {
// 默认角色
roleDisplay = `<div class="detail-item">
<span class="detail-label">角色</span>
<span class="detail-value">🔵 默认</span>
</div>`;
}
content.innerHTML = `
<div class="batch-queue-detail-info">
${queue.title ? `<div class="detail-item">
<span class="detail-label">任务标题</span>
<span class="detail-value">${escapeHtml(queue.title)}</span>
</div>` : ''}
${roleDisplay}
<div class="detail-item">
<span class="detail-label">队列ID</span>
<span class="detail-value"><code>${escapeHtml(queue.id)}</code></span>
+33 -25
View File
@@ -70,36 +70,34 @@
<nav class="main-sidebar-nav">
<div class="nav-item" data-page="chat">
<div class="nav-item-content" data-title="对话" onclick="switchPage('chat')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<span>对话</span>
</div>
</div>
<div class="nav-item" data-page="tasks">
<div class="nav-item-content" data-title="任务管理" onclick="switchPage('tasks')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="13 2 13 9 20 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="9" y1="13" x2="15" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="9" y1="17" x2="15" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
<span>任务管理</span>
</div>
</div>
<div class="nav-item" data-page="vulnerabilities">
<div class="nav-item-content" data-title="漏洞管理" onclick="switchPage('vulnerabilities')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
<path d="M9 12l2 2 4-4"></path>
</svg>
<span>漏洞管理</span>
</div>
</div>
<div class="nav-item nav-item-has-submenu" data-page="mcp">
<div class="nav-item-content" data-title="MCP" onclick="toggleSubmenu('mcp')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h4l3 8 4-16 3 8h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
<span>MCP</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -117,10 +115,9 @@
</div>
<div class="nav-item nav-item-has-submenu" data-page="knowledge">
<div class="nav-item-content" data-title="知识" onclick="toggleSubmenu('knowledge')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 7h6M10 11h6M10 15h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
<span>知识</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -138,9 +135,11 @@
</div>
<div class="nav-item nav-item-has-submenu" data-page="roles">
<div class="nav-item-content" data-title="角色" onclick="toggleSubmenu('roles')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<span>角色</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -155,9 +154,9 @@
</div>
<div class="nav-item" data-page="settings">
<div class="nav-item-content" data-title="系统设置" onclick="switchPage('settings')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>系统设置</span>
</div>
@@ -611,7 +610,7 @@
<div class="page-header">
<h2>任务管理</h2>
<div class="page-header-actions">
<button class="btn-primary" onclick="showBatchImportModal()">批量导入任务</button>
<button class="btn-primary" onclick="showBatchImportModal()">新建任务</button>
<label class="auto-refresh-toggle">
<input type="checkbox" id="tasks-auto-refresh" checked onchange="toggleTasksAutoRefresh(this.checked)">
<span>自动刷新</span>
@@ -1238,11 +1237,11 @@
</div>
</div>
<!-- 批量导入任务模态框 -->
<!-- 新建任务模态框 -->
<div id="batch-import-modal" class="modal">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2>批量导入任务</h2>
<h2>新建任务</h2>
<span class="modal-close" onclick="closeBatchImportModal()">&times;</span>
</div>
<div class="modal-body">
@@ -1253,6 +1252,15 @@
为批量任务队列设置一个标题,方便后续查找和管理。
</div>
</div>
<div class="form-group">
<label for="batch-queue-role">角色</label>
<select id="batch-queue-role" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
<option value="">默认</option>
</select>
<div class="form-hint" style="margin-top: 4px;">
选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。
</div>
</div>
<div class="form-group">
<label for="batch-tasks-input">任务列表(每行一个任务)<span style="color: red;">*</span></label>
<textarea id="batch-tasks-input" rows="15" placeholder="请输入任务列表,每行一个任务,例如:&#10;扫描 192.168.1.1 的开放端口&#10;检查 https://example.com 是否存在SQL注入&#10;枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>