Compare commits

...

48 Commits

Author SHA1 Message Date
公明 b64f1c682c Update config.yaml 2026-04-17 12:40:07 +08:00
公明 3bd5408d5a Add files via upload 2026-04-17 11:54:16 +08:00
公明 fb0724a862 Add files via upload 2026-04-17 11:53:20 +08:00
公明 15c7692988 Add files via upload 2026-04-17 11:26:32 +08:00
公明 6fb96dcc0c Add files via upload 2026-04-17 11:24:21 +08:00
公明 9efc0ca8bb Merge pull request #101 from donnel666/feat/claude-api-bridge
feat: add Claude API bridge - transparent OpenAI-to-Anthropic protoco…
2026-04-17 10:08:10 +08:00
donnel 352e245389 Remove sensitive password from config.yaml
Remove the password from the configuration for security.
2026-04-16 13:53:56 +08:00
donnel 4442e7de30 feat: add Claude API bridge - transparent OpenAI-to-Anthropic protocol conversion
When provider is set to "claude" in config, all OpenAI-compatible API calls
are automatically bridged to Anthropic Claude Messages API, including:

- Non-streaming and streaming chat completions
- Tool calls (function calling) with full bidirectional conversion
- Eino multi-agent via HTTP transport hook (claudeRoundTripper)
- System message extraction, auth header conversion (Bearer → x-api-key)
- SSE stream format conversion (content_block_delta → OpenAI delta)
- TestOpenAI handler support for Claude connectivity testing

