mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24344cafdb | |||
| a5b95d5b2e | |||
| 49cd0166f8 | |||
| a834231342 | |||
| 20a498455e | |||
| f4028ae66f | |||
| 0a5bb1eab4 | |||
| d4f2b0f93d | |||
| 1fb8cc2fbc | |||
| 3ddf280400 | |||
| 961deb81dd | |||
| ae3bc41c88 | |||
| bb9e3f9477 | |||
| a57720fb29 | |||
| 9e34b480e7 | |||
| cd30953a84 | |||
| a273d6d7ba | |||
| 87d9e50781 | |||
| 54b9e2e2fa | |||
| 946d347dc9 | |||
| ed8c0b15dd | |||
| f658cc6e93 | |||
| 7bf0697526 | |||
| 7e8cc3e2b8 |
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.4.10"
|
||||
version: "v1.4.15"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
+19
-8
@@ -404,6 +404,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
}
|
||||
configHandler.SetSkillsToolRegistrar(skillsRegistrar)
|
||||
|
||||
handler.RegisterBatchTaskMCPTools(mcpServer, agentHandler, log.Logger)
|
||||
batchTaskToolRegistrar := func() error {
|
||||
handler.RegisterBatchTaskMCPTools(mcpServer, agentHandler, log.Logger)
|
||||
return nil
|
||||
}
|
||||
configHandler.SetBatchTaskToolRegistrar(batchTaskToolRegistrar)
|
||||
|
||||
// 设置知识库初始化器(用于动态初始化,需要在 App 创建后设置)
|
||||
configHandler.SetKnowledgeInitializer(func() (*handler.KnowledgeHandler, error) {
|
||||
knowledgeHandler, err := initializeKnowledge(cfg, db, knowledgeDBConn, mcpServer, agentHandler, app, log.Logger)
|
||||
@@ -652,6 +659,7 @@ func setupRoutes(
|
||||
protected.GET("/batch-tasks/:queueId", agentHandler.GetBatchQueue)
|
||||
protected.POST("/batch-tasks/:queueId/start", agentHandler.StartBatchQueue)
|
||||
protected.POST("/batch-tasks/:queueId/pause", agentHandler.PauseBatchQueue)
|
||||
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
|
||||
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
|
||||
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
||||
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
|
||||
@@ -675,6 +683,7 @@ func setupRoutes(
|
||||
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)
|
||||
@@ -682,6 +691,7 @@ func setupRoutes(
|
||||
// 监控
|
||||
protected.GET("/monitor", monitorHandler.Monitor)
|
||||
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
|
||||
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
|
||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
||||
@@ -691,6 +701,7 @@ func setupRoutes(
|
||||
protected.GET("/config/tools", configHandler.GetTools)
|
||||
protected.PUT("/config", configHandler.UpdateConfig)
|
||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||
|
||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||
@@ -1330,8 +1341,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
|
||||
// manage_webshell_add - 添加新的 webshell 连接
|
||||
addTool := mcp.Tool{
|
||||
Name: builtin.ToolManageWebshellAdd,
|
||||
Description: "添加新的 WebShell 连接到管理系统。支持 PHP、ASP、ASPX、JSP 等类型的一句话木马。",
|
||||
Name: builtin.ToolManageWebshellAdd,
|
||||
Description: "添加新的 WebShell 连接到管理系统。支持 PHP、ASP、ASPX、JSP 等类型的一句话木马。",
|
||||
ShortDescription: "添加 WebShell 连接",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -1422,8 +1433,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
|
||||
// manage_webshell_update - 更新 webshell 连接
|
||||
updateTool := mcp.Tool{
|
||||
Name: builtin.ToolManageWebshellUpdate,
|
||||
Description: "更新已存在的 WebShell 连接信息。",
|
||||
Name: builtin.ToolManageWebshellUpdate,
|
||||
Description: "更新已存在的 WebShell 连接信息。",
|
||||
ShortDescription: "更新 WebShell 连接",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -1519,8 +1530,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
|
||||
// manage_webshell_delete - 删除 webshell 连接
|
||||
deleteTool := mcp.Tool{
|
||||
Name: builtin.ToolManageWebshellDelete,
|
||||
Description: "删除指定的 WebShell 连接。",
|
||||
Name: builtin.ToolManageWebshellDelete,
|
||||
Description: "删除指定的 WebShell 连接。",
|
||||
ShortDescription: "删除 WebShell 连接",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -1561,8 +1572,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
|
||||
// manage_webshell_test - 测试 webshell 连接
|
||||
testTool := mcp.Tool{
|
||||
Name: builtin.ToolManageWebshellTest,
|
||||
Description: "测试指定的 WebShell 连接是否可用,会尝试执行一个简单的命令(如 whoami 或 dir)。",
|
||||
Name: builtin.ToolManageWebshellTest,
|
||||
Description: "测试指定的 WebShell 连接是否可用,会尝试执行一个简单的命令(如 whoami 或 dir)。",
|
||||
ShortDescription: "测试 WebShell 连接",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
|
||||
+154
-31
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -10,14 +11,22 @@ import (
|
||||
|
||||
// BatchTaskQueueRow 批量任务队列数据库行
|
||||
type BatchTaskQueueRow struct {
|
||||
ID string
|
||||
Title sql.NullString
|
||||
Role sql.NullString
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
StartedAt sql.NullTime
|
||||
CompletedAt sql.NullTime
|
||||
CurrentIndex int
|
||||
ID string
|
||||
Title sql.NullString
|
||||
Role sql.NullString
|
||||
AgentMode sql.NullString
|
||||
ScheduleMode sql.NullString
|
||||
CronExpr sql.NullString
|
||||
NextRunAt sql.NullTime
|
||||
ScheduleEnabled sql.NullInt64
|
||||
LastScheduleTriggerAt sql.NullTime
|
||||
LastScheduleError sql.NullString
|
||||
LastRunError sql.NullString
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
StartedAt sql.NullTime
|
||||
CompletedAt sql.NullTime
|
||||
CurrentIndex int
|
||||
}
|
||||
|
||||
// BatchTaskRow 批量任务数据库行
|
||||
@@ -34,7 +43,16 @@ type BatchTaskRow struct {
|
||||
}
|
||||
|
||||
// CreateBatchQueue 创建批量任务队列
|
||||
func (db *DB) CreateBatchQueue(queueID string, title string, role string, tasks []map[string]interface{}) error {
|
||||
func (db *DB) CreateBatchQueue(
|
||||
queueID string,
|
||||
title string,
|
||||
role string,
|
||||
agentMode string,
|
||||
scheduleMode string,
|
||||
cronExpr string,
|
||||
nextRunAt *time.Time,
|
||||
tasks []map[string]interface{},
|
||||
) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
@@ -42,9 +60,14 @@ func (db *DB) CreateBatchQueue(queueID string, title string, role string, tasks
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
var nextRunAtValue interface{}
|
||||
if nextRunAt != nil {
|
||||
nextRunAtValue = *nextRunAt
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO batch_task_queues (id, title, role, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
queueID, title, role, "pending", now, 0,
|
||||
"INSERT INTO batch_task_queues (id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
queueID, title, role, agentMode, scheduleMode, cronExpr, nextRunAtValue, 1, "pending", now, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建批量任务队列失败: %w", err)
|
||||
@@ -60,7 +83,7 @@ func (db *DB) CreateBatchQueue(queueID string, title string, role string, tasks
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO batch_tasks (id, queue_id, message, status) VALUES (?, ?, ?, ?)",
|
||||
taskID, queueID, message, "pending",
|
||||
@@ -78,9 +101,9 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
|
||||
var row BatchTaskQueueRow
|
||||
var createdAt string
|
||||
err := db.QueryRow(
|
||||
"SELECT id, title, role, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
|
||||
"SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
|
||||
queueID,
|
||||
).Scan(&row.ID, &row.Title, &row.Role, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
|
||||
).Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -104,7 +127,7 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
|
||||
// GetAllBatchQueues 获取所有批量任务队列
|
||||
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, title, role, status, created_at, started_at, completed_at, current_index FROM batch_task_queues ORDER BY created_at DESC",
|
||||
"SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, 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)
|
||||
@@ -115,7 +138,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.Role, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
|
||||
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &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)
|
||||
@@ -135,7 +158,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
||||
|
||||
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
|
||||
func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueueRow, error) {
|
||||
query := "SELECT id, title, role, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
|
||||
query := "SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
|
||||
// 状态筛选
|
||||
@@ -163,7 +186,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.Role, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
|
||||
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &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)
|
||||
@@ -237,7 +260,7 @@ func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
|
||||
func (db *DB) UpdateBatchQueueStatus(queueID, status string) error {
|
||||
var err error
|
||||
now := time.Now()
|
||||
|
||||
|
||||
if status == "running" {
|
||||
_, err = db.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, started_at = COALESCE(started_at, ?) WHERE id = ?",
|
||||
@@ -254,7 +277,7 @@ func (db *DB) UpdateBatchQueueStatus(queueID, status string) error {
|
||||
status, queueID,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
|
||||
}
|
||||
@@ -265,41 +288,41 @@ func (db *DB) UpdateBatchQueueStatus(queueID, status string) error {
|
||||
func (db *DB) UpdateBatchTaskStatus(queueID, taskID, status string, conversationID, result, errorMsg string) error {
|
||||
var err error
|
||||
now := time.Now()
|
||||
|
||||
|
||||
// 构建更新语句
|
||||
var updates []string
|
||||
var args []interface{}
|
||||
|
||||
|
||||
updates = append(updates, "status = ?")
|
||||
args = append(args, status)
|
||||
|
||||
|
||||
if conversationID != "" {
|
||||
updates = append(updates, "conversation_id = ?")
|
||||
args = append(args, conversationID)
|
||||
}
|
||||
|
||||
|
||||
if result != "" {
|
||||
updates = append(updates, "result = ?")
|
||||
args = append(args, result)
|
||||
}
|
||||
|
||||
|
||||
if errorMsg != "" {
|
||||
updates = append(updates, "error = ?")
|
||||
args = append(args, errorMsg)
|
||||
}
|
||||
|
||||
|
||||
if status == "running" {
|
||||
updates = append(updates, "started_at = COALESCE(started_at, ?)")
|
||||
args = append(args, now)
|
||||
}
|
||||
|
||||
|
||||
if status == "completed" || status == "failed" || status == "cancelled" {
|
||||
updates = append(updates, "completed_at = COALESCE(completed_at, ?)")
|
||||
args = append(args, now)
|
||||
}
|
||||
|
||||
|
||||
args = append(args, queueID, taskID)
|
||||
|
||||
|
||||
// 构建SQL语句
|
||||
sql := "UPDATE batch_tasks SET "
|
||||
for i, update := range updates {
|
||||
@@ -309,7 +332,7 @@ func (db *DB) UpdateBatchTaskStatus(queueID, taskID, status string, conversation
|
||||
sql += update
|
||||
}
|
||||
sql += " WHERE queue_id = ? AND id = ?"
|
||||
|
||||
|
||||
_, err = db.Exec(sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务状态失败: %w", err)
|
||||
@@ -329,6 +352,107 @@ func (db *DB) UpdateBatchQueueCurrentIndex(queueID string, currentIndex int) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueSchedule 更新批量任务队列调度相关信息
|
||||
func (db *DB) UpdateBatchQueueSchedule(queueID, scheduleMode, cronExpr string, nextRunAt *time.Time) error {
|
||||
var nextRunAtValue interface{}
|
||||
if nextRunAt != nil {
|
||||
nextRunAtValue = *nextRunAt
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET schedule_mode = ?, cron_expr = ?, next_run_at = ? WHERE id = ?",
|
||||
scheduleMode, cronExpr, nextRunAtValue, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务调度配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueScheduleEnabled 是否允许 Cron 自动触发(手工「开始执行」不受影响)
|
||||
func (db *DB) UpdateBatchQueueScheduleEnabled(queueID string, enabled bool) error {
|
||||
v := 0
|
||||
if enabled {
|
||||
v = 1
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET schedule_enabled = ? WHERE id = ?",
|
||||
v, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务调度开关失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordBatchQueueScheduledTriggerStart 记录一次由调度触发的开始时间并清空调度层错误
|
||||
func (db *DB) RecordBatchQueueScheduledTriggerStart(queueID string, at time.Time) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET last_schedule_trigger_at = ?, last_schedule_error = NULL WHERE id = ?",
|
||||
at, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("记录调度触发时间失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBatchQueueLastScheduleError 调度启动失败等原因(如状态不允许、重置失败)
|
||||
func (db *DB) SetBatchQueueLastScheduleError(queueID, msg string) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET last_schedule_error = ? WHERE id = ?",
|
||||
msg, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入调度错误信息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBatchQueueLastRunError 最近一轮执行中出现的子任务失败摘要(空串表示清空)
|
||||
func (db *DB) SetBatchQueueLastRunError(queueID, msg string) error {
|
||||
var v interface{}
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
v = nil
|
||||
} else {
|
||||
v = msg
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET last_run_error = ? WHERE id = ?",
|
||||
v, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入最近运行错误失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetBatchQueueForRerun 重置队列和任务状态用于下一轮调度执行
|
||||
func (db *DB) ResetBatchQueueForRerun(queueID string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, current_index = 0, started_at = NULL, completed_at = NULL WHERE id = ?",
|
||||
"pending", queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置批量任务队列状态失败: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ?",
|
||||
"pending", queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置批量任务状态失败: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateBatchTaskMessage 更新批量任务消息
|
||||
func (db *DB) UpdateBatchTaskMessage(queueID, taskID, message string) error {
|
||||
_, err := db.Exec(
|
||||
@@ -387,4 +511,3 @@ func (db *DB) DeleteBatchQueue(queueID string) error {
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
@@ -310,15 +310,14 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
|
||||
var err error
|
||||
|
||||
if search != "" {
|
||||
// 使用LIKE进行模糊搜索,搜索标题和消息内容
|
||||
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
||||
searchPattern := "%" + search + "%"
|
||||
// 使用DISTINCT避免重复,因为一个对话可能有多条消息匹配
|
||||
rows, err = db.Query(
|
||||
`SELECT DISTINCT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at
|
||||
FROM conversations c
|
||||
LEFT JOIN messages m ON c.id = m.conversation_id
|
||||
WHERE c.title LIKE ? OR m.content LIKE ?
|
||||
ORDER BY c.updated_at DESC
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
searchPattern, searchPattern, limit, offset,
|
||||
)
|
||||
|
||||
@@ -205,6 +205,15 @@ func (db *DB) initTables() error {
|
||||
CREATE TABLE IF NOT EXISTS batch_task_queues (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
role TEXT,
|
||||
agent_mode TEXT NOT NULL DEFAULT 'single',
|
||||
schedule_mode TEXT NOT NULL DEFAULT 'manual',
|
||||
cron_expr TEXT,
|
||||
next_run_at DATETIME,
|
||||
schedule_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_schedule_trigger_at DATETIME,
|
||||
last_schedule_error TEXT,
|
||||
last_run_error TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
started_at DATETIME,
|
||||
@@ -495,7 +504,7 @@ func (db *DB) migrateConversationGroupMappingsTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,添加title和role字段
|
||||
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,补充新字段
|
||||
func (db *DB) migrateBatchTaskQueuesTable() error {
|
||||
// 检查title字段是否存在
|
||||
var count int
|
||||
@@ -535,6 +544,131 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查agent_mode字段是否存在
|
||||
var agentModeCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='agent_mode'").Scan(&agentModeCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'single'"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if agentModeCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'single'"); err != nil {
|
||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查schedule_mode字段是否存在
|
||||
var scheduleModeCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='schedule_mode'").Scan(&scheduleModeCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'manual'"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加schedule_mode字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if scheduleModeCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'manual'"); err != nil {
|
||||
db.logger.Warn("添加schedule_mode字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查cron_expr字段是否存在
|
||||
var cronExprCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='cron_expr'").Scan(&cronExprCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN cron_expr TEXT"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加cron_expr字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if cronExprCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN cron_expr TEXT"); err != nil {
|
||||
db.logger.Warn("添加cron_expr字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查next_run_at字段是否存在
|
||||
var nextRunAtCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='next_run_at'").Scan(&nextRunAtCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN next_run_at DATETIME"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加next_run_at字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if nextRunAtCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN next_run_at DATETIME"); err != nil {
|
||||
db.logger.Warn("添加next_run_at字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// schedule_enabled:0=暂停 Cron 自动调度,1=允许(手工执行不受影响)
|
||||
var scheduleEnCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='schedule_enabled'").Scan(&scheduleEnCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_enabled INTEGER NOT NULL DEFAULT 1"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加schedule_enabled字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if scheduleEnCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_enabled INTEGER NOT NULL DEFAULT 1"); err != nil {
|
||||
db.logger.Warn("添加schedule_enabled字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
var lastTrigCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='last_schedule_trigger_at'").Scan(&lastTrigCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_trigger_at DATETIME"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加last_schedule_trigger_at字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if lastTrigCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_trigger_at DATETIME"); err != nil {
|
||||
db.logger.Warn("添加last_schedule_trigger_at字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
var lastSchedErrCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='last_schedule_error'").Scan(&lastSchedErrCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_error TEXT"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加last_schedule_error字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if lastSchedErrCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_error TEXT"); err != nil {
|
||||
db.logger.Warn("添加last_schedule_error字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
var lastRunErrCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='last_run_error'").Scan(&lastRunErrCount)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_run_error TEXT"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加last_run_error字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if lastRunErrCount == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_run_error TEXT"); err != nil {
|
||||
db.logger.Warn("添加last_run_error字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -403,6 +403,35 @@ func (db *DB) UpdateGroupPinned(id string, pinned bool) error {
|
||||
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
|
||||
|
||||
@@ -160,13 +160,17 @@ func runMCPToolInvocation(
|
||||
}
|
||||
|
||||
// UnknownToolReminderHandler 供 compose.ToolsNodeConfig.UnknownToolsHandler 使用:
|
||||
// 模型请求了未注册的工具名时,仅返回说明性文本,error 恒为 nil,以便 ReAct 继续迭代而不中断图执行。
|
||||
// 模型请求了未注册的工具名时,返回一个「可恢复」的错误,让上层 runner 触发重试与纠错提示,
|
||||
// 同时避免 UI 永远停留在“执行中”(runner 会在 recoverable 分支 flush 掉 pending 的 tool_call)。
|
||||
// 不进行名称猜测或映射,避免误执行。
|
||||
func UnknownToolReminderHandler() func(ctx context.Context, name, input string) (string, error) {
|
||||
return func(ctx context.Context, name, input string) (string, error) {
|
||||
_ = ctx
|
||||
_ = input
|
||||
return unknownToolReminderText(strings.TrimSpace(name)), nil
|
||||
requested := strings.TrimSpace(name)
|
||||
// Return a recoverable error that still carries a friendly, bilingual hint.
|
||||
// This will be caught by multiagent runner as "tool not found" and trigger a retry.
|
||||
return "", fmt.Errorf("tool %q not found: %s", requested, unknownToolReminderText(requested))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+229
-19
@@ -24,6 +24,7 @@ import (
|
||||
"cyberstrike-ai/internal/skills"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -81,6 +82,9 @@ type AgentHandler struct {
|
||||
}
|
||||
skillsManager *skills.Manager // Skills管理器
|
||||
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
||||
batchCronParser cron.Parser
|
||||
batchRunnerMu sync.Mutex
|
||||
batchRunning map[string]struct{}
|
||||
}
|
||||
|
||||
// NewAgentHandler 创建新的Agent处理器
|
||||
@@ -93,14 +97,18 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
||||
logger.Warn("从数据库加载批量任务队列失败", zap.Error(err))
|
||||
}
|
||||
|
||||
return &AgentHandler{
|
||||
handler := &AgentHandler{
|
||||
agent: agent,
|
||||
db: db,
|
||||
logger: logger,
|
||||
tasks: NewAgentTaskManager(),
|
||||
batchTaskManager: batchTaskManager,
|
||||
config: cfg,
|
||||
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
|
||||
batchRunning: make(map[string]struct{}),
|
||||
}
|
||||
go handler.batchQueueSchedulerLoop()
|
||||
return handler
|
||||
}
|
||||
|
||||
// SetKnowledgeManager 设置知识库管理器(用于记录检索日志)
|
||||
@@ -990,6 +998,24 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
||||
return
|
||||
}
|
||||
|
||||
// 当 Agent 同时发送 thinking_stream_* 和 thinking(带同一 streamId)时,
|
||||
// thinking_stream_* 已经会在 flushThinkingStreams() 聚合落库;
|
||||
// 这里跳过同 streamId 的 thinking,避免 processDetails 双份展示。
|
||||
if eventType == "thinking" {
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||
if tb, exists := thinkingStreams[sid]; exists && tb != nil {
|
||||
if strings.TrimSpace(tb.b.String()) != "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if flushedThinking[sid] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存过程详情到数据库(排除 response/done;response 正文已在 messages 表)
|
||||
// response_start/response_delta 已聚合为 planning,不落逐条。
|
||||
if assistantMessageID != "" &&
|
||||
@@ -1557,9 +1583,26 @@ func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
|
||||
|
||||
// BatchTaskRequest 批量任务请求
|
||||
type BatchTaskRequest struct {
|
||||
Title string `json:"title"` // 任务标题(可选)
|
||||
Tasks []string `json:"tasks" binding:"required"` // 任务列表,每行一个任务
|
||||
Role string `json:"role,omitempty"` // 角色名称(可选,空字符串表示默认角色)
|
||||
Title string `json:"title"` // 任务标题(可选)
|
||||
Tasks []string `json:"tasks" binding:"required"` // 任务列表,每行一个任务
|
||||
Role string `json:"role,omitempty"` // 角色名称(可选,空字符串表示默认角色)
|
||||
AgentMode string `json:"agentMode,omitempty"` // single | multi
|
||||
ScheduleMode string `json:"scheduleMode,omitempty"` // manual | cron
|
||||
CronExpr string `json:"cronExpr,omitempty"` // scheduleMode=cron 时必填
|
||||
}
|
||||
|
||||
func normalizeBatchQueueAgentMode(mode string) string {
|
||||
if strings.TrimSpace(mode) == "multi" {
|
||||
return "multi"
|
||||
}
|
||||
return "single"
|
||||
}
|
||||
|
||||
func normalizeBatchQueueScheduleMode(mode string) string {
|
||||
if strings.TrimSpace(mode) == "cron" {
|
||||
return "cron"
|
||||
}
|
||||
return "manual"
|
||||
}
|
||||
|
||||
// CreateBatchQueue 创建批量任务队列
|
||||
@@ -1588,7 +1631,25 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
queue := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, validTasks)
|
||||
agentMode := normalizeBatchQueueAgentMode(req.AgentMode)
|
||||
scheduleMode := normalizeBatchQueueScheduleMode(req.ScheduleMode)
|
||||
cronExpr := strings.TrimSpace(req.CronExpr)
|
||||
var nextRunAt *time.Time
|
||||
if scheduleMode == "cron" {
|
||||
if cronExpr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "启用 Cron 调度时,调度表达式不能为空"})
|
||||
return
|
||||
}
|
||||
schedule, err := h.batchCronParser.Parse(cronExpr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 Cron 表达式: " + err.Error()})
|
||||
return
|
||||
}
|
||||
next := schedule.Next(time.Now())
|
||||
nextRunAt = &next
|
||||
}
|
||||
|
||||
queue := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, agentMode, scheduleMode, cronExpr, nextRunAt, validTasks)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"queueId": queue.ID,
|
||||
"queue": queue,
|
||||
@@ -1681,21 +1742,15 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
|
||||
// StartBatchQueue 开始执行批量任务队列
|
||||
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if !exists {
|
||||
ok, err := h.startBatchQueueExecution(queueID, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if queue.Status != "pending" && queue.Status != "paused" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "队列状态不允许启动"})
|
||||
return
|
||||
}
|
||||
|
||||
// 在后台执行批量任务
|
||||
go h.executeBatchQueue(queueID)
|
||||
|
||||
h.batchTaskManager.UpdateQueueStatus(queueID, "running")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务已开始执行", "queueId": queueID})
|
||||
}
|
||||
|
||||
@@ -1710,6 +1765,28 @@ func (h *AgentHandler) PauseBatchQueue(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"})
|
||||
}
|
||||
|
||||
// SetBatchQueueScheduleEnabled 开启/关闭 Cron 自动调度(手工执行不受影响)
|
||||
func (h *AgentHandler) SetBatchQueueScheduleEnabled(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
if _, exists := h.batchTaskManager.GetBatchQueue(queueID); !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ScheduleEnabled bool `json:"scheduleEnabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !h.batchTaskManager.SetScheduleEnabled(queueID, req.ScheduleEnabled) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||
return
|
||||
}
|
||||
queue, _ := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
c.JSON(http.StatusOK, gin.H{"queue": queue})
|
||||
}
|
||||
|
||||
// DeleteBatchQueue 删除批量任务队列
|
||||
func (h *AgentHandler) DeleteBatchQueue(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
@@ -1806,8 +1883,125 @@ func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "任务已删除", "queue": queue})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) markBatchQueueRunning(queueID string) bool {
|
||||
h.batchRunnerMu.Lock()
|
||||
defer h.batchRunnerMu.Unlock()
|
||||
if _, exists := h.batchRunning[queueID]; exists {
|
||||
return false
|
||||
}
|
||||
h.batchRunning[queueID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AgentHandler) unmarkBatchQueueRunning(queueID string) {
|
||||
h.batchRunnerMu.Lock()
|
||||
defer h.batchRunnerMu.Unlock()
|
||||
delete(h.batchRunning, queueID)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*time.Time, error) {
|
||||
expr := strings.TrimSpace(cronExpr)
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
schedule, err := h.batchCronParser.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := schedule.Next(from)
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) startBatchQueueExecution(queueID string, scheduled bool) (bool, error) {
|
||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
if !h.markBatchQueueRunning(queueID) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if scheduled {
|
||||
if queue.ScheduleMode != "cron" {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
err := fmt.Errorf("队列未启用 cron 调度")
|
||||
h.batchTaskManager.SetLastScheduleError(queueID, err.Error())
|
||||
return true, err
|
||||
}
|
||||
if queue.Status == "running" || queue.Status == "paused" || queue.Status == "cancelled" {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
err := fmt.Errorf("当前队列状态不允许被调度执行")
|
||||
h.batchTaskManager.SetLastScheduleError(queueID, err.Error())
|
||||
return true, err
|
||||
}
|
||||
if !h.batchTaskManager.ResetQueueForRerun(queueID) {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
err := fmt.Errorf("重置队列失败")
|
||||
h.batchTaskManager.SetLastScheduleError(queueID, err.Error())
|
||||
return true, err
|
||||
}
|
||||
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
|
||||
} else if queue.Status != "pending" && queue.Status != "paused" {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
return true, fmt.Errorf("队列状态不允许启动")
|
||||
}
|
||||
|
||||
if queue != nil && queue.AgentMode == "multi" && (h.config == nil || !h.config.MultiAgent.Enabled) {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
err := fmt.Errorf("当前队列配置为多代理,但系统未启用多代理")
|
||||
if scheduled {
|
||||
h.batchTaskManager.SetLastScheduleError(queueID, err.Error())
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
|
||||
if scheduled {
|
||||
h.batchTaskManager.RecordScheduledRunStart(queueID)
|
||||
}
|
||||
h.batchTaskManager.UpdateQueueStatus(queueID, "running")
|
||||
if queue != nil && queue.ScheduleMode == "cron" {
|
||||
nextRunAt, err := h.nextBatchQueueRunAt(queue.CronExpr, time.Now())
|
||||
if err == nil {
|
||||
h.batchTaskManager.UpdateQueueSchedule(queueID, "cron", queue.CronExpr, nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
go h.executeBatchQueue(queueID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) batchQueueSchedulerLoop() {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
queues := h.batchTaskManager.GetAllQueues()
|
||||
now := time.Now()
|
||||
for _, queue := range queues {
|
||||
if queue == nil || queue.ScheduleMode != "cron" || !queue.ScheduleEnabled || queue.Status == "cancelled" || queue.Status == "running" || queue.Status == "paused" {
|
||||
continue
|
||||
}
|
||||
nextRunAt := queue.NextRunAt
|
||||
if nextRunAt == nil {
|
||||
next, err := h.nextBatchQueueRunAt(queue.CronExpr, now)
|
||||
if err != nil {
|
||||
h.logger.Warn("批量任务 cron 表达式无效,跳过调度", zap.String("queueId", queue.ID), zap.String("cronExpr", queue.CronExpr), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
h.batchTaskManager.UpdateQueueSchedule(queue.ID, "cron", queue.CronExpr, next)
|
||||
nextRunAt = next
|
||||
}
|
||||
if nextRunAt != nil && (nextRunAt.Before(now) || nextRunAt.Equal(now)) {
|
||||
if _, err := h.startBatchQueueExecution(queue.ID, true); err != nil {
|
||||
h.logger.Warn("自动调度批量任务失败", zap.String("queueId", queue.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeBatchQueue 执行批量任务队列
|
||||
func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
defer h.unmarkBatchQueueRunning(queueID)
|
||||
h.logger.Info("开始执行批量任务队列", zap.String("queueId", queueID))
|
||||
|
||||
for {
|
||||
@@ -1820,7 +2014,17 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
// 获取下一个任务
|
||||
task, hasNext := h.batchTaskManager.GetNextTask(queueID)
|
||||
if !hasNext {
|
||||
// 所有任务完成
|
||||
// 所有任务完成:汇总子任务失败信息便于排障
|
||||
q, ok := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
lastRunErr := ""
|
||||
if ok {
|
||||
for _, t := range q.Tasks {
|
||||
if t.Status == "failed" && t.Error != "" {
|
||||
lastRunErr = t.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
h.batchTaskManager.SetLastRunError(queueID, lastRunErr)
|
||||
h.batchTaskManager.UpdateQueueStatus(queueID, "completed")
|
||||
h.logger.Info("批量任务队列执行完成", zap.String("queueId", queueID))
|
||||
break
|
||||
@@ -1900,7 +2104,13 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
|
||||
useBatchMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.BatchUseMultiAgent
|
||||
useBatchMulti := false
|
||||
if queue.AgentMode == "multi" {
|
||||
useBatchMulti = h.config != nil && h.config.MultiAgent.Enabled
|
||||
} else if queue.AgentMode == "" {
|
||||
// 兼容历史数据:未配置队列代理模式时,沿用旧的系统级开关
|
||||
useBatchMulti = h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.BatchUseMultiAgent
|
||||
}
|
||||
var result *agent.AgentLoopResult
|
||||
var resultMA *multiagent.RunResult
|
||||
var runErr error
|
||||
|
||||
@@ -27,24 +27,32 @@ type BatchTask struct {
|
||||
|
||||
// BatchTaskQueue 批量任务队列
|
||||
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"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
CurrentIndex int `json:"currentIndex"`
|
||||
mu sync.RWMutex
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称(空字符串表示默认角色)
|
||||
AgentMode string `json:"agentMode"` // single | multi
|
||||
ScheduleMode string `json:"scheduleMode"` // manual | cron
|
||||
CronExpr string `json:"cronExpr,omitempty"`
|
||||
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||
ScheduleEnabled bool `json:"scheduleEnabled"`
|
||||
LastScheduleTriggerAt *time.Time `json:"lastScheduleTriggerAt,omitempty"`
|
||||
LastScheduleError string `json:"lastScheduleError,omitempty"`
|
||||
LastRunError string `json:"lastRunError,omitempty"`
|
||||
Tasks []*BatchTask `json:"tasks"`
|
||||
Status string `json:"status"` // pending, running, paused, completed, cancelled
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
CurrentIndex int `json:"currentIndex"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// BatchTaskManager 批量任务管理器
|
||||
type BatchTaskManager struct {
|
||||
db *database.DB
|
||||
queues map[string]*BatchTaskQueue
|
||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||
mu sync.RWMutex
|
||||
db *database.DB
|
||||
queues map[string]*BatchTaskQueue
|
||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBatchTaskManager 创建批量任务管理器
|
||||
@@ -63,19 +71,32 @@ func (m *BatchTaskManager) SetDB(db *database.DB) {
|
||||
}
|
||||
|
||||
// CreateBatchQueue 创建批量任务队列
|
||||
func (m *BatchTaskManager) CreateBatchQueue(title, role string, tasks []string) *BatchTaskQueue {
|
||||
func (m *BatchTaskManager) CreateBatchQueue(
|
||||
title, role, agentMode, scheduleMode, cronExpr string,
|
||||
nextRunAt *time.Time,
|
||||
tasks []string,
|
||||
) *BatchTaskQueue {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queueID := time.Now().Format("20060102150405") + "-" + generateShortID()
|
||||
queue := &BatchTaskQueue{
|
||||
ID: queueID,
|
||||
Title: title,
|
||||
Role: role,
|
||||
Tasks: make([]*BatchTask, 0, len(tasks)),
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
CurrentIndex: 0,
|
||||
ID: queueID,
|
||||
Title: title,
|
||||
Role: role,
|
||||
AgentMode: normalizeBatchQueueAgentMode(agentMode),
|
||||
ScheduleMode: normalizeBatchQueueScheduleMode(scheduleMode),
|
||||
CronExpr: strings.TrimSpace(cronExpr),
|
||||
NextRunAt: nextRunAt,
|
||||
ScheduleEnabled: true,
|
||||
Tasks: make([]*BatchTask, 0, len(tasks)),
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
CurrentIndex: 0,
|
||||
}
|
||||
if queue.ScheduleMode != "cron" {
|
||||
queue.CronExpr = ""
|
||||
queue.NextRunAt = nil
|
||||
}
|
||||
|
||||
// 准备数据库保存的任务数据
|
||||
@@ -100,7 +121,16 @@ func (m *BatchTaskManager) CreateBatchQueue(title, role string, tasks []string)
|
||||
|
||||
// 保存到数据库
|
||||
if m.db != nil {
|
||||
if err := m.db.CreateBatchQueue(queueID, title, role, dbTasks); err != nil {
|
||||
if err := m.db.CreateBatchQueue(
|
||||
queueID,
|
||||
title,
|
||||
role,
|
||||
queue.AgentMode,
|
||||
queue.ScheduleMode,
|
||||
queue.CronExpr,
|
||||
queue.NextRunAt,
|
||||
dbTasks,
|
||||
); err != nil {
|
||||
// 如果数据库保存失败,记录错误但继续(使用内存缓存)
|
||||
// 这里可以添加日志记录
|
||||
}
|
||||
@@ -151,6 +181,8 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
||||
|
||||
queue := &BatchTaskQueue{
|
||||
ID: queueRow.ID,
|
||||
AgentMode: "single",
|
||||
ScheduleMode: "manual",
|
||||
Status: queueRow.Status,
|
||||
CreatedAt: queueRow.CreatedAt,
|
||||
CurrentIndex: queueRow.CurrentIndex,
|
||||
@@ -163,6 +195,33 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
||||
if queueRow.Role.Valid {
|
||||
queue.Role = queueRow.Role.String
|
||||
}
|
||||
if queueRow.AgentMode.Valid {
|
||||
queue.AgentMode = normalizeBatchQueueAgentMode(queueRow.AgentMode.String)
|
||||
}
|
||||
if queueRow.ScheduleMode.Valid {
|
||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
||||
}
|
||||
if queueRow.CronExpr.Valid && queue.ScheduleMode == "cron" {
|
||||
queue.CronExpr = strings.TrimSpace(queueRow.CronExpr.String)
|
||||
}
|
||||
if queueRow.NextRunAt.Valid && queue.ScheduleMode == "cron" {
|
||||
t := queueRow.NextRunAt.Time
|
||||
queue.NextRunAt = &t
|
||||
}
|
||||
queue.ScheduleEnabled = true
|
||||
if queueRow.ScheduleEnabled.Valid && queueRow.ScheduleEnabled.Int64 == 0 {
|
||||
queue.ScheduleEnabled = false
|
||||
}
|
||||
if queueRow.LastScheduleTriggerAt.Valid {
|
||||
t := queueRow.LastScheduleTriggerAt.Time
|
||||
queue.LastScheduleTriggerAt = &t
|
||||
}
|
||||
if queueRow.LastScheduleError.Valid {
|
||||
queue.LastScheduleError = strings.TrimSpace(queueRow.LastScheduleError.String)
|
||||
}
|
||||
if queueRow.LastRunError.Valid {
|
||||
queue.LastRunError = strings.TrimSpace(queueRow.LastRunError.String)
|
||||
}
|
||||
if queueRow.StartedAt.Valid {
|
||||
queue.StartedAt = &queueRow.StartedAt.Time
|
||||
}
|
||||
@@ -347,6 +406,8 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
||||
|
||||
queue := &BatchTaskQueue{
|
||||
ID: queueRow.ID,
|
||||
AgentMode: "single",
|
||||
ScheduleMode: "manual",
|
||||
Status: queueRow.Status,
|
||||
CreatedAt: queueRow.CreatedAt,
|
||||
CurrentIndex: queueRow.CurrentIndex,
|
||||
@@ -359,6 +420,33 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
||||
if queueRow.Role.Valid {
|
||||
queue.Role = queueRow.Role.String
|
||||
}
|
||||
if queueRow.AgentMode.Valid {
|
||||
queue.AgentMode = normalizeBatchQueueAgentMode(queueRow.AgentMode.String)
|
||||
}
|
||||
if queueRow.ScheduleMode.Valid {
|
||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
||||
}
|
||||
if queueRow.CronExpr.Valid && queue.ScheduleMode == "cron" {
|
||||
queue.CronExpr = strings.TrimSpace(queueRow.CronExpr.String)
|
||||
}
|
||||
if queueRow.NextRunAt.Valid && queue.ScheduleMode == "cron" {
|
||||
t := queueRow.NextRunAt.Time
|
||||
queue.NextRunAt = &t
|
||||
}
|
||||
queue.ScheduleEnabled = true
|
||||
if queueRow.ScheduleEnabled.Valid && queueRow.ScheduleEnabled.Int64 == 0 {
|
||||
queue.ScheduleEnabled = false
|
||||
}
|
||||
if queueRow.LastScheduleTriggerAt.Valid {
|
||||
t := queueRow.LastScheduleTriggerAt.Time
|
||||
queue.LastScheduleTriggerAt = &t
|
||||
}
|
||||
if queueRow.LastScheduleError.Valid {
|
||||
queue.LastScheduleError = strings.TrimSpace(queueRow.LastScheduleError.String)
|
||||
}
|
||||
if queueRow.LastRunError.Valid {
|
||||
queue.LastRunError = strings.TrimSpace(queueRow.LastRunError.String)
|
||||
}
|
||||
if queueRow.StartedAt.Valid {
|
||||
queue.StartedAt = &queueRow.StartedAt.Time
|
||||
}
|
||||
@@ -469,6 +557,127 @@ func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateQueueSchedule 更新队列调度配置
|
||||
func (m *BatchTaskManager) UpdateQueueSchedule(queueID, scheduleMode, cronExpr string, nextRunAt *time.Time) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(scheduleMode)
|
||||
if queue.ScheduleMode == "cron" {
|
||||
queue.CronExpr = strings.TrimSpace(cronExpr)
|
||||
queue.NextRunAt = nextRunAt
|
||||
} else {
|
||||
queue.CronExpr = ""
|
||||
queue.NextRunAt = nil
|
||||
}
|
||||
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueSchedule(queueID, queue.ScheduleMode, queue.CronExpr, queue.NextRunAt); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetScheduleEnabled 暂停/恢复 Cron 自动调度(不影响手工执行)
|
||||
func (m *BatchTaskManager) SetScheduleEnabled(queueID string, enabled bool) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
queue.ScheduleEnabled = enabled
|
||||
if m.db != nil {
|
||||
_ = m.db.UpdateBatchQueueScheduleEnabled(queueID, enabled)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RecordScheduledRunStart Cron 触发成功、即将执行子任务时调用
|
||||
func (m *BatchTaskManager) RecordScheduledRunStart(queueID string) {
|
||||
now := time.Now()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
queue.LastScheduleTriggerAt = &now
|
||||
queue.LastScheduleError = ""
|
||||
if m.db != nil {
|
||||
_ = m.db.RecordBatchQueueScheduledTriggerStart(queueID, now)
|
||||
}
|
||||
}
|
||||
|
||||
// SetLastScheduleError 调度层失败(未成功开始执行)
|
||||
func (m *BatchTaskManager) SetLastScheduleError(queueID, msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
queue.LastScheduleError = strings.TrimSpace(msg)
|
||||
if m.db != nil {
|
||||
_ = m.db.SetBatchQueueLastScheduleError(queueID, queue.LastScheduleError)
|
||||
}
|
||||
}
|
||||
|
||||
// SetLastRunError 最近一轮批量执行中的失败摘要
|
||||
func (m *BatchTaskManager) SetLastRunError(queueID, msg string) {
|
||||
msg = strings.TrimSpace(msg)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
queue.LastRunError = msg
|
||||
if m.db != nil {
|
||||
_ = m.db.SetBatchQueueLastRunError(queueID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetQueueForRerun 重置队列与子任务状态,供 cron 下一轮执行
|
||||
func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
queue.Status = "pending"
|
||||
queue.CurrentIndex = 0
|
||||
queue.StartedAt = nil
|
||||
queue.CompletedAt = nil
|
||||
queue.NextRunAt = nil
|
||||
for _, task := range queue.Tasks {
|
||||
task.Status = "pending"
|
||||
task.ConversationID = ""
|
||||
task.StartedAt = nil
|
||||
task.CompletedAt = nil
|
||||
task.Error = ""
|
||||
task.Result = ""
|
||||
}
|
||||
|
||||
if m.db != nil {
|
||||
if err := m.db.ResetBatchQueueForRerun(queueID); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateTaskMessage 更新任务消息(仅限待执行状态)
|
||||
func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) error {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RegisterBatchTaskMCPTools 注册批量任务队列相关 MCP 工具(需传入已初始化 DB 的 AgentHandler)
|
||||
func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *zap.Logger) {
|
||||
if mcpServer == nil || h == nil || logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
reg := func(tool mcp.Tool, fn func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error)) {
|
||||
mcpServer.RegisterTool(tool, fn)
|
||||
}
|
||||
|
||||
// --- list ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskList,
|
||||
Description: "列出批量任务队列,支持按状态筛选与关键字搜索。用于查看队列 id、状态、进度及 Cron 配置等。",
|
||||
ShortDescription: "列出批量任务队列",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "筛选状态:all(默认)、pending、running、paused、completed、cancelled",
|
||||
"enum": []string{"all", "pending", "running", "paused", "completed", "cancelled"},
|
||||
},
|
||||
"keyword": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "按队列 ID 或标题模糊搜索",
|
||||
},
|
||||
"page": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "页码,从 1 开始,默认 1",
|
||||
},
|
||||
"page_size": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "每页条数,默认 20,最大 100",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
status := mcpArgString(args, "status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
keyword := mcpArgString(args, "keyword")
|
||||
page := int(mcpArgFloat(args, "page"))
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := int(mcpArgFloat(args, "page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
queues, total, err := h.batchTaskManager.ListQueues(pageSize, offset, status, keyword)
|
||||
if err != nil {
|
||||
return batchMCPTextResult(fmt.Sprintf("列出队列失败: %v", err), true), nil
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"queues": queues,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
}
|
||||
logger.Info("MCP batch_task_list", zap.String("status", status), zap.Int("total", total))
|
||||
return batchMCPJSONResult(payload)
|
||||
})
|
||||
|
||||
// --- get ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskGet,
|
||||
Description: "根据 queue_id 获取单个批量任务队列详情(含子任务列表、Cron、调度开关与最近错误信息)。",
|
||||
ShortDescription: "获取批量任务队列详情",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
if qid == "" {
|
||||
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||
}
|
||||
queue, ok := h.batchTaskManager.GetBatchQueue(qid)
|
||||
if !ok {
|
||||
return batchMCPTextResult("队列不存在: "+qid, true), nil
|
||||
}
|
||||
return batchMCPJSONResult(queue)
|
||||
})
|
||||
|
||||
// --- create ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskCreate,
|
||||
Description: `创建新的批量任务队列。任务列表使用 tasks(字符串数组)或 tasks_text(多行,每行一条)。
|
||||
agent_mode: single(默认)或 multi(需系统启用多代理)。schedule_mode: manual(默认)或 cron;为 cron 时必须提供 cron_expr(如 "0 */6 * * *")。
|
||||
重要:创建成功后队列处于 pending,不会自动开始跑子任务。若要立即执行或手工开跑,必须再调用工具 batch_task_start(传入返回的 queue_id)。Cron 队列若需按表达式自动触发下一轮,还需保持调度开关开启(可用 batch_task_schedule_enabled)。`,
|
||||
ShortDescription: "创建批量任务队列(创建后需 batch_task_start 才会执行)",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"title": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选标题",
|
||||
},
|
||||
"role": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "角色名称,空表示默认",
|
||||
},
|
||||
"tasks": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "任务指令列表,每项一条",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"tasks_text": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "多行文本,每行一条任务(与 tasks 二选一)",
|
||||
},
|
||||
"agent_mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "single 或 multi",
|
||||
"enum": []string{"single", "multi"},
|
||||
},
|
||||
"schedule_mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "manual 或 cron",
|
||||
"enum": []string{"manual", "cron"},
|
||||
},
|
||||
"cron_expr": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "schedule_mode 为 cron 时必填",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
tasks, errMsg := batchMCPTasksFromArgs(args)
|
||||
if errMsg != "" {
|
||||
return batchMCPTextResult(errMsg, true), nil
|
||||
}
|
||||
title := mcpArgString(args, "title")
|
||||
role := mcpArgString(args, "role")
|
||||
agentMode := normalizeBatchQueueAgentMode(mcpArgString(args, "agent_mode"))
|
||||
scheduleMode := normalizeBatchQueueScheduleMode(mcpArgString(args, "schedule_mode"))
|
||||
cronExpr := strings.TrimSpace(mcpArgString(args, "cron_expr"))
|
||||
var nextRunAt *time.Time
|
||||
if scheduleMode == "cron" {
|
||||
if cronExpr == "" {
|
||||
return batchMCPTextResult("Cron 调度模式下 cron_expr 不能为空", true), nil
|
||||
}
|
||||
sch, err := h.batchCronParser.Parse(cronExpr)
|
||||
if err != nil {
|
||||
return batchMCPTextResult("无效的 Cron 表达式: "+err.Error(), true), nil
|
||||
}
|
||||
n := sch.Next(time.Now())
|
||||
nextRunAt = &n
|
||||
}
|
||||
queue := h.batchTaskManager.CreateBatchQueue(title, role, agentMode, scheduleMode, cronExpr, nextRunAt, tasks)
|
||||
logger.Info("MCP batch_task_create", zap.String("queueId", queue.ID), zap.Int("taskCount", len(tasks)))
|
||||
return batchMCPJSONResult(map[string]interface{}{
|
||||
"queue_id": queue.ID,
|
||||
"queue": queue,
|
||||
"reminder": "队列已创建,当前为 pending。需要开始执行时请调用 MCP工具 batch_task_start(queue_id 同上)。Cron 自动调度需 schedule_enabled 为 true,可用 batch_task_schedule_enabled。",
|
||||
})
|
||||
})
|
||||
|
||||
// --- start ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskStart,
|
||||
Description: `启动或继续执行批量任务队列(pending / paused)。
|
||||
与 batch_task_create 配合使用:仅创建队列不会自动执行,需调用本工具才会开始跑子任务。`,
|
||||
ShortDescription: "启动/继续批量任务队列(创建后需调用才会执行)",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
if qid == "" {
|
||||
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||
}
|
||||
ok, err := h.startBatchQueueExecution(qid, false)
|
||||
if !ok {
|
||||
return batchMCPTextResult("队列不存在: "+qid, true), nil
|
||||
}
|
||||
if err != nil {
|
||||
return batchMCPTextResult("启动失败: "+err.Error(), true), nil
|
||||
}
|
||||
logger.Info("MCP batch_task_start", zap.String("queueId", qid))
|
||||
return batchMCPTextResult("已提交启动,队列将开始执行。", false), nil
|
||||
})
|
||||
|
||||
// --- pause ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskPause,
|
||||
Description: "暂停正在运行的批量任务队列(当前子任务会被取消)。",
|
||||
ShortDescription: "暂停批量任务队列",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
if qid == "" {
|
||||
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||
}
|
||||
if !h.batchTaskManager.PauseQueue(qid) {
|
||||
return batchMCPTextResult("无法暂停:队列不存在或当前非 running 状态", true), nil
|
||||
}
|
||||
logger.Info("MCP batch_task_pause", zap.String("queueId", qid))
|
||||
return batchMCPTextResult("队列已暂停。", false), nil
|
||||
})
|
||||
|
||||
// --- delete queue ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskDelete,
|
||||
Description: "删除批量任务队列及其子任务记录。",
|
||||
ShortDescription: "删除批量任务队列",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
if qid == "" {
|
||||
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||
}
|
||||
if !h.batchTaskManager.DeleteQueue(qid) {
|
||||
return batchMCPTextResult("删除失败:队列不存在", true), nil
|
||||
}
|
||||
logger.Info("MCP batch_task_delete", zap.String("queueId", qid))
|
||||
return batchMCPTextResult("队列已删除。", false), nil
|
||||
})
|
||||
|
||||
// --- schedule enabled ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskScheduleEnabled,
|
||||
Description: `设置是否允许 Cron 自动触发该队列。关闭后仍保留 Cron 表达式,仅停止定时自动跑;可用手工「启动」执行。
|
||||
仅对 schedule_mode 为 cron 的队列有意义。`,
|
||||
ShortDescription: "开关批量任务 Cron 自动调度",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
"schedule_enabled": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "true 允许定时触发,false 仅手工执行",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id", "schedule_enabled"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
if qid == "" {
|
||||
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||
}
|
||||
en, ok := mcpArgBool(args, "schedule_enabled")
|
||||
if !ok {
|
||||
return batchMCPTextResult("schedule_enabled 必须为布尔值", true), nil
|
||||
}
|
||||
if _, exists := h.batchTaskManager.GetBatchQueue(qid); !exists {
|
||||
return batchMCPTextResult("队列不存在", true), nil
|
||||
}
|
||||
if !h.batchTaskManager.SetScheduleEnabled(qid, en) {
|
||||
return batchMCPTextResult("更新失败", true), nil
|
||||
}
|
||||
queue, _ := h.batchTaskManager.GetBatchQueue(qid)
|
||||
logger.Info("MCP batch_task_schedule_enabled", zap.String("queueId", qid), zap.Bool("enabled", en))
|
||||
return batchMCPJSONResult(queue)
|
||||
})
|
||||
|
||||
// --- add task ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskAdd,
|
||||
Description: "向处于 pending 状态的队列追加一条子任务。",
|
||||
ShortDescription: "批量队列添加子任务",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
"message": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "任务指令内容",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id", "message"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
msg := strings.TrimSpace(mcpArgString(args, "message"))
|
||||
if qid == "" || msg == "" {
|
||||
return batchMCPTextResult("queue_id 与 message 均不能为空", true), nil
|
||||
}
|
||||
task, err := h.batchTaskManager.AddTaskToQueue(qid, msg)
|
||||
if err != nil {
|
||||
return batchMCPTextResult(err.Error(), true), nil
|
||||
}
|
||||
queue, _ := h.batchTaskManager.GetBatchQueue(qid)
|
||||
logger.Info("MCP batch_task_add_task", zap.String("queueId", qid), zap.String("taskId", task.ID))
|
||||
return batchMCPJSONResult(map[string]interface{}{"task": task, "queue": queue})
|
||||
})
|
||||
|
||||
// --- update task ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskUpdate,
|
||||
Description: "修改 pending 队列中仍为 pending 的子任务文案。",
|
||||
ShortDescription: "更新批量子任务内容",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
"task_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "子任务 ID",
|
||||
},
|
||||
"message": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "新的任务指令",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id", "task_id", "message"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
tid := mcpArgString(args, "task_id")
|
||||
msg := strings.TrimSpace(mcpArgString(args, "message"))
|
||||
if qid == "" || tid == "" || msg == "" {
|
||||
return batchMCPTextResult("queue_id、task_id、message 均不能为空", true), nil
|
||||
}
|
||||
if err := h.batchTaskManager.UpdateTaskMessage(qid, tid, msg); err != nil {
|
||||
return batchMCPTextResult(err.Error(), true), nil
|
||||
}
|
||||
queue, _ := h.batchTaskManager.GetBatchQueue(qid)
|
||||
logger.Info("MCP batch_task_update_task", zap.String("queueId", qid), zap.String("taskId", tid))
|
||||
return batchMCPJSONResult(queue)
|
||||
})
|
||||
|
||||
// --- remove task ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskRemove,
|
||||
Description: "从 pending 队列中删除仍为 pending 的子任务。",
|
||||
ShortDescription: "删除批量子任务",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"queue_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "队列 ID",
|
||||
},
|
||||
"task_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "子任务 ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id", "task_id"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
qid := mcpArgString(args, "queue_id")
|
||||
tid := mcpArgString(args, "task_id")
|
||||
if qid == "" || tid == "" {
|
||||
return batchMCPTextResult("queue_id 与 task_id 均不能为空", true), nil
|
||||
}
|
||||
if err := h.batchTaskManager.DeleteTask(qid, tid); err != nil {
|
||||
return batchMCPTextResult(err.Error(), true), nil
|
||||
}
|
||||
queue, _ := h.batchTaskManager.GetBatchQueue(qid)
|
||||
logger.Info("MCP batch_task_remove_task", zap.String("queueId", qid), zap.String("taskId", tid))
|
||||
return batchMCPJSONResult(queue)
|
||||
})
|
||||
|
||||
logger.Info("批量任务 MCP 工具已注册", zap.Int("count", 10))
|
||||
}
|
||||
|
||||
func batchMCPTextResult(text string, isErr bool) *mcp.ToolResult {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{Type: "text", Text: text}},
|
||||
IsError: isErr,
|
||||
}
|
||||
}
|
||||
|
||||
func batchMCPJSONResult(v interface{}) (*mcp.ToolResult, error) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return batchMCPTextResult(fmt.Sprintf("JSON 编码失败: %v", err), true), nil
|
||||
}
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: string(b)}}}, nil
|
||||
}
|
||||
|
||||
func batchMCPTasksFromArgs(args map[string]interface{}) ([]string, string) {
|
||||
if raw, ok := args["tasks"]; ok && raw != nil {
|
||||
switch t := raw.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, x := range t {
|
||||
if s, ok := x.(string); ok {
|
||||
if tr := strings.TrimSpace(s); tr != "" {
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if txt := mcpArgString(args, "tasks_text"); txt != "" {
|
||||
lines := strings.Split(txt, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if tr := strings.TrimSpace(line); tr != "" {
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, ""
|
||||
}
|
||||
}
|
||||
return nil, "需要提供 tasks(字符串数组)或 tasks_text(多行文本,每行一条任务)"
|
||||
}
|
||||
|
||||
func mcpArgString(args map[string]interface{}, key string) string {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
return strings.TrimSpace(strconv.FormatFloat(t, 'f', -1, 64))
|
||||
case json.Number:
|
||||
return strings.TrimSpace(t.String())
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
func mcpArgFloat(args map[string]interface{}, key string) float64 {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mcpArgBool(args map[string]interface{}, key string) (val bool, ok bool) {
|
||||
v, exists := args[key]
|
||||
if !exists {
|
||||
return false, false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t, true
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(t))
|
||||
if s == "true" || s == "1" || s == "yes" {
|
||||
return true, true
|
||||
}
|
||||
if s == "false" || s == "0" || s == "no" {
|
||||
return false, true
|
||||
}
|
||||
case float64:
|
||||
return t != 0, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package handler
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -35,6 +37,9 @@ type WebshellToolRegistrar func() error
|
||||
// SkillsToolRegistrar Skills工具注册器接口
|
||||
type SkillsToolRegistrar func() error
|
||||
|
||||
// BatchTaskToolRegistrar 批量任务 MCP 工具注册器(ApplyConfig 时重新注册)
|
||||
type BatchTaskToolRegistrar func() error
|
||||
|
||||
// RetrieverUpdater 检索器更新接口
|
||||
type RetrieverUpdater interface {
|
||||
UpdateConfig(config *knowledge.RetrievalConfig)
|
||||
@@ -66,6 +71,7 @@ type ConfigHandler struct {
|
||||
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
|
||||
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
|
||||
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
|
||||
batchTaskToolRegistrar BatchTaskToolRegistrar // 批量任务 MCP 工具(可选)
|
||||
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
|
||||
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
|
||||
appUpdater AppUpdater // App更新器(可选)
|
||||
@@ -139,6 +145,13 @@ func (h *ConfigHandler) SetSkillsToolRegistrar(registrar SkillsToolRegistrar) {
|
||||
h.skillsToolRegistrar = registrar
|
||||
}
|
||||
|
||||
// SetBatchTaskToolRegistrar 设置批量任务 MCP 工具注册器
|
||||
func (h *ConfigHandler) SetBatchTaskToolRegistrar(registrar BatchTaskToolRegistrar) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.batchTaskToolRegistrar = registrar
|
||||
}
|
||||
|
||||
// SetRetrieverUpdater 设置检索器更新器
|
||||
func (h *ConfigHandler) SetRetrieverUpdater(updater RetrieverUpdater) {
|
||||
h.mu.Lock()
|
||||
@@ -754,6 +767,137 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
||||
}
|
||||
|
||||
// TestOpenAIRequest 测试OpenAI连接请求
|
||||
type TestOpenAIRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// TestOpenAI 测试OpenAI API连接是否可用
|
||||
func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
var req TestOpenAIRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.APIKey) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "模型不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
// 构造一个最小的 chat completion 请求
|
||||
payload := map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "Hi"},
|
||||
},
|
||||
"max_tokens": 5,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造请求失败"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造HTTP请求失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey))
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "连接失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 尝试提取错误信息
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
errMsg := string(respBody)
|
||||
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
|
||||
errMsg = errResp.Error.Message
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", resp.StatusCode, errMsg),
|
||||
"status_code": resp.StatusCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析响应并严格验证是否为有效的 chat completion 响应
|
||||
var chatResp struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "API 响应不是有效的 JSON,请检查 Base URL 是否正确",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 严格校验:必须包含 choices 且有 assistant 回复
|
||||
if len(chatResp.Choices) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确(通常以 /v1 结尾)",
|
||||
})
|
||||
return
|
||||
}
|
||||
if chatResp.ID == "" && chatResp.Model == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "API 响应格式不符合 OpenAI 规范,请检查 Base URL 是否正确",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"model": chatResp.Model,
|
||||
"latency_ms": latency.Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyConfig 应用配置(重新加载并重启相关服务)
|
||||
func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
// 先检查是否需要动态初始化知识库(在锁外执行,避免阻塞其他请求)
|
||||
@@ -866,6 +1010,16 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新注册批量任务 MCP 工具
|
||||
if h.batchTaskToolRegistrar != nil {
|
||||
h.logger.Info("重新注册批量任务 MCP 工具")
|
||||
if err := h.batchTaskToolRegistrar(); err != nil {
|
||||
h.logger.Error("重新注册批量任务 MCP 工具失败", zap.Error(err))
|
||||
} else {
|
||||
h.logger.Info("批量任务 MCP 工具已重新注册")
|
||||
}
|
||||
}
|
||||
|
||||
// 如果知识库启用,重新注册知识库工具
|
||||
if h.config.Knowledge.Enabled && h.knowledgeToolRegistrar != nil {
|
||||
h.logger.Info("重新注册知识库工具")
|
||||
|
||||
@@ -234,6 +234,18 @@ func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, mappings)
|
||||
}
|
||||
|
||||
// UpdateConversationPinnedRequest 更新对话置顶状态请求
|
||||
type UpdateConversationPinnedRequest struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
|
||||
@@ -246,6 +246,41 @@ func (h *MonitorHandler) GetExecution(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
|
||||
}
|
||||
|
||||
// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求)
|
||||
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
// 先从内部MCP服务器查找
|
||||
if exec, exists := h.mcpServer.GetExecution(id); exists {
|
||||
result[id] = exec.ToolName
|
||||
continue
|
||||
}
|
||||
// 再从外部MCP管理器查找
|
||||
if h.externalMCPMgr != nil {
|
||||
if exec, exists := h.externalMCPMgr.GetExecution(id); exists {
|
||||
result[id] = exec.ToolName
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 最后从数据库查找
|
||||
if h.db != nil {
|
||||
if exec, err := h.db.GetToolExecution(id); err == nil && exec != nil {
|
||||
result[id] = exec.ToolName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (h *MonitorHandler) GetStats(c *gin.Context) {
|
||||
stats := h.loadStats()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,6 +14,13 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// terminalResize is sent by the frontend when the xterm.js terminal is resized.
|
||||
type terminalResize struct {
|
||||
Type string `json:"type"`
|
||||
Cols uint16 `json:"cols"`
|
||||
Rows uint16 `json:"rows"`
|
||||
}
|
||||
|
||||
// wsUpgrader 仅用于系统设置中的终端 WebSocket,会复用已有的登录保护(JWT 中间件在上层路由组)
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
@@ -37,12 +45,13 @@ func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
|
||||
}
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"COLUMNS=256",
|
||||
"LINES=40",
|
||||
"COLUMNS=80",
|
||||
"LINES=24",
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
|
||||
// Use 80x24 as a safe default; the frontend will send the actual size immediately after connecting.
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 80, Rows: 24})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -84,6 +93,14 @@ func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
// Check if this is a resize message (JSON with type:"resize")
|
||||
if msgType == websocket.TextMessage && len(data) > 0 && data[0] == '{' {
|
||||
var resize terminalResize
|
||||
if json.Unmarshal(data, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 {
|
||||
_ = pty.Setsize(ptmx, &pty.Winsize{Cols: resize.Cols, Rows: resize.Rows})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := ptmx.Write(data); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
break
|
||||
|
||||
@@ -11,14 +11,14 @@ const (
|
||||
ToolSearchKnowledgeBase = "search_knowledge_base"
|
||||
|
||||
// Skills工具
|
||||
ToolListSkills = "list_skills"
|
||||
ToolReadSkill = "read_skill"
|
||||
ToolListSkills = "list_skills"
|
||||
ToolReadSkill = "read_skill"
|
||||
|
||||
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
||||
ToolWebshellExec = "webshell_exec"
|
||||
ToolWebshellFileList = "webshell_file_list"
|
||||
ToolWebshellFileRead = "webshell_file_read"
|
||||
ToolWebshellFileWrite = "webshell_file_write"
|
||||
ToolWebshellExec = "webshell_exec"
|
||||
ToolWebshellFileList = "webshell_file_list"
|
||||
ToolWebshellFileRead = "webshell_file_read"
|
||||
ToolWebshellFileWrite = "webshell_file_write"
|
||||
|
||||
// WebShell 连接管理工具(用于通过 MCP 管理 webshell 连接)
|
||||
ToolManageWebshellList = "manage_webshell_list"
|
||||
@@ -26,6 +26,18 @@ const (
|
||||
ToolManageWebshellUpdate = "manage_webshell_update"
|
||||
ToolManageWebshellDelete = "manage_webshell_delete"
|
||||
ToolManageWebshellTest = "manage_webshell_test"
|
||||
|
||||
// 批量任务队列(与 Web 端批量任务一致,供模型创建/启停/查询队列)
|
||||
ToolBatchTaskList = "batch_task_list"
|
||||
ToolBatchTaskGet = "batch_task_get"
|
||||
ToolBatchTaskCreate = "batch_task_create"
|
||||
ToolBatchTaskStart = "batch_task_start"
|
||||
ToolBatchTaskPause = "batch_task_pause"
|
||||
ToolBatchTaskDelete = "batch_task_delete"
|
||||
ToolBatchTaskScheduleEnabled = "batch_task_schedule_enabled"
|
||||
ToolBatchTaskAdd = "batch_task_add_task"
|
||||
ToolBatchTaskUpdate = "batch_task_update_task"
|
||||
ToolBatchTaskRemove = "batch_task_remove_task"
|
||||
)
|
||||
|
||||
// IsBuiltinTool 检查工具名称是否是内置工具
|
||||
@@ -44,7 +56,17 @@ func IsBuiltinTool(toolName string) bool {
|
||||
ToolManageWebshellAdd,
|
||||
ToolManageWebshellUpdate,
|
||||
ToolManageWebshellDelete,
|
||||
ToolManageWebshellTest:
|
||||
ToolManageWebshellTest,
|
||||
ToolBatchTaskList,
|
||||
ToolBatchTaskGet,
|
||||
ToolBatchTaskCreate,
|
||||
ToolBatchTaskStart,
|
||||
ToolBatchTaskPause,
|
||||
ToolBatchTaskDelete,
|
||||
ToolBatchTaskScheduleEnabled,
|
||||
ToolBatchTaskAdd,
|
||||
ToolBatchTaskUpdate,
|
||||
ToolBatchTaskRemove:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -68,5 +90,15 @@ func GetAllBuiltinTools() []string {
|
||||
ToolManageWebshellUpdate,
|
||||
ToolManageWebshellDelete,
|
||||
ToolManageWebshellTest,
|
||||
ToolBatchTaskList,
|
||||
ToolBatchTaskGet,
|
||||
ToolBatchTaskCreate,
|
||||
ToolBatchTaskStart,
|
||||
ToolBatchTaskPause,
|
||||
ToolBatchTaskDelete,
|
||||
ToolBatchTaskScheduleEnabled,
|
||||
ToolBatchTaskAdd,
|
||||
ToolBatchTaskUpdate,
|
||||
ToolBatchTaskRemove,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ type RunResult struct {
|
||||
LastReActOutput string
|
||||
}
|
||||
|
||||
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
||||
// correlate tool_result events (even when the framework omits ToolCallID) and
|
||||
// avoid leaving the UI stuck in "running" state on recoverable errors.
|
||||
type toolCallPendingInfo struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
EinoAgent string
|
||||
EinoRole string
|
||||
}
|
||||
|
||||
// RunDeepAgent 使用 Eino DeepAgent 执行一轮对话(流式事件通过 progress 回调输出)。
|
||||
func RunDeepAgent(
|
||||
ctx context.Context,
|
||||
@@ -223,6 +233,9 @@ func RunDeepAgent(
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: subTools,
|
||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||
{Invokable: softRecoveryToolCallMiddleware()},
|
||||
},
|
||||
},
|
||||
EmitInternalEvents: true,
|
||||
},
|
||||
@@ -278,6 +291,9 @@ func RunDeepAgent(
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: mainTools,
|
||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||
{Invokable: softRecoveryToolCallMiddleware()},
|
||||
},
|
||||
},
|
||||
EmitInternalEvents: true,
|
||||
},
|
||||
@@ -326,6 +342,69 @@ attemptLoop:
|
||||
var einoMainRound int
|
||||
var einoLastAgent string
|
||||
subAgentToolStep := make(map[string]int)
|
||||
// Track tool calls emitted in this attempt so we can:
|
||||
// - attach toolCallId to tool_result when framework omits it
|
||||
// - flush running tool calls as failed when a recoverable tool execution error happens
|
||||
pendingByID := make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent := make(map[string][]string)
|
||||
markPending := func(tc toolCallPendingInfo) {
|
||||
if tc.ToolCallID == "" {
|
||||
return
|
||||
}
|
||||
pendingByID[tc.ToolCallID] = tc
|
||||
pendingQueueByAgent[tc.EinoAgent] = append(pendingQueueByAgent[tc.EinoAgent], tc.ToolCallID)
|
||||
}
|
||||
popNextPendingForAgent := func(agentName string) (toolCallPendingInfo, bool) {
|
||||
q := pendingQueueByAgent[agentName]
|
||||
for len(q) > 0 {
|
||||
id := q[0]
|
||||
q = q[1:]
|
||||
pendingQueueByAgent[agentName] = q
|
||||
if tc, ok := pendingByID[id]; ok {
|
||||
delete(pendingByID, id)
|
||||
return tc, true
|
||||
}
|
||||
}
|
||||
return toolCallPendingInfo{}, false
|
||||
}
|
||||
removePendingByID := func(toolCallID string) {
|
||||
if toolCallID == "" {
|
||||
return
|
||||
}
|
||||
delete(pendingByID, toolCallID)
|
||||
// queue cleanup is lazy in popNextPendingForAgent
|
||||
}
|
||||
flushAllPendingAsFailed := func(err error) {
|
||||
if progress == nil {
|
||||
pendingByID = make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent = make(map[string][]string)
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
for _, tc := range pendingByID {
|
||||
toolName := tc.ToolName
|
||||
if strings.TrimSpace(toolName) == "" {
|
||||
toolName = "unknown"
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": false,
|
||||
"isError": true,
|
||||
"result": msg,
|
||||
"resultPreview": msg,
|
||||
"toolCallId": tc.ToolCallID,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": tc.EinoAgent,
|
||||
"einoRole": tc.EinoRole,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
pendingByID = make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent = make(map[string][]string)
|
||||
}
|
||||
|
||||
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
||||
Agent: da,
|
||||
@@ -370,6 +449,9 @@ attemptLoop:
|
||||
logger.Warn("eino: recoverable tool execution error, will retry with corrective hint",
|
||||
zap.Error(ev.Err), zap.Int("attempt", attempt))
|
||||
}
|
||||
// Ensure UI/tool timeline doesn't get stuck at "running" for tool calls that
|
||||
// will never receive a proper tool_result due to the recoverable error.
|
||||
flushAllPendingAsFailed(ev.Err)
|
||||
retryHints = append(retryHints, toolExecutionRetryHint())
|
||||
if progress != nil {
|
||||
progress("eino_recovery", toolExecutionRecoveryTimelineMessage(attempt), map[string]interface{}{
|
||||
@@ -385,6 +467,7 @@ attemptLoop:
|
||||
}
|
||||
|
||||
// Non-recoverable error.
|
||||
flushAllPendingAsFailed(ev.Err)
|
||||
if progress != nil {
|
||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
@@ -535,7 +618,7 @@ attemptLoop:
|
||||
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
||||
lastToolChunk = &schema.Message{ToolCalls: merged}
|
||||
}
|
||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -543,7 +626,7 @@ attemptLoop:
|
||||
if gerr != nil || msg == nil {
|
||||
continue
|
||||
}
|
||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||
|
||||
if mv.Role == schema.Assistant {
|
||||
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
@@ -611,8 +694,31 @@ attemptLoop:
|
||||
"einoRole": einoRoleTag(ev.AgentName),
|
||||
"source": "eino",
|
||||
}
|
||||
if msg.ToolCallID != "" {
|
||||
data["toolCallId"] = msg.ToolCallID
|
||||
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
||||
// Some framework paths (e.g. UnknownToolsHandler) may omit ToolCallID on tool messages.
|
||||
// Infer from the tool_call emission order for this agent to keep UI state consistent.
|
||||
if toolCallID == "" {
|
||||
// In some internal tool execution paths, ev.AgentName may be empty for tool-role
|
||||
// messages. Try several fallbacks to avoid leaving UI tool_call status stuck.
|
||||
if inferred, ok := popNextPendingForAgent(ev.AgentName); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else {
|
||||
// last resort: pick any pending toolCallID
|
||||
for id := range pendingByID {
|
||||
toolCallID = id
|
||||
delete(pendingByID, id)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
removePendingByID(toolCallID)
|
||||
}
|
||||
if toolCallID != "" {
|
||||
data["toolCallId"] = toolCallID
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
}
|
||||
@@ -755,7 +861,14 @@ func toolCallsRichSignature(msg *schema.Message) string {
|
||||
return base + "|" + strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func tryEmitToolCallsOnce(msg *schema.Message, agentName, orchestratorName, conversationID string, progress func(string, string, interface{}), seen map[string]struct{}, subAgentToolStep map[string]int) {
|
||||
func tryEmitToolCallsOnce(
|
||||
msg *schema.Message,
|
||||
agentName, orchestratorName, conversationID string,
|
||||
progress func(string, string, interface{}),
|
||||
seen map[string]struct{},
|
||||
subAgentToolStep map[string]int,
|
||||
markPending func(toolCallPendingInfo),
|
||||
) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
|
||||
return
|
||||
}
|
||||
@@ -767,10 +880,16 @@ func tryEmitToolCallsOnce(msg *schema.Message, agentName, orchestratorName, conv
|
||||
return
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, progress, subAgentToolStep)
|
||||
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, progress, subAgentToolStep, markPending)
|
||||
}
|
||||
|
||||
func emitToolCallsFromMessage(msg *schema.Message, agentName, orchestratorName, conversationID string, progress func(string, string, interface{}), subAgentToolStep map[string]int) {
|
||||
func emitToolCallsFromMessage(
|
||||
msg *schema.Message,
|
||||
agentName, orchestratorName, conversationID string,
|
||||
progress func(string, string, interface{}),
|
||||
subAgentToolStep map[string]int,
|
||||
markPending func(toolCallPendingInfo),
|
||||
) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
|
||||
return
|
||||
}
|
||||
@@ -819,6 +938,16 @@ func emitToolCallsFromMessage(msg *schema.Message, agentName, orchestratorName,
|
||||
if toolCallID == "" && tc.Index != nil {
|
||||
toolCallID = fmt.Sprintf("eino-stream-%d", *tc.Index)
|
||||
}
|
||||
// Record pending tool calls for later tool_result correlation / recovery flushing.
|
||||
// We intentionally record even for unknown tools to avoid "running" badge getting stuck.
|
||||
if markPending != nil && toolCallID != "" {
|
||||
markPending(toolCallPendingInfo{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: display,
|
||||
EinoAgent: agentName,
|
||||
EinoRole: role,
|
||||
})
|
||||
}
|
||||
progress("tool_call", fmt.Sprintf("正在调用工具: %s", display), map[string]interface{}{
|
||||
"toolName": display,
|
||||
"arguments": argStr,
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// maxToolCallRecoveryAttempts 含首次运行:首次 + 自动重试次数。
|
||||
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
|
||||
// 该常量同时用于 JSON 参数错误和工具执行错误(如子代理名称不存在)的恢复重试。
|
||||
const maxToolCallRecoveryAttempts = 3
|
||||
const maxToolCallRecoveryAttempts = 5
|
||||
|
||||
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
|
||||
func toolCallArgumentsJSONRetryHint() *schema.Message {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// softRecoveryToolCallMiddleware returns an InvokableToolMiddleware that catches
|
||||
// specific recoverable errors from tool execution (JSON parse errors, tool-not-found,
|
||||
// etc.) and converts them into soft errors: nil error + descriptive error content
|
||||
// returned to the LLM. This allows the model to self-correct within the same
|
||||
// iteration rather than crashing the entire graph and requiring a full replay.
|
||||
//
|
||||
// Without this middleware, a JSON parse failure in any tool's InvokableRun propagates
|
||||
// as a hard error through the Eino ToolsNode → [NodeRunError] → ev.Err, which
|
||||
// either triggers the full-replay retry loop (expensive) or terminates the run
|
||||
// entirely once retries are exhausted. With it, the LLM simply sees an error message
|
||||
// in the tool result and can adjust its next tool call accordingly.
|
||||
func softRecoveryToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
output, err := next(ctx, input)
|
||||
if err == nil {
|
||||
return output, nil
|
||||
}
|
||||
if !isSoftRecoverableToolError(err) {
|
||||
return output, err
|
||||
}
|
||||
// Convert the hard error into a soft error: the LLM will see this
|
||||
// message as the tool's output and can self-correct.
|
||||
msg := buildSoftRecoveryMessage(input.Name, input.Arguments, err)
|
||||
return &compose.ToolOutput{Result: msg}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isSoftRecoverableToolError determines whether a tool execution error should be
|
||||
// silently converted to a tool-result message rather than crashing the graph.
|
||||
func isSoftRecoverableToolError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
|
||||
// JSON unmarshal/parse failures — the model generated truncated or malformed arguments.
|
||||
if isJSONRelatedError(s) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Sub-agent type not found (from deep/task_tool.go)
|
||||
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tool not found in ToolsNode indexes
|
||||
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isJSONRelatedError checks whether an error string indicates a JSON parsing problem.
|
||||
func isJSONRelatedError(lower string) bool {
|
||||
if !strings.Contains(lower, "json") {
|
||||
return false
|
||||
}
|
||||
jsonIndicators := []string{
|
||||
"unexpected end of json",
|
||||
"unmarshal",
|
||||
"invalid character",
|
||||
"cannot unmarshal",
|
||||
"invalid tool arguments",
|
||||
"failed to unmarshal",
|
||||
"must be in json format",
|
||||
"unexpected eof",
|
||||
}
|
||||
for _, ind := range jsonIndicators {
|
||||
if strings.Contains(lower, ind) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSoftRecoveryMessage creates a bilingual error message that the LLM can act on.
|
||||
func buildSoftRecoveryMessage(toolName, arguments string, err error) string {
|
||||
// Truncate arguments preview to avoid flooding the context.
|
||||
argPreview := arguments
|
||||
if len(argPreview) > 300 {
|
||||
argPreview = argPreview[:300] + "... (truncated)"
|
||||
}
|
||||
|
||||
// Try to determine if it's specifically a JSON parse error for a friendlier message.
|
||||
errStr := err.Error()
|
||||
var jsonErr *json.SyntaxError
|
||||
isJSONErr := strings.Contains(strings.ToLower(errStr), "json") ||
|
||||
strings.Contains(strings.ToLower(errStr), "unmarshal")
|
||||
_ = jsonErr // suppress unused
|
||||
|
||||
if isJSONErr {
|
||||
return fmt.Sprintf(
|
||||
"[Tool Error] The arguments for tool '%s' are not valid JSON and could not be parsed.\n"+
|
||||
"Error: %s\n"+
|
||||
"Arguments received: %s\n\n"+
|
||||
"Please fix the JSON (ensure double-quoted keys, matched braces/brackets, no trailing commas, "+
|
||||
"no truncation) and call the tool again.\n\n"+
|
||||
"[工具错误] 工具 '%s' 的参数不是合法 JSON,无法解析。\n"+
|
||||
"错误:%s\n"+
|
||||
"收到的参数:%s\n\n"+
|
||||
"请修正 JSON(确保双引号键名、括号配对、无尾部逗号、无截断),然后重新调用工具。",
|
||||
toolName, errStr, argPreview,
|
||||
toolName, errStr, argPreview,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"[Tool Error] Tool '%s' execution failed: %s\n"+
|
||||
"Arguments: %s\n\n"+
|
||||
"Please review the available tools and their expected arguments, then retry.\n\n"+
|
||||
"[工具错误] 工具 '%s' 执行失败:%s\n"+
|
||||
"参数:%s\n\n"+
|
||||
"请检查可用工具及其参数要求,然后重试。",
|
||||
toolName, errStr, argPreview,
|
||||
toolName, errStr, argPreview,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
func TestIsSoftRecoverableToolError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "unexpected end of JSON input",
|
||||
err: errors.New("unexpected end of JSON input"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "failed to unmarshal task tool input json",
|
||||
err: errors.New("failed to unmarshal task tool input json: unexpected end of JSON input"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid tool arguments JSON",
|
||||
err: errors.New("invalid tool arguments JSON: unexpected end of JSON input"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "json invalid character",
|
||||
err: errors.New(`invalid character '}' looking for beginning of value in JSON`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "subagent type not found",
|
||||
err: errors.New("subagent type recon_agent not found"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "tool not found",
|
||||
err: errors.New("tool nmap_scan not found in toolsNode indexes"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated network error",
|
||||
err: errors.New("connection refused"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "context cancelled",
|
||||
err: context.Canceled,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "real json unmarshal error",
|
||||
err: func() error {
|
||||
var v map[string]interface{}
|
||||
return json.Unmarshal([]byte(`{"key": `), &v)
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSoftRecoverableToolError(tt.err)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isSoftRecoverableToolError(%v) = %v, want %v", tt.err, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryToolCallMiddleware_PassesThrough(t *testing.T) {
|
||||
mw := softRecoveryToolCallMiddleware()
|
||||
called := false
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
called = true
|
||||
return &compose.ToolOutput{Result: "success"}, nil
|
||||
}
|
||||
wrapped := mw(next)
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "test_tool",
|
||||
Arguments: `{"key": "value"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("next endpoint was not called")
|
||||
}
|
||||
if out.Result != "success" {
|
||||
t.Fatalf("expected 'success', got %q", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryToolCallMiddleware_ConvertsJSONError(t *testing.T) {
|
||||
mw := softRecoveryToolCallMiddleware()
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
return nil, errors.New("failed to unmarshal task tool input json: unexpected end of JSON input")
|
||||
}
|
||||
wrapped := mw(next)
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "task",
|
||||
Arguments: `{"subagent_type": "recon`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||
}
|
||||
if out == nil || out.Result == "" {
|
||||
t.Fatal("expected non-empty recovery message")
|
||||
}
|
||||
if !containsAll(out.Result, "[Tool Error]", "task", "JSON") {
|
||||
t.Fatalf("recovery message missing expected content: %s", out.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftRecoveryToolCallMiddleware_PropagatesNonRecoverable(t *testing.T) {
|
||||
mw := softRecoveryToolCallMiddleware()
|
||||
origErr := errors.New("connection timeout to remote server")
|
||||
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
return nil, origErr
|
||||
}
|
||||
wrapped := mw(next)
|
||||
_, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "test_tool",
|
||||
Arguments: `{}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate for non-recoverable errors")
|
||||
}
|
||||
if err != origErr {
|
||||
t.Fatalf("expected original error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if !contains(s, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && searchString(s, sub)
|
||||
}
|
||||
|
||||
func searchString(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
BIN
Binary file not shown.
+141
-101
@@ -1,6 +1,7 @@
|
||||
package burp;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -10,6 +11,7 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
|
||||
private CyberStrikeAITab tab;
|
||||
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||
private String lastInstruction = HttpMessageFormatter.defaultInstruction();
|
||||
|
||||
@Override
|
||||
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
|
||||
@@ -36,111 +38,149 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
if (selected == null || selected.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
CyberStrikeAIClient.Config cfg = tab.currentConfig();
|
||||
String token = tab.getToken();
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
JOptionPane.showMessageDialog(tab.getUiComponent(),
|
||||
"Please click Validate first to obtain a token.",
|
||||
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
String prompt = HttpMessageFormatter.toPrompt(helpers, selected[0]);
|
||||
String title = HttpMessageFormatter.getRequestTitle(helpers, selected[0]);
|
||||
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
|
||||
String runId = tab.startNewRun(title, agentModeStr, selected[0]);
|
||||
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
|
||||
|
||||
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
|
||||
@Override
|
||||
public void onEvent(String type, String message, String rawJson) {
|
||||
if (type == null) type = "";
|
||||
switch (type) {
|
||||
case "response_delta":
|
||||
case "eino_agent_reply_stream_delta":
|
||||
// delta chunk (content only)
|
||||
tab.appendFinalToRun(runId, message);
|
||||
break;
|
||||
case "response":
|
||||
// final response (full)
|
||||
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
||||
tab.appendFinalToRun(runId, message);
|
||||
tab.setFinalResponse(runId, message);
|
||||
break;
|
||||
case "progress":
|
||||
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||
tab.setRunStatus(runId, "running");
|
||||
break;
|
||||
case "cancelled":
|
||||
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
|
||||
tab.setRunStatus(runId, "cancelled");
|
||||
break;
|
||||
case "error":
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
break;
|
||||
case "thinking_stream_start":
|
||||
if (tab.isShowDebugEvents()) {
|
||||
tab.resetThinkingStream(runId);
|
||||
}
|
||||
break;
|
||||
case "thinking_stream_delta":
|
||||
case "tool_call":
|
||||
case "tool_result":
|
||||
case "tool_result_delta":
|
||||
// debug; hide by default
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
if ("thinking_stream_delta".equals(type)) {
|
||||
tab.appendThinkingDelta(runId, message);
|
||||
} else {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "conversation":
|
||||
// Capture conversationId for stop/cancel.
|
||||
if (rawJson != null) {
|
||||
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
|
||||
if (convId != null && !convId.trim().isEmpty()) {
|
||||
tab.setRunConversationId(runId, convId);
|
||||
}
|
||||
}
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
case "done":
|
||||
// handled in onDone too
|
||||
break;
|
||||
default:
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message, Exception e) {
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||
if (e != null) {
|
||||
callbacks.printError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDone() {
|
||||
tab.appendProgressToRun(runId, "\n\n[done]\n");
|
||||
tab.setRunStatus(runId, "done");
|
||||
}
|
||||
});
|
||||
sendMessage(selected[0]);
|
||||
});
|
||||
|
||||
items.add(sendItem);
|
||||
return items;
|
||||
}
|
||||
|
||||
private void sendMessage(IHttpRequestResponse msg) {
|
||||
if (msg == null) return;
|
||||
CyberStrikeAIClient.Config cfg = tab.currentConfig();
|
||||
String token = tab.getToken();
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
JOptionPane.showMessageDialog(tab.getUiComponent(),
|
||||
"Please click Validate first to obtain a token.",
|
||||
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
String instruction = showInstructionEditor(tab.getUiComponent(), lastInstruction);
|
||||
if (instruction == null) {
|
||||
return;
|
||||
}
|
||||
lastInstruction = instruction;
|
||||
|
||||
String prompt = HttpMessageFormatter.toPrompt(helpers, msg, instruction);
|
||||
String title = HttpMessageFormatter.getRequestTitle(helpers, msg);
|
||||
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
|
||||
String runId = tab.startNewRun(title, agentModeStr, msg);
|
||||
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
|
||||
|
||||
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
|
||||
@Override
|
||||
public void onEvent(String type, String message, String rawJson) {
|
||||
if (type == null) type = "";
|
||||
switch (type) {
|
||||
case "response_delta":
|
||||
case "eino_agent_reply_stream_delta":
|
||||
tab.appendFinalToRun(runId, message);
|
||||
break;
|
||||
case "response":
|
||||
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
||||
tab.appendFinalToRun(runId, message);
|
||||
tab.setFinalResponse(runId, message);
|
||||
break;
|
||||
case "progress":
|
||||
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||
tab.setRunStatus(runId, "running");
|
||||
break;
|
||||
case "cancelled":
|
||||
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
|
||||
tab.setRunStatus(runId, "cancelled");
|
||||
break;
|
||||
case "error":
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
break;
|
||||
case "thinking_stream_start":
|
||||
if (tab.isShowDebugEvents()) {
|
||||
tab.resetThinkingStream(runId);
|
||||
}
|
||||
break;
|
||||
case "thinking_stream_delta":
|
||||
case "tool_call":
|
||||
case "tool_result":
|
||||
case "tool_result_delta":
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
if ("thinking_stream_delta".equals(type)) {
|
||||
tab.appendThinkingDelta(runId, message);
|
||||
} else {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "conversation":
|
||||
if (rawJson != null) {
|
||||
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
|
||||
if (convId != null && !convId.trim().isEmpty()) {
|
||||
tab.setRunConversationId(runId, convId);
|
||||
}
|
||||
}
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
case "done":
|
||||
break;
|
||||
default:
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message, Exception e) {
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||
if (e != null) {
|
||||
callbacks.printError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDone() {
|
||||
tab.appendProgressToRun(runId, "\n\n[done]\n");
|
||||
tab.setRunStatus(runId, "done");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String showInstructionEditor(Component parent, String initialValue) {
|
||||
JTextArea editor = new JTextArea(
|
||||
initialValue == null || initialValue.trim().isEmpty()
|
||||
? HttpMessageFormatter.defaultInstruction()
|
||||
: initialValue,
|
||||
6,
|
||||
70
|
||||
);
|
||||
editor.setLineWrap(true);
|
||||
editor.setWrapStyleWord(true);
|
||||
editor.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
|
||||
|
||||
JPanel panel = new JPanel(new BorderLayout(0, 8));
|
||||
panel.add(new JLabel("Edit instruction before sending:"), BorderLayout.NORTH);
|
||||
panel.add(new JScrollPane(editor), BorderLayout.CENTER);
|
||||
|
||||
int result = JOptionPane.showConfirmDialog(
|
||||
parent,
|
||||
panel,
|
||||
"Customize Prompt Instruction",
|
||||
JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.PLAIN_MESSAGE
|
||||
);
|
||||
if (result != JOptionPane.OK_OPTION) {
|
||||
return null;
|
||||
}
|
||||
String value = editor.getText();
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return HttpMessageFormatter.defaultInstruction();
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -5,6 +5,8 @@ import java.util.List;
|
||||
|
||||
final class HttpMessageFormatter {
|
||||
private HttpMessageFormatter() {}
|
||||
private static final String DEFAULT_INSTRUCTION =
|
||||
"针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口";
|
||||
|
||||
static String getRequestTitle(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
||||
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
||||
@@ -22,7 +24,15 @@ final class HttpMessageFormatter {
|
||||
return method + " " + host + shortPath + q;
|
||||
}
|
||||
|
||||
static String defaultInstruction() {
|
||||
return DEFAULT_INSTRUCTION;
|
||||
}
|
||||
|
||||
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
||||
return toPrompt(helpers, msg, DEFAULT_INSTRUCTION);
|
||||
}
|
||||
|
||||
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg, String instruction) {
|
||||
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
||||
String method = reqInfo.getMethod();
|
||||
String url = reqInfo.getUrl() != null ? reqInfo.getUrl().toString() : "(unknown)";
|
||||
@@ -53,8 +63,12 @@ final class HttpMessageFormatter {
|
||||
+ respBody;
|
||||
}
|
||||
|
||||
String prefix = (instruction == null || instruction.trim().isEmpty())
|
||||
? DEFAULT_INSTRUCTION
|
||||
: instruction.trim();
|
||||
|
||||
return ""
|
||||
+ "针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口\n\n"
|
||||
+ prefix + "\n\n"
|
||||
+ "[Target]\n"
|
||||
+ method + " " + url + "\n\n"
|
||||
+ "[Request]\n"
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,293 @@
|
||||
name: "quake_search"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import os
|
||||
|
||||
# ==================== Quake配置 ====================
|
||||
# 请在此处配置您的Quake API Token
|
||||
# 您也可以在环境变量中设置:QUAKE_API_KEY
|
||||
# enable 默认为 false,需开启才能调用该MCP
|
||||
QUAKE_API_KEY = "" # 请填写您的Quake API Token
|
||||
# ==================================================
|
||||
|
||||
# Quake API基础URL
|
||||
base_url = "https://quake.360.cn/api/v3/search/quake_service"
|
||||
|
||||
# 解析参数(从JSON字符串或命令行参数)
|
||||
def parse_args():
|
||||
# 尝试从第一个参数读取JSON配置
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
arg1 = str(sys.argv[1])
|
||||
config = json.loads(arg1)
|
||||
if isinstance(config, dict):
|
||||
return config
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 传统位置参数方式(向后兼容)
|
||||
# 参数位置:query=1, size=2, start=3, fields=4, latest=5
|
||||
config = {}
|
||||
if len(sys.argv) > 1:
|
||||
config["query"] = str(sys.argv[1])
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
config["size"] = int(sys.argv[2])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if len(sys.argv) > 3:
|
||||
try:
|
||||
config["start"] = int(sys.argv[3])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if len(sys.argv) > 4:
|
||||
config["fields"] = str(sys.argv[4])
|
||||
if len(sys.argv) > 5:
|
||||
val = sys.argv[5]
|
||||
if isinstance(val, str):
|
||||
config["latest"] = val.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
config["latest"] = bool(val)
|
||||
return config
|
||||
|
||||
# 标准化 fields 参数:支持字符串和数组
|
||||
def normalize_fields(fields_value):
|
||||
if fields_value is None:
|
||||
return None
|
||||
|
||||
if isinstance(fields_value, str):
|
||||
raw = fields_value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
return [x.strip() for x in raw.split(",") if x.strip()]
|
||||
|
||||
if isinstance(fields_value, list):
|
||||
output = []
|
||||
for item in fields_value:
|
||||
text = str(item).strip()
|
||||
if text:
|
||||
output.append(text)
|
||||
return output or None
|
||||
|
||||
return None
|
||||
|
||||
try:
|
||||
config = parse_args()
|
||||
|
||||
if not isinstance(config, dict):
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
|
||||
"type": "TypeError"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
api_key = os.getenv("QUAKE_API_KEY", QUAKE_API_KEY).strip()
|
||||
query = str(config.get("query", "")).strip()
|
||||
|
||||
if not api_key:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": "缺少Quake配置: api_key(Quake API Token)",
|
||||
"required_config": ["api_key"],
|
||||
"note": "请在YAML文件的QUAKE_API_KEY配置项中填写Token,或在环境变量QUAKE_API_KEY中设置。Token可在Quake用户中心获取。"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if not query:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": "缺少必需参数: query(搜索查询语句)",
|
||||
"required_params": ["query"],
|
||||
"examples": [
|
||||
'domain:"example.com"',
|
||||
'ip:"1.1.1.1"',
|
||||
'port:443',
|
||||
'service.name:"http"',
|
||||
'port:22 AND country_cn:"中国"'
|
||||
]
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# 构建请求体
|
||||
data = {
|
||||
"query": query
|
||||
}
|
||||
|
||||
# 可选参数 size(通常最大100)
|
||||
if "size" in config and config["size"] is not None:
|
||||
try:
|
||||
size = int(config["size"])
|
||||
if size > 0:
|
||||
data["size"] = size
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 可选参数 start(分页偏移,默认0)
|
||||
if "start" in config and config["start"] is not None:
|
||||
try:
|
||||
start = int(config["start"])
|
||||
if start >= 0:
|
||||
data["start"] = start
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# fields 映射到 Quake 的 include 字段
|
||||
include_fields = normalize_fields(config.get("fields"))
|
||||
if include_fields:
|
||||
data["include"] = include_fields
|
||||
|
||||
# latest 参数,默认 true(取最新索引结果)
|
||||
latest_value = config.get("latest", True)
|
||||
if isinstance(latest_value, bool):
|
||||
data["latest"] = latest_value
|
||||
elif isinstance(latest_value, str):
|
||||
data["latest"] = latest_value.lower() in ("true", "1", "yes")
|
||||
elif isinstance(latest_value, (int, float)):
|
||||
data["latest"] = latest_value != 0
|
||||
else:
|
||||
data["latest"] = True
|
||||
|
||||
headers = {
|
||||
"X-QuakeToken": api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(base_url, json=data, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
result_data = response.json()
|
||||
|
||||
# Quake API code==0 表示成功
|
||||
if result_data.get("code") != 0:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"Quake API错误: {result_data.get('message', '未知错误')}",
|
||||
"error_code": result_data.get("code", "unknown"),
|
||||
"suggestion": "请检查API Token、查询语法和账户积分是否正常"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
results = result_data.get("data", [])
|
||||
meta = result_data.get("meta", {})
|
||||
pagination = meta.get("pagination", {}) if isinstance(meta, dict) else {}
|
||||
|
||||
output = {
|
||||
"status": "success",
|
||||
"query": query,
|
||||
"size": data.get("size", pagination.get("size", len(results))),
|
||||
"start": data.get("start", pagination.get("page_index", 0)),
|
||||
"total": result_data.get("total_count", pagination.get("total", 0)),
|
||||
"results_count": len(results),
|
||||
"fields": include_fields or "all",
|
||||
"results": results,
|
||||
"message": f"成功获取 {len(results)} 条结果"
|
||||
}
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"请求失败: {str(e)}",
|
||||
"suggestion": "请检查网络连通性或Quake API服务状态"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"执行出错: {str(e)}",
|
||||
"type": type(e).__name__
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
enabled: false
|
||||
short_description: "Quake网络空间搜索接口,支持自定义query、size、fields"
|
||||
description: |
|
||||
Quake(360 网络空间测绘)资产搜索工具,调用 Quake API v3 实时检索互联网资产。
|
||||
|
||||
**主要功能:**
|
||||
- 支持 Quake DSL 查询语法(query)
|
||||
- 支持返回数量控制(size)
|
||||
- 支持字段裁剪(fields,对应 Quake include)
|
||||
- 支持分页偏移(start)
|
||||
|
||||
**鉴权方式:**
|
||||
- Header 使用 `X-QuakeToken`
|
||||
- 可在本文件中填写 `QUAKE_API_KEY`,或通过环境变量 `QUAKE_API_KEY` 注入
|
||||
|
||||
**常见查询示例:**
|
||||
- `domain:"example.com"`
|
||||
- `ip:"1.1.1.1"`
|
||||
- `port:443`
|
||||
- `service.name:"http" AND country_cn:"中国"`
|
||||
|
||||
**注意事项:**
|
||||
- API 调用会消耗积分,请按需控制 `size`
|
||||
- `fields` 会映射到请求体 `include` 字段,多个字段用英文逗号分隔
|
||||
- 如遇语法报错,请先在 Quake 控制台验证 DSL
|
||||
parameters:
|
||||
- name: "query"
|
||||
type: "string"
|
||||
description: |
|
||||
Quake DSL 查询语句(必需)。
|
||||
|
||||
**示例:**
|
||||
- `domain:"example.com"`
|
||||
- `ip:"1.1.1.1"`
|
||||
- `port:443`
|
||||
- `service.name:"http" AND country_cn:"中国"`
|
||||
required: true
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "size"
|
||||
type: "int"
|
||||
description: |
|
||||
返回结果数量(可选)。
|
||||
|
||||
建议范围:1-100(具体受账户权限/接口限制影响)。
|
||||
required: false
|
||||
position: 2
|
||||
format: "positional"
|
||||
default: 10
|
||||
- name: "start"
|
||||
type: "int"
|
||||
description: |
|
||||
分页起始偏移(可选),从 0 开始。
|
||||
required: false
|
||||
position: 3
|
||||
format: "positional"
|
||||
default: 0
|
||||
- name: "fields"
|
||||
type: "string"
|
||||
description: |
|
||||
返回字段(可选),多个字段用英文逗号分隔。
|
||||
|
||||
该参数会映射到 Quake 请求体中的 `include` 字段。
|
||||
**示例:**
|
||||
- `ip,port`
|
||||
- `ip,port,service.name,service.http.title,location.country_cn`
|
||||
required: false
|
||||
position: 4
|
||||
format: "positional"
|
||||
default: "ip,port"
|
||||
- name: "latest"
|
||||
type: "bool"
|
||||
description: |
|
||||
是否优先返回最新索引结果(可选)。
|
||||
默认 `true`。
|
||||
required: false
|
||||
position: 5
|
||||
format: "positional"
|
||||
default: true
|
||||
@@ -0,0 +1,403 @@
|
||||
name: "shodan_search"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import os
|
||||
import math
|
||||
|
||||
# ==================== Shodan配置 ====================
|
||||
# 请在此处配置您的Shodan API Key
|
||||
# 您也可以在环境变量中设置:SHODAN_API_KEY
|
||||
# enable 默认为 false,需开启才能调用该MCP
|
||||
SHODAN_API_KEY = "" # 请替换为您自己的Shodan API Key
|
||||
# ==================================================
|
||||
|
||||
# Shodan API基础URL
|
||||
base_url = "https://api.shodan.io"
|
||||
|
||||
# 解析参数(从JSON字符串或命令行参数)
|
||||
def parse_args():
|
||||
# 尝试从第一个参数读取JSON配置
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
arg1 = str(sys.argv[1])
|
||||
config = json.loads(arg1)
|
||||
if isinstance(config, dict):
|
||||
return config
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 传统位置参数方式(向后兼容)
|
||||
# 兼容两种序列:
|
||||
# 1) query,page,facets,minify,fields,count_only,size
|
||||
# 2) query,page,minify,fields,count_only,size (facets省略时执行器会压缩参数)
|
||||
config = {}
|
||||
if len(sys.argv) > 1:
|
||||
config["query"] = str(sys.argv[1])
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
config["page"] = int(sys.argv[2])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
def is_bool_like(val):
|
||||
if isinstance(val, bool):
|
||||
return True
|
||||
if not isinstance(val, str):
|
||||
return False
|
||||
return val.strip().lower() in ("true", "false", "1", "0", "yes", "no")
|
||||
|
||||
remaining = [str(x) for x in sys.argv[3:]]
|
||||
if remaining:
|
||||
# facets 省略时,第一个剩余参数通常是 minify(布尔)
|
||||
first_is_bool = is_bool_like(remaining[0])
|
||||
idx = 0
|
||||
if not first_is_bool:
|
||||
config["facets"] = remaining[idx]
|
||||
idx += 1
|
||||
|
||||
if idx < len(remaining):
|
||||
val = remaining[idx]
|
||||
config["minify"] = val.lower() in ("true", "1", "yes")
|
||||
idx += 1
|
||||
|
||||
if idx < len(remaining):
|
||||
config["fields"] = remaining[idx]
|
||||
idx += 1
|
||||
|
||||
if idx < len(remaining):
|
||||
val = remaining[idx]
|
||||
config["count_only"] = val.lower() in ("true", "1", "yes")
|
||||
idx += 1
|
||||
|
||||
if idx < len(remaining):
|
||||
try:
|
||||
config["size"] = int(remaining[idx])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return config
|
||||
|
||||
def normalize_bool(value, default_value):
|
||||
if value is None:
|
||||
return default_value
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes")
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
return default_value
|
||||
|
||||
try:
|
||||
config = parse_args()
|
||||
|
||||
if not isinstance(config, dict):
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
|
||||
"type": "TypeError"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
api_key = os.getenv("SHODAN_API_KEY", SHODAN_API_KEY).strip()
|
||||
query = str(config.get("query", "")).strip()
|
||||
|
||||
if not api_key:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": "缺少Shodan配置: api_key(Shodan API密钥)",
|
||||
"required_config": ["api_key"],
|
||||
"note": "请在YAML文件的SHODAN_API_KEY配置项中填写您的API密钥,或在环境变量SHODAN_API_KEY中设置。API密钥可在Shodan账户页面查看: https://account.shodan.io/"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if not query:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": "缺少必需参数: query(搜索查询语句)",
|
||||
"required_params": ["query"],
|
||||
"examples": [
|
||||
"product:nginx",
|
||||
"apache country:DE",
|
||||
"port:22",
|
||||
"ssl.cert.subject.cn:example.com",
|
||||
"org:\"Amazon\" port:443"
|
||||
]
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
count_only = normalize_bool(config.get("count_only"), False)
|
||||
minify = normalize_bool(config.get("minify"), True)
|
||||
requested_size = config.get("size", None)
|
||||
if requested_size is not None:
|
||||
try:
|
||||
requested_size = int(requested_size)
|
||||
if requested_size <= 0:
|
||||
requested_size = None
|
||||
else:
|
||||
# 防止单次请求过大导致额度和响应时间问题
|
||||
requested_size = min(requested_size, 1000)
|
||||
except (ValueError, TypeError):
|
||||
requested_size = None
|
||||
|
||||
# 根据 count_only 选择搜索端点
|
||||
endpoint = "/shodan/host/count" if count_only else "/shodan/host/search"
|
||||
url = f"{base_url}{endpoint}"
|
||||
|
||||
params = {
|
||||
"key": api_key,
|
||||
"query": query
|
||||
}
|
||||
|
||||
# 可选参数 facets(search 和 count 都支持)
|
||||
if "facets" in config and config["facets"]:
|
||||
facets_value = str(config["facets"]).strip()
|
||||
if facets_value:
|
||||
params["facets"] = facets_value
|
||||
|
||||
# search 接口的可选参数
|
||||
if not count_only:
|
||||
if "page" in config and config["page"] is not None:
|
||||
try:
|
||||
page = int(config["page"])
|
||||
if page > 0:
|
||||
params["page"] = page
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
minify_effective = minify
|
||||
|
||||
if "fields" in config and config["fields"]:
|
||||
fields_value = str(config["fields"]).strip()
|
||||
if fields_value:
|
||||
params["fields"] = fields_value
|
||||
# Shodan API约束:fields 与 minify=true 互斥
|
||||
minify_effective = False
|
||||
|
||||
params["minify"] = "true" if minify_effective else "false"
|
||||
|
||||
try:
|
||||
if count_only:
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
result_data = response.json()
|
||||
|
||||
if isinstance(result_data, dict) and result_data.get("error"):
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"Shodan API错误: {result_data.get('error', '未知错误')}",
|
||||
"suggestion": "请检查API密钥、查询语法和账户查询额度"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
output = {
|
||||
"status": "success",
|
||||
"mode": "count",
|
||||
"query": query,
|
||||
"total": result_data.get("total", 0),
|
||||
"facets": result_data.get("facets", {}),
|
||||
"size": requested_size,
|
||||
"note": "count模式仅返回统计,不返回明细结果",
|
||||
"message": "统计查询完成(未返回资产明细)"
|
||||
}
|
||||
else:
|
||||
start_page = int(params.get("page", 1))
|
||||
# Shodan search 每页固定最多100条
|
||||
# 如果未指定 size,则保持原始行为(单页)
|
||||
target_size = requested_size if requested_size else 100
|
||||
pages_needed = 1 if not requested_size else max(1, int(math.ceil(target_size / 100.0)))
|
||||
|
||||
all_matches = []
|
||||
last_result_data = {}
|
||||
current_page = start_page
|
||||
pages_fetched = 0
|
||||
|
||||
for _ in range(pages_needed):
|
||||
page_params = dict(params)
|
||||
page_params["page"] = current_page
|
||||
|
||||
response = requests.get(url, params=page_params, timeout=30)
|
||||
response.raise_for_status()
|
||||
result_data = response.json()
|
||||
last_result_data = result_data if isinstance(result_data, dict) else {}
|
||||
pages_fetched += 1
|
||||
|
||||
if isinstance(last_result_data, dict) and last_result_data.get("error"):
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"Shodan API错误: {last_result_data.get('error', '未知错误')}",
|
||||
"suggestion": "请检查API密钥、查询语法和账户查询额度"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
page_matches = last_result_data.get("matches", []) if isinstance(last_result_data, dict) else []
|
||||
if not page_matches:
|
||||
break
|
||||
|
||||
all_matches.extend(page_matches)
|
||||
if len(all_matches) >= target_size:
|
||||
break
|
||||
current_page += 1
|
||||
|
||||
matches = all_matches[:target_size]
|
||||
output = {
|
||||
"status": "success",
|
||||
"mode": "search",
|
||||
"query": query,
|
||||
"page": start_page,
|
||||
"size": target_size,
|
||||
"pages_fetched": pages_fetched,
|
||||
"total": last_result_data.get("total", 0),
|
||||
"results_count": len(matches),
|
||||
"facets": last_result_data.get("facets", {}),
|
||||
"results": matches,
|
||||
"message": f"成功获取 {len(matches)} 条结果"
|
||||
}
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
response_body = ""
|
||||
status_code = None
|
||||
if hasattr(e, "response") and e.response is not None:
|
||||
status_code = e.response.status_code
|
||||
try:
|
||||
response_body = e.response.text[:500]
|
||||
except Exception:
|
||||
response_body = ""
|
||||
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"请求失败: {str(e)}",
|
||||
"status_code": status_code,
|
||||
"response": response_body,
|
||||
"suggestion": "请检查网络连接、Shodan API状态、API密钥与查询额度"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"执行出错: {str(e)}",
|
||||
"type": type(e).__name__
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
enabled: false
|
||||
short_description: "Shodan网络空间搜索,支持search与count模式"
|
||||
description: |
|
||||
Shodan 资产搜索工具,基于官方 Developer API 实现,支持快速检索和统计分析。
|
||||
|
||||
**主要功能:**
|
||||
- 使用 `/shodan/host/search` 进行资产搜索
|
||||
- 使用 `/shodan/host/count` 进行无明细统计(节省查询信用)
|
||||
- 支持按 `size` 控制返回条数(自动翻页聚合)
|
||||
- 支持分页(page)
|
||||
- 支持分面统计(facets)
|
||||
- 支持结果字段裁剪(fields)
|
||||
- 支持 `minify` 控制返回数据体积
|
||||
|
||||
**鉴权方式:**
|
||||
- Query 参数使用 `key`
|
||||
- 可在本文件中填写 `SHODAN_API_KEY`,或通过环境变量 `SHODAN_API_KEY` 注入
|
||||
|
||||
**查询语法示例:**
|
||||
- `product:nginx`
|
||||
- `apache country:DE`
|
||||
- `port:22`
|
||||
- `org:"Amazon" port:443`
|
||||
- `ssl.cert.subject.cn:example.com`
|
||||
|
||||
**注意事项:**
|
||||
- 带过滤器的查询通常会消耗 query credits
|
||||
- 翻页(超过第1页)会额外消耗额度
|
||||
- `size` 大于 100 时会自动请求更多页(每页最多 100)
|
||||
- `size` 最大限制为 1000(防止过量请求)
|
||||
- `count_only=true` 使用统计接口,不返回 matches 明细
|
||||
parameters:
|
||||
- name: "query"
|
||||
type: "string"
|
||||
description: |
|
||||
Shodan 搜索语句(必需)。
|
||||
|
||||
支持 Shodan filter 语法(`filter:value`)与关键字组合。
|
||||
示例:
|
||||
- `product:nginx`
|
||||
- `apache country:DE`
|
||||
- `port:22`
|
||||
- `org:"Amazon" port:443`
|
||||
required: true
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "page"
|
||||
type: "int"
|
||||
description: |
|
||||
页码(可选,仅 search 模式生效),从 1 开始,默认 1。
|
||||
required: false
|
||||
position: 2
|
||||
format: "positional"
|
||||
default: 1
|
||||
- name: "facets"
|
||||
type: "string"
|
||||
description: |
|
||||
分面统计字段(可选)。
|
||||
|
||||
多个字段用英文逗号分隔,也可指定数量:
|
||||
- `org,os`
|
||||
- `country:20,org:10`
|
||||
required: false
|
||||
position: 3
|
||||
format: "positional"
|
||||
- name: "minify"
|
||||
type: "bool"
|
||||
description: |
|
||||
是否精简返回字段(可选,仅 search 模式生效)。
|
||||
默认 `true`。
|
||||
required: false
|
||||
position: 4
|
||||
format: "positional"
|
||||
default: true
|
||||
- name: "fields"
|
||||
type: "string"
|
||||
description: |
|
||||
指定返回字段(可选,仅 search 模式生效)。
|
||||
|
||||
多个字段用英文逗号分隔,例如:
|
||||
- `ip_str,port,org,hostnames,http.title`
|
||||
- `tags,http.title,http.favicon.hash`
|
||||
required: false
|
||||
position: 5
|
||||
format: "positional"
|
||||
- name: "count_only"
|
||||
type: "bool"
|
||||
description: |
|
||||
是否仅统计总数(可选)。
|
||||
|
||||
- `false`(默认):调用 `/shodan/host/search` 返回明细
|
||||
- `true`:调用 `/shodan/host/count` 仅返回 total 和 facets
|
||||
required: false
|
||||
position: 6
|
||||
format: "positional"
|
||||
default: false
|
||||
- name: "size"
|
||||
type: "int"
|
||||
description: |
|
||||
返回结果数量(可选,仅 search 模式生效)。
|
||||
|
||||
- 支持 `10 / 20 / 100 / n`
|
||||
- Shodan 单页最多 100,超过 100 时会自动翻页拼接
|
||||
- 为避免额度和时延问题,最大值限制为 1000
|
||||
- 未传时默认返回单页结果(最多 100 条)
|
||||
required: false
|
||||
position: 7
|
||||
format: "positional"
|
||||
+822
-24
@@ -441,7 +441,9 @@ body {
|
||||
/* 确保底部左右角都是圆角 */
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
overflow: hidden;
|
||||
/* 勿用 overflow:hidden:会覆盖 .page-content 的 overflow-y:auto,任务多时无法滚动 */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 对话页面布局 */
|
||||
@@ -1524,8 +1526,7 @@ header {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
color: #666;
|
||||
@@ -2083,7 +2084,6 @@ header {
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
z-index: 15;
|
||||
backdrop-filter: blur(6px);
|
||||
animation: mentionFadeIn 0.15s ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -2270,9 +2270,7 @@ header {
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(245, 245, 245, 0.85);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2428,7 +2426,6 @@ header {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
overflow: auto;
|
||||
animation: fadeIn 0.2s ease-in;
|
||||
}
|
||||
@@ -8201,6 +8198,41 @@ header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 列表 + 底部分页同一外框,与 MCP 监控「表格 + 分页」一致 */
|
||||
.batch-queues-board {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.batch-queues-board .batch-queues-list {
|
||||
margin-bottom: 0;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.batch-queues-board #batch-queues-pagination {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.batch-queues-board #batch-queues-pagination .monitor-pagination {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.batch-queues-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -8219,26 +8251,370 @@ header {
|
||||
.batch-queues-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.batch-queue-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-queue-item:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.batch-queue-item--cron-wait {
|
||||
border-left: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.batch-queue-item--cron-wait:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.batch-queue-item__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.batch-queue-item__top-actions {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.batch-queue-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.batch-queue-icon-btn:hover {
|
||||
color: var(--error-color, #dc3545);
|
||||
border-color: rgba(220, 53, 69, 0.35);
|
||||
background: rgba(220, 53, 69, 0.06);
|
||||
}
|
||||
|
||||
.batch-queue-icon-btn__svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.batch-queue-item__band {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
padding: 12px 14px;
|
||||
margin: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.batch-queue-item__band-left {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__band-right {
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.batch-queue-progress-meta--band {
|
||||
align-items: stretch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.batch-queue-progress-meta--band .batch-queue-progress-note {
|
||||
text-align: right;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.batch-queue-item__band-right {
|
||||
margin-left: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.batch-queue-progress-meta--band {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.batch-queue-progress-meta--band .batch-queue-progress-note {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-queue-card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.batch-queue-primary {
|
||||
flex: 1 1 280px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.batch-queue-card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.batch-queue-card-title--muted {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-queue-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.batch-queue-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.batch-queue-chip-emoji {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-queue-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.batch-queue-chip--role .batch-queue-chip-text {
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.batch-queue-chip--schedule {
|
||||
color: var(--accent-color);
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: inset 2px 0 0 0 var(--accent-color);
|
||||
}
|
||||
|
||||
.batch-queue-chip--warn {
|
||||
color: #b45309;
|
||||
border-color: rgba(180, 83, 9, 0.35);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
|
||||
.batch-queue-status-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.batch-queue-next-run {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-color);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.batch-queue-next-run__icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.batch-queue-next-run__text {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.batch-queue-footnotes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batch-queue-footnotes__id code {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
word-break: break-all;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batch-queue-footnotes__sep {
|
||||
opacity: 0.45;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.batch-queue-side {
|
||||
flex: 0 1 232px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.batch-queue-delete-btn {
|
||||
font-size: 0.75rem !important;
|
||||
padding: 5px 12px !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
background: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
.batch-queue-delete-btn:hover {
|
||||
color: var(--error-color, #dc3545) !important;
|
||||
border-color: rgba(220, 53, 69, 0.4) !important;
|
||||
background: rgba(220, 53, 69, 0.06) !important;
|
||||
}
|
||||
|
||||
.batch-queue-progress-stack {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.batch-queue-progress-bar.batch-queue-progress-bar--card {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.batch-queue-progress-fill--cron-wait {
|
||||
background: linear-gradient(90deg, var(--accent-color), var(--accent-hover), var(--accent-color));
|
||||
background-size: 200% 100%;
|
||||
animation: batch-queue-progress-shimmer 2.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes batch-queue-progress-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-queue-progress-stack .batch-queue-progress-meta {
|
||||
width: 100%;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.batch-queue-progress-fraction {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.batch-queue-stats--pills {
|
||||
margin: 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-queue-stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.batch-queue-stat-pill__k {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-queue-stat-pill__v {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.batch-queue-stat-pill--ok .batch-queue-stat-pill__v {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.batch-queue-stat-pill--err .batch-queue-stat-pill__v {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.batch-queue-stat-pill--muted .batch-queue-stat-pill__v {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.batch-queue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -8278,12 +8654,106 @@ header {
|
||||
border: 1px solid rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.batch-queue-cron-active {
|
||||
box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
.batch-queue-status-paused {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
color: #b38600;
|
||||
border: 1px solid rgba(255, 193, 7, 0.45);
|
||||
}
|
||||
|
||||
.batch-queue-status-completed {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
color: var(--success-color);
|
||||
border: 1px solid rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
/* Cron 队列:本轮跑完、等待下次触发(区别于「一次性任务已完成」) */
|
||||
.batch-queue-status-cron-cycle {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px dashed var(--accent-color);
|
||||
}
|
||||
|
||||
.batch-queue-status-wrap {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.batch-queue-cron-sublabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.batch-queue-cron-sublabel.detail {
|
||||
margin-top: 6px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.batch-queue-detail-status-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.batch-queue-progress-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-progress-note {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.batch-queue-progress-note.detail {
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
margin-top: 2px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.batch-queue-cron-callout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.batch-queue-cron-callout-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.batch-queue-cron-callout p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.batch-queue-status-cancelled {
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
color: var(--text-secondary);
|
||||
@@ -8342,6 +8812,288 @@ header {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 批量队列详情:信息分层(状态 → 摘要 → 告警 → 折叠技术信息) */
|
||||
.batch-queue-detail-layout {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.batch-queue-detail-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.batch-queue-detail-hero__sub {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.batch-queue-detail-hero__note {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.batch-queue-detail-kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bq-kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 34%) 1fr;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bq-kv--block {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(100px, 28%) 1fr;
|
||||
}
|
||||
|
||||
.bq-kv:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bq-kv__k {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bq-kv__v {
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bq-kv__v code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bq-kv__v--control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.bq-kv,
|
||||
.bq-kv--block {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.bq-cron-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bq-cron-toggle input {
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bq-cron-toggle__hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bq-alert {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bq-alert--err {
|
||||
background: rgba(220, 53, 69, 0.07);
|
||||
border: 1px solid rgba(220, 53, 69, 0.28);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bq-alert--err strong {
|
||||
display: block;
|
||||
color: var(--error-color);
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bq-alert--err p {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.batch-queue-detail-tech {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.batch-queue-detail-tech__sum {
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.batch-queue-detail-tech__sum::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.batch-queue-detail-tech__body {
|
||||
padding: 4px 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.batch-queue-detail-tech__body .bq-kv:first-child {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.batch-queue-cron-callout--compact {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.batch-queue-cron-callout--compact p {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* 列表卡片紧凑布局 */
|
||||
.batch-queue-item__config {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.batch-queue-item__idline {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.batch-queue-item__idline code {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.batch-queue-item__idsep {
|
||||
opacity: 0.45;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.batch-queue-inline-warn {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.batch-queue-item__mid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 0 6px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.batch-queue-item__mid-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__sublabel {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.batch-queue-item__mid-right {
|
||||
flex: 0 1 200px;
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.batch-queue-progress-bar--list {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.batch-queue-item__pct {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.batch-queue-item__pct-frac {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.batch-queue-statsline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.batch-queue-statsline__sep {
|
||||
margin: 0 8px;
|
||||
opacity: 0.4;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.batch-queue-statsline__item--ok {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.batch-queue-statsline__item--err {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.batch-queue-detail-info {
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
@@ -8426,12 +9178,21 @@ header {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.batch-queue-detail-layout + .batch-queue-tasks-list h4 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.batch-queue-item__title-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-task-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -8561,15 +9322,56 @@ header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.batch-queue-item__top {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.batch-queue-item__mid {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.batch-queue-item__mid-right {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.batch-queue-item__pct {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.batch-queue-card-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.batch-queue-side {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.batch-queue-delete-btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.batch-queue-progress-stack {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.batch-queue-stats {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-queue-stats--pills {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.batch-task-header {
|
||||
flex-direction: column;
|
||||
@@ -9807,8 +10609,7 @@ header {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(6px);
|
||||
background: #ffffff;
|
||||
}
|
||||
.webshell-memo-input {
|
||||
flex: 1;
|
||||
@@ -10726,8 +11527,7 @@ header {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset;
|
||||
}
|
||||
|
||||
@@ -12097,7 +12897,6 @@ header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
backdrop-filter: blur(20px);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -12156,7 +12955,6 @@ header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
|
||||
@@ -254,6 +254,14 @@
|
||||
"conversationIdLabel": "Conversation ID",
|
||||
"statusPending": "Pending",
|
||||
"statusPaused": "Paused",
|
||||
"statusCronCycleIdle": "Round done · scheduled loop",
|
||||
"statusCronRunning": "Running · cron queue",
|
||||
"cronNextRunLine": "Next run: {{time}}",
|
||||
"cronRoundDoneProgressHint": "Cron queue: subtasks finished; next round starts on schedule",
|
||||
"cronRunningProgressHint": "This round is running; the next full cycle follows Cron / next run time",
|
||||
"cronPendingScheduled": "Cron scheduled · next {{time}}",
|
||||
"cronPendingProgressNote": "Will start on schedule, or click Start to run a round now",
|
||||
"cronRecurringCallout": "Cron queues start a new round at each scheduled time. Turn off \"Allow Cron auto-run\" to stop looping.",
|
||||
"confirmCancelTasks": "Cancel {{n}} selected task(s)?",
|
||||
"batchCancelResultPartial": "Batch cancel: {{success}} succeeded, {{fail}} failed",
|
||||
"batchCancelResultSuccess": "Successfully cancelled {{n}} task(s)",
|
||||
@@ -276,6 +284,7 @@
|
||||
"deleteQueueConfirm": "Delete this batch queue? This cannot be undone.",
|
||||
"deleteQueueFailed": "Failed to delete batch queue",
|
||||
"batchQueueTitle": "Batch task queue",
|
||||
"batchQueueUntitled": "Untitled queue",
|
||||
"resumeExecute": "Resume",
|
||||
"taskIncomplete": "Task information incomplete",
|
||||
"cannotGetTaskMessageInput": "Cannot get task message input",
|
||||
@@ -285,7 +294,7 @@
|
||||
"addTaskFailed": "Failed to add task",
|
||||
"confirmDeleteTask": "Delete this task?\n\nTask: {{message}}\n\nThis cannot be undone.",
|
||||
"deleteTaskFailed": "Failed to delete task",
|
||||
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
||||
"paginationShow": "Show {{start}}-{{end}} of {{total}} records",
|
||||
"paginationPerPage": "Per page",
|
||||
"paginationFirst": "First",
|
||||
"paginationPrev": "Previous",
|
||||
@@ -1302,7 +1311,13 @@
|
||||
"maxRetriesHint": "Retries on rate limit or server error",
|
||||
"retryDelay": "Retry delay (ms)",
|
||||
"retryDelayPlaceholder": "1000",
|
||||
"retryDelayHint": "Delay between retries (ms)"
|
||||
"retryDelayHint": "Delay between retries (ms)",
|
||||
"testConnection": "Test Connection",
|
||||
"testFillRequired": "Please fill in API Key and Model first",
|
||||
"testing": "Testing connection...",
|
||||
"testSuccess": "Connection successful",
|
||||
"testFailed": "Connection failed",
|
||||
"testError": "Test error"
|
||||
},
|
||||
"settingsTerminal": {
|
||||
"title": "Terminal",
|
||||
@@ -1488,6 +1503,18 @@
|
||||
"role": "Role",
|
||||
"defaultRole": "Default",
|
||||
"roleHint": "Select a role; all tasks will be executed using that role's configuration (prompt and tools).",
|
||||
"agentMode": "Agent mode",
|
||||
"agentModeSingle": "Single-agent (ReAct)",
|
||||
"agentModeMulti": "Multi-agent (Eino)",
|
||||
"agentModeHint": "Single-agent is recommended by default; use multi-agent for complex tasks (requires system multi-agent enabled).",
|
||||
"scheduleMode": "Schedule mode",
|
||||
"scheduleModeManual": "Manual",
|
||||
"scheduleModeCron": "Cron expression",
|
||||
"scheduleModeHint": "Manual is for one-time runs; Cron is for recurring runs. Validate tasks manually first.",
|
||||
"cronExpr": "Cron expression",
|
||||
"cronExprPlaceholder": "e.g. 0 */2 * * * (run every 2 hours)",
|
||||
"cronExprHint": "Use standard 5-field Cron: minute hour day month weekday. Example: `0 2 * * *` runs at 02:00 daily.",
|
||||
"cronExprRequired": "Please fill in a Cron expression when Cron schedule is selected",
|
||||
"tasksList": "Task list (one task per line)",
|
||||
"tasksListPlaceholder": "Enter task list, one per line",
|
||||
"tasksListPlaceholderExample": "Enter task list, one per line, for example:\nScan open ports of 192.168.1.1\nCheck if https://example.com has SQL injection\nEnumerate subdomains of example.com",
|
||||
@@ -1508,13 +1535,22 @@
|
||||
"status": "Status",
|
||||
"createdAt": "Created at",
|
||||
"startedAt": "Started at",
|
||||
"nextRunAt": "Next run at",
|
||||
"scheduleCronAuto": "Allow Cron auto-run",
|
||||
"scheduleCronAutoHint": "When off, the cron expression is kept but the queue will not run on schedule; use Start to run manually.",
|
||||
"lastScheduleTriggerAt": "Last scheduled trigger",
|
||||
"lastScheduleError": "Last schedule error",
|
||||
"lastRunError": "Last run failure summary",
|
||||
"cronSchedulePausedBadge": "Schedule paused",
|
||||
"scheduleToggleFailed": "Failed to update schedule toggle",
|
||||
"completedAt": "Completed at",
|
||||
"taskTotal": "Total tasks",
|
||||
"taskList": "Task list",
|
||||
"startLabel": "Start",
|
||||
"completeLabel": "Complete",
|
||||
"errorLabel": "Error",
|
||||
"resultLabel": "Result"
|
||||
"resultLabel": "Result",
|
||||
"technicalDetails": "Technical details (ID, times, schedule)"
|
||||
},
|
||||
"editBatchTaskModal": {
|
||||
"title": "Edit task",
|
||||
|
||||
@@ -254,6 +254,14 @@
|
||||
"conversationIdLabel": "对话ID",
|
||||
"statusPending": "待执行",
|
||||
"statusPaused": "已暂停",
|
||||
"statusCronCycleIdle": "本轮已完成 · 定时循环中",
|
||||
"statusCronRunning": "执行中 · 定时队列",
|
||||
"cronNextRunLine": "下次执行:{{time}}",
|
||||
"cronRoundDoneProgressHint": "定时队列:子任务已跑完,到点将自动下一轮",
|
||||
"cronRunningProgressHint": "本轮执行中;下一整轮仍按 Cron 与「下次执行时间」排程",
|
||||
"cronPendingScheduled": "Cron 已排程 · 下次 {{time}}",
|
||||
"cronPendingProgressNote": "到点将自动开始;也可手动点「开始执行」立即跑一轮",
|
||||
"cronRecurringCallout": "Cron 队列会在「下次执行时间」自动开始新一轮;关闭「允许 Cron 自动调度」即停止循环。",
|
||||
"confirmCancelTasks": "确定要取消 {{n}} 个任务吗?",
|
||||
"batchCancelResultPartial": "批量取消完成:成功 {{success}} 个,失败 {{fail}} 个",
|
||||
"batchCancelResultSuccess": "成功取消 {{n}} 个任务",
|
||||
@@ -276,6 +284,7 @@
|
||||
"deleteQueueConfirm": "确定要删除这个批量任务队列吗?此操作不可恢复。",
|
||||
"deleteQueueFailed": "删除批量任务队列失败",
|
||||
"batchQueueTitle": "批量任务队列",
|
||||
"batchQueueUntitled": "未命名队列",
|
||||
"resumeExecute": "继续执行",
|
||||
"taskIncomplete": "任务信息不完整",
|
||||
"cannotGetTaskMessageInput": "无法获取任务消息输入框",
|
||||
@@ -285,7 +294,7 @@
|
||||
"addTaskFailed": "添加任务失败",
|
||||
"confirmDeleteTask": "确定要删除这个任务吗?\n\n任务内容: {{message}}\n\n此操作不可恢复。",
|
||||
"deleteTaskFailed": "删除任务失败",
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
||||
"paginationPerPage": "每页显示",
|
||||
"paginationFirst": "首页",
|
||||
"paginationPrev": "上一页",
|
||||
@@ -1302,7 +1311,13 @@
|
||||
"maxRetriesHint": "最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试",
|
||||
"retryDelay": "重试间隔(毫秒)",
|
||||
"retryDelayPlaceholder": "1000",
|
||||
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟"
|
||||
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟",
|
||||
"testConnection": "测试连接",
|
||||
"testFillRequired": "请先填写 API Key 和模型",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "连接成功",
|
||||
"testFailed": "连接失败",
|
||||
"testError": "测试出错"
|
||||
},
|
||||
"settingsTerminal": {
|
||||
"title": "终端",
|
||||
@@ -1488,6 +1503,18 @@
|
||||
"role": "角色",
|
||||
"defaultRole": "默认",
|
||||
"roleHint": "选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。",
|
||||
"agentMode": "代理模式",
|
||||
"agentModeSingle": "单代理(ReAct)",
|
||||
"agentModeMulti": "多代理(Eino)",
|
||||
"agentModeHint": "建议默认单代理;复杂任务可使用多代理(需系统已启用多代理)。",
|
||||
"scheduleMode": "调度方式",
|
||||
"scheduleModeManual": "手工执行",
|
||||
"scheduleModeCron": "调度表达式(Cron)",
|
||||
"scheduleModeHint": "手工执行用于一次性任务;Cron 用于周期任务,建议先手工验证任务正确性。",
|
||||
"cronExpr": "Cron 表达式",
|
||||
"cronExprPlaceholder": "例如:0 */2 * * *(每2小时执行一次)",
|
||||
"cronExprHint": "采用标准 5 段 Cron:分 时 日 月 周,例如 `0 2 * * *` 表示每天 02:00 执行。",
|
||||
"cronExprRequired": "请选择 Cron 调度后填写 Cron 表达式",
|
||||
"tasksList": "任务列表(每行一个任务)",
|
||||
"tasksListPlaceholder": "请输入任务列表,每行一个任务",
|
||||
"tasksListPlaceholderExample": "请输入任务列表,每行一个任务,例如:\n扫描 192.168.1.1 的开放端口\n检查 https://example.com 是否存在SQL注入\n枚举 example.com 的子域名",
|
||||
@@ -1508,13 +1535,22 @@
|
||||
"status": "状态",
|
||||
"createdAt": "创建时间",
|
||||
"startedAt": "开始时间",
|
||||
"nextRunAt": "下次执行时间",
|
||||
"scheduleCronAuto": "允许 Cron 自动调度",
|
||||
"scheduleCronAutoHint": "关闭后仅保留表达式配置,不会按时间自动跑;可随时手工点「开始执行」。",
|
||||
"lastScheduleTriggerAt": "最近调度触发时间",
|
||||
"lastScheduleError": "最近调度失败原因",
|
||||
"lastRunError": "最近运行失败摘要",
|
||||
"cronSchedulePausedBadge": "调度已暂停",
|
||||
"scheduleToggleFailed": "更新调度开关失败",
|
||||
"completedAt": "完成时间",
|
||||
"taskTotal": "任务总数",
|
||||
"taskList": "任务列表",
|
||||
"startLabel": "开始",
|
||||
"completeLabel": "完成",
|
||||
"errorLabel": "错误",
|
||||
"resultLabel": "结果"
|
||||
"resultLabel": "结果",
|
||||
"technicalDetails": "技术信息(ID / 时间 / 调度)"
|
||||
},
|
||||
"editBatchTaskModal": {
|
||||
"title": "编辑任务",
|
||||
|
||||
+102
-78
@@ -1494,11 +1494,14 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
mcpExecutionIds.forEach((execId, index) => {
|
||||
const detailBtn = document.createElement('button');
|
||||
detailBtn.className = 'mcp-detail-btn';
|
||||
detailBtn.dataset.execId = execId;
|
||||
detailBtn.dataset.execIndex = String(index + 1);
|
||||
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
||||
detailBtn.onclick = () => showMCPDetail(execId);
|
||||
buttonsContainer.appendChild(detailBtn);
|
||||
updateButtonWithToolName(detailBtn, execId, index + 1);
|
||||
});
|
||||
// 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
|
||||
batchUpdateButtonToolNames(buttonsContainer, mcpExecutionIds);
|
||||
|
||||
mcpSection.appendChild(buttonsContainer);
|
||||
contentWrapper.appendChild(mcpSection);
|
||||
@@ -1861,6 +1864,34 @@ async function updateButtonWithToolName(button, executionId, index) {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取工具名称并更新按钮(消除 N 次单独 API 请求,合并为 1 次)
|
||||
async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
|
||||
if (!executionIds || executionIds.length === 0) return;
|
||||
try {
|
||||
const response = await apiFetch('/api/monitor/executions/names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: executionIds }),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const nameMap = await response.json(); // { execId: toolName }
|
||||
// 更新对应按钮的文本
|
||||
const buttons = buttonsContainer.querySelectorAll('.mcp-detail-btn[data-exec-id]');
|
||||
buttons.forEach(btn => {
|
||||
const execId = btn.dataset.execId;
|
||||
const index = btn.dataset.execIndex;
|
||||
const toolName = nameMap[execId];
|
||||
if (toolName) {
|
||||
const displayToolName = toolName.includes('::') ? toolName.split('::')[1] : toolName;
|
||||
const span = btn.querySelector('span');
|
||||
if (span) span.textContent = `${displayToolName} #${index}`;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量获取工具名称失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示MCP调用详情
|
||||
async function showMCPDetail(executionId) {
|
||||
try {
|
||||
@@ -2380,15 +2411,14 @@ async function loadConversation(conversationId) {
|
||||
}
|
||||
|
||||
// 获取当前对话所属的分组ID(用于高亮显示)
|
||||
// 确保分组映射已加载
|
||||
// 确保分组映射已加载(使用缓存避免重复请求)
|
||||
if (Object.keys(conversationGroupMappingCache).length === 0) {
|
||||
await loadConversationGroupMapping();
|
||||
}
|
||||
currentConversationGroupId = conversationGroupMappingCache[conversationId] || null;
|
||||
|
||||
// 无论是否在分组详情页面,都刷新分组列表,确保高亮状态正确
|
||||
// 这样可以清除之前分组的高亮状态,确保UI状态一致
|
||||
await loadGroups();
|
||||
|
||||
// 异步刷新分组列表高亮状态(不阻塞消息渲染)
|
||||
loadGroups();
|
||||
|
||||
// 更新当前对话ID
|
||||
currentConversationId = conversationId;
|
||||
@@ -2430,13 +2460,15 @@ async function loadConversation(conversationId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
// 加载消息 — 分批渲染避免长时间阻塞主线程
|
||||
if (conversation.messages && conversation.messages.length > 0) {
|
||||
conversation.messages.forEach(msg => {
|
||||
// 检查消息内容是否为"处理中...",如果是,检查processDetails中是否有错误或取消事件
|
||||
const FIRST_BATCH = 20; // 首批同步渲染(用户可见区域)
|
||||
const BATCH_SIZE = 10; // 后续每批条数
|
||||
|
||||
// 渲染单条消息的辅助函数
|
||||
const renderOneMessage = (msg) => {
|
||||
let displayContent = msg.content;
|
||||
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
// 查找最后一个error或cancelled事件
|
||||
for (let i = msg.processDetails.length - 1; i >= 0; i--) {
|
||||
const detail = msg.processDetails[i];
|
||||
if (detail.eventType === 'error' || detail.eventType === 'cancelled') {
|
||||
@@ -2445,47 +2477,63 @@ async function loadConversation(conversationId) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 传递消息的创建时间
|
||||
|
||||
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
|
||||
// 绑定后端 messageId,供按需加载过程详情使用
|
||||
const messageEl = document.getElementById(messageId);
|
||||
if (messageEl && msg && msg.id) {
|
||||
messageEl.dataset.backendMessageId = String(msg.id);
|
||||
attachDeleteTurnButton(messageEl);
|
||||
}
|
||||
// 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
|
||||
if (msg.role === 'assistant') {
|
||||
// 延迟一下,确保消息已经渲染
|
||||
setTimeout(() => {
|
||||
// 如果后端未返回 processDetails 字段,传 null 表示“尚未加载,点击展开时再请求”
|
||||
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
||||
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
||||
// 如果有过程详情,检查是否有错误或取消事件,如果有,确保详情默认折叠
|
||||
if (msg.processDetails && msg.processDetails.length > 0) {
|
||||
const hasErrorOrCancelled = msg.processDetails.some(d =>
|
||||
d.eventType === 'error' || d.eventType === 'cancelled'
|
||||
);
|
||||
if (hasErrorOrCancelled) {
|
||||
collapseAllProgressDetails(messageId, null);
|
||||
}
|
||||
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
||||
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
||||
if (msg.processDetails && msg.processDetails.length > 0) {
|
||||
const hasErrorOrCancelled = msg.processDetails.some(d =>
|
||||
d.eventType === 'error' || d.eventType === 'cancelled'
|
||||
);
|
||||
if (hasErrorOrCancelled) {
|
||||
collapseAllProgressDetails(messageId, null);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const msgs = conversation.messages;
|
||||
const firstBatch = msgs.slice(0, FIRST_BATCH);
|
||||
const rest = msgs.slice(FIRST_BATCH);
|
||||
|
||||
// 首批同步渲染
|
||||
firstBatch.forEach(renderOneMessage);
|
||||
|
||||
// 剩余消息通过 requestAnimationFrame 分批渲染,避免阻塞 UI
|
||||
if (rest.length > 0) {
|
||||
const savedConvId = conversationId;
|
||||
let offset = 0;
|
||||
const renderNextBatch = () => {
|
||||
// 如果用户已经切换到其他对话,停止渲染
|
||||
if (currentConversationId !== savedConvId) return;
|
||||
const batch = rest.slice(offset, offset + BATCH_SIZE);
|
||||
batch.forEach(renderOneMessage);
|
||||
offset += BATCH_SIZE;
|
||||
if (offset < rest.length) {
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
} else {
|
||||
// 所有消息渲染完毕,滚动到底部
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
}
|
||||
} else {
|
||||
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
|
||||
// 滚动到底部(首批渲染后立即滚动,剩余批次渲染后会再次滚动)
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
|
||||
|
||||
// 添加攻击链按钮
|
||||
addAttackChainButton(conversationId);
|
||||
|
||||
// 刷新对话列表
|
||||
loadConversations();
|
||||
} catch (error) {
|
||||
console.error('加载对话失败:', error);
|
||||
alert('加载对话失败: ' + error.message);
|
||||
@@ -4421,20 +4469,17 @@ async function loadGroups() {
|
||||
async function loadConversationsWithGroups(searchQuery = '') {
|
||||
const loadSeq = ++conversationsListLoadSeq;
|
||||
try {
|
||||
// 总是重新加载分组列表和分组映射,确保缓存是最新的
|
||||
// 这样可以正确处理分组被删除后的情况
|
||||
await loadGroups();
|
||||
if (loadSeq !== conversationsListLoadSeq) return;
|
||||
await loadConversationGroupMapping();
|
||||
if (loadSeq !== conversationsListLoadSeq) return;
|
||||
|
||||
// 如果有搜索关键词,使用更大的limit以获取所有匹配结果
|
||||
const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100;
|
||||
// 并行加载分组列表、分组映射和对话列表(消除串行等待)
|
||||
const limit = (searchQuery && searchQuery.trim()) ? 100 : 100;
|
||||
let url = `/api/conversations?limit=${limit}`;
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
||||
}
|
||||
const response = await apiFetch(url);
|
||||
const [,, response] = await Promise.all([
|
||||
loadGroups(),
|
||||
loadConversationGroupMapping(),
|
||||
apiFetch(url),
|
||||
]);
|
||||
if (loadSeq !== conversationsListLoadSeq) return;
|
||||
|
||||
const listContainer = document.getElementById('conversations-list');
|
||||
@@ -5432,48 +5477,27 @@ async function removeConversationFromGroup(convId, groupId) {
|
||||
// 加载对话分组映射
|
||||
async function loadConversationGroupMapping() {
|
||||
try {
|
||||
// 获取所有分组,然后获取每个分组的对话
|
||||
let groups;
|
||||
if (Array.isArray(groupsCache) && groupsCache.length > 0) {
|
||||
groups = groupsCache;
|
||||
} else {
|
||||
const response = await apiFetch('/api/groups');
|
||||
if (!response.ok) {
|
||||
// 如果API请求失败,使用空数组,不打印警告(这是正常错误处理)
|
||||
groups = [];
|
||||
} else {
|
||||
groups = await response.json();
|
||||
// 确保groups是有效数组,只在真正异常时才打印警告
|
||||
if (!Array.isArray(groups)) {
|
||||
// 只在返回的不是数组且不是null/undefined时才打印警告(可能是后端返回了错误格式)
|
||||
if (groups !== null && groups !== undefined) {
|
||||
console.warn('loadConversationGroupMapping: groups不是有效数组,使用空数组', groups);
|
||||
}
|
||||
groups = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用批量 API 一次性获取所有映射(消除 N+1 串行请求)
|
||||
const response = await apiFetch('/api/groups/mappings');
|
||||
|
||||
// 保存待保留的映射
|
||||
const preservedMappings = { ...pendingGroupMappings };
|
||||
|
||||
|
||||
conversationGroupMappingCache = {};
|
||||
|
||||
for (const group of groups) {
|
||||
const response = await apiFetch(`/api/groups/${group.id}/conversations`);
|
||||
const conversations = await response.json();
|
||||
// 确保conversations是有效数组
|
||||
if (Array.isArray(conversations)) {
|
||||
conversations.forEach(conv => {
|
||||
conversationGroupMappingCache[conv.id] = group.id;
|
||||
if (response.ok) {
|
||||
const mappings = await response.json();
|
||||
if (Array.isArray(mappings)) {
|
||||
mappings.forEach(m => {
|
||||
conversationGroupMappingCache[m.conversationId] = m.groupId;
|
||||
// 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了)
|
||||
if (preservedMappings[conv.id] === group.id) {
|
||||
delete pendingGroupMappings[conv.id];
|
||||
if (preservedMappings[m.conversationId] === m.groupId) {
|
||||
delete pendingGroupMappings[m.conversationId];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 恢复待保留的映射(这些是后端API尚未同步的映射)
|
||||
Object.assign(conversationGroupMappingCache, preservedMappings);
|
||||
} catch (error) {
|
||||
|
||||
@@ -74,6 +74,17 @@ if (typeof window !== 'undefined') {
|
||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
||||
const toolCallStatusMap = new Map();
|
||||
|
||||
function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
|
||||
if (!progressId) return;
|
||||
const pid = String(progressId);
|
||||
for (const [toolCallId, mapping] of Array.from(toolCallStatusMap.entries())) {
|
||||
if (!mapping) continue;
|
||||
if (mapping.progressId != null && String(mapping.progressId) !== pid) continue;
|
||||
updateToolCallStatus(toolCallId, finalStatus);
|
||||
toolCallStatusMap.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
||||
const responseStreamStateByProgressId = new Map();
|
||||
|
||||
@@ -388,6 +399,11 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
||||
const progressElement = document.getElementById(progressId);
|
||||
if (!progressElement) return;
|
||||
|
||||
// Ensure any "running" tool_call badges are closed before we snapshot timeline HTML.
|
||||
// Otherwise, once the progress element is removed, later 'done' events may not be able
|
||||
// to update the original timeline DOM and the copied HTML would stay "执行中".
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
|
||||
const mcpIds = Array.isArray(mcpExecutionIds) ? mcpExecutionIds : [];
|
||||
|
||||
// 获取时间线内容
|
||||
@@ -444,13 +460,16 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
||||
mcpIds.forEach((execId, index) => {
|
||||
const detailBtn = document.createElement('button');
|
||||
detailBtn.className = 'mcp-detail-btn';
|
||||
detailBtn.dataset.execId = execId;
|
||||
detailBtn.dataset.execIndex = String(index + 1);
|
||||
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
||||
detailBtn.onclick = () => showMCPDetail(execId);
|
||||
buttonsContainer.appendChild(detailBtn);
|
||||
if (typeof updateButtonWithToolName === 'function') {
|
||||
updateButtonWithToolName(detailBtn, execId, index + 1);
|
||||
}
|
||||
});
|
||||
// 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
|
||||
if (typeof batchUpdateButtonToolNames === 'function') {
|
||||
batchUpdateButtonToolNames(buttonsContainer, mcpIds);
|
||||
}
|
||||
}
|
||||
if (!buttonsContainer.querySelector('.process-detail-btn')) {
|
||||
const progressDetailBtn = document.createElement('button');
|
||||
@@ -937,6 +956,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
message: event.message || '',
|
||||
data: event.data
|
||||
});
|
||||
// If the backend triggers a recovery run, any "running" tool_call items in this progress
|
||||
// should be closed to avoid being stuck forever.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -958,7 +980,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
if (toolCallId && toolCallItemId) {
|
||||
toolCallStatusMap.set(toolCallId, {
|
||||
itemId: toolCallItemId,
|
||||
timeline: timeline
|
||||
timeline: timeline,
|
||||
progressId: progressId
|
||||
});
|
||||
|
||||
// 添加执行中状态指示器
|
||||
@@ -1224,6 +1247,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 立即刷新任务状态
|
||||
loadActiveTasks();
|
||||
// Close any remaining running tool calls for this progress.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
break;
|
||||
|
||||
case 'response_start': {
|
||||
@@ -1337,9 +1362,23 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||
}
|
||||
|
||||
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
|
||||
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
|
||||
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
|
||||
if (streamState && streamState.itemId) {
|
||||
const planningItem = document.getElementById(streamState.itemId);
|
||||
if (planningItem && planningItem.parentNode) {
|
||||
planningItem.parentNode.removeChild(planningItem);
|
||||
}
|
||||
}
|
||||
|
||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||
hideProgressMessageForFinalReply(progressId);
|
||||
|
||||
// Before integrating/removing the progress DOM, close any outstanding running tool calls
|
||||
// so the copied timeline HTML reflects the final status.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
|
||||
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
|
||||
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
@@ -1403,6 +1442,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 立即刷新任务状态(执行失败时任务状态会更新)
|
||||
loadActiveTasks();
|
||||
// Close any remaining running tool calls for this progress.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
@@ -1438,6 +1479,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 立即刷新任务状态(确保任务状态同步)
|
||||
loadActiveTasks();
|
||||
// Close any remaining running tool calls for this progress (best-effort).
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
|
||||
// 延迟再次刷新任务状态(确保后端已完成状态更新)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -125,8 +125,6 @@ async function loadConfig(loadTools = true) {
|
||||
if (maMode) maMode.value = (ma.default_mode === 'multi') ? 'multi' : 'single';
|
||||
const maRobot = document.getElementById('multi-agent-robot-use');
|
||||
if (maRobot) maRobot.checked = ma.robot_use_multi_agent === true;
|
||||
const maBatch = document.getElementById('multi-agent-batch-use');
|
||||
if (maBatch) maBatch.checked = ma.batch_use_multi_agent === true;
|
||||
|
||||
// 填充知识库配置
|
||||
const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled');
|
||||
@@ -820,7 +818,7 @@ async function applySettings() {
|
||||
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
||||
default_mode: document.getElementById('multi-agent-default-mode')?.value === 'multi' ? 'multi' : 'single',
|
||||
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
||||
batch_use_multi_agent: document.getElementById('multi-agent-batch-use')?.checked === true
|
||||
batch_use_multi_agent: false
|
||||
},
|
||||
knowledge: knowledgeConfig,
|
||||
robots: {
|
||||
@@ -959,6 +957,57 @@ async function applySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试OpenAI连接
|
||||
async function testOpenAIConnection() {
|
||||
const btn = document.getElementById('test-openai-btn');
|
||||
const resultEl = document.getElementById('test-openai-result');
|
||||
|
||||
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||
const model = document.getElementById('openai-model').value.trim();
|
||||
|
||||
if (!apiKey || !model) {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.style.pointerEvents = 'none';
|
||||
btn.style.opacity = '0.5';
|
||||
resultEl.style.color = 'var(--text-muted, #888)';
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...';
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/config/test-openai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: model
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||
const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : '';
|
||||
const modelInfo = result.model ? ` [${result.model}]` : '';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
||||
} else {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + (result.error || '未知错误');
|
||||
}
|
||||
} catch (error) {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testError') : '测试出错') + ': ' + error.message;
|
||||
} finally {
|
||||
btn.style.pointerEvents = '';
|
||||
btn.style.opacity = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 保存工具配置(独立函数,用于MCP管理页面)
|
||||
async function saveToolsConfig() {
|
||||
try {
|
||||
|
||||
+245
-131
@@ -3,6 +3,60 @@ function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
/** 插值不转 HTML 实体(避免日期里的 / 变成 / 再被 escapeHtml 成乱码) */
|
||||
function _tPlain(key, opts) {
|
||||
if (typeof window.t !== 'function') return key;
|
||||
const base = opts && typeof opts === 'object' ? opts : {};
|
||||
const interp = base.interpolation && typeof base.interpolation === 'object' ? base.interpolation : {};
|
||||
return window.t(key, {
|
||||
...base,
|
||||
interpolation: { escapeValue: false, ...interp }
|
||||
});
|
||||
}
|
||||
|
||||
/** Cron 队列在「本轮 completed」等状态下的展示文案(底层 status 不变,仅 UI 强调循环调度) */
|
||||
function getBatchQueueStatusPresentation(queue) {
|
||||
const map = {
|
||||
pending: { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
running: { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
paused: { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
completed: { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
cancelled: { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
const base = map[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
|
||||
const cronOn = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||||
const nextStr = queue.nextRunAt ? new Date(queue.nextRunAt).toLocaleString() : '';
|
||||
const empty = { sublabel: null, progressNote: null, callout: null };
|
||||
|
||||
if (cronOn && queue.status === 'completed') {
|
||||
return {
|
||||
text: _t('tasks.statusCronCycleIdle'),
|
||||
class: 'batch-queue-status-cron-cycle',
|
||||
sublabel: nextStr ? _tPlain('tasks.cronNextRunLine', { time: nextStr }) : null,
|
||||
progressNote: _t('tasks.cronRoundDoneProgressHint'),
|
||||
callout: _t('tasks.cronRecurringCallout')
|
||||
};
|
||||
}
|
||||
if (cronOn && queue.status === 'running') {
|
||||
return {
|
||||
text: _t('tasks.statusCronRunning'),
|
||||
class: 'batch-queue-status-running batch-queue-cron-active',
|
||||
sublabel: nextStr ? _tPlain('tasks.cronNextRunLine', { time: nextStr }) : null,
|
||||
progressNote: _t('tasks.cronRunningProgressHint'),
|
||||
callout: null
|
||||
};
|
||||
}
|
||||
if (cronOn && queue.status === 'pending' && nextStr) {
|
||||
return {
|
||||
...base,
|
||||
...empty,
|
||||
sublabel: _tPlain('tasks.cronPendingScheduled', { time: nextStr }),
|
||||
progressNote: _t('tasks.cronPendingProgressNote')
|
||||
};
|
||||
}
|
||||
return { ...base, ...empty };
|
||||
}
|
||||
|
||||
// HTML转义函数(如果未定义)
|
||||
if (typeof escapeHtml === 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
@@ -725,6 +779,9 @@ async function showBatchImportModal() {
|
||||
const input = document.getElementById('batch-tasks-input');
|
||||
const titleInput = document.getElementById('batch-queue-title');
|
||||
const roleSelect = document.getElementById('batch-queue-role');
|
||||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
if (modal && input) {
|
||||
input.value = '';
|
||||
if (titleInput) {
|
||||
@@ -734,6 +791,16 @@ async function showBatchImportModal() {
|
||||
if (roleSelect) {
|
||||
roleSelect.value = '';
|
||||
}
|
||||
if (agentModeSelect) {
|
||||
agentModeSelect.value = 'single';
|
||||
}
|
||||
if (scheduleModeSelect) {
|
||||
scheduleModeSelect.value = 'manual';
|
||||
}
|
||||
if (cronExprInput) {
|
||||
cronExprInput.value = '';
|
||||
}
|
||||
handleBatchScheduleModeChange();
|
||||
updateBatchImportStats('');
|
||||
|
||||
// 加载并填充角色列表
|
||||
@@ -776,6 +843,24 @@ function closeBatchImportModal() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleBatchScheduleModeChange() {
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronGroup = document.getElementById('batch-queue-cron-group');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
const isCron = scheduleModeSelect && scheduleModeSelect.value === 'cron';
|
||||
if (cronGroup) {
|
||||
cronGroup.style.display = isCron ? 'block' : 'none';
|
||||
}
|
||||
if (cronExprInput) {
|
||||
if (isCron) {
|
||||
cronExprInput.setAttribute('required', 'required');
|
||||
} else {
|
||||
cronExprInput.removeAttribute('required');
|
||||
cronExprInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新新建任务统计
|
||||
function updateBatchImportStats(text) {
|
||||
const statsEl = document.getElementById('batch-import-stats');
|
||||
@@ -807,6 +892,9 @@ async function createBatchQueue() {
|
||||
const input = document.getElementById('batch-tasks-input');
|
||||
const titleInput = document.getElementById('batch-queue-title');
|
||||
const roleSelect = document.getElementById('batch-queue-role');
|
||||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
@@ -827,6 +915,13 @@ async function createBatchQueue() {
|
||||
|
||||
// 获取角色(可选,空字符串表示默认角色)
|
||||
const role = roleSelect ? roleSelect.value || '' : '';
|
||||
const agentMode = agentModeSelect ? (agentModeSelect.value === 'multi' ? 'multi' : 'single') : 'single';
|
||||
const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual';
|
||||
const cronExpr = cronExprInput ? cronExprInput.value.trim() : '';
|
||||
if (scheduleMode === 'cron' && !cronExpr) {
|
||||
alert(_t('batchImportModal.cronExprRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/batch-tasks', {
|
||||
@@ -834,7 +929,7 @@ async function createBatchQueue() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title, tasks, role }),
|
||||
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -978,15 +1073,7 @@ function renderBatchQueues() {
|
||||
}
|
||||
|
||||
list.innerHTML = queues.map(queue => {
|
||||
const statusMap = {
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
|
||||
const status = statusMap[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
|
||||
const pres = getBatchQueueStatusPresentation(queue);
|
||||
|
||||
// 统计任务状态
|
||||
const stats = {
|
||||
@@ -1010,58 +1097,73 @@ function renderBatchQueues() {
|
||||
// 允许删除待执行、已完成或已取消状态的队列
|
||||
const canDelete = queue.status === 'pending' || queue.status === 'completed' || queue.status === 'cancelled';
|
||||
|
||||
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 : _t('batchQueueDetailModal.defaultRole');
|
||||
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="${_t('batchQueueDetailModal.role')}: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
|
||||
|
||||
const isCronCycleIdle = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false && queue.status === 'completed';
|
||||
const cardMod = isCronCycleIdle ? ' batch-queue-item--cron-wait' : '';
|
||||
const progressFillMod = isCronCycleIdle ? ' batch-queue-progress-fill--cron-wait' : '';
|
||||
|
||||
const agentLabel = queue.agentMode === 'multi' ? _t('batchImportModal.agentModeMulti') : _t('batchImportModal.agentModeSingle');
|
||||
let scheduleLabel = queue.scheduleMode === 'cron' ? _t('batchImportModal.scheduleModeCron') : _t('batchImportModal.scheduleModeManual');
|
||||
if (queue.scheduleMode === 'cron' && queue.cronExpr) {
|
||||
scheduleLabel += ` (${queue.cronExpr})`;
|
||||
}
|
||||
const configLine = [roleName, agentLabel, scheduleLabel].map(s => escapeHtml(s)).join(' · ');
|
||||
const cronPausedNote = queue.scheduleMode === 'cron' && queue.scheduleEnabled === false
|
||||
? ` <span class="batch-queue-inline-warn" title="${escapeHtml(_t('batchQueueDetailModal.scheduleCronAutoHint'))}">(${escapeHtml(_t('batchQueueDetailModal.cronSchedulePausedBadge'))})</span>`
|
||||
: '';
|
||||
const shortId = queue.id.length > 14 ? escapeHtml(queue.id.slice(0, 12)) + '\u2026' : escapeHtml(queue.id);
|
||||
const titleBlock = queue.title
|
||||
? `<h4 class="batch-queue-card-title">${escapeHtml(queue.title)}</h4>`
|
||||
: `<h4 class="batch-queue-card-title batch-queue-card-title--muted">${escapeHtml(_t('tasks.batchQueueUntitled'))}</h4>`;
|
||||
const doneCount = stats.completed + stats.failed + stats.cancelled;
|
||||
const statsCompact = `<span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.totalLabel'))}\u00a0${stats.total}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.pendingLabel'))}\u00a0${stats.pending}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.runningLabel'))}\u00a0${stats.running}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item batch-queue-statsline__item--ok">${escapeHtml(_t('tasks.completedLabel'))}\u00a0${stats.completed}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item batch-queue-statsline__item--err">${escapeHtml(_t('tasks.failedLabel'))}\u00a0${stats.failed}</span>${stats.cancelled > 0 ? `<span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.cancelledLabel'))}\u00a0${stats.cancelled}</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">${_t('tasks.queueIdLabel')}: ${escapeHtml(queue.id)}</span>
|
||||
<span class="batch-queue-time">${_t('tasks.createdTimeLabel')}: ${new Date(queue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="batch-queue-progress">
|
||||
<div class="batch-queue-progress-bar">
|
||||
<div class="batch-queue-progress-fill" style="width: ${progress}%"></div>
|
||||
<div class="batch-queue-item batch-queue-item--compact${cardMod}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||||
<div class="batch-queue-item__inner">
|
||||
<div class="batch-queue-item__top">
|
||||
<div class="batch-queue-item__title-col">
|
||||
${titleBlock}
|
||||
<p class="batch-queue-item__config">${configLine}${cronPausedNote}</p>
|
||||
<p class="batch-queue-item__idline"><code title="${escapeHtml(queue.id)}">${shortId}</code><span class="batch-queue-item__idsep">\u00b7</span><span>${escapeHtml(_t('tasks.createdTimeLabel'))}\u00a0${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></p>
|
||||
</div>
|
||||
<div class="batch-queue-item__top-actions" onclick="event.stopPropagation();">
|
||||
${canDelete ? `<button type="button" class="batch-queue-icon-btn" onclick="deleteBatchQueueFromList('${queue.id}')" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
|
||||
</div>
|
||||
<span class="batch-queue-progress-text">${progress}% (${stats.completed + stats.failed + stats.cancelled}/${stats.total})</span>
|
||||
</div>
|
||||
<div class="batch-queue-actions" style="display: flex; align-items: center; gap: 8px; margin-left: 12px;" onclick="event.stopPropagation();">
|
||||
${canDelete ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="${_t('tasks.deleteQueue')}">${_t('common.delete')}</button>` : ''}
|
||||
<div class="batch-queue-item__mid">
|
||||
<div class="batch-queue-item__mid-left">
|
||||
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||||
${pres.sublabel ? `<span class="batch-queue-item__sublabel">${escapeHtml(pres.sublabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="batch-queue-item__mid-right">
|
||||
<div class="batch-queue-progress-bar batch-queue-progress-bar--card batch-queue-progress-bar--list">
|
||||
<div class="batch-queue-progress-fill${progressFillMod}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
<span class="batch-queue-item__pct">${progress}%\u00a0<span class="batch-queue-item__pct-frac">(${doneCount}/${stats.total})</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-queue-stats">
|
||||
<span>${_t('tasks.totalLabel')}: ${stats.total}</span>
|
||||
<span>${_t('tasks.pendingLabel')}: ${stats.pending}</span>
|
||||
<span>${_t('tasks.runningLabel')}: ${stats.running}</span>
|
||||
<span style="color: var(--success-color);">${_t('tasks.completedLabel')}: ${stats.completed}</span>
|
||||
<span style="color: var(--error-color);">${_t('tasks.failedLabel')}: ${stats.failed}</span>
|
||||
${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">${_t('tasks.cancelledLabel')}: ${stats.cancelled}</span>` : ''}
|
||||
<div class="batch-queue-statsline" aria-label="${escapeHtml(_t('tasks.batchQueueTitle'))}">${statsCompact}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
}).join('');
|
||||
|
||||
// 渲染分页控件
|
||||
renderBatchQueuesPagination();
|
||||
}
|
||||
|
||||
// 渲染批量任务队列分页控件(参考Skills管理页面样式)
|
||||
// 渲染批量任务队列分页控件(结构与样式对齐 MCP 监控 .monitor-pagination)
|
||||
function renderBatchQueuesPagination() {
|
||||
const paginationContainer = document.getElementById('batch-queues-pagination');
|
||||
if (!paginationContainer) return;
|
||||
|
||||
const { currentPage, pageSize, total, totalPages } = batchQueuesState;
|
||||
|
||||
// 即使只有一页也显示分页信息(参考Skills样式)
|
||||
// 即使只有一页也显示分页信息(与 MCP 监控一致)
|
||||
if (total === 0) {
|
||||
paginationContainer.innerHTML = '';
|
||||
return;
|
||||
@@ -1071,7 +1173,7 @@ function renderBatchQueuesPagination() {
|
||||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||||
|
||||
let paginationHTML = '<div class="pagination">';
|
||||
let paginationHTML = '<div class="monitor-pagination">';
|
||||
|
||||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||||
paginationHTML += `
|
||||
@@ -1103,41 +1205,6 @@ function renderBatchQueuesPagination() {
|
||||
paginationHTML += '</div>';
|
||||
|
||||
paginationContainer.innerHTML = paginationHTML;
|
||||
|
||||
// 确保分页组件与列表内容区域对齐(不包括滚动条)
|
||||
function alignPaginationWidth() {
|
||||
const batchQueuesList = document.getElementById('batch-queues-list');
|
||||
if (batchQueuesList && paginationContainer) {
|
||||
// 获取列表的实际内容宽度(不包括滚动条)
|
||||
const listClientWidth = batchQueuesList.clientWidth; // 可视区域宽度(不包括滚动条)
|
||||
const listScrollHeight = batchQueuesList.scrollHeight; // 内容总高度
|
||||
const listClientHeight = batchQueuesList.clientHeight; // 可视区域高度
|
||||
const hasScrollbar = listScrollHeight > listClientHeight;
|
||||
|
||||
// 如果列表有垂直滚动条,分页组件应该与列表内容区域对齐(clientWidth)
|
||||
// 如果没有滚动条,使用100%宽度
|
||||
if (hasScrollbar) {
|
||||
// 分页组件应该与列表内容区域对齐,不包括滚动条
|
||||
paginationContainer.style.width = `${listClientWidth}px`;
|
||||
} else {
|
||||
// 如果没有滚动条,使用100%宽度
|
||||
paginationContainer.style.width = '100%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
alignPaginationWidth();
|
||||
|
||||
// 监听窗口大小变化和列表内容变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
alignPaginationWidth();
|
||||
});
|
||||
|
||||
const batchQueuesList = document.getElementById('batch-queues-list');
|
||||
if (batchQueuesList) {
|
||||
resizeObserver.observe(batchQueuesList);
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定页面
|
||||
@@ -1198,7 +1265,8 @@ async function showBatchQueueDetail(queueId) {
|
||||
const result = await response.json();
|
||||
const queue = result.queue;
|
||||
batchQueuesState.currentQueueId = queueId;
|
||||
|
||||
const pres = getBatchQueueStatusPresentation(queue);
|
||||
|
||||
if (title) {
|
||||
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||||
title.textContent = queue.title ? _t('tasks.batchQueueTitle') + ' - ' + String(queue.title) : _t('tasks.batchQueueTitle');
|
||||
@@ -1227,15 +1295,6 @@ async function showBatchQueueDetail(queueId) {
|
||||
deleteBtn.style.display = (queue.status === 'pending' || queue.status === 'completed' || queue.status === 'cancelled' || queue.status === 'paused') ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
// 队列状态映射
|
||||
const queueStatusMap = {
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
|
||||
// 任务状态映射
|
||||
const taskStatusMap = {
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-task-status-pending' },
|
||||
@@ -1245,13 +1304,10 @@ async function showBatchQueueDetail(queueId) {
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-task-status-cancelled' }
|
||||
};
|
||||
|
||||
// 获取角色信息(如果队列有角色配置)
|
||||
let roleDisplay = '';
|
||||
let roleLineVal = '';
|
||||
if (queue.role && queue.role !== '') {
|
||||
// 如果有角色配置,尝试获取角色详细信息
|
||||
let roleName = queue.role;
|
||||
let roleIcon = '👤';
|
||||
// 从已加载的角色列表中查找角色图标
|
||||
let roleIcon = '\uD83D\uDC64';
|
||||
if (Array.isArray(loadedRoles) && loadedRoles.length > 0) {
|
||||
const role = loadedRoles.find(r => r.name === roleName);
|
||||
if (role && role.icon) {
|
||||
@@ -1262,55 +1318,57 @@ async function showBatchQueueDetail(queueId) {
|
||||
const codePoint = parseInt(unicodeMatch[1], 16);
|
||||
icon = String.fromCodePoint(codePoint);
|
||||
} catch (e) {
|
||||
// 转换失败,使用默认图标
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
roleIcon = icon;
|
||||
}
|
||||
}
|
||||
roleDisplay = `<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
||||
<span class="detail-value">${roleIcon} ${escapeHtml(roleName)}</span>
|
||||
</div>`;
|
||||
roleLineVal = roleIcon + ' ' + escapeHtml(roleName);
|
||||
} else {
|
||||
// 默认角色
|
||||
roleDisplay = `<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
||||
<span class="detail-value">🔵 ` + _t('batchQueueDetailModal.defaultRole') + `</span>
|
||||
</div>`;
|
||||
roleLineVal = '\uD83D\uDD35 ' + escapeHtml(_t('batchQueueDetailModal.defaultRole'));
|
||||
}
|
||||
const agentModeText = queue.agentMode === 'multi' ? _t('batchImportModal.agentModeMulti') : _t('batchImportModal.agentModeSingle');
|
||||
const scheduleModeText = queue.scheduleMode === 'cron' ? _t('batchImportModal.scheduleModeCron') : _t('batchImportModal.scheduleModeManual');
|
||||
const scheduleDetail = escapeHtml(scheduleModeText) + (queue.scheduleMode === 'cron' && queue.cronExpr ? `(${escapeHtml(queue.cronExpr)})` : '');
|
||||
const showProgressNoteInModal = !!(pres.progressNote && !pres.callout);
|
||||
|
||||
|
||||
// 保存滚动位置,防止刷新时滚动条弹回顶部
|
||||
const modalBody = content.closest('.modal-body');
|
||||
const tasksList = content.querySelector('.batch-queue-tasks-list');
|
||||
const savedModalBodyScrollTop = modalBody ? modalBody.scrollTop : 0;
|
||||
const savedTasksListScrollTop = tasksList ? tasksList.scrollTop : 0;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="batch-queue-detail-info">
|
||||
${queue.title ? `<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.queueTitle') + `</span>
|
||||
<span class="detail-value">${escapeHtml(queue.title)}</span>
|
||||
</div>` : ''}
|
||||
${roleDisplay}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.queueId') + `</span>
|
||||
<span class="detail-value"><code>${escapeHtml(queue.id)}</code></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.status') + `</span>
|
||||
<span class="detail-value"><span class="batch-queue-status ${queueStatusMap[queue.status]?.class || ''}">${queueStatusMap[queue.status]?.text || queue.status}</span></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.createdAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
${queue.startedAt ? `<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.startedAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.startedAt).toLocaleString()}</span>
|
||||
</div>` : ''}
|
||||
${queue.completedAt ? `<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.completedAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.completedAt).toLocaleString()}</span>
|
||||
</div>` : ''}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.taskTotal') + `</span>
|
||||
<span class="detail-value">${queue.tasks.length}</span>
|
||||
<div class="batch-queue-detail-layout">
|
||||
<section class="batch-queue-detail-hero">
|
||||
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||||
${pres.sublabel ? `<p class="batch-queue-detail-hero__sub">${escapeHtml(pres.sublabel)}</p>` : ''}
|
||||
${showProgressNoteInModal ? `<p class="batch-queue-detail-hero__note">${escapeHtml(pres.progressNote)}</p>` : ''}
|
||||
</section>
|
||||
<section class="batch-queue-detail-kv">
|
||||
${queue.title ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</span><span class="bq-kv__v">${escapeHtml(queue.title)}</span></div>` : ''}
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.role'))}</span><span class="bq-kv__v">${roleLineVal}</span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.agentMode'))}</span><span class="bq-kv__v">${escapeHtml(agentModeText)}</span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.scheduleMode'))}</span><span class="bq-kv__v">${scheduleDetail}</span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.taskTotal'))}</span><span class="bq-kv__v">${queue.tasks.length}</span></div>
|
||||
${queue.scheduleMode === 'cron' ? `<div class="bq-kv bq-kv--block"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.scheduleCronAuto'))}</span><span class="bq-kv__v bq-kv__v--control"><label class="bq-cron-toggle"><input type="checkbox" ${queue.scheduleEnabled !== false ? 'checked' : ''} onchange="updateBatchQueueScheduleEnabled(this.checked)" /><span class="bq-cron-toggle__hint">${escapeHtml(_t('batchQueueDetailModal.scheduleCronAutoHint'))}</span></label></span></div>` : ''}
|
||||
</section>
|
||||
${queue.lastScheduleError ? `<div class="bq-alert bq-alert--err"><strong>${escapeHtml(_t('batchQueueDetailModal.lastScheduleError'))}</strong><p>${escapeHtml(queue.lastScheduleError)}</p></div>` : ''}
|
||||
${queue.lastRunError ? `<div class="bq-alert bq-alert--err"><strong>${escapeHtml(_t('batchQueueDetailModal.lastRunError'))}</strong><p>${escapeHtml(queue.lastRunError)}</p></div>` : ''}
|
||||
${pres.callout ? `<div class="batch-queue-cron-callout batch-queue-cron-callout--compact"><span class="batch-queue-cron-callout-icon" aria-hidden="true">\u21BB</span><p>${escapeHtml(pres.callout)}</p></div>` : ''}
|
||||
<details class="batch-queue-detail-tech">
|
||||
<summary class="batch-queue-detail-tech__sum">${escapeHtml(_t('batchQueueDetailModal.technicalDetails'))}</summary>
|
||||
<div class="batch-queue-detail-tech__body">
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueId'))}</span><span class="bq-kv__v"><code>${escapeHtml(queue.id)}</code></span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.createdAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></div>
|
||||
${queue.startedAt ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.startedAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.startedAt).toLocaleString())}</span></div>` : ''}
|
||||
${queue.completedAt ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.completedAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.completedAt).toLocaleString())}</span></div>` : ''}
|
||||
${queue.scheduleMode === 'cron' && queue.nextRunAt && !pres.sublabel ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.nextRunAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.nextRunAt).toLocaleString())}</span></div>` : ''}
|
||||
${queue.lastScheduleTriggerAt ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.lastScheduleTriggerAt'))}</span><span class="bq-kv__v">${escapeHtml(new Date(queue.lastScheduleTriggerAt).toLocaleString())}</span></div>` : ''}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="batch-queue-tasks-list">
|
||||
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
||||
@@ -1338,8 +1396,17 @@ async function showBatchQueueDetail(queueId) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 恢复滚动位置
|
||||
if (savedModalBodyScrollTop > 0 && modalBody) {
|
||||
modalBody.scrollTop = savedModalBodyScrollTop;
|
||||
}
|
||||
const newTasksList = content.querySelector('.batch-queue-tasks-list');
|
||||
if (savedTasksListScrollTop > 0 && newTasksList) {
|
||||
newTasksList.scrollTop = savedTasksListScrollTop;
|
||||
}
|
||||
|
||||
modal.style.display = 'block';
|
||||
|
||||
|
||||
// 如果队列正在运行,自动刷新
|
||||
if (queue.status === 'running') {
|
||||
startBatchQueueRefresh(queueId);
|
||||
@@ -1819,6 +1886,28 @@ async function deleteBatchTask(queueId, taskId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateBatchQueueScheduleEnabled(enabled) {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/schedule-enabled`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scheduleEnabled: enabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || _t('batchQueueDetailModal.scheduleToggleFailed'));
|
||||
}
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(_t('batchQueueDetailModal.scheduleToggleFailed') + ': ' + e.message);
|
||||
showBatchQueueDetail(queueId);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
window.showBatchImportModal = showBatchImportModal;
|
||||
window.closeBatchImportModal = closeBatchImportModal;
|
||||
@@ -1842,3 +1931,28 @@ window.closeAddBatchTaskModal = closeAddBatchTaskModal;
|
||||
window.saveAddBatchTask = saveAddBatchTask;
|
||||
window.deleteBatchTaskFromElement = deleteBatchTaskFromElement;
|
||||
window.deleteBatchQueueFromList = deleteBatchQueueFromList;
|
||||
window.handleBatchScheduleModeChange = handleBatchScheduleModeChange;
|
||||
window.updateBatchQueueScheduleEnabled = updateBatchQueueScheduleEnabled;
|
||||
|
||||
// 语言切换后,列表/分页/详情弹窗由 JS 渲染的文案需用当前语言重绘(applyTranslations 不会处理 innerHTML 内容)
|
||||
document.addEventListener('languagechange', function () {
|
||||
try {
|
||||
const tasksPage = document.getElementById('page-tasks');
|
||||
if (!tasksPage || !tasksPage.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
if (document.getElementById('batch-queues-list')) {
|
||||
renderBatchQueues();
|
||||
}
|
||||
const detailModal = document.getElementById('batch-queue-detail-modal');
|
||||
if (
|
||||
detailModal &&
|
||||
detailModal.style.display === 'block' &&
|
||||
batchQueuesState.currentQueueId
|
||||
) {
|
||||
showBatchQueueDetail(batchQueuesState.currentQueueId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('languagechange tasks refresh failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,6 +121,13 @@
|
||||
ws.onopen = function () {
|
||||
if (tab.term) {
|
||||
tab.term.focus();
|
||||
// Send the actual terminal dimensions to the backend immediately
|
||||
// so the PTY size matches what xterm.js is displaying.
|
||||
if (tab.term.cols && tab.term.rows) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,6 +232,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (tab.ws && tab.ws.readyState === WebSocket.OPEN && term.cols && term.rows) {
|
||||
try {
|
||||
tab.ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
// Ctrl+L:本地清屏,同时把 ^L 也发给后端
|
||||
if (data === '\x0c') {
|
||||
@@ -235,6 +250,12 @@
|
||||
sendToWS(data);
|
||||
});
|
||||
|
||||
// Notify backend when the terminal is resized so the PTY dimensions stay in sync.
|
||||
// This is critical for full-screen programs like vi/vim/less to render correctly.
|
||||
term.onResize(function (size) {
|
||||
sendResize();
|
||||
});
|
||||
|
||||
tab.term = term;
|
||||
tab.fitAddon = fitAddon;
|
||||
// 立即建立 WebSocket,让后端 PTY/Shell 马上启动并输出提示符;
|
||||
|
||||
+29
-11
@@ -1150,9 +1150,10 @@
|
||||
oninput="filterBatchQueues()">
|
||||
</label>
|
||||
</div>
|
||||
<div id="batch-queues-list" class="batch-queues-list"></div>
|
||||
<!-- 分页控件 -->
|
||||
<div id="batch-queues-pagination" class="pagination-container pagination-fixed"></div>
|
||||
<div class="batch-queues-board">
|
||||
<div id="batch-queues-list" class="batch-queues-list"></div>
|
||||
<div id="batch-queues-pagination" class="pagination-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1371,6 +1372,10 @@
|
||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
||||
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
||||
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
||||
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1426,14 +1431,6 @@
|
||||
</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotUseHint">需同时勾选「启用多代理」;调用量与成本更高。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="multi-agent-batch-use" class="modern-checkbox" />
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text" data-i18n="settingsBasic.multiAgentBatchUse">批量任务队列也使用多代理</span>
|
||||
</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.multiAgentBatchUseHint">开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2318,6 +2315,27 @@ version: 1.0.0<br>
|
||||
</select>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.roleHint">选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-agent-mode" data-i18n="batchImportModal.agentMode">代理模式</label>
|
||||
<select id="batch-queue-agent-mode" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
|
||||
<option value="single" data-i18n="batchImportModal.agentModeSingle">单代理(ReAct)</option>
|
||||
<option value="multi" data-i18n="batchImportModal.agentModeMulti">多代理(Eino)</option>
|
||||
</select>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.agentModeHint">建议默认单代理;复杂任务可使用多代理(需系统已启用多代理)。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-schedule-mode" data-i18n="batchImportModal.scheduleMode">调度方式</label>
|
||||
<select id="batch-queue-schedule-mode" onchange="handleBatchScheduleModeChange()" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
|
||||
<option value="manual" data-i18n="batchImportModal.scheduleModeManual">手工执行</option>
|
||||
<option value="cron" data-i18n="batchImportModal.scheduleModeCron">调度表达式(Cron)</option>
|
||||
</select>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.scheduleModeHint">手工执行用于一次性任务;Cron 用于周期任务,建议先手工验证任务正确性。</div>
|
||||
</div>
|
||||
<div class="form-group" id="batch-queue-cron-group" style="display: none;">
|
||||
<label for="batch-queue-cron-expr"><span data-i18n="batchImportModal.cronExpr">Cron 表达式</span><span style="color: red;">*</span></label>
|
||||
<input type="text" id="batch-queue-cron-expr" data-i18n="batchImportModal.cronExprPlaceholder" data-i18n-attr="placeholder" placeholder="例如:0 */2 * * *(每2小时执行一次)" />
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.cronExprHint">采用标准 5 段 Cron:分 时 日 月 周,例如 `0 2 * * *` 表示每天 02:00 执行。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-tasks-input"><span data-i18n="batchImportModal.tasksList">任务列表(每行一个任务)</span><span style="color: red;">*</span></label>
|
||||
<textarea id="batch-tasks-input" rows="15" data-i18n="batchImportModal.tasksListPlaceholderExample" data-i18n-attr="placeholder" placeholder="请输入任务列表,每行一个任务,例如: 扫描 192.168.1.1 的开放端口 检查 https://example.com 是否存在SQL注入 枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
|
||||
|
||||
Reference in New Issue
Block a user