Zero impact when provider is "openai" or empty (default behavior unchanged).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:45:35 +08:00
公明 715240dc5e Add files via upload 2026-04-15 00:54:15 +08:00
公明 5f8b19e179 Add files via upload 2026-04-15 00:53:14 +08:00
公明 ea48f3d71b Add files via upload 2026-04-15 00:43:35 +08:00
公明 e3013aa230 Add files via upload 2026-04-15 00:39:23 +08:00
公明 1cf34797b8 Add files via upload 2026-04-15 00:38:07 +08:00
公明 62241e0e66 Add files via upload 2026-04-15 00:13:09 +08:00
公明 dda4edb952 Add files via upload 2026-04-15 00:08:35 +08:00
公明 5bf6317dcb Add files via upload 2026-04-14 19:30:39 +08:00
公明 9331fbfea1 Add files via upload 2026-04-14 19:28:17 +08:00
公明 b1ac985c28 Add files via upload 2026-04-14 19:06:52 +08:00
公明 4f4a725034 Add files via upload 2026-04-14 19:02:28 +08:00
公明 3e689a5dcb Add files via upload 2026-04-14 12:53:49 +08:00
公明 de18ae5b0f Add files via upload 2026-04-14 10:36:50 +08:00
公明 517906207a Update config.yaml 2026-04-14 10:31:19 +08:00
公明 7407d6822f Add files via upload 2026-04-14 10:30:40 +08:00
公明 24344cafdb Update config.yaml 2026-04-13 23:52:58 +08:00
公明 a5b95d5b2e Add files via upload 2026-04-13 23:52:07 +08:00
公明 49cd0166f8 Add files via upload 2026-04-13 23:50:34 +08:00
公明 a834231342 Add files via upload 2026-04-13 23:38:27 +08:00
公明 20a498455e Add files via upload 2026-04-13 23:33:02 +08:00
公明 f4028ae66f Add files via upload 2026-04-13 23:17:01 +08:00
公明 0a5bb1eab4 Add files via upload 2026-04-13 23:11:02 +08:00
公明 d4f2b0f93d Update version to v1.4.14 in config.yaml 2026-04-13 21:33:41 +08:00
公明 1fb8cc2fbc Add files via upload 2026-04-13 18:11:04 +08:00
公明 3ddf280400 Add files via upload 2026-04-13 17:53:55 +08:00
公明 961deb81dd Add files via upload 2026-04-10 16:46:44 +08:00
公明 ae3bc41c88 Add files via upload 2026-04-10 16:44:49 +08:00
公明 bb9e3f9477 Update version to v1.4.13 in config.yaml 2026-04-09 21:45:39 +08:00
公明 a57720fb29 Add files via upload 2026-04-09 21:40:43 +08:00
公明 9e34b480e7 Add files via upload 2026-04-09 21:34:26 +08:00
公明 cd30953a84 Add files via upload 2026-04-09 20:44:17 +08:00
公明 a273d6d7ba Update config.yaml 2026-04-09 20:16:07 +08:00
公明 87d9e50781 Add files via upload 2026-04-09 20:15:07 +08:00
公明 54b9e2e2fa Add files via upload 2026-04-09 20:11:25 +08:00
公明 946d347dc9 Add files via upload 2026-04-09 11:03:55 +08:00
公明 ed8c0b15dd Add files via upload 2026-04-09 11:01:26 +08:00
公明 f658cc6e93 Add files via upload 2026-04-08 23:43:20 +08:00
公明 7bf0697526 Add files via upload 2026-04-08 22:15:25 +08:00
公明 7e8cc3e2b8 Add files via upload 2026-04-08 22:11:36 +08:00
公明 0183d9f15f Add files via upload 2026-04-08 18:14:22 +08:00
45 changed files with 6717 additions and 592 deletions
+3 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.10"
version: "v1.4.18"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -34,7 +34,9 @@ log:
# - DeepSeek: https://api.deepseek.com/v1
# - 其他兼容 OpenAI 协议的 API
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
openai:
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
api_key: sk-xxxxxx # API 密钥(必填)
model: qwen3-max # 模型名称(必填)
+5 -4
View File
@@ -9,8 +9,8 @@ toolchain go1.24.4
require (
github.com/bytedance/sonic v1.15.0
github.com/cloudwego/eino v0.8.4
github.com/cloudwego/eino-ext/components/model/openai v0.1.10
github.com/cloudwego/eino v0.8.8
github.com/cloudwego/eino-ext/components/model/openai v0.1.12
github.com/creack/pty v1.1.24
github.com/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1
@@ -21,6 +21,7 @@ require (
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1
go.uber.org/zap v1.26.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
@@ -33,7 +34,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
@@ -51,7 +52,7 @@ require (
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
+10
View File
@@ -22,10 +22,16 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.8.4 h1:aFKJK82MmPR6dm5y5J7IXivYSvh4HkcXwf18j6vyhmk=
github.com/cloudwego/eino v0.8.4/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino v0.8.8 h1:64NuheQBmxOXe/28Tm85rkBkxXMB5ZhjSu/j0RDFyZU=
github.com/cloudwego/eino v0.8.8/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino-ext/components/model/openai v0.1.10 h1:zVkU4rZUUUUAPEXOGs98n8nsT/NZvQ9zWY0B9h2US7k=
github.com/cloudwego/eino-ext/components/model/openai v0.1.10/go.mod h1:smEeTKXe8uz+HDUBQn0yZhpx7mmOUKFQyguLfjAQ57I=
github.com/cloudwego/eino-ext/components/model/openai v0.1.12 h1:vcwNXeT7bpaXMNwUhtcHZwMYY8II2jAihuooyivmEZ0=
github.com/cloudwego/eino-ext/components/model/openai v0.1.12/go.mod h1:ve/+/hLZMvxD5AieQ355xHIFhAZVlsG4rdwTnE16aQU=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 h1:yOZII6VYaL00CVZYba+HUixFygsW0Xz/1QjQ5htj1Ls=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -113,6 +119,8 @@ github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
@@ -136,6 +144,8 @@ github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/Q
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+21 -8
View File
@@ -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,9 @@ 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/metadata", agentHandler.UpdateBatchQueueMetadata)
protected.PUT("/batch-tasks/:queueId/schedule", agentHandler.UpdateBatchQueueSchedule)
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 +685,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 +693,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 +703,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 +1343,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 +1435,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 +1532,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 +1574,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",
+1
View File
@@ -129,6 +129,7 @@ type MCPConfig struct {
}
type OpenAIConfig struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude"claude 时自动桥接为 Anthropic Messages API
APIKey string `yaml:"api_key" json:"api_key"`
BaseURL string `yaml:"base_url" json:"base_url"`
Model string `yaml:"model" json:"model"`
+166 -31
View File
@@ -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,119 @@ func (db *DB) UpdateBatchQueueCurrentIndex(queueID string, currentIndex int) err
return nil
}
// UpdateBatchQueueMetadata 更新批量任务队列标题和角色
func (db *DB) UpdateBatchQueueMetadata(queueID, title, role string) error {
_, err := db.Exec(
"UPDATE batch_task_queues SET title = ?, role = ? WHERE id = ?",
title, role, queueID,
)
if err != nil {
return fmt.Errorf("更新批量任务队列元数据失败: %w", 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, last_run_error = NULL, last_schedule_error = 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 +523,3 @@ func (db *DB) DeleteBatchQueue(queueID string) error {
return tx.Commit()
}
+5 -6
View File
@@ -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,
)
+135 -1
View File
@@ -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_enabled0=暂停 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
}
+29
View File
@@ -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
+13 -3
View File
@@ -108,7 +108,13 @@ func runMCPToolInvocation(
var args map[string]interface{}
if argumentsInJSON != "" && argumentsInJSON != "null" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("invalid tool arguments JSON: %w", err)
// Return soft error (nil error) so the eino graph continues and the LLM can self-correct,
// instead of a hard error that terminates the iteration loop.
return ToolErrorPrefix + fmt.Sprintf(
"Invalid tool arguments JSON: %s\n\nPlease ensure the arguments are a valid JSON object "+
"(double-quoted keys, matched braces, no trailing commas) and retry.\n\n"+
"(工具参数 JSON 解析失败:%s。请确保 arguments 是合法的 JSON 对象并重试。)",
err.Error(), err.Error()), nil
}
}
if args == nil {
@@ -154,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))
}
}
+318 -20
View File
@@ -24,6 +24,7 @@ import (
"cyberstrike-ai/internal/skills"
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
"go.uber.org/zap"
)
@@ -81,11 +82,14 @@ type AgentHandler struct {
}
skillsManager *skills.Manager // Skills管理器
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
batchCronParser cron.Parser
batchRunnerMu sync.Mutex
batchRunning map[string]struct{}
}
// NewAgentHandler 创建新的Agent处理器
func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, logger *zap.Logger) *AgentHandler {
batchTaskManager := NewBatchTaskManager()
batchTaskManager := NewBatchTaskManager(logger)
batchTaskManager.SetDB(db)
// 从数据库加载所有批量任务队列
@@ -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/doneresponse 正文已在 messages 表)
// response_start/response_delta 已聚合为 planning,不落逐条。
if assistantMessageID != "" &&
@@ -1557,9 +1583,27 @@ 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 时必填
ExecuteNow bool `json:"executeNow,omitempty"` // 创建后是否立即执行(默认 false)
}
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,10 +1632,49 @@ 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, createErr := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, agentMode, scheduleMode, cronExpr, nextRunAt, validTasks)
if createErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": createErr.Error()})
return
}
started := false
if req.ExecuteNow {
ok, err := h.startBatchQueueExecution(queue.ID, false)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "queueId": queue.ID})
return
}
started = true
if refreshed, exists := h.batchTaskManager.GetBatchQueue(queue.ID); exists {
queue = refreshed
}
}
c.JSON(http.StatusOK, gin.H{
"queueId": queue.ID,
"queue": queue,
"started": started,
})
}
@@ -1642,6 +1725,11 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
if offset < 0 {
offset = 0
}
// 防止恶意大 offset 导致 DB 性能问题
const maxOffset = 100000
if offset > maxOffset {
offset = maxOffset
}
// 默认status为"all"
if status == "" {
@@ -1681,21 +1769,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 +1792,89 @@ func (h *AgentHandler) PauseBatchQueue(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"})
}
// UpdateBatchQueueMetadata 修改批量任务队列的标题和角色
func (h *AgentHandler) UpdateBatchQueueMetadata(c *gin.Context) {
queueID := c.Param("queueId")
var req struct {
Title string `json:"title"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.batchTaskManager.UpdateQueueMetadata(queueID, req.Title, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, _ := h.batchTaskManager.GetBatchQueue(queueID)
c.JSON(http.StatusOK, gin.H{"queue": updated})
}
// UpdateBatchQueueSchedule 修改批量任务队列的调度配置(scheduleMode / cronExpr
func (h *AgentHandler) UpdateBatchQueueSchedule(c *gin.Context) {
queueID := c.Param("queueId")
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
// 仅在非 running 状态下允许修改调度
if queue.Status == "running" {
c.JSON(http.StatusBadRequest, gin.H{"error": "队列正在运行中,无法修改调度配置"})
return
}
var req struct {
ScheduleMode string `json:"scheduleMode"`
CronExpr string `json:"cronExpr"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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
}
h.batchTaskManager.UpdateQueueSchedule(queueID, scheduleMode, cronExpr, nextRunAt)
updated, _ := h.batchTaskManager.GetBatchQueue(queueID)
c.JSON(http.StatusOK, gin.H{"queue": updated})
}
// 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 +1971,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 +2102,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 +2192,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
+392 -82
View File
@@ -9,8 +9,35 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// 批量任务状态常量
const (
BatchQueueStatusPending = "pending"
BatchQueueStatusRunning = "running"
BatchQueueStatusPaused = "paused"
BatchQueueStatusCompleted = "completed"
BatchQueueStatusCancelled = "cancelled"
BatchTaskStatusPending = "pending"
BatchTaskStatusRunning = "running"
BatchTaskStatusCompleted = "completed"
BatchTaskStatusFailed = "failed"
BatchTaskStatusCancelled = "cancelled"
// MaxBatchTasksPerQueue 单个队列最大任务数
MaxBatchTasksPerQueue = 10000
// MaxBatchQueueTitleLen 队列标题最大长度
MaxBatchQueueTitleLen = 200
// MaxBatchQueueRoleLen 角色名最大长度
MaxBatchQueueRoleLen = 100
)
// BatchTask 批量任务项
@@ -27,29 +54,42 @@ 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
logger *zap.Logger
queues map[string]*BatchTaskQueue
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
mu sync.RWMutex
}
// NewBatchTaskManager 创建批量任务管理器
func NewBatchTaskManager() *BatchTaskManager {
func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
if logger == nil {
logger = zap.NewNop()
}
return &BatchTaskManager{
logger: logger,
queues: make(map[string]*BatchTaskQueue),
taskCancels: make(map[string]context.CancelFunc),
}
@@ -63,19 +103,43 @@ 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, error) {
// 输入校验
if utf8.RuneCountInString(title) > MaxBatchQueueTitleLen {
return nil, fmt.Errorf("标题不能超过 %d 个字符", MaxBatchQueueTitleLen)
}
if utf8.RuneCountInString(role) > MaxBatchQueueRoleLen {
return nil, fmt.Errorf("角色名不能超过 %d 个字符", MaxBatchQueueRoleLen)
}
if len(tasks) > MaxBatchTasksPerQueue {
return nil, fmt.Errorf("单个队列最多 %d 条任务", MaxBatchTasksPerQueue)
}
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: BatchQueueStatusPending,
CreatedAt: time.Now(),
CurrentIndex: 0,
}
if queue.ScheduleMode != "cron" {
queue.CronExpr = ""
queue.NextRunAt = nil
}
// 准备数据库保存的任务数据
@@ -89,7 +153,7 @@ func (m *BatchTaskManager) CreateBatchQueue(title, role string, tasks []string)
task := &BatchTask{
ID: taskID,
Message: message,
Status: "pending",
Status: BatchTaskStatusPending,
}
queue.Tasks = append(queue.Tasks, task)
dbTasks = append(dbTasks, map[string]interface{}{
@@ -100,14 +164,22 @@ 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 {
m.logger.Warn("batch queue DB create failed", zap.String("queueId", queueID), zap.Error(err))
}
}
m.queues[queueID] = queue
return queue
return queue, nil
}
// GetBatchQueue 获取批量任务队列
@@ -151,6 +223,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 +237,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 +448,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 +462,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
}
@@ -424,10 +554,10 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
task.ConversationID = conversationID
}
now := time.Now()
if status == "running" && task.StartedAt == nil {
if status == BatchTaskStatusRunning && task.StartedAt == nil {
task.StartedAt = &now
}
if status == "completed" || status == "failed" || status == "cancelled" {
if status == BatchTaskStatusCompleted || status == BatchTaskStatusFailed || status == BatchTaskStatusCancelled {
task.CompletedAt = &now
}
break
@@ -437,7 +567,7 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
// 记录错误但继续(使用内存缓存)
m.logger.Warn("batch task DB status update failed", zap.String("queueId", queueID), zap.String("taskId", taskID), zap.Error(err))
}
}
}
@@ -454,22 +584,176 @@ func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
queue.Status = status
now := time.Now()
if status == "running" && queue.StartedAt == nil {
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
queue.StartedAt = &now
}
if status == "completed" || status == "cancelled" {
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
queue.CompletedAt = &now
}
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
// 记录错误但继续(使用内存缓存)
m.logger.Warn("batch queue DB status update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
}
// UpdateTaskMessage 更新任务消息(仅限待执行状态)
// 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 {
m.logger.Warn("batch queue DB schedule update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
}
// UpdateQueueMetadata 更新队列标题和角色(非 running 时可用)
func (m *BatchTaskManager) UpdateQueueMetadata(queueID, title, role string) error {
if utf8.RuneCountInString(title) > MaxBatchQueueTitleLen {
return fmt.Errorf("标题不能超过 %d 个字符", MaxBatchQueueTitleLen)
}
if utf8.RuneCountInString(role) > MaxBatchQueueRoleLen {
return fmt.Errorf("角色名不能超过 %d 个字符", MaxBatchQueueRoleLen)
}
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return fmt.Errorf("队列不存在")
}
if queue.Status == BatchQueueStatusRunning {
return fmt.Errorf("队列正在运行中,无法修改")
}
queue.Title = title
queue.Role = role
if m.db != nil {
if err := m.db.UpdateBatchQueueMetadata(queueID, title, role); err != nil {
m.logger.Warn("batch queue DB metadata update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
return 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 = BatchQueueStatusPending
queue.CurrentIndex = 0
queue.StartedAt = nil
queue.CompletedAt = nil
queue.NextRunAt = nil
queue.LastRunError = ""
queue.LastScheduleError = ""
for _, task := range queue.Tasks {
task.Status = BatchTaskStatusPending
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 更新任务消息(队列空闲时可改;任务需非 running)
func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -479,17 +763,15 @@ func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) er
return fmt.Errorf("队列不存在")
}
// 检查队列状态,只有待执行状态的队列才能编辑任务
if queue.Status != "pending" {
return fmt.Errorf("只有待执行状态的队列才能编辑任务")
if !queueAllowsTaskListMutationLocked(queue) {
return fmt.Errorf("队列正在执行或未就绪,无法编辑任务")
}
// 查找并更新任务
for _, task := range queue.Tasks {
if task.ID == taskID {
// 只有待执行状态的任务才能编辑
if task.Status != "pending" {
return fmt.Errorf("只有待执行状态的任务才能编辑")
if task.Status == BatchTaskStatusRunning {
return fmt.Errorf("执行中的任务不能编辑")
}
task.Message = message
@@ -506,7 +788,7 @@ func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) er
return fmt.Errorf("任务不存在")
}
// AddTaskToQueue 添加任务到队列(仅限待执行状态
// AddTaskToQueue 添加任务到队列(队列空闲时可添加:含 cron 本轮 completed、手动暂停后等
func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -516,9 +798,8 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
return nil, fmt.Errorf("队列不存在")
}
// 检查队列状态,只有待执行状态的队列才能添加任务
if queue.Status != "pending" {
return nil, fmt.Errorf("只有待执行状态的队列才能添加任务")
if !queueAllowsTaskListMutationLocked(queue) {
return nil, fmt.Errorf("队列正在执行或未就绪,无法添加任务")
}
if message == "" {
@@ -530,7 +811,7 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
task := &BatchTask{
ID: taskID,
Message: message,
Status: "pending",
Status: BatchTaskStatusPending,
}
// 添加到内存队列
@@ -548,7 +829,7 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
return task, nil
}
// DeleteTask 删除任务(仅限待执行状态
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -558,18 +839,16 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
return fmt.Errorf("队列不存在")
}
// 检查队列状态,只有待执行状态的队列才能删除任务
if queue.Status != "pending" {
return fmt.Errorf("只有待执行状态的队列才能删除任务")
if !queueAllowsTaskListMutationLocked(queue) {
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
}
// 查找并删除任务
taskIndex := -1
for i, task := range queue.Tasks {
if task.ID == taskID {
// 只有待执行状态的任务才能删除
if task.Status != "pending" {
return fmt.Errorf("只有待执行状态的任务才能删除")
if task.Status == BatchTaskStatusRunning {
return fmt.Errorf("执行中的任务不能删除")
}
taskIndex = i
break
@@ -595,10 +874,41 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
return nil
}
func queueHasRunningTaskLocked(queue *BatchTaskQueue) bool {
if queue == nil {
return false
}
for _, t := range queue.Tasks {
if t != nil && t.Status == BatchTaskStatusRunning {
return true
}
}
return false
}
// queueAllowsTaskListMutationLocked 是否允许增删改子任务文案/列表(必须在持有 BatchTaskManager.mu 下调用)
func queueAllowsTaskListMutationLocked(queue *BatchTaskQueue) bool {
if queue == nil {
return false
}
if queue.Status == BatchQueueStatusRunning {
return false
}
if queueHasRunningTaskLocked(queue) {
return false
}
switch queue.Status {
case BatchQueueStatusPending, BatchQueueStatusPaused, BatchQueueStatusCompleted, BatchQueueStatusCancelled:
return true
default:
return false
}
}
// GetNextTask 获取下一个待执行的任务
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
@@ -607,7 +917,7 @@ func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
for i := queue.CurrentIndex; i < len(queue.Tasks); i++ {
task := queue.Tasks[i]
if task.Status == "pending" {
if task.Status == BatchTaskStatusPending {
queue.CurrentIndex = i
return task, true
}
@@ -631,7 +941,7 @@ func (m *BatchTaskManager) MoveToNextTask(queueID string) {
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchQueueCurrentIndex(queueID, queue.CurrentIndex); err != nil {
// 记录错误但继续(使用内存缓存)
m.logger.Warn("batch queue DB index update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
}
@@ -650,19 +960,18 @@ func (m *BatchTaskManager) SetTaskCancel(queueID string, cancel context.CancelFu
// PauseQueue 暂停队列
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return false
}
if queue.Status != "running" {
m.mu.Unlock()
if queue.Status != BatchQueueStatusRunning {
return false
}
queue.Status = "paused"
queue.Status = BatchQueueStatusPaused
// 取消当前正在执行的任务(通过取消context)
if cancel, exists := m.taskCancels[queueID]; exists {
@@ -670,12 +979,10 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
delete(m.taskCancels, queueID)
}
m.mu.Unlock()
// 同步队列状态到数据库
// 同步队列状态到数据库(在锁内完成,避免竞态)
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, "paused"); err != nil {
// 记录错误但继续(使用内存缓存)
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusPaused); err != nil {
m.logger.Warn("batch queue DB pause update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
@@ -685,30 +992,30 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
// CancelQueue 取消队列(保留此方法以保持向后兼容,但建议使用PauseQueue)
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return false
}
if queue.Status == "completed" || queue.Status == "cancelled" {
m.mu.Unlock()
if queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled {
return false
}
queue.Status = "cancelled"
queue.Status = BatchQueueStatusCancelled
now := time.Now()
queue.CompletedAt = &now
// 取消所有待执行的任务
for _, task := range queue.Tasks {
if task.Status == "pending" {
task.Status = "cancelled"
if task.Status == BatchTaskStatusPending {
task.Status = BatchTaskStatusCancelled
task.CompletedAt = &now
// 同步到数据库
if m.db != nil {
m.db.UpdateBatchTaskStatus(queueID, task.ID, "cancelled", "", "", "")
if err := m.db.UpdateBatchTaskStatus(queueID, task.ID, BatchTaskStatusCancelled, "", "", ""); err != nil {
m.logger.Warn("batch task DB cancel update failed", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
}
}
@@ -719,35 +1026,38 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
delete(m.taskCancels, queueID)
}
m.mu.Unlock()
// 同步队列状态到数据库
// 同步队列状态到数据库(在锁内完成)
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, "cancelled"); err != nil {
// 记录错误但继续(使用内存缓存)
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusCancelled); err != nil {
m.logger.Warn("batch queue DB cancel update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
return true
}
// DeleteQueue 删除队列
// DeleteQueue 删除队列(运行中的队列不允许删除)
func (m *BatchTaskManager) DeleteQueue(queueID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, exists := m.queues[queueID]
queue, exists := m.queues[queueID]
if !exists {
return false
}
// 运行中的队列不允许删除,防止孤儿协程和数据丢失
if queue.Status == BatchQueueStatusRunning {
return false
}
// 清理取消函数
delete(m.taskCancels, queueID)
// 从数据库删除
if m.db != nil {
if err := m.db.DeleteBatchQueue(queueID); err != nil {
// 记录错误但继续(使用内存缓存)
m.logger.Warn("batch queue DB delete failed", zap.String("queueId", queueID), zap.Error(err))
}
}
+766
View File
@@ -0,0 +1,766 @@
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/status/截断后的 message、各状态计数。完整子任务(含 result/error/conversationId/时间等)请用 batch_task_get(queue_id)。",
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
if offset > 100000 {
offset = 100000
}
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
}
slim := make([]batchTaskQueueMCPListItem, 0, len(queues))
for _, q := range queues {
if q == nil {
continue
}
slim = append(slim, toBatchTaskQueueMCPListItem(q))
}
payload := map[string]interface{}{
"queues": slim,
"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 * * *"
默认创建后不会立即执行可通过 execute_now=true 在创建后立即启动也可后续调用 batch_task_start 手工启动Cron 队列若需按表达式自动触发下一轮还需保持调度开关开启可用 batch_task_schedule_enabled`,
ShortDescription: "创建批量任务队列(可选立即执行)",
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 时必填。标准 5 段格式:分钟 小时 日 月 星期,例如 \"0 */6 * * *\"(每6小时)、\"30 2 * * 1-5\"(工作日凌晨2:30",
},
"execute_now": map[string]interface{}{
"type": "boolean",
"description": "是否创建后立即执行,默认 false",
},
},
},
}, 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
}
executeNow, ok := mcpArgBool(args, "execute_now")
if !ok {
executeNow = false
}
queue, createErr := h.batchTaskManager.CreateBatchQueue(title, role, agentMode, scheduleMode, cronExpr, nextRunAt, tasks)
if createErr != nil {
return batchMCPTextResult("创建队列失败: "+createErr.Error(), true), nil
}
started := false
if executeNow {
ok, err := h.startBatchQueueExecution(queue.ID, false)
if !ok {
return batchMCPTextResult("队列不存在: "+queue.ID, true), nil
}
if err != nil {
return batchMCPTextResult("创建成功但启动失败: "+err.Error(), true), nil
}
started = true
if refreshed, exists := h.batchTaskManager.GetBatchQueue(queue.ID); exists {
queue = refreshed
}
}
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,
"started": started,
"execute_now": executeNow,
"reminder": func() string {
if started {
return "队列已创建并立即启动。"
}
return "队列已创建,当前为 pending。需要开始执行时请调用 MCP 工具 batch_task_startqueue_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
})
// --- update metadata (title/role) ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskUpdateMetadata,
Description: "修改批量任务队列的标题和角色。仅在队列非 running 状态下可修改。",
ShortDescription: "修改批量任务队列标题/角色",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"queue_id": map[string]interface{}{
"type": "string",
"description": "队列 ID",
},
"title": map[string]interface{}{
"type": "string",
"description": "新标题(空字符串清除标题)",
},
"role": map[string]interface{}{
"type": "string",
"description": "新角色名(空字符串使用默认角色)",
},
},
"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
}
title := mcpArgString(args, "title")
role := mcpArgString(args, "role")
if err := h.batchTaskManager.UpdateQueueMetadata(qid, title, role); err != nil {
return batchMCPTextResult(err.Error(), true), nil
}
updated, _ := h.batchTaskManager.GetBatchQueue(qid)
logger.Info("MCP batch_task_update_metadata", zap.String("queueId", qid))
return batchMCPJSONResult(updated)
})
// --- update schedule ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskUpdateSchedule,
Description: `修改批量任务队列的调度方式和 Cron 表达式仅在队列非 running 状态下可修改
schedule_mode cron 时必须提供有效 cron_expr manual 时会清除 Cron 配置`,
ShortDescription: "修改批量任务调度配置(Cron 表达式)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"queue_id": map[string]interface{}{
"type": "string",
"description": "队列 ID",
},
"schedule_mode": map[string]interface{}{
"type": "string",
"description": "manual 或 cron",
"enum": []string{"manual", "cron"},
},
"cron_expr": map[string]interface{}{
"type": "string",
"description": "Cron 表达式(schedule_mode 为 cron 时必填)。标准 5 段格式:分钟 小时 日 月 星期,如 \"0 */6 * * *\"(每6小时)、\"30 2 * * 1-5\"(工作日凌晨2:30",
},
},
"required": []string{"queue_id", "schedule_mode"},
},
}, 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, exists := h.batchTaskManager.GetBatchQueue(qid)
if !exists {
return batchMCPTextResult("队列不存在: "+qid, true), nil
}
if queue.Status == "running" {
return batchMCPTextResult("队列正在运行中,无法修改调度配置", true), nil
}
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
}
h.batchTaskManager.UpdateQueueSchedule(qid, scheduleMode, cronExpr, nextRunAt)
updated, _ := h.batchTaskManager.GetBatchQueue(qid)
logger.Info("MCP batch_task_update_schedule", zap.String("queueId", qid), zap.String("scheduleMode", scheduleMode), zap.String("cronExpr", cronExpr))
return batchMCPJSONResult(updated)
})
// --- 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", 12))
}
// --- batch_task_list 精简结构(避免把每条子任务的 result 等大段文本塞进列表上下文) ---
const mcpBatchListTaskMessageMaxRunes = 160
// batchTaskMCPListSummary 列表中的子任务摘要(完整字段用 batch_task_get
type batchTaskMCPListSummary struct {
ID string `json:"id"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
// batchTaskQueueMCPListItem 列表中的队列摘要
type batchTaskQueueMCPListItem struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Role string `json:"role,omitempty"`
AgentMode string `json:"agentMode"`
ScheduleMode string `json:"scheduleMode"`
CronExpr string `json:"cronExpr,omitempty"`
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
ScheduleEnabled bool `json:"scheduleEnabled"`
LastScheduleTriggerAt *time.Time `json:"lastScheduleTriggerAt,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CurrentIndex int `json:"currentIndex"`
TaskTotal int `json:"task_total"`
TaskCounts map[string]int `json:"task_counts"`
Tasks []batchTaskMCPListSummary `json:"tasks"`
}
func truncateStringRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
n := 0
for i := range s {
if n == maxRunes {
out := strings.TrimSpace(s[:i])
if out == "" {
return "…"
}
return out + "…"
}
n++
}
return s
}
const mcpBatchListMaxTasksPerQueue = 200 // 列表中每个队列最多返回的子任务摘要数
func toBatchTaskQueueMCPListItem(q *BatchTaskQueue) batchTaskQueueMCPListItem {
counts := map[string]int{
"pending": 0,
"running": 0,
"completed": 0,
"failed": 0,
"cancelled": 0,
}
tasks := make([]batchTaskMCPListSummary, 0, len(q.Tasks))
for _, t := range q.Tasks {
if t == nil {
continue
}
counts[t.Status]++
// 列表视图限制子任务摘要数量,完整列表通过 batch_task_get 查看
if len(tasks) < mcpBatchListMaxTasksPerQueue {
tasks = append(tasks, batchTaskMCPListSummary{
ID: t.ID,
Status: t.Status,
Message: truncateStringRunes(t.Message, mcpBatchListTaskMessageMaxRunes),
})
}
}
return batchTaskQueueMCPListItem{
ID: q.ID,
Title: q.Title,
Role: q.Role,
AgentMode: q.AgentMode,
ScheduleMode: q.ScheduleMode,
CronExpr: q.CronExpr,
NextRunAt: q.NextRunAt,
ScheduleEnabled: q.ScheduleEnabled,
LastScheduleTriggerAt: q.LastScheduleTriggerAt,
Status: q.Status,
CreatedAt: q.CreatedAt,
StartedAt: q.StartedAt,
CompletedAt: q.CompletedAt,
CurrentIndex: q.CurrentIndex,
TaskTotal: len(tasks),
TaskCounts: counts,
Tasks: tasks,
}
}
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
}
+160
View File
@@ -16,6 +16,7 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
@@ -35,6 +36,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 +70,7 @@ type ConfigHandler struct {
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
batchTaskToolRegistrar BatchTaskToolRegistrar // 批量任务 MCP 工具(可选)
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
appUpdater AppUpdater // App更新器(可选)
@@ -139,6 +144,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()
@@ -312,6 +324,17 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
searchTermLower = strings.ToLower(searchTerm)
}
// 解析状态筛选参数: "true" = 仅已启用, "false" = 仅已停用, "" = 全部
enabledFilter := c.Query("enabled")
var filterEnabled *bool
if enabledFilter == "true" {
v := true
filterEnabled = &v
} else if enabledFilter == "false" {
v := false
filterEnabled = &v
}
// 解析角色参数,用于过滤工具并标注启用状态
roleName := c.Query("role")
var roleToolsSet map[string]bool // 角色配置的工具集合
@@ -375,6 +398,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
}
}
// 状态筛选
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
continue
}
allTools = append(allTools, toolInfo)
}
@@ -431,6 +459,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
}
}
// 状态筛选
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
continue
}
allTools = append(allTools, toolInfo)
}
}
@@ -473,6 +506,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
}
}
// 状态筛选
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
continue
}
allTools = append(allTools, toolInfo)
}
}
@@ -754,6 +792,115 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
}
// TestOpenAIRequest 测试OpenAI连接请求
type TestOpenAIRequest struct {
Provider string `json:"provider"`
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 == "" {
if strings.EqualFold(strings.TrimSpace(req.Provider), "claude") {
baseURL = "https://api.anthropic.com"
} else {
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,
}
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
tmpCfg := &config.OpenAIConfig{
Provider: req.Provider,
BaseURL: baseURL,
APIKey: strings.TrimSpace(req.APIKey),
Model: req.Model,
}
client := openai.NewClient(tmpCfg, nil, h.logger)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
start := time.Now()
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"`
}
err := client.ChatCompletion(ctx, payload, &chatResp)
latency := time.Since(start)
if err != nil {
if apiErr, ok := err.(*openai.APIError); ok {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body),
"status_code": apiErr.StatusCode,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "连接失败: " + err.Error(),
})
return
}
// 严格校验:必须包含 choices 且有 assistant 回复
if len(chatResp.Choices) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确",
})
return
}
if chatResp.ID == "" && chatResp.Model == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "API 响应格式不符合预期,请检查 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 +1013,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("重新注册知识库工具")
@@ -1092,6 +1249,9 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
root := doc.Content[0]
openaiNode := ensureMap(root, "openai")
if cfg.Provider != "" {
setStringInMap(openaiNode, "provider", cfg.Provider)
}
setStringInMap(openaiNode, "api_key", cfg.APIKey)
setStringInMap(openaiNode, "base_url", cfg.BaseURL)
setStringInMap(openaiNode, "model", cfg.Model)
+12
View File
@@ -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"`
+35
View File
@@ -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()
+25 -3
View File
@@ -403,6 +403,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "角色名称(可选)",
},
"agentMode": map[string]interface{}{
"type": "string",
"description": "代理模式(single | multi",
"enum": []string{"single", "multi"},
},
"scheduleMode": map[string]interface{}{
"type": "string",
"description": "调度方式(manual | cron",
"enum": []string{"manual", "cron"},
},
"cronExpr": map[string]interface{}{
"type": "string",
"description": "Cron 表达式(scheduleMode=cron 时必填)",
},
"executeNow": map[string]interface{}{
"type": "boolean",
"description": "是否创建后立即执行(默认 false)",
},
},
},
"BatchQueue": map[string]interface{}{
@@ -1540,9 +1558,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"message": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
},
"required": []string{"message"},
@@ -1711,6 +1729,10 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"queue": map[string]interface{}{
"$ref": "#/components/schemas/BatchQueue",
},
"started": map[string]interface{}{
"type": "boolean",
"description": "是否已立即启动执行",
},
},
},
},
+20 -3
View File
@@ -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
+45 -7
View File
@@ -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,20 @@ 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"
ToolBatchTaskUpdateMetadata = "batch_task_update_metadata"
ToolBatchTaskUpdateSchedule = "batch_task_update_schedule"
ToolBatchTaskScheduleEnabled = "batch_task_schedule_enabled"
ToolBatchTaskAdd = "batch_task_add_task"
ToolBatchTaskUpdate = "batch_task_update_task"
ToolBatchTaskRemove = "batch_task_remove_task"
)
// IsBuiltinTool 检查工具名称是否是内置工具
@@ -44,7 +58,19 @@ func IsBuiltinTool(toolName string) bool {
ToolManageWebshellAdd,
ToolManageWebshellUpdate,
ToolManageWebshellDelete,
ToolManageWebshellTest:
ToolManageWebshellTest,
ToolBatchTaskList,
ToolBatchTaskGet,
ToolBatchTaskCreate,
ToolBatchTaskStart,
ToolBatchTaskPause,
ToolBatchTaskDelete,
ToolBatchTaskUpdateMetadata,
ToolBatchTaskUpdateSchedule,
ToolBatchTaskScheduleEnabled,
ToolBatchTaskAdd,
ToolBatchTaskUpdate,
ToolBatchTaskRemove:
return true
default:
return false
@@ -68,5 +94,17 @@ func GetAllBuiltinTools() []string {
ToolManageWebshellUpdate,
ToolManageWebshellDelete,
ToolManageWebshellTest,
ToolBatchTaskList,
ToolBatchTaskGet,
ToolBatchTaskCreate,
ToolBatchTaskStart,
ToolBatchTaskPause,
ToolBatchTaskDelete,
ToolBatchTaskUpdateMetadata,
ToolBatchTaskUpdateSchedule,
ToolBatchTaskScheduleEnabled,
ToolBatchTaskAdd,
ToolBatchTaskUpdate,
ToolBatchTaskRemove,
}
}
+184 -29
View File
@@ -19,6 +19,7 @@ import (
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
@@ -36,6 +37,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,
@@ -131,6 +142,9 @@ func RunDeepAgent(
},
}
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
baseModelCfg := &einoopenai.ChatModelConfig{
APIKey: appCfg.OpenAI.APIKey,
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
@@ -223,6 +237,9 @@ func RunDeepAgent(
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: subTools,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{
{Invokable: softRecoveryToolCallMiddleware()},
},
},
EmitInternalEvents: true,
},
@@ -278,6 +295,9 @@ func RunDeepAgent(
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: mainTools,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{
{Invokable: softRecoveryToolCallMiddleware()},
},
},
EmitInternalEvents: true,
},
@@ -302,34 +322,20 @@ func RunDeepAgent(
var lastRunMsgs []adk.Message
var lastAssistant string
// retryHints tracks the corrective hint to append for each retry attempt.
// Index i corresponds to the hint that will be appended on attempt i+1.
var retryHints []adk.Message
attemptLoop:
for attempt := 0; attempt < maxToolCallArgumentsJSONAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+attempt)
for attempt := 0; attempt < maxToolCallRecoveryAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+len(retryHints))
msgs = append(msgs, baseMsgs...)
for i := 0; i < attempt; i++ {
msgs = append(msgs, toolCallArgumentsJSONRetryHint())
}
msgs = append(msgs, retryHints...)
if attempt > 0 {
mcpIDsMu.Lock()
mcpIDs = mcpIDs[:0]
mcpIDsMu.Unlock()
if logger != nil {
logger.Warn("eino DeepAgent: 工具参数 JSON 被接口拒绝,追加提示后重试",
zap.Int("attempt", attempt),
zap.Int("maxAttempts", maxToolCallArgumentsJSONAttempts))
}
if progress != nil {
// 使用专用事件类型 eino_recovery,便于前端时间线展示(progress 仅改标题,不进时间线)
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1, // 第几轮完整运行(1 为首次,重试后递增)
"maxRuns": maxToolCallArgumentsJSONAttempts,
"reason": "invalid_tool_arguments_json",
})
}
}
// 仅保留主代理最后一次 assistant 输出;每轮重试重置,避免拼接失败轮次的片段。
@@ -340,6 +346,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,
@@ -357,12 +426,52 @@ attemptLoop:
continue
}
if ev.Err != nil {
if isRecoverableToolCallArgumentsJSONError(ev.Err) && attempt+1 < maxToolCallArgumentsJSONAttempts {
canRetry := attempt+1 < maxToolCallRecoveryAttempts
// Recoverable: API-level JSON argument validation error.
if canRetry && isRecoverableToolCallArgumentsJSONError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
}
retryHints = append(retryHints, toolCallArgumentsJSONRetryHint())
if progress != nil {
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "invalid_tool_arguments_json",
})
}
continue attemptLoop
}
// Recoverable: tool execution error (unknown sub-agent, tool not found, bad JSON in args, etc.).
if canRetry && isRecoverableToolExecutionError(ev.Err) {
if logger != nil {
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{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "tool_execution_error",
})
}
continue attemptLoop
}
// Non-recoverable error.
flushAllPendingAsFailed(ev.Err)
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
"conversationId": conversationID,
@@ -513,7 +622,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
}
@@ -521,7 +630,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) != "" {
@@ -589,8 +698,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)
}
@@ -733,7 +865,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
}
@@ -745,10 +884,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
}
@@ -797,6 +942,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,
+4 -3
View File
@@ -7,9 +7,10 @@ import (
"github.com/cloudwego/eino/schema"
)
// maxToolCallArgumentsJSONAttempts 含首次运行:首次 + 自动重试次数。
// maxToolCallRecoveryAttempts 含首次运行:首次 + 自动重试次数。
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
const maxToolCallArgumentsJSONAttempts = 3
// 该常量同时用于 JSON 参数错误和工具执行错误(如子代理名称不存在)的恢复重试。
const maxToolCallRecoveryAttempts = 5
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
func toolCallArgumentsJSONRetryHint() *schema.Message {
@@ -24,7 +25,7 @@ func toolCallArgumentsJSONRecoveryTimelineMessage(attempt int) string {
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"The API rejected invalid JSON in tool arguments. A system hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallArgumentsJSONAttempts, attempt+1, maxToolCallArgumentsJSONAttempts,
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
@@ -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
}
@@ -0,0 +1,76 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// isRecoverableToolExecutionError detects tool-level execution errors that can be
// recovered by retrying with a corrective hint. These errors originate from eino
// framework internals (e.g. task_tool.go, tool_node.go) when the LLM produces
// invalid tool calls such as non-existent sub-agent types, malformed JSON arguments,
// or unregistered tool names.
func isRecoverableToolExecutionError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// 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 (from compose/tool_node.go, when UnknownToolsHandler is nil)
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
return true
}
// Invalid tool arguments JSON (from einomcp/mcp_tools.go or eino internals)
if strings.Contains(s, "invalid tool arguments json") {
return true
}
// Failed to unmarshal task tool input json (from deep/task_tool.go)
if strings.Contains(s, "failed to unmarshal") && strings.Contains(s, "json") {
return true
}
// Generic tool call stream/invoke failure wrapping the above
if (strings.Contains(s, "failed to stream tool call") || strings.Contains(s, "failed to invoke tool")) &&
(strings.Contains(s, "not found") || strings.Contains(s, "json") || strings.Contains(s, "unmarshal")) {
return true
}
return false
}
// toolExecutionRetryHint returns a user message appended to the conversation to prompt
// the LLM to correct its tool call after a tool execution error.
func toolExecutionRetryHint() *schema.Message {
return schema.UserMessage(`[System] Your previous tool call failed because:
- The tool or sub-agent name you used does not exist, OR
- The tool call arguments were not valid JSON.
Please carefully review the available tools and sub-agents listed in your context, use only exact registered names (case-sensitive), and ensure all arguments are well-formed JSON objects. Then retry your action.
[系统提示] 上一次工具调用失败可能原因
- 你使用的工具名或子代理名称不存在
- 工具调用参数不是合法 JSON
请仔细检查上下文中列出的可用工具和子代理名称须完全匹配区分大小写确保所有参数均为合法的 JSON 对象然后重新执行`)
}
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
// displayed in the UI timeline when a tool execution error triggers a retry.
func toolExecutionRecoveryTimelineMessage(attempt int) string {
return fmt.Sprintf(
"工具调用执行失败(工具/子代理名称不存在或参数 JSON 无效)。已向对话追加纠错提示并要求模型重新生成。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"Tool call execution failed (unknown tool/sub-agent name or invalid JSON arguments). "+
"A corrective hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -64,6 +64,9 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
if strings.TrimSpace(c.config.APIKey) == "" {
return fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return c.claudeChatCompletion(ctx, payload, out)
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
@@ -156,6 +159,9 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
if strings.TrimSpace(c.config.APIKey) == "" {
return "", fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return c.claudeChatCompletionStream(ctx, payload, onDelta)
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
@@ -294,6 +300,9 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
if strings.TrimSpace(c.config.APIKey) == "" {
return "", nil, "", fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return c.claudeChatCompletionStreamWithToolCalls(ctx, payload, onContentDelta)
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
@@ -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();
}
}
@@ -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"
+293
View File
@@ -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_keyQuake 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
+403
View File
@@ -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_keyShodan 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
}
# 可选参数 facetssearch 和 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"
+1111 -28
View File
File diff suppressed because it is too large Load Diff
+52 -3
View File
@@ -251,9 +251,18 @@
"clearHistory": "Clear history",
"cancelTask": "Cancel task",
"viewConversation": "View conversation",
"retryTask": "Retry",
"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 +285,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 +295,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",
@@ -498,6 +508,8 @@
"toolSearchPlaceholder": "Enter tool name...",
"statusFilter": "Status filter",
"filterAll": "All",
"filterEnabled": "Enabled",
"filterDisabled": "Disabled",
"selectedCount": "{{count}} selected",
"selectAll": "Select all",
"deselectAll": "Deselect all",
@@ -1302,7 +1314,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 +1506,21 @@
"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",
"cronExprInvalid": "Invalid Cron expression format. Must have 5 fields (minute hour day month weekday), e.g.: 0 */2 * * *",
"executeNow": "Run immediately after creation",
"executeNowHint": "Default is off. When disabled, the queue stays pending and can be started manually later.",
"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",
@@ -1499,6 +1532,8 @@
"title": "Batch queue details",
"addTask": "Add task",
"startExecute": "Start",
"startExecuteNow": "Run now (one round)",
"startExecuteNowConfirm": "This is a Cron queue. Clicking Start will run the current round immediately instead of waiting for the next Cron time. Continue?",
"pauseQueue": "Pause queue",
"deleteQueue": "Delete queue",
"queueTitle": "Task title",
@@ -1508,13 +1543,27 @@
"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.",
"editSchedule": "Edit Schedule",
"editScheduleTitle": "Edit Schedule Configuration",
"editScheduleSuccess": "Schedule updated",
"editScheduleError": "Failed to update schedule",
"editMetadata": "Edit Info",
"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",
+52 -3
View File
@@ -251,9 +251,18 @@
"clearHistory": "清空历史",
"cancelTask": "取消任务",
"viewConversation": "查看对话",
"retryTask": "重试",
"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 +285,7 @@
"deleteQueueConfirm": "确定要删除这个批量任务队列吗?此操作不可恢复。",
"deleteQueueFailed": "删除批量任务队列失败",
"batchQueueTitle": "批量任务队列",
"batchQueueUntitled": "未命名队列",
"resumeExecute": "继续执行",
"taskIncomplete": "任务信息不完整",
"cannotGetTaskMessageInput": "无法获取任务消息输入框",
@@ -285,7 +295,7 @@
"addTaskFailed": "添加任务失败",
"confirmDeleteTask": "确定要删除这个任务吗?\n\n任务内容: {{message}}\n\n此操作不可恢复。",
"deleteTaskFailed": "删除任务失败",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
"paginationPerPage": "每页显示",
"paginationFirst": "首页",
"paginationPrev": "上一页",
@@ -498,6 +508,8 @@
"toolSearchPlaceholder": "输入工具名称...",
"statusFilter": "状态筛选",
"filterAll": "全部",
"filterEnabled": "已启用",
"filterDisabled": "已停用",
"selectedCount": "已选择 {{count}} 项",
"selectAll": "全选",
"deselectAll": "全不选",
@@ -1302,7 +1314,13 @@
"maxRetriesHint": "最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试",
"retryDelay": "重试间隔(毫秒)",
"retryDelayPlaceholder": "1000",
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟"
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟",
"testConnection": "测试连接",
"testFillRequired": "请先填写 API Key 和模型",
"testing": "测试中...",
"testSuccess": "连接成功",
"testFailed": "连接失败",
"testError": "测试出错"
},
"settingsTerminal": {
"title": "终端",
@@ -1488,6 +1506,21 @@
"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 表达式",
"cronExprInvalid": "Cron 表达式格式错误,需要 5 段(分 时 日 月 周),例如:0 */2 * * *",
"executeNow": "创建后立即执行",
"executeNowHint": "默认不立即执行;关闭后队列保持待执行,可在需要时手动开始。",
"tasksList": "任务列表(每行一个任务)",
"tasksListPlaceholder": "请输入任务列表,每行一个任务",
"tasksListPlaceholderExample": "请输入任务列表,每行一个任务,例如:\n扫描 192.168.1.1 的开放端口\n检查 https://example.com 是否存在SQL注入\n枚举 example.com 的子域名",
@@ -1499,6 +1532,8 @@
"title": "批量任务队列详情",
"addTask": "添加任务",
"startExecute": "开始执行",
"startExecuteNow": "立即执行一轮",
"startExecuteNowConfirm": "这是 Cron 队列,点击后会立即执行当前这一轮,不会等待下次 Cron 时间。确定立即执行吗?",
"pauseQueue": "暂停队列",
"deleteQueue": "删除队列",
"queueTitle": "任务标题",
@@ -1508,13 +1543,27 @@
"status": "状态",
"createdAt": "创建时间",
"startedAt": "开始时间",
"nextRunAt": "下次执行时间",
"scheduleCronAuto": "允许 Cron 自动调度",
"scheduleCronAutoHint": "关闭后仅保留表达式配置,不会按时间自动跑;可随时手工点「开始执行」。",
"editSchedule": "修改调度",
"editScheduleTitle": "修改调度配置",
"editScheduleSuccess": "调度配置已更新",
"editScheduleError": "更新调度配置失败",
"editMetadata": "编辑信息",
"lastScheduleTriggerAt": "最近调度触发时间",
"lastScheduleError": "最近调度失败原因",
"lastRunError": "最近运行失败摘要",
"cronSchedulePausedBadge": "调度已暂停",
"scheduleToggleFailed": "更新调度开关失败",
"completedAt": "完成时间",
"taskTotal": "任务总数",
"taskList": "任务列表",
"startLabel": "开始",
"completeLabel": "完成",
"errorLabel": "错误",
"resultLabel": "结果"
"resultLabel": "结果",
"technicalDetails": "技术信息(ID / 时间 / 调度)"
},
"editBatchTaskModal": {
"title": "编辑任务",
+102 -78
View File
@@ -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) {
+47 -4
View File
@@ -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(() => {
+3 -1
View File
@@ -232,7 +232,9 @@ function showSubmenuPopup(navItem, menuId) {
}
// 初始化页面
function initPage(pageId) {
async function initPage(pageId) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
if (window.i18nReady) await window.i18nReady;
switch(pageId) {
case 'dashboard':
if (typeof refreshDashboard === 'function') {
+82 -4
View File
@@ -102,6 +102,10 @@ async function loadConfig(loadTools = true) {
currentConfig = await response.json();
// 填充OpenAI配置
const providerEl = document.getElementById('openai-provider');
if (providerEl) {
providerEl.value = currentConfig.openai.provider || 'openai';
}
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
document.getElementById('openai-model').value = currentConfig.openai.model || '';
@@ -125,8 +129,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');
@@ -275,10 +277,15 @@ async function loadConfig(loadTools = true) {
// 工具搜索关键词
let toolsSearchKeyword = '';
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
let toolsStatusFilter = '';
// 加载工具列表(分页)
async function loadToolsList(page = 1, searchKeyword = '') {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
if (window.i18nReady) await window.i18nReady;
const toolsList = document.getElementById('tools-list');
// 显示加载状态
if (toolsList) {
// 清空整个容器,包括可能存在的分页控件
@@ -294,6 +301,9 @@ async function loadToolsList(page = 1, searchKeyword = '') {
if (searchKeyword) {
url += `&search=${encodeURIComponent(searchKeyword)}`;
}
if (toolsStatusFilter !== '') {
url += `&enabled=${toolsStatusFilter}`;
}
// 使用较短的超时时间(10秒),避免长时间等待
const controller = new AbortController();
@@ -389,6 +399,17 @@ function handleSearchKeyPress(event) {
}
}
// 按状态筛选工具
function filterToolsByStatus(status) {
toolsStatusFilter = status;
// 更新按钮激活状态
document.querySelectorAll('.tools-status-filter .btn-filter').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === status);
});
// 重置到第一页并重新加载
loadToolsList(1, toolsSearchKeyword);
}
// 渲染工具列表
function renderToolsList() {
const toolsList = document.getElementById('tools-list');
@@ -736,6 +757,7 @@ async function applySettings() {
});
// 验证必填字段
const provider = document.getElementById('openai-provider')?.value || 'openai';
const apiKey = document.getElementById('openai-api-key').value.trim();
const baseUrl = document.getElementById('openai-base-url').value.trim();
const model = document.getElementById('openai-model').value.trim();
@@ -804,6 +826,7 @@ async function applySettings() {
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
const config = {
openai: {
provider: provider,
api_key: apiKey,
base_url: baseUrl,
model: model
@@ -820,7 +843,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 +982,59 @@ async function applySettings() {
}
}
// 测试OpenAI连接
async function testOpenAIConnection() {
const btn = document.getElementById('test-openai-btn');
const resultEl = document.getElementById('test-openai-result');
const provider = document.getElementById('openai-provider')?.value || 'openai';
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({
provider: provider,
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 {
@@ -1175,6 +1251,8 @@ async function fetchExternalMCPs() {
// 加载外部MCP列表并渲染
async function loadExternalMCPs() {
try {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
if (window.i18nReady) await window.i18nReady;
const data = await fetchExternalMCPs();
renderExternalMCPList(data.servers || {});
renderExternalMCPStats(data.stats || {});
+541 -155
View File
@@ -3,6 +3,69 @@ function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
/** 插值不转 HTML 实体(避免日期里的 / 变成 &#x2F; 再被 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 };
}
/** 队列是否处于「可改子任务列表/文案」的空闲态(与后端 batch_task_manager.queueAllowsTaskListMutationLocked 对齐) */
function batchQueueAllowsSubtaskMutation(queue) {
if (!queue) return false;
if (queue.status === 'running') return false;
const hasRunningSubtask = Array.isArray(queue.tasks) && queue.tasks.some(t => t && t.status === 'running');
if (hasRunningSubtask) return false;
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
}
// HTML转义函数(如果未定义)
if (typeof escapeHtml === 'undefined') {
function escapeHtml(text) {
@@ -725,6 +788,10 @@ 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');
const executeNowCheckbox = document.getElementById('batch-queue-execute-now');
if (modal && input) {
input.value = '';
if (titleInput) {
@@ -734,6 +801,19 @@ async function showBatchImportModal() {
if (roleSelect) {
roleSelect.value = '';
}
if (agentModeSelect) {
agentModeSelect.value = 'single';
}
if (scheduleModeSelect) {
scheduleModeSelect.value = 'manual';
}
if (cronExprInput) {
cronExprInput.value = '';
}
if (executeNowCheckbox) {
executeNowCheckbox.checked = false;
}
handleBatchScheduleModeChange();
updateBatchImportStats('');
// 加载并填充角色列表
@@ -776,6 +856,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 +905,10 @@ 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');
const executeNowCheckbox = document.getElementById('batch-queue-execute-now');
if (!input) return;
const text = input.value.trim();
@@ -827,14 +929,26 @@ 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() : '';
const executeNow = executeNowCheckbox ? !!executeNowCheckbox.checked : false;
if (scheduleMode === 'cron' && !cronExpr) {
alert(_t('batchImportModal.cronExprRequired'));
return;
}
if (scheduleMode === 'cron' && !/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(cronExpr)) {
alert(_t('batchImportModal.cronExprInvalid') || 'Cron 表达式格式错误,需要 5 段(分 时 日 月 周)');
return;
}
try {
const response = await apiFetch('/api/batch-tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, tasks, role }),
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr, executeNow }),
});
if (!response.ok) {
@@ -978,15 +1092,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 +1116,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 noActionsClass = canDelete ? '' : ' batch-queue-item--no-actions';
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}${noActionsClass}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
<div class="batch-queue-item__inner batch-queue-item__inner--grid">
<div class="batch-queue-item__lead">
<div class="batch-queue-item__title-row">
<span class="batch-queue-item__role-icon" aria-hidden="true">${escapeHtml(roleIcon)}</span>
<div class="batch-queue-item__titles">${titleBlock}</div>
</div>
<span class="batch-queue-progress-text">${progress}% (${stats.completed + stats.failed + stats.cancelled}/${stats.total})</span>
<p class="batch-queue-item__config">${configLine}${cronPausedNote}</p>
<p class="batch-queue-item__idline batch-queue-item__idline--lead"><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-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__cluster">
<div class="batch-queue-item__status-inline">
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
<span class="batch-queue-item__pct">${progress}%\u00a0<span class="batch-queue-item__pct-frac">(${doneCount}/${stats.total})</span></span>
</div>
${pres.sublabel ? `<span class="batch-queue-item__sublabel">${escapeHtml(pres.sublabel)}</span>` : ''}
</div>
<div class="batch-queue-item__progress-col">
<div class="batch-queue-progress-bar batch-queue-progress-bar--card batch-queue-progress-bar--list batch-queue-progress-bar--card-row">
<div class="batch-queue-progress-fill${progressFillMod}" style="width: ${progress}%"></div>
</div>
</div>
<div class="batch-queue-item__actions-col" 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>
</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>
</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 +1192,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 +1224,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 +1284,9 @@ async function showBatchQueueDetail(queueId) {
const result = await response.json();
const queue = result.queue;
batchQueuesState.currentQueueId = queueId;
const pres = getBatchQueueStatusPresentation(queue);
const allowSubtaskMutation = batchQueueAllowsSubtaskMutation(queue);
if (title) {
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &amp;...(看起来像“变形/乱码”)
title.textContent = queue.title ? _t('tasks.batchQueueTitle') + ' - ' + String(queue.title) : _t('tasks.batchQueueTitle');
@@ -1207,7 +1295,7 @@ async function showBatchQueueDetail(queueId) {
// 更新按钮显示
const pauseBtn = document.getElementById('batch-queue-pause-btn');
if (addTaskBtn) {
addTaskBtn.style.display = queue.status === 'pending' ? 'inline-block' : 'none';
addTaskBtn.style.display = allowSubtaskMutation ? 'inline-block' : 'none';
}
if (startBtn) {
// pending状态显示"开始执行"paused状态显示"继续执行"
@@ -1215,7 +1303,10 @@ async function showBatchQueueDetail(queueId) {
if (startBtn && queue.status === 'paused') {
startBtn.textContent = _t('tasks.resumeExecute');
} else if (startBtn && queue.status === 'pending') {
startBtn.textContent = _t('batchQueueDetailModal.startExecute');
const isCronPending = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
startBtn.textContent = isCronPending
? _t('batchQueueDetailModal.startExecuteNow')
: _t('batchQueueDetailModal.startExecute');
}
}
if (pauseBtn) {
@@ -1227,15 +1318,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 +1327,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,61 +1341,99 @@ 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;
const prevTechDetails = content.querySelector('details.batch-queue-detail-tech');
const prevLayout = content.querySelector('.batch-queue-detail-layout[data-bq-detail-for]');
const prevDetailFor = prevLayout ? prevLayout.getAttribute('data-bq-detail-for') : null;
const sameQueueAsBefore = prevDetailFor === queue.id;
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
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 class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
<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">
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</span><span class="bq-kv__v">${escapeHtml(queue.title || _t('tasks.batchQueueUntitled'))}${allowSubtaskMutation ? ` <button class="btn-secondary btn-small" onclick="showEditMetadataInline()" style="margin-left:8px;padding:1px 8px;font-size:12px;">${escapeHtml(_t('common.edit'))}</button>` : ''}</span></div>
<div class="bq-kv bq-kv--block" id="bq-edit-metadata-row" style="display:none;">
<span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.editMetadata') || '编辑信息')}</span>
<span class="bq-kv__v bq-kv__v--control">
<label style="font-size:12px;margin-right:4px;">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</label>
<input type="text" id="bq-edit-title" value="${escapeHtml(queue.title || '')}" placeholder="${escapeHtml(_t('batchImportModal.queueTitleHint') || '')}" style="padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;width:160px;" />
<label style="font-size:12px;margin-left:12px;margin-right:4px;">${escapeHtml(_t('batchQueueDetailModal.role'))}</label>
<select id="bq-edit-role" style="padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;min-width:120px;max-width:200px;">
<option value="">${escapeHtml(_t('batchImportModal.defaultRole'))}</option>
${(() => {
const roles = (Array.isArray(loadedRoles) ? loadedRoles : []).filter(r => r.name !== '默认' && r.enabled !== false).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'zh-CN'));
const currentInList = !queue.role || queue.role === '' || roles.some(r => r.name === queue.role);
const orphan = !currentInList ? `<option value="${escapeHtml(queue.role)}" selected>${escapeHtml(queue.role)} (${escapeHtml(_t('batchQueueDetailModal.roleNotFound') || '已移除')})</option>` : '';
return orphan + roles.map(r => `<option value="${escapeHtml(r.name)}" ${r.name === (queue.role || '') ? 'selected' : ''}>${escapeHtml(r.name)}</option>`).join('');
})()}
</select>
<button class="btn-primary btn-small" onclick="saveEditMetadata()" style="margin-left:8px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.save'))}</button>
<button class="btn-secondary btn-small" onclick="hideEditMetadataInline()" style="margin-left:4px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.cancel'))}</button>
</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 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}${allowSubtaskMutation ? ` <button class="btn-secondary btn-small" onclick="showEditScheduleInline()" style="margin-left:8px;padding:1px 8px;font-size:12px;">${escapeHtml(_t('batchQueueDetailModal.editSchedule'))}</button>` : ''}</span></div>
<div class="bq-kv bq-kv--block" id="bq-edit-schedule-row" style="display:none;">
<span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.editScheduleTitle'))}</span>
<span class="bq-kv__v bq-kv__v--control">
<select id="bq-edit-schedule-mode" onchange="toggleEditScheduleCronInput()" style="padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;">
<option value="manual" ${queue.scheduleMode !== 'cron' ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeManual'))}</option>
<option value="cron" ${queue.scheduleMode === 'cron' ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeCron'))}</option>
</select>
<input type="text" id="bq-edit-cron-expr" value="${escapeHtml(queue.cronExpr || '')}" placeholder="${_t('batchImportModal.cronExprPlaceholder', { interpolation: { escapeValue: false } })}" style="margin-left:8px;padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;width:220px;${queue.scheduleMode !== 'cron' ? 'display:none;' : ''}" />
<button class="btn-primary btn-small" onclick="saveEditSchedule()" style="margin-left:8px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.save'))}</button>
<button class="btn-secondary btn-small" onclick="hideEditScheduleInline()" style="margin-left:4px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.cancel'))}</button>
</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="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>
${queue.tasks.map((task, index) => {
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
const canEdit = queue.status === 'pending' && task.status === 'pending';
const canEdit = allowSubtaskMutation && task.status !== 'running';
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "&#39;").replace(/"/g, "&quot;").replace(/\n/g, "\\n");
return `
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
@@ -1326,6 +1443,7 @@ async function showBatchQueueDetail(queueId) {
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
</div>
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
@@ -1338,11 +1456,27 @@ 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;
}
const newTechDetails = content.querySelector('details.batch-queue-detail-tech');
if (newTechDetails && savedTechDetailsOpen) {
newTechDetails.open = true;
}
modal.style.display = 'block';
// 如果队列正在运行,自动刷新
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
if (queue.status === 'running') {
startBatchQueueRefresh(queueId);
} else {
stopBatchQueueRefresh();
}
} catch (error) {
console.error('获取队列详情失败:', error);
@@ -1354,8 +1488,22 @@ async function showBatchQueueDetail(queueId) {
async function startBatchQueue() {
const queueId = batchQueuesState.currentQueueId;
if (!queueId) return;
const btn = document.getElementById('batch-queue-start-btn');
if (btn) { btn.disabled = true; }
try {
// Cron 队列点击“开始执行”会立即运行一轮,这里二次确认避免误触
const queueResponse = await apiFetch(`/api/batch-tasks/${queueId}`);
if (!queueResponse.ok) {
throw new Error(_t('tasks.getQueueDetailFailed'));
}
const queueResult = await queueResponse.json();
const queue = queueResult && queueResult.queue ? queueResult.queue : null;
const isCronPending = queue && queue.status === 'pending' && queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
if (isCronPending) {
const okNow = confirm(_t('batchQueueDetailModal.startExecuteNowConfirm'));
if (!okNow) return;
}
const response = await apiFetch(`/api/batch-tasks/${queueId}/start`, {
method: 'POST',
});
@@ -1371,6 +1519,8 @@ async function startBatchQueue() {
} catch (error) {
console.error('启动批量任务失败:', error);
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
} finally {
if (btn) { btn.disabled = false; }
}
}
@@ -1378,11 +1528,12 @@ async function startBatchQueue() {
async function pauseBatchQueue() {
const queueId = batchQueuesState.currentQueueId;
if (!queueId) return;
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
return;
}
const btn = document.getElementById('batch-queue-pause-btn');
if (btn) { btn.disabled = true; }
try {
const response = await apiFetch(`/api/batch-tasks/${queueId}/pause`, {
method: 'POST',
@@ -1399,6 +1550,8 @@ async function pauseBatchQueue() {
} catch (error) {
console.error('暂停批量任务失败:', error);
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
} finally {
if (btn) { btn.disabled = false; }
}
}
@@ -1406,11 +1559,12 @@ async function pauseBatchQueue() {
async function deleteBatchQueue() {
const queueId = batchQueuesState.currentQueueId;
if (!queueId) return;
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
return;
}
const btn = document.getElementById('batch-queue-delete-btn');
if (btn) { btn.disabled = true; }
try {
const response = await apiFetch(`/api/batch-tasks/${queueId}`, {
method: 'DELETE',
@@ -1426,6 +1580,8 @@ async function deleteBatchQueue() {
} catch (error) {
console.error('删除批量任务队列失败:', error);
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
} finally {
if (btn) { btn.disabled = false; }
}
}
@@ -1475,8 +1631,19 @@ function startBatchQueueRefresh(queueId) {
if (batchQueuesState.refreshInterval) {
clearInterval(batchQueuesState.refreshInterval);
}
batchQueuesState.refreshInterval = setInterval(() => {
// 如果编辑或添加任务的模态框正在打开,跳过本次刷新防止丢失编辑内容
const editModal = document.getElementById('edit-batch-task-modal');
const addModal = document.getElementById('add-batch-task-modal');
const editScheduleRow = document.getElementById('bq-edit-schedule-row');
const editMetadataRow = document.getElementById('bq-edit-metadata-row');
if ((editModal && editModal.style.display === 'block') ||
(addModal && addModal.style.display === 'block') ||
(editScheduleRow && editScheduleRow.style.display !== 'none') ||
(editMetadataRow && editMetadataRow.style.display !== 'none')) {
return;
}
if (batchQueuesState.currentQueueId === queueId) {
showBatchQueueDetail(queueId);
refreshBatchQueues();
@@ -1514,7 +1681,9 @@ function viewBatchTaskConversation(conversationId) {
// 编辑批量任务的状态
const editBatchTaskState = {
queueId: null,
taskId: null
taskId: null,
_escHandler: null,
_saveHandler: null
};
// 从元素获取任务信息并打开编辑模态框
@@ -1565,24 +1734,30 @@ function editBatchTask(queueId, taskId, currentMessage) {
messageInput.select();
}, 100);
// 清理旧的事件监听器(防止泄漏)
if (editBatchTaskState._escHandler) {
document.removeEventListener('keydown', editBatchTaskState._escHandler);
}
if (editBatchTaskState._saveHandler) {
messageInput.removeEventListener('keydown', editBatchTaskState._saveHandler);
}
// 添加ESC键监听
const handleKeyDown = (e) => {
editBatchTaskState._escHandler = (e) => {
if (e.key === 'Escape') {
closeEditBatchTaskModal();
document.removeEventListener('keydown', handleKeyDown);
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', editBatchTaskState._escHandler);
// 添加Enter+Ctrl/Cmd保存功能
const handleKeyPress = (e) => {
editBatchTaskState._saveHandler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
saveBatchTask();
document.removeEventListener('keydown', handleKeyPress);
}
};
messageInput.addEventListener('keydown', handleKeyPress);
messageInput.addEventListener('keydown', editBatchTaskState._saveHandler);
}
// 关闭编辑批量任务模态框
@@ -1591,6 +1766,18 @@ function closeEditBatchTaskModal() {
if (modal) {
modal.style.display = 'none';
}
// 清理事件监听器
if (editBatchTaskState._escHandler) {
document.removeEventListener('keydown', editBatchTaskState._escHandler);
editBatchTaskState._escHandler = null;
}
if (editBatchTaskState._saveHandler) {
const messageInput = document.getElementById('edit-task-message');
if (messageInput) {
messageInput.removeEventListener('keydown', editBatchTaskState._saveHandler);
}
editBatchTaskState._saveHandler = null;
}
editBatchTaskState.queueId = null;
editBatchTaskState.taskId = null;
}
@@ -1671,28 +1858,46 @@ function showAddBatchTaskModal() {
messageInput.focus();
}, 100);
// 清理旧的事件监听器
if (showAddBatchTaskModal._escHandler) {
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
}
if (showAddBatchTaskModal._saveHandler && messageInput) {
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
}
// 添加ESC键监听
const handleKeyDown = (e) => {
showAddBatchTaskModal._escHandler = (e) => {
if (e.key === 'Escape') {
closeAddBatchTaskModal();
document.removeEventListener('keydown', handleKeyDown);
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', showAddBatchTaskModal._escHandler);
// 添加Enter+Ctrl/Cmd保存功能
const handleKeyPress = (e) => {
showAddBatchTaskModal._saveHandler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
saveAddBatchTask();
messageInput.removeEventListener('keydown', handleKeyPress);
}
};
messageInput.addEventListener('keydown', handleKeyPress);
messageInput.addEventListener('keydown', showAddBatchTaskModal._saveHandler);
}
// 关闭添加批量任务模态框
function closeAddBatchTaskModal() {
// 清理事件监听器
if (showAddBatchTaskModal._escHandler) {
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
showAddBatchTaskModal._escHandler = null;
}
if (showAddBatchTaskModal._saveHandler) {
const messageInput = document.getElementById('add-task-message');
if (messageInput) {
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
}
showAddBatchTaskModal._saveHandler = null;
}
const modal = document.getElementById('add-batch-task-modal');
const messageInput = document.getElementById('add-task-message');
if (modal) {
@@ -1819,6 +2024,154 @@ 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);
}
}
// --- 元数据(标题/角色)内联编辑 ---
function showEditMetadataInline() {
// 关闭调度编辑行(互斥)
const schedRow = document.getElementById('bq-edit-schedule-row');
if (schedRow) schedRow.style.display = 'none';
const row = document.getElementById('bq-edit-metadata-row');
if (row) row.style.display = '';
}
function hideEditMetadataInline() {
const row = document.getElementById('bq-edit-metadata-row');
if (row) row.style.display = 'none';
}
async function saveEditMetadata() {
const queueId = batchQueuesState.currentQueueId;
if (!queueId) return;
const titleInput = document.getElementById('bq-edit-title');
const roleInput = document.getElementById('bq-edit-role');
const title = titleInput ? titleInput.value.trim() : '';
const role = roleInput ? roleInput.value.trim() : '';
try {
const response = await apiFetch(`/api/batch-tasks/${queueId}/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, role }),
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(result.error || _t('tasks.updateTaskFailed'));
}
showBatchQueueDetail(queueId);
refreshBatchQueues();
} catch (e) {
console.error(e);
alert(e.message);
}
}
// --- 重试失败任务 ---
async function retryBatchTask(queueId, taskId) {
if (!queueId || !taskId) return;
try {
// 获取任务消息
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
const detail = await detailResp.json();
const task = detail.queue.tasks.find(t => t.id === taskId);
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
const message = task.message;
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!addResp.ok) {
const r = await addResp.json().catch(() => ({}));
throw new Error(r.error || _t('tasks.addTaskFailed'));
}
// 新任务添加成功后才删除旧任务
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
if (!delResp.ok) {
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
console.warn('删除旧任务失败,但新任务已添加');
}
showBatchQueueDetail(queueId);
refreshBatchQueues();
} catch (e) {
console.error('重试任务失败:', e);
alert(e.message);
}
}
// --- 调度配置内联编辑 ---
function showEditScheduleInline() {
// 关闭元数据编辑行(互斥)
const metaRow = document.getElementById('bq-edit-metadata-row');
if (metaRow) metaRow.style.display = 'none';
const row = document.getElementById('bq-edit-schedule-row');
if (row) row.style.display = '';
}
function hideEditScheduleInline() {
const row = document.getElementById('bq-edit-schedule-row');
if (row) row.style.display = 'none';
}
function toggleEditScheduleCronInput() {
const modeSelect = document.getElementById('bq-edit-schedule-mode');
const cronInput = document.getElementById('bq-edit-cron-expr');
if (modeSelect && cronInput) {
cronInput.style.display = modeSelect.value === 'cron' ? '' : 'none';
}
}
async function saveEditSchedule() {
const queueId = batchQueuesState.currentQueueId;
if (!queueId) return;
const modeSelect = document.getElementById('bq-edit-schedule-mode');
const cronInput = document.getElementById('bq-edit-cron-expr');
if (!modeSelect) return;
const scheduleMode = modeSelect.value;
const cronExpr = cronInput ? cronInput.value.trim() : '';
if (scheduleMode === 'cron' && !cronExpr) {
alert(_t('batchImportModal.cronExprRequired'));
return;
}
if (scheduleMode === 'cron' && !/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(cronExpr)) {
alert(_t('batchImportModal.cronExprInvalid') || 'Cron 表达式格式错误,需要 5 段(分 时 日 月 周)');
return;
}
try {
const response = await apiFetch(`/api/batch-tasks/${queueId}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scheduleMode, cronExpr }),
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(result.error || _t('batchQueueDetailModal.editScheduleError'));
}
showBatchQueueDetail(queueId);
refreshBatchQueues();
} catch (e) {
console.error(e);
alert(_t('batchQueueDetailModal.editScheduleError') + ': ' + e.message);
}
}
// 导出函数
window.showBatchImportModal = showBatchImportModal;
window.closeBatchImportModal = closeBatchImportModal;
@@ -1842,3 +2195,36 @@ window.closeAddBatchTaskModal = closeAddBatchTaskModal;
window.saveAddBatchTask = saveAddBatchTask;
window.deleteBatchTaskFromElement = deleteBatchTaskFromElement;
window.deleteBatchQueueFromList = deleteBatchQueueFromList;
window.handleBatchScheduleModeChange = handleBatchScheduleModeChange;
window.updateBatchQueueScheduleEnabled = updateBatchQueueScheduleEnabled;
window.showEditMetadataInline = showEditMetadataInline;
window.hideEditMetadataInline = hideEditMetadataInline;
window.saveEditMetadata = saveEditMetadata;
window.retryBatchTask = retryBatchTask;
window.showEditScheduleInline = showEditScheduleInline;
window.hideEditScheduleInline = hideEditScheduleInline;
window.toggleEditScheduleCronInput = toggleEditScheduleCronInput;
window.saveEditSchedule = saveEditSchedule;
// 语言切换后,列表/分页/详情弹窗由 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);
}
});
+21
View File
@@ -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 马上启动并输出提示符;
+50 -13
View File
@@ -725,6 +725,11 @@
<div class="tools-actions">
<button class="btn-secondary" onclick="selectAllTools()" data-i18n="mcp.selectAll">全选</button>
<button class="btn-secondary" onclick="deselectAllTools()" data-i18n="mcp.deselectAll">全不选</button>
<div class="tools-status-filter">
<button class="btn-filter active" data-filter="" onclick="filterToolsByStatus('')" data-i18n="mcp.filterAll">全部</button>
<button class="btn-filter" data-filter="true" onclick="filterToolsByStatus('true')" data-i18n="mcp.filterEnabled">已启用</button>
<button class="btn-filter" data-filter="false" onclick="filterToolsByStatus('false')" data-i18n="mcp.filterDisabled">已停用</button>
</div>
<div class="search-box">
<input type="text" id="tools-search" data-i18n="mcp.toolSearchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..." onkeypress="handleSearchKeyPress(event)" oninput="if(this.value.trim() === '') clearSearch()" />
<button class="btn-search" onclick="searchTools()" data-i18n="common.search" data-i18n-attr="title" title="搜索">🔍</button>
@@ -1132,7 +1137,7 @@
<!-- 批量任务队列列表 -->
<div class="batch-queues-section" id="batch-queues-section" style="display: none;">
<!-- 筛选控件 -->
<div class="batch-queues-filters tasks-filters">
<div class="batch-queues-filters tasks-filters batch-queues-filters--compact">
<label>
<span data-i18n="tasksPage.statusFilter">状态筛选</span>
<select id="batch-queues-status-filter" onchange="filterBatchQueues()">
@@ -1144,15 +1149,16 @@
<option value="cancelled" data-i18n="tasksPage.statusCancelled">已取消</option>
</select>
</label>
<label style="flex: 1; max-width: 300px;">
<label class="batch-queues-filters__search">
<span data-i18n="tasksPage.searchQueuePlaceholder">搜索队列ID、标题或创建时间</span>
<input type="text" id="batch-queues-search" data-i18n="tasksPage.searchKeywordPlaceholder" data-i18n-attr="placeholder" placeholder="输入关键字搜索..."
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>
@@ -1359,6 +1365,13 @@
<div class="settings-subsection">
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
<div class="settings-form">
<div class="form-group">
<label for="openai-provider">API 提供商</label>
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
<option value="openai">OpenAI / 兼容 OpenAI 协议</option>
<option value="claude">Claude (Anthropic Messages API)</option>
</select>
</div>
<div class="form-group">
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
@@ -1371,6 +1384,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 +1443,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 +2327,34 @@ 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-queue-execute-now" class="batch-execute-now-label">
<input type="checkbox" id="batch-queue-execute-now" />
<span data-i18n="batchImportModal.executeNow">创建后立即执行</span>
</label>
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.executeNowHint">默认不立即执行;关闭后队列保持待执行,可在需要时手动开始。</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="请输入任务列表,每行一个任务,例如:&#10;扫描 192.168.1.1 的开放端口&#10;检查 https://example.com 是否存在SQL注入&#10;枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>