mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-18 14:04:52 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b64f1c682c | |||
| 3bd5408d5a | |||
| fb0724a862 | |||
| 15c7692988 | |||
| 6fb96dcc0c | |||
| 9efc0ca8bb | |||
| 352e245389 | |||
| 4442e7de30 | |||
| 715240dc5e | |||
| 5f8b19e179 | |||
| ea48f3d71b | |||
| e3013aa230 | |||
| 1cf34797b8 | |||
| 62241e0e66 | |||
| dda4edb952 | |||
| 5bf6317dcb | |||
| 9331fbfea1 | |||
| b1ac985c28 | |||
| 4f4a725034 | |||
| 3e689a5dcb | |||
| de18ae5b0f | |||
| 517906207a | |||
| 7407d6822f | |||
| 24344cafdb | |||
| a5b95d5b2e | |||
| 49cd0166f8 | |||
| a834231342 | |||
| 20a498455e | |||
| f4028ae66f | |||
| 0a5bb1eab4 | |||
| d4f2b0f93d | |||
| 1fb8cc2fbc | |||
| 3ddf280400 | |||
| 961deb81dd | |||
| ae3bc41c88 | |||
| bb9e3f9477 | |||
| a57720fb29 | |||
| 9e34b480e7 | |||
| cd30953a84 |
+3
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.4.12"
|
version: "v1.4.18"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -34,7 +34,9 @@ log:
|
|||||||
# - DeepSeek: https://api.deepseek.com/v1
|
# - DeepSeek: https://api.deepseek.com/v1
|
||||||
# - 其他兼容 OpenAI 协议的 API
|
# - 其他兼容 OpenAI 协议的 API
|
||||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
||||||
|
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||||
openai:
|
openai:
|
||||||
|
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
|
||||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
||||||
api_key: sk-xxxxxx # API 密钥(必填)
|
api_key: sk-xxxxxx # API 密钥(必填)
|
||||||
model: qwen3-max # 模型名称(必填)
|
model: qwen3-max # 模型名称(必填)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ toolchain go1.24.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.15.0
|
github.com/bytedance/sonic v1.15.0
|
||||||
github.com/cloudwego/eino v0.8.4
|
github.com/cloudwego/eino v0.8.8
|
||||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.10
|
github.com/cloudwego/eino-ext/components/model/openai v0.1.12
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/eino-contrib/jsonschema v1.0.3
|
github.com/eino-contrib/jsonschema v1.0.3
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
@@ -21,6 +21,7 @@ require (
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/pkoukk/tiktoken-go v0.1.8
|
github.com/pkoukk/tiktoken-go v0.1.8
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -33,7 +34,7 @@ require (
|
|||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/dlclark/regexp2 v1.10.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/evanphx/json-patch v0.5.2 // 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/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||||
|
|||||||
@@ -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/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/cloudwego/eino v0.8.4 h1:aFKJK82MmPR6dm5y5J7IXivYSvh4HkcXwf18j6vyhmk=
|
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.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 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.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 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.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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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=
|
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/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 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.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 h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
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=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
|||||||
+18
-8
@@ -404,6 +404,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
}
|
}
|
||||||
configHandler.SetSkillsToolRegistrar(skillsRegistrar)
|
configHandler.SetSkillsToolRegistrar(skillsRegistrar)
|
||||||
|
|
||||||
|
handler.RegisterBatchTaskMCPTools(mcpServer, agentHandler, log.Logger)
|
||||||
|
batchTaskToolRegistrar := func() error {
|
||||||
|
handler.RegisterBatchTaskMCPTools(mcpServer, agentHandler, log.Logger)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configHandler.SetBatchTaskToolRegistrar(batchTaskToolRegistrar)
|
||||||
|
|
||||||
// 设置知识库初始化器(用于动态初始化,需要在 App 创建后设置)
|
// 设置知识库初始化器(用于动态初始化,需要在 App 创建后设置)
|
||||||
configHandler.SetKnowledgeInitializer(func() (*handler.KnowledgeHandler, error) {
|
configHandler.SetKnowledgeInitializer(func() (*handler.KnowledgeHandler, error) {
|
||||||
knowledgeHandler, err := initializeKnowledge(cfg, db, knowledgeDBConn, mcpServer, agentHandler, app, log.Logger)
|
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.GET("/batch-tasks/:queueId", agentHandler.GetBatchQueue)
|
||||||
protected.POST("/batch-tasks/:queueId/start", agentHandler.StartBatchQueue)
|
protected.POST("/batch-tasks/:queueId/start", agentHandler.StartBatchQueue)
|
||||||
protected.POST("/batch-tasks/:queueId/pause", agentHandler.PauseBatchQueue)
|
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.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
|
||||||
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
||||||
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
|
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
|
||||||
@@ -1333,8 +1343,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
|
|
||||||
// manage_webshell_add - 添加新的 webshell 连接
|
// manage_webshell_add - 添加新的 webshell 连接
|
||||||
addTool := mcp.Tool{
|
addTool := mcp.Tool{
|
||||||
Name: builtin.ToolManageWebshellAdd,
|
Name: builtin.ToolManageWebshellAdd,
|
||||||
Description: "添加新的 WebShell 连接到管理系统。支持 PHP、ASP、ASPX、JSP 等类型的一句话木马。",
|
Description: "添加新的 WebShell 连接到管理系统。支持 PHP、ASP、ASPX、JSP 等类型的一句话木马。",
|
||||||
ShortDescription: "添加 WebShell 连接",
|
ShortDescription: "添加 WebShell 连接",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1425,8 +1435,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
|
|
||||||
// manage_webshell_update - 更新 webshell 连接
|
// manage_webshell_update - 更新 webshell 连接
|
||||||
updateTool := mcp.Tool{
|
updateTool := mcp.Tool{
|
||||||
Name: builtin.ToolManageWebshellUpdate,
|
Name: builtin.ToolManageWebshellUpdate,
|
||||||
Description: "更新已存在的 WebShell 连接信息。",
|
Description: "更新已存在的 WebShell 连接信息。",
|
||||||
ShortDescription: "更新 WebShell 连接",
|
ShortDescription: "更新 WebShell 连接",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1522,8 +1532,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
|
|
||||||
// manage_webshell_delete - 删除 webshell 连接
|
// manage_webshell_delete - 删除 webshell 连接
|
||||||
deleteTool := mcp.Tool{
|
deleteTool := mcp.Tool{
|
||||||
Name: builtin.ToolManageWebshellDelete,
|
Name: builtin.ToolManageWebshellDelete,
|
||||||
Description: "删除指定的 WebShell 连接。",
|
Description: "删除指定的 WebShell 连接。",
|
||||||
ShortDescription: "删除 WebShell 连接",
|
ShortDescription: "删除 WebShell 连接",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1564,8 +1574,8 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
|||||||
|
|
||||||
// manage_webshell_test - 测试 webshell 连接
|
// manage_webshell_test - 测试 webshell 连接
|
||||||
testTool := mcp.Tool{
|
testTool := mcp.Tool{
|
||||||
Name: builtin.ToolManageWebshellTest,
|
Name: builtin.ToolManageWebshellTest,
|
||||||
Description: "测试指定的 WebShell 连接是否可用,会尝试执行一个简单的命令(如 whoami 或 dir)。",
|
Description: "测试指定的 WebShell 连接是否可用,会尝试执行一个简单的命令(如 whoami 或 dir)。",
|
||||||
ShortDescription: "测试 WebShell 连接",
|
ShortDescription: "测试 WebShell 连接",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ type MCPConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIConfig 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"`
|
APIKey string `yaml:"api_key" json:"api_key"`
|
||||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
Model string `yaml:"model" json:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
|
|||||||
+153
-18
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -10,14 +11,22 @@ import (
|
|||||||
|
|
||||||
// BatchTaskQueueRow 批量任务队列数据库行
|
// BatchTaskQueueRow 批量任务队列数据库行
|
||||||
type BatchTaskQueueRow struct {
|
type BatchTaskQueueRow struct {
|
||||||
ID string
|
ID string
|
||||||
Title sql.NullString
|
Title sql.NullString
|
||||||
Role sql.NullString
|
Role sql.NullString
|
||||||
Status string
|
AgentMode sql.NullString
|
||||||
CreatedAt time.Time
|
ScheduleMode sql.NullString
|
||||||
StartedAt sql.NullTime
|
CronExpr sql.NullString
|
||||||
CompletedAt sql.NullTime
|
NextRunAt sql.NullTime
|
||||||
CurrentIndex int
|
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 批量任务数据库行
|
// BatchTaskRow 批量任务数据库行
|
||||||
@@ -34,7 +43,16 @@ type BatchTaskRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateBatchQueue 创建批量任务队列
|
// 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()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("开始事务失败: %w", err)
|
return fmt.Errorf("开始事务失败: %w", err)
|
||||||
@@ -42,9 +60,14 @@ func (db *DB) CreateBatchQueue(queueID string, title string, role string, tasks
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
var nextRunAtValue interface{}
|
||||||
|
if nextRunAt != nil {
|
||||||
|
nextRunAtValue = *nextRunAt
|
||||||
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
"INSERT INTO batch_task_queues (id, title, role, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?)",
|
"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, "pending", now, 0,
|
queueID, title, role, agentMode, scheduleMode, cronExpr, nextRunAtValue, 1, "pending", now, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建批量任务队列失败: %w", err)
|
return fmt.Errorf("创建批量任务队列失败: %w", err)
|
||||||
@@ -78,9 +101,9 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
|
|||||||
var row BatchTaskQueueRow
|
var row BatchTaskQueueRow
|
||||||
var createdAt string
|
var createdAt string
|
||||||
err := db.QueryRow(
|
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,
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -104,7 +127,7 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
|
|||||||
// GetAllBatchQueues 获取所有批量任务队列
|
// GetAllBatchQueues 获取所有批量任务队列
|
||||||
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
||||||
rows, err := db.Query(
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
|
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
|
||||||
@@ -115,7 +138,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var row BatchTaskQueueRow
|
var row BatchTaskQueueRow
|
||||||
var createdAt string
|
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)
|
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
|
||||||
}
|
}
|
||||||
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
@@ -135,7 +158,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
|||||||
|
|
||||||
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
|
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
|
||||||
func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueueRow, error) {
|
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{}{}
|
args := []interface{}{}
|
||||||
|
|
||||||
// 状态筛选
|
// 状态筛选
|
||||||
@@ -163,7 +186,7 @@ func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*Bat
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var row BatchTaskQueueRow
|
var row BatchTaskQueueRow
|
||||||
var createdAt string
|
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)
|
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
|
||||||
}
|
}
|
||||||
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
@@ -329,6 +352,119 @@ func (db *DB) UpdateBatchQueueCurrentIndex(queueID string, currentIndex int) err
|
|||||||
return nil
|
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 更新批量任务消息
|
// UpdateBatchTaskMessage 更新批量任务消息
|
||||||
func (db *DB) UpdateBatchTaskMessage(queueID, taskID, message string) error {
|
func (db *DB) UpdateBatchTaskMessage(queueID, taskID, message string) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
@@ -387,4 +523,3 @@ func (db *DB) DeleteBatchQueue(queueID string) error {
|
|||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,15 @@ func (db *DB) initTables() error {
|
|||||||
CREATE TABLE IF NOT EXISTS batch_task_queues (
|
CREATE TABLE IF NOT EXISTS batch_task_queues (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT,
|
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,
|
status TEXT NOT NULL,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
started_at DATETIME,
|
started_at DATETIME,
|
||||||
@@ -495,7 +504,7 @@ func (db *DB) migrateConversationGroupMappingsTable() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,添加title和role字段
|
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,补充新字段
|
||||||
func (db *DB) migrateBatchTaskQueuesTable() error {
|
func (db *DB) migrateBatchTaskQueuesTable() error {
|
||||||
// 检查title字段是否存在
|
// 检查title字段是否存在
|
||||||
var count int
|
var count int
|
||||||
@@ -535,6 +544,131 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查agent_mode字段是否存在
|
||||||
|
var agentModeCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='agent_mode'").Scan(&agentModeCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'single'"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if agentModeCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'single'"); err != nil {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查schedule_mode字段是否存在
|
||||||
|
var scheduleModeCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='schedule_mode'").Scan(&scheduleModeCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'manual'"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加schedule_mode字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if scheduleModeCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'manual'"); err != nil {
|
||||||
|
db.logger.Warn("添加schedule_mode字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查cron_expr字段是否存在
|
||||||
|
var cronExprCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='cron_expr'").Scan(&cronExprCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN cron_expr TEXT"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加cron_expr字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if cronExprCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN cron_expr TEXT"); err != nil {
|
||||||
|
db.logger.Warn("添加cron_expr字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查next_run_at字段是否存在
|
||||||
|
var nextRunAtCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='next_run_at'").Scan(&nextRunAtCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN next_run_at DATETIME"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加next_run_at字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if nextRunAtCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN next_run_at DATETIME"); err != nil {
|
||||||
|
db.logger.Warn("添加next_run_at字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedule_enabled:0=暂停 Cron 自动调度,1=允许(手工执行不受影响)
|
||||||
|
var scheduleEnCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='schedule_enabled'").Scan(&scheduleEnCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_enabled INTEGER NOT NULL DEFAULT 1"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加schedule_enabled字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if scheduleEnCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN schedule_enabled INTEGER NOT NULL DEFAULT 1"); err != nil {
|
||||||
|
db.logger.Warn("添加schedule_enabled字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastTrigCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='last_schedule_trigger_at'").Scan(&lastTrigCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_trigger_at DATETIME"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加last_schedule_trigger_at字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if lastTrigCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_trigger_at DATETIME"); err != nil {
|
||||||
|
db.logger.Warn("添加last_schedule_trigger_at字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSchedErrCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='last_schedule_error'").Scan(&lastSchedErrCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_error TEXT"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加last_schedule_error字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if lastSchedErrCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_schedule_error TEXT"); err != nil {
|
||||||
|
db.logger.Warn("添加last_schedule_error字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastRunErrCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='last_run_error'").Scan(&lastRunErrCount)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_run_error TEXT"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加last_run_error字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if lastRunErrCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN last_run_error TEXT"); err != nil {
|
||||||
|
db.logger.Warn("添加last_run_error字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+318
-20
@@ -24,6 +24,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/skills"
|
"cyberstrike-ai/internal/skills"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,11 +82,14 @@ type AgentHandler struct {
|
|||||||
}
|
}
|
||||||
skillsManager *skills.Manager // Skills管理器
|
skillsManager *skills.Manager // Skills管理器
|
||||||
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
||||||
|
batchCronParser cron.Parser
|
||||||
|
batchRunnerMu sync.Mutex
|
||||||
|
batchRunning map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentHandler 创建新的Agent处理器
|
// NewAgentHandler 创建新的Agent处理器
|
||||||
func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, logger *zap.Logger) *AgentHandler {
|
func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, logger *zap.Logger) *AgentHandler {
|
||||||
batchTaskManager := NewBatchTaskManager()
|
batchTaskManager := NewBatchTaskManager(logger)
|
||||||
batchTaskManager.SetDB(db)
|
batchTaskManager.SetDB(db)
|
||||||
|
|
||||||
// 从数据库加载所有批量任务队列
|
// 从数据库加载所有批量任务队列
|
||||||
@@ -93,14 +97,18 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
|||||||
logger.Warn("从数据库加载批量任务队列失败", zap.Error(err))
|
logger.Warn("从数据库加载批量任务队列失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentHandler{
|
handler := &AgentHandler{
|
||||||
agent: agent,
|
agent: agent,
|
||||||
db: db,
|
db: db,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
tasks: NewAgentTaskManager(),
|
tasks: NewAgentTaskManager(),
|
||||||
batchTaskManager: batchTaskManager,
|
batchTaskManager: batchTaskManager,
|
||||||
config: cfg,
|
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 设置知识库管理器(用于记录检索日志)
|
// SetKnowledgeManager 设置知识库管理器(用于记录检索日志)
|
||||||
@@ -990,6 +998,24 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当 Agent 同时发送 thinking_stream_* 和 thinking(带同一 streamId)时,
|
||||||
|
// thinking_stream_* 已经会在 flushThinkingStreams() 聚合落库;
|
||||||
|
// 这里跳过同 streamId 的 thinking,避免 processDetails 双份展示。
|
||||||
|
if eventType == "thinking" {
|
||||||
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
|
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||||
|
if tb, exists := thinkingStreams[sid]; exists && tb != nil {
|
||||||
|
if strings.TrimSpace(tb.b.String()) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flushedThinking[sid] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存过程详情到数据库(排除 response/done;response 正文已在 messages 表)
|
// 保存过程详情到数据库(排除 response/done;response 正文已在 messages 表)
|
||||||
// response_start/response_delta 已聚合为 planning,不落逐条。
|
// response_start/response_delta 已聚合为 planning,不落逐条。
|
||||||
if assistantMessageID != "" &&
|
if assistantMessageID != "" &&
|
||||||
@@ -1557,9 +1583,27 @@ func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
|
|||||||
|
|
||||||
// BatchTaskRequest 批量任务请求
|
// BatchTaskRequest 批量任务请求
|
||||||
type BatchTaskRequest struct {
|
type BatchTaskRequest struct {
|
||||||
Title string `json:"title"` // 任务标题(可选)
|
Title string `json:"title"` // 任务标题(可选)
|
||||||
Tasks []string `json:"tasks" binding:"required"` // 任务列表,每行一个任务
|
Tasks []string `json:"tasks" binding:"required"` // 任务列表,每行一个任务
|
||||||
Role string `json:"role,omitempty"` // 角色名称(可选,空字符串表示默认角色)
|
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 创建批量任务队列
|
// CreateBatchQueue 创建批量任务队列
|
||||||
@@ -1588,10 +1632,49 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
|
|||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"queueId": queue.ID,
|
"queueId": queue.ID,
|
||||||
"queue": queue,
|
"queue": queue,
|
||||||
|
"started": started,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1642,6 +1725,11 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
|
|||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
// 防止恶意大 offset 导致 DB 性能问题
|
||||||
|
const maxOffset = 100000
|
||||||
|
if offset > maxOffset {
|
||||||
|
offset = maxOffset
|
||||||
|
}
|
||||||
|
|
||||||
// 默认status为"all"
|
// 默认status为"all"
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -1681,21 +1769,15 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
|
|||||||
// StartBatchQueue 开始执行批量任务队列
|
// StartBatchQueue 开始执行批量任务队列
|
||||||
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
||||||
queueID := c.Param("queueId")
|
queueID := c.Param("queueId")
|
||||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
ok, err := h.startBatchQueueExecution(queueID, false)
|
||||||
if !exists {
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||||
return
|
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})
|
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": "批量任务已暂停"})
|
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 删除批量任务队列
|
// DeleteBatchQueue 删除批量任务队列
|
||||||
func (h *AgentHandler) DeleteBatchQueue(c *gin.Context) {
|
func (h *AgentHandler) DeleteBatchQueue(c *gin.Context) {
|
||||||
queueID := c.Param("queueId")
|
queueID := c.Param("queueId")
|
||||||
@@ -1806,8 +1971,125 @@ func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "任务已删除", "queue": queue})
|
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 执行批量任务队列
|
// executeBatchQueue 执行批量任务队列
|
||||||
func (h *AgentHandler) executeBatchQueue(queueID string) {
|
func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||||
|
defer h.unmarkBatchQueueRunning(queueID)
|
||||||
h.logger.Info("开始执行批量任务队列", zap.String("queueId", queueID))
|
h.logger.Info("开始执行批量任务队列", zap.String("queueId", queueID))
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -1820,7 +2102,17 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
// 获取下一个任务
|
// 获取下一个任务
|
||||||
task, hasNext := h.batchTaskManager.GetNextTask(queueID)
|
task, hasNext := h.batchTaskManager.GetNextTask(queueID)
|
||||||
if !hasNext {
|
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.batchTaskManager.UpdateQueueStatus(queueID, "completed")
|
||||||
h.logger.Info("批量任务队列执行完成", zap.String("queueId", queueID))
|
h.logger.Info("批量任务队列执行完成", zap.String("queueId", queueID))
|
||||||
break
|
break
|
||||||
@@ -1900,7 +2192,13 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
||||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||||
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
|
// 注意: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 result *agent.AgentLoopResult
|
||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
|||||||
@@ -9,8 +9,35 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"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 批量任务项
|
// BatchTask 批量任务项
|
||||||
@@ -27,29 +54,42 @@ type BatchTask struct {
|
|||||||
|
|
||||||
// BatchTaskQueue 批量任务队列
|
// BatchTaskQueue 批量任务队列
|
||||||
type BatchTaskQueue struct {
|
type BatchTaskQueue struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Role string `json:"role,omitempty"` // 角色名称(空字符串表示默认角色)
|
Role string `json:"role,omitempty"` // 角色名称(空字符串表示默认角色)
|
||||||
Tasks []*BatchTask `json:"tasks"`
|
AgentMode string `json:"agentMode"` // single | multi
|
||||||
Status string `json:"status"` // pending, running, paused, completed, cancelled
|
ScheduleMode string `json:"scheduleMode"` // manual | cron
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CronExpr string `json:"cronExpr,omitempty"`
|
||||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
ScheduleEnabled bool `json:"scheduleEnabled"`
|
||||||
CurrentIndex int `json:"currentIndex"`
|
LastScheduleTriggerAt *time.Time `json:"lastScheduleTriggerAt,omitempty"`
|
||||||
mu sync.RWMutex
|
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 批量任务管理器
|
// BatchTaskManager 批量任务管理器
|
||||||
type BatchTaskManager struct {
|
type BatchTaskManager struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
queues map[string]*BatchTaskQueue
|
logger *zap.Logger
|
||||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
queues map[string]*BatchTaskQueue
|
||||||
mu sync.RWMutex
|
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBatchTaskManager 创建批量任务管理器
|
// NewBatchTaskManager 创建批量任务管理器
|
||||||
func NewBatchTaskManager() *BatchTaskManager {
|
func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
return &BatchTaskManager{
|
return &BatchTaskManager{
|
||||||
|
logger: logger,
|
||||||
queues: make(map[string]*BatchTaskQueue),
|
queues: make(map[string]*BatchTaskQueue),
|
||||||
taskCancels: make(map[string]context.CancelFunc),
|
taskCancels: make(map[string]context.CancelFunc),
|
||||||
}
|
}
|
||||||
@@ -63,19 +103,43 @@ func (m *BatchTaskManager) SetDB(db *database.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateBatchQueue 创建批量任务队列
|
// 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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
queueID := time.Now().Format("20060102150405") + "-" + generateShortID()
|
queueID := time.Now().Format("20060102150405") + "-" + generateShortID()
|
||||||
queue := &BatchTaskQueue{
|
queue := &BatchTaskQueue{
|
||||||
ID: queueID,
|
ID: queueID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Role: role,
|
Role: role,
|
||||||
Tasks: make([]*BatchTask, 0, len(tasks)),
|
AgentMode: normalizeBatchQueueAgentMode(agentMode),
|
||||||
Status: "pending",
|
ScheduleMode: normalizeBatchQueueScheduleMode(scheduleMode),
|
||||||
CreatedAt: time.Now(),
|
CronExpr: strings.TrimSpace(cronExpr),
|
||||||
CurrentIndex: 0,
|
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{
|
task := &BatchTask{
|
||||||
ID: taskID,
|
ID: taskID,
|
||||||
Message: message,
|
Message: message,
|
||||||
Status: "pending",
|
Status: BatchTaskStatusPending,
|
||||||
}
|
}
|
||||||
queue.Tasks = append(queue.Tasks, task)
|
queue.Tasks = append(queue.Tasks, task)
|
||||||
dbTasks = append(dbTasks, map[string]interface{}{
|
dbTasks = append(dbTasks, map[string]interface{}{
|
||||||
@@ -100,14 +164,22 @@ func (m *BatchTaskManager) CreateBatchQueue(title, role string, tasks []string)
|
|||||||
|
|
||||||
// 保存到数据库
|
// 保存到数据库
|
||||||
if m.db != nil {
|
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
|
m.queues[queueID] = queue
|
||||||
return queue
|
return queue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBatchQueue 获取批量任务队列
|
// GetBatchQueue 获取批量任务队列
|
||||||
@@ -151,6 +223,8 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
|||||||
|
|
||||||
queue := &BatchTaskQueue{
|
queue := &BatchTaskQueue{
|
||||||
ID: queueRow.ID,
|
ID: queueRow.ID,
|
||||||
|
AgentMode: "single",
|
||||||
|
ScheduleMode: "manual",
|
||||||
Status: queueRow.Status,
|
Status: queueRow.Status,
|
||||||
CreatedAt: queueRow.CreatedAt,
|
CreatedAt: queueRow.CreatedAt,
|
||||||
CurrentIndex: queueRow.CurrentIndex,
|
CurrentIndex: queueRow.CurrentIndex,
|
||||||
@@ -163,6 +237,33 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
|||||||
if queueRow.Role.Valid {
|
if queueRow.Role.Valid {
|
||||||
queue.Role = queueRow.Role.String
|
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 {
|
if queueRow.StartedAt.Valid {
|
||||||
queue.StartedAt = &queueRow.StartedAt.Time
|
queue.StartedAt = &queueRow.StartedAt.Time
|
||||||
}
|
}
|
||||||
@@ -347,6 +448,8 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
|||||||
|
|
||||||
queue := &BatchTaskQueue{
|
queue := &BatchTaskQueue{
|
||||||
ID: queueRow.ID,
|
ID: queueRow.ID,
|
||||||
|
AgentMode: "single",
|
||||||
|
ScheduleMode: "manual",
|
||||||
Status: queueRow.Status,
|
Status: queueRow.Status,
|
||||||
CreatedAt: queueRow.CreatedAt,
|
CreatedAt: queueRow.CreatedAt,
|
||||||
CurrentIndex: queueRow.CurrentIndex,
|
CurrentIndex: queueRow.CurrentIndex,
|
||||||
@@ -359,6 +462,33 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
|||||||
if queueRow.Role.Valid {
|
if queueRow.Role.Valid {
|
||||||
queue.Role = queueRow.Role.String
|
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 {
|
if queueRow.StartedAt.Valid {
|
||||||
queue.StartedAt = &queueRow.StartedAt.Time
|
queue.StartedAt = &queueRow.StartedAt.Time
|
||||||
}
|
}
|
||||||
@@ -424,10 +554,10 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
|
|||||||
task.ConversationID = conversationID
|
task.ConversationID = conversationID
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if status == "running" && task.StartedAt == nil {
|
if status == BatchTaskStatusRunning && task.StartedAt == nil {
|
||||||
task.StartedAt = &now
|
task.StartedAt = &now
|
||||||
}
|
}
|
||||||
if status == "completed" || status == "failed" || status == "cancelled" {
|
if status == BatchTaskStatusCompleted || status == BatchTaskStatusFailed || status == BatchTaskStatusCancelled {
|
||||||
task.CompletedAt = &now
|
task.CompletedAt = &now
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -437,7 +567,7 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
|
|||||||
// 同步到数据库
|
// 同步到数据库
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != 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
|
queue.Status = status
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if status == "running" && queue.StartedAt == nil {
|
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
|
||||||
queue.StartedAt = &now
|
queue.StartedAt = &now
|
||||||
}
|
}
|
||||||
if status == "completed" || status == "cancelled" {
|
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
|
||||||
queue.CompletedAt = &now
|
queue.CompletedAt = &now
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步到数据库
|
// 同步到数据库
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != 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 {
|
func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -479,17 +763,15 @@ func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) er
|
|||||||
return fmt.Errorf("队列不存在")
|
return fmt.Errorf("队列不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查队列状态,只有待执行状态的队列才能编辑任务
|
if !queueAllowsTaskListMutationLocked(queue) {
|
||||||
if queue.Status != "pending" {
|
return fmt.Errorf("队列正在执行或未就绪,无法编辑任务")
|
||||||
return fmt.Errorf("只有待执行状态的队列才能编辑任务")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找并更新任务
|
// 查找并更新任务
|
||||||
for _, task := range queue.Tasks {
|
for _, task := range queue.Tasks {
|
||||||
if task.ID == taskID {
|
if task.ID == taskID {
|
||||||
// 只有待执行状态的任务才能编辑
|
if task.Status == BatchTaskStatusRunning {
|
||||||
if task.Status != "pending" {
|
return fmt.Errorf("执行中的任务不能编辑")
|
||||||
return fmt.Errorf("只有待执行状态的任务才能编辑")
|
|
||||||
}
|
}
|
||||||
task.Message = message
|
task.Message = message
|
||||||
|
|
||||||
@@ -506,7 +788,7 @@ func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) er
|
|||||||
return fmt.Errorf("任务不存在")
|
return fmt.Errorf("任务不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTaskToQueue 添加任务到队列(仅限待执行状态)
|
// AddTaskToQueue 添加任务到队列(队列空闲时可添加:含 cron 本轮 completed、手动暂停后等)
|
||||||
func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask, error) {
|
func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -516,9 +798,8 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
|||||||
return nil, fmt.Errorf("队列不存在")
|
return nil, fmt.Errorf("队列不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查队列状态,只有待执行状态的队列才能添加任务
|
if !queueAllowsTaskListMutationLocked(queue) {
|
||||||
if queue.Status != "pending" {
|
return nil, fmt.Errorf("队列正在执行或未就绪,无法添加任务")
|
||||||
return nil, fmt.Errorf("只有待执行状态的队列才能添加任务")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if message == "" {
|
if message == "" {
|
||||||
@@ -530,7 +811,7 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
|||||||
task := &BatchTask{
|
task := &BatchTask{
|
||||||
ID: taskID,
|
ID: taskID,
|
||||||
Message: message,
|
Message: message,
|
||||||
Status: "pending",
|
Status: BatchTaskStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加到内存队列
|
// 添加到内存队列
|
||||||
@@ -548,7 +829,7 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
|||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTask 删除任务(仅限待执行状态)
|
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
|
||||||
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -558,18 +839,16 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
|||||||
return fmt.Errorf("队列不存在")
|
return fmt.Errorf("队列不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查队列状态,只有待执行状态的队列才能删除任务
|
if !queueAllowsTaskListMutationLocked(queue) {
|
||||||
if queue.Status != "pending" {
|
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
|
||||||
return fmt.Errorf("只有待执行状态的队列才能删除任务")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找并删除任务
|
// 查找并删除任务
|
||||||
taskIndex := -1
|
taskIndex := -1
|
||||||
for i, task := range queue.Tasks {
|
for i, task := range queue.Tasks {
|
||||||
if task.ID == taskID {
|
if task.ID == taskID {
|
||||||
// 只有待执行状态的任务才能删除
|
if task.Status == BatchTaskStatusRunning {
|
||||||
if task.Status != "pending" {
|
return fmt.Errorf("执行中的任务不能删除")
|
||||||
return fmt.Errorf("只有待执行状态的任务才能删除")
|
|
||||||
}
|
}
|
||||||
taskIndex = i
|
taskIndex = i
|
||||||
break
|
break
|
||||||
@@ -595,10 +874,41 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
|||||||
return nil
|
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 获取下一个待执行的任务
|
// GetNextTask 获取下一个待执行的任务
|
||||||
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
||||||
m.mu.RLock()
|
m.mu.Lock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -607,7 +917,7 @@ func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
|||||||
|
|
||||||
for i := queue.CurrentIndex; i < len(queue.Tasks); i++ {
|
for i := queue.CurrentIndex; i < len(queue.Tasks); i++ {
|
||||||
task := queue.Tasks[i]
|
task := queue.Tasks[i]
|
||||||
if task.Status == "pending" {
|
if task.Status == BatchTaskStatusPending {
|
||||||
queue.CurrentIndex = i
|
queue.CurrentIndex = i
|
||||||
return task, true
|
return task, true
|
||||||
}
|
}
|
||||||
@@ -631,7 +941,7 @@ func (m *BatchTaskManager) MoveToNextTask(queueID string) {
|
|||||||
// 同步到数据库
|
// 同步到数据库
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
if err := m.db.UpdateBatchQueueCurrentIndex(queueID, queue.CurrentIndex); err != 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 暂停队列
|
// PauseQueue 暂停队列
|
||||||
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
m.mu.Unlock()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if queue.Status != "running" {
|
if queue.Status != BatchQueueStatusRunning {
|
||||||
m.mu.Unlock()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.Status = "paused"
|
queue.Status = BatchQueueStatusPaused
|
||||||
|
|
||||||
// 取消当前正在执行的任务(通过取消context)
|
// 取消当前正在执行的任务(通过取消context)
|
||||||
if cancel, exists := m.taskCancels[queueID]; exists {
|
if cancel, exists := m.taskCancels[queueID]; exists {
|
||||||
@@ -670,12 +979,10 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
|||||||
delete(m.taskCancels, queueID)
|
delete(m.taskCancels, queueID)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Unlock()
|
// 同步队列状态到数据库(在锁内完成,避免竞态)
|
||||||
|
|
||||||
// 同步队列状态到数据库
|
|
||||||
if m.db != nil {
|
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)
|
// CancelQueue 取消队列(保留此方法以保持向后兼容,但建议使用PauseQueue)
|
||||||
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
m.mu.Unlock()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if queue.Status == "completed" || queue.Status == "cancelled" {
|
if queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled {
|
||||||
m.mu.Unlock()
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.Status = "cancelled"
|
queue.Status = BatchQueueStatusCancelled
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
queue.CompletedAt = &now
|
queue.CompletedAt = &now
|
||||||
|
|
||||||
// 取消所有待执行的任务
|
// 取消所有待执行的任务
|
||||||
for _, task := range queue.Tasks {
|
for _, task := range queue.Tasks {
|
||||||
if task.Status == "pending" {
|
if task.Status == BatchTaskStatusPending {
|
||||||
task.Status = "cancelled"
|
task.Status = BatchTaskStatusCancelled
|
||||||
task.CompletedAt = &now
|
task.CompletedAt = &now
|
||||||
// 同步到数据库
|
|
||||||
if m.db != nil {
|
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)
|
delete(m.taskCancels, queueID)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Unlock()
|
// 同步队列状态到数据库(在锁内完成)
|
||||||
|
|
||||||
// 同步队列状态到数据库
|
|
||||||
if m.db != nil {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteQueue 删除队列
|
// DeleteQueue 删除队列(运行中的队列不允许删除)
|
||||||
func (m *BatchTaskManager) DeleteQueue(queueID string) bool {
|
func (m *BatchTaskManager) DeleteQueue(queueID string) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
_, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 运行中的队列不允许删除,防止孤儿协程和数据丢失
|
||||||
|
if queue.Status == BatchQueueStatusRunning {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 清理取消函数
|
// 清理取消函数
|
||||||
delete(m.taskCancels, queueID)
|
delete(m.taskCancels, queueID)
|
||||||
|
|
||||||
// 从数据库删除
|
// 从数据库删除
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
if err := m.db.DeleteBatchQueue(queueID); err != nil {
|
if err := m.db.DeleteBatchQueue(queueID); err != nil {
|
||||||
// 记录错误但继续(使用内存缓存)
|
m.logger.Warn("batch queue DB delete failed", zap.String("queueId", queueID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_start(queue_id 同上)。Cron 自动调度需 schedule_enabled 为 true,可用 batch_task_schedule_enabled。"
|
||||||
|
}(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- start ---
|
||||||
|
reg(mcp.Tool{
|
||||||
|
Name: builtin.ToolBatchTaskStart,
|
||||||
|
Description: `启动或继续执行批量任务队列(pending / paused)。
|
||||||
|
与 batch_task_create 配合使用:仅创建队列不会自动执行,需调用本工具才会开始跑子任务。`,
|
||||||
|
ShortDescription: "启动/继续批量任务队列(创建后需调用才会执行)",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"queue_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "队列 ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"queue_id"},
|
||||||
|
},
|
||||||
|
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
qid := mcpArgString(args, "queue_id")
|
||||||
|
if qid == "" {
|
||||||
|
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||||
|
}
|
||||||
|
ok, err := h.startBatchQueueExecution(qid, false)
|
||||||
|
if !ok {
|
||||||
|
return batchMCPTextResult("队列不存在: "+qid, true), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return batchMCPTextResult("启动失败: "+err.Error(), true), nil
|
||||||
|
}
|
||||||
|
logger.Info("MCP batch_task_start", zap.String("queueId", qid))
|
||||||
|
return batchMCPTextResult("已提交启动,队列将开始执行。", false), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- pause ---
|
||||||
|
reg(mcp.Tool{
|
||||||
|
Name: builtin.ToolBatchTaskPause,
|
||||||
|
Description: "暂停正在运行的批量任务队列(当前子任务会被取消)。",
|
||||||
|
ShortDescription: "暂停批量任务队列",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"queue_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "队列 ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"queue_id"},
|
||||||
|
},
|
||||||
|
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
qid := mcpArgString(args, "queue_id")
|
||||||
|
if qid == "" {
|
||||||
|
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||||
|
}
|
||||||
|
if !h.batchTaskManager.PauseQueue(qid) {
|
||||||
|
return batchMCPTextResult("无法暂停:队列不存在或当前非 running 状态", true), nil
|
||||||
|
}
|
||||||
|
logger.Info("MCP batch_task_pause", zap.String("queueId", qid))
|
||||||
|
return batchMCPTextResult("队列已暂停。", false), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- delete queue ---
|
||||||
|
reg(mcp.Tool{
|
||||||
|
Name: builtin.ToolBatchTaskDelete,
|
||||||
|
Description: "删除批量任务队列及其子任务记录。",
|
||||||
|
ShortDescription: "删除批量任务队列",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"queue_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "队列 ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"queue_id"},
|
||||||
|
},
|
||||||
|
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
qid := mcpArgString(args, "queue_id")
|
||||||
|
if qid == "" {
|
||||||
|
return batchMCPTextResult("queue_id 不能为空", true), nil
|
||||||
|
}
|
||||||
|
if !h.batchTaskManager.DeleteQueue(qid) {
|
||||||
|
return batchMCPTextResult("删除失败:队列不存在", true), nil
|
||||||
|
}
|
||||||
|
logger.Info("MCP batch_task_delete", zap.String("queueId", qid))
|
||||||
|
return batchMCPTextResult("队列已删除。", false), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
}
|
||||||
+82
-55
@@ -3,9 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,6 +16,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/knowledge"
|
"cyberstrike-ai/internal/knowledge"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -37,6 +36,9 @@ type WebshellToolRegistrar func() error
|
|||||||
// SkillsToolRegistrar Skills工具注册器接口
|
// SkillsToolRegistrar Skills工具注册器接口
|
||||||
type SkillsToolRegistrar func() error
|
type SkillsToolRegistrar func() error
|
||||||
|
|
||||||
|
// BatchTaskToolRegistrar 批量任务 MCP 工具注册器(ApplyConfig 时重新注册)
|
||||||
|
type BatchTaskToolRegistrar func() error
|
||||||
|
|
||||||
// RetrieverUpdater 检索器更新接口
|
// RetrieverUpdater 检索器更新接口
|
||||||
type RetrieverUpdater interface {
|
type RetrieverUpdater interface {
|
||||||
UpdateConfig(config *knowledge.RetrievalConfig)
|
UpdateConfig(config *knowledge.RetrievalConfig)
|
||||||
@@ -68,6 +70,7 @@ type ConfigHandler struct {
|
|||||||
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
|
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
|
||||||
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
|
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
|
||||||
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
|
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
|
||||||
|
batchTaskToolRegistrar BatchTaskToolRegistrar // 批量任务 MCP 工具(可选)
|
||||||
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
|
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
|
||||||
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
|
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
|
||||||
appUpdater AppUpdater // App更新器(可选)
|
appUpdater AppUpdater // App更新器(可选)
|
||||||
@@ -141,6 +144,13 @@ func (h *ConfigHandler) SetSkillsToolRegistrar(registrar SkillsToolRegistrar) {
|
|||||||
h.skillsToolRegistrar = registrar
|
h.skillsToolRegistrar = registrar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetBatchTaskToolRegistrar 设置批量任务 MCP 工具注册器
|
||||||
|
func (h *ConfigHandler) SetBatchTaskToolRegistrar(registrar BatchTaskToolRegistrar) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.batchTaskToolRegistrar = registrar
|
||||||
|
}
|
||||||
|
|
||||||
// SetRetrieverUpdater 设置检索器更新器
|
// SetRetrieverUpdater 设置检索器更新器
|
||||||
func (h *ConfigHandler) SetRetrieverUpdater(updater RetrieverUpdater) {
|
func (h *ConfigHandler) SetRetrieverUpdater(updater RetrieverUpdater) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
@@ -314,6 +324,17 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
searchTermLower = strings.ToLower(searchTerm)
|
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")
|
roleName := c.Query("role")
|
||||||
var roleToolsSet map[string]bool // 角色配置的工具集合
|
var roleToolsSet map[string]bool // 角色配置的工具集合
|
||||||
@@ -377,6 +398,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
allTools = append(allTools, toolInfo)
|
allTools = append(allTools, toolInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +459,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
allTools = append(allTools, toolInfo)
|
allTools = append(allTools, toolInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,6 +506,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
allTools = append(allTools, toolInfo)
|
allTools = append(allTools, toolInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,9 +794,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
|
|
||||||
// TestOpenAIRequest 测试OpenAI连接请求
|
// TestOpenAIRequest 测试OpenAI连接请求
|
||||||
type TestOpenAIRequest struct {
|
type TestOpenAIRequest struct {
|
||||||
BaseURL string `json:"base_url"`
|
Provider string `json:"provider"`
|
||||||
APIKey string `json:"api_key"`
|
BaseURL string `json:"base_url"`
|
||||||
Model string `json:"model"`
|
APIKey string `json:"api_key"`
|
||||||
|
Model string `json:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenAI 测试OpenAI API连接是否可用
|
// TestOpenAI 测试OpenAI API连接是否可用
|
||||||
@@ -782,7 +819,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
|
|
||||||
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://api.openai.com/v1"
|
if strings.EqualFold(strings.TrimSpace(req.Provider), "claude") {
|
||||||
|
baseURL = "https://api.anthropic.com"
|
||||||
|
} else {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造一个最小的 chat completion 请求
|
// 构造一个最小的 chat completion 请求
|
||||||
@@ -794,57 +835,19 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
"max_tokens": 5,
|
"max_tokens": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
|
||||||
if err != nil {
|
tmpCfg := &config.OpenAIConfig{
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造请求失败"})
|
Provider: req.Provider,
|
||||||
return
|
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)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造HTTP请求失败: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey))
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
|
||||||
latency := time.Since(start)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "连接失败: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
// 尝试提取错误信息
|
|
||||||
var errResp struct {
|
|
||||||
Error struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
errMsg := string(respBody)
|
|
||||||
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
|
|
||||||
errMsg = errResp.Error.Message
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", resp.StatusCode, errMsg),
|
|
||||||
"status_code": resp.StatusCode,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析响应并严格验证是否为有效的 chat completion 响应
|
|
||||||
var chatResp struct {
|
var chatResp struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
@@ -856,10 +859,21 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "API 响应不是有效的 JSON,请检查 Base URL 是否正确",
|
"error": "连接失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -868,14 +882,14 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
if len(chatResp.Choices) == 0 {
|
if len(chatResp.Choices) == 0 {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确(通常以 /v1 结尾)",
|
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if chatResp.ID == "" && chatResp.Model == "" {
|
if chatResp.ID == "" && chatResp.Model == "" {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "API 响应格式不符合 OpenAI 规范,请检查 Base URL 是否正确",
|
"error": "API 响应格式不符合预期,请检查 Base URL 是否正确",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -999,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 {
|
if h.config.Knowledge.Enabled && h.knowledgeToolRegistrar != nil {
|
||||||
h.logger.Info("重新注册知识库工具")
|
h.logger.Info("重新注册知识库工具")
|
||||||
@@ -1225,6 +1249,9 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
|
|||||||
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||||
root := doc.Content[0]
|
root := doc.Content[0]
|
||||||
openaiNode := ensureMap(root, "openai")
|
openaiNode := ensureMap(root, "openai")
|
||||||
|
if cfg.Provider != "" {
|
||||||
|
setStringInMap(openaiNode, "provider", cfg.Provider)
|
||||||
|
}
|
||||||
setStringInMap(openaiNode, "api_key", cfg.APIKey)
|
setStringInMap(openaiNode, "api_key", cfg.APIKey)
|
||||||
setStringInMap(openaiNode, "base_url", cfg.BaseURL)
|
setStringInMap(openaiNode, "base_url", cfg.BaseURL)
|
||||||
setStringInMap(openaiNode, "model", cfg.Model)
|
setStringInMap(openaiNode, "model", cfg.Model)
|
||||||
|
|||||||
@@ -403,6 +403,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "角色名称(可选)",
|
"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{}{
|
"BatchQueue": map[string]interface{}{
|
||||||
@@ -1540,9 +1558,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"message": map[string]interface{}{"type": "string"},
|
"message": map[string]interface{}{"type": "string"},
|
||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
@@ -1711,6 +1729,10 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"queue": map[string]interface{}{
|
"queue": map[string]interface{}{
|
||||||
"$ref": "#/components/schemas/BatchQueue",
|
"$ref": "#/components/schemas/BatchQueue",
|
||||||
},
|
},
|
||||||
|
"started": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否已立即启动执行",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -13,6 +14,13 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"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 中间件在上层路由组)
|
// wsUpgrader 仅用于系统设置中的终端 WebSocket,会复用已有的登录保护(JWT 中间件在上层路由组)
|
||||||
var wsUpgrader = websocket.Upgrader{
|
var wsUpgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
@@ -37,12 +45,13 @@ func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
cmd := exec.Command(shell)
|
cmd := exec.Command(shell)
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"COLUMNS=256",
|
"COLUMNS=80",
|
||||||
"LINES=40",
|
"LINES=24",
|
||||||
"TERM=xterm-256color",
|
"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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,6 +93,14 @@ func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
|
|||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
continue
|
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 {
|
if _, err := ptmx.Write(data); err != nil {
|
||||||
_ = cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ const (
|
|||||||
ToolSearchKnowledgeBase = "search_knowledge_base"
|
ToolSearchKnowledgeBase = "search_knowledge_base"
|
||||||
|
|
||||||
// Skills工具
|
// Skills工具
|
||||||
ToolListSkills = "list_skills"
|
ToolListSkills = "list_skills"
|
||||||
ToolReadSkill = "read_skill"
|
ToolReadSkill = "read_skill"
|
||||||
|
|
||||||
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
||||||
ToolWebshellExec = "webshell_exec"
|
ToolWebshellExec = "webshell_exec"
|
||||||
ToolWebshellFileList = "webshell_file_list"
|
ToolWebshellFileList = "webshell_file_list"
|
||||||
ToolWebshellFileRead = "webshell_file_read"
|
ToolWebshellFileRead = "webshell_file_read"
|
||||||
ToolWebshellFileWrite = "webshell_file_write"
|
ToolWebshellFileWrite = "webshell_file_write"
|
||||||
|
|
||||||
// WebShell 连接管理工具(用于通过 MCP 管理 webshell 连接)
|
// WebShell 连接管理工具(用于通过 MCP 管理 webshell 连接)
|
||||||
ToolManageWebshellList = "manage_webshell_list"
|
ToolManageWebshellList = "manage_webshell_list"
|
||||||
@@ -26,6 +26,20 @@ const (
|
|||||||
ToolManageWebshellUpdate = "manage_webshell_update"
|
ToolManageWebshellUpdate = "manage_webshell_update"
|
||||||
ToolManageWebshellDelete = "manage_webshell_delete"
|
ToolManageWebshellDelete = "manage_webshell_delete"
|
||||||
ToolManageWebshellTest = "manage_webshell_test"
|
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 检查工具名称是否是内置工具
|
// IsBuiltinTool 检查工具名称是否是内置工具
|
||||||
@@ -44,7 +58,19 @@ func IsBuiltinTool(toolName string) bool {
|
|||||||
ToolManageWebshellAdd,
|
ToolManageWebshellAdd,
|
||||||
ToolManageWebshellUpdate,
|
ToolManageWebshellUpdate,
|
||||||
ToolManageWebshellDelete,
|
ToolManageWebshellDelete,
|
||||||
ToolManageWebshellTest:
|
ToolManageWebshellTest,
|
||||||
|
ToolBatchTaskList,
|
||||||
|
ToolBatchTaskGet,
|
||||||
|
ToolBatchTaskCreate,
|
||||||
|
ToolBatchTaskStart,
|
||||||
|
ToolBatchTaskPause,
|
||||||
|
ToolBatchTaskDelete,
|
||||||
|
ToolBatchTaskUpdateMetadata,
|
||||||
|
ToolBatchTaskUpdateSchedule,
|
||||||
|
ToolBatchTaskScheduleEnabled,
|
||||||
|
ToolBatchTaskAdd,
|
||||||
|
ToolBatchTaskUpdate,
|
||||||
|
ToolBatchTaskRemove:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -68,5 +94,17 @@ func GetAllBuiltinTools() []string {
|
|||||||
ToolManageWebshellUpdate,
|
ToolManageWebshellUpdate,
|
||||||
ToolManageWebshellDelete,
|
ToolManageWebshellDelete,
|
||||||
ToolManageWebshellTest,
|
ToolManageWebshellTest,
|
||||||
|
ToolBatchTaskList,
|
||||||
|
ToolBatchTaskGet,
|
||||||
|
ToolBatchTaskCreate,
|
||||||
|
ToolBatchTaskStart,
|
||||||
|
ToolBatchTaskPause,
|
||||||
|
ToolBatchTaskDelete,
|
||||||
|
ToolBatchTaskUpdateMetadata,
|
||||||
|
ToolBatchTaskUpdateSchedule,
|
||||||
|
ToolBatchTaskScheduleEnabled,
|
||||||
|
ToolBatchTaskAdd,
|
||||||
|
ToolBatchTaskUpdate,
|
||||||
|
ToolBatchTaskRemove,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/agents"
|
"cyberstrike-ai/internal/agents"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/einomcp"
|
"cyberstrike-ai/internal/einomcp"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
@@ -141,6 +142,9 @@ func RunDeepAgent(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
|
||||||
|
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||||
|
|
||||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||||
APIKey: appCfg.OpenAI.APIKey,
|
APIKey: appCfg.OpenAI.APIKey,
|
||||||
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
|
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,9 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
|
|||||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||||
return fmt.Errorf("openai api key is empty")
|
return fmt.Errorf("openai api key is empty")
|
||||||
}
|
}
|
||||||
|
if c.isClaude() {
|
||||||
|
return c.claudeChatCompletion(ctx, payload, out)
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
@@ -156,6 +159,9 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
|||||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||||
return "", fmt.Errorf("openai api key is empty")
|
return "", fmt.Errorf("openai api key is empty")
|
||||||
}
|
}
|
||||||
|
if c.isClaude() {
|
||||||
|
return c.claudeChatCompletionStream(ctx, payload, onDelta)
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
@@ -294,6 +300,9 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||||
return "", nil, "", fmt.Errorf("openai api key is empty")
|
return "", nil, "", fmt.Errorf("openai api key is empty")
|
||||||
}
|
}
|
||||||
|
if c.isClaude() {
|
||||||
|
return c.claudeChatCompletionStreamWithToolCalls(ctx, payload, onContentDelta)
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
|
|||||||
BIN
Binary file not shown.
+141
-101
@@ -1,6 +1,7 @@
|
|||||||
package burp;
|
package burp;
|
||||||
|
|
||||||
import javax.swing.*;
|
import javax.swing.*;
|
||||||
|
import java.awt.*;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
|||||||
|
|
||||||
private CyberStrikeAITab tab;
|
private CyberStrikeAITab tab;
|
||||||
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||||
|
private String lastInstruction = HttpMessageFormatter.defaultInstruction();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
|
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
|
||||||
@@ -36,111 +38,149 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
|||||||
if (selected == null || selected.length == 0) {
|
if (selected == null || selected.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sendMessage(selected[0]);
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
items.add(sendItem);
|
items.add(sendItem);
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendMessage(IHttpRequestResponse msg) {
|
||||||
|
if (msg == null) return;
|
||||||
|
CyberStrikeAIClient.Config cfg = tab.currentConfig();
|
||||||
|
String token = tab.getToken();
|
||||||
|
if (token == null || token.trim().isEmpty()) {
|
||||||
|
JOptionPane.showMessageDialog(tab.getUiComponent(),
|
||||||
|
"Please click Validate first to obtain a token.",
|
||||||
|
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String instruction = showInstructionEditor(tab.getUiComponent(), lastInstruction);
|
||||||
|
if (instruction == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastInstruction = instruction;
|
||||||
|
|
||||||
|
String prompt = HttpMessageFormatter.toPrompt(helpers, msg, instruction);
|
||||||
|
String title = HttpMessageFormatter.getRequestTitle(helpers, msg);
|
||||||
|
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
|
||||||
|
String runId = tab.startNewRun(title, agentModeStr, msg);
|
||||||
|
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
|
||||||
|
|
||||||
|
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
|
||||||
|
@Override
|
||||||
|
public void onEvent(String type, String message, String rawJson) {
|
||||||
|
if (type == null) type = "";
|
||||||
|
switch (type) {
|
||||||
|
case "response_delta":
|
||||||
|
case "eino_agent_reply_stream_delta":
|
||||||
|
tab.appendFinalToRun(runId, message);
|
||||||
|
break;
|
||||||
|
case "response":
|
||||||
|
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
||||||
|
tab.appendFinalToRun(runId, message);
|
||||||
|
tab.setFinalResponse(runId, message);
|
||||||
|
break;
|
||||||
|
case "progress":
|
||||||
|
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "running");
|
||||||
|
break;
|
||||||
|
case "cancelled":
|
||||||
|
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "cancelled");
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "error");
|
||||||
|
break;
|
||||||
|
case "thinking_stream_start":
|
||||||
|
if (tab.isShowDebugEvents()) {
|
||||||
|
tab.resetThinkingStream(runId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "thinking_stream_delta":
|
||||||
|
case "tool_call":
|
||||||
|
case "tool_result":
|
||||||
|
case "tool_result_delta":
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
if ("thinking_stream_delta".equals(type)) {
|
||||||
|
tab.appendThinkingDelta(runId, message);
|
||||||
|
} else {
|
||||||
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "conversation":
|
||||||
|
if (rawJson != null) {
|
||||||
|
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
|
||||||
|
if (convId != null && !convId.trim().isEmpty()) {
|
||||||
|
tab.setRunConversationId(runId, convId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message, Exception e) {
|
||||||
|
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "error");
|
||||||
|
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||||
|
if (e != null) {
|
||||||
|
callbacks.printError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDone() {
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[done]\n");
|
||||||
|
tab.setRunStatus(runId, "done");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String showInstructionEditor(Component parent, String initialValue) {
|
||||||
|
JTextArea editor = new JTextArea(
|
||||||
|
initialValue == null || initialValue.trim().isEmpty()
|
||||||
|
? HttpMessageFormatter.defaultInstruction()
|
||||||
|
: initialValue,
|
||||||
|
6,
|
||||||
|
70
|
||||||
|
);
|
||||||
|
editor.setLineWrap(true);
|
||||||
|
editor.setWrapStyleWord(true);
|
||||||
|
editor.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13));
|
||||||
|
|
||||||
|
JPanel panel = new JPanel(new BorderLayout(0, 8));
|
||||||
|
panel.add(new JLabel("Edit instruction before sending:"), BorderLayout.NORTH);
|
||||||
|
panel.add(new JScrollPane(editor), BorderLayout.CENTER);
|
||||||
|
|
||||||
|
int result = JOptionPane.showConfirmDialog(
|
||||||
|
parent,
|
||||||
|
panel,
|
||||||
|
"Customize Prompt Instruction",
|
||||||
|
JOptionPane.OK_CANCEL_OPTION,
|
||||||
|
JOptionPane.PLAIN_MESSAGE
|
||||||
|
);
|
||||||
|
if (result != JOptionPane.OK_OPTION) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String value = editor.getText();
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return HttpMessageFormatter.defaultInstruction();
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -5,6 +5,8 @@ import java.util.List;
|
|||||||
|
|
||||||
final class HttpMessageFormatter {
|
final class HttpMessageFormatter {
|
||||||
private HttpMessageFormatter() {}
|
private HttpMessageFormatter() {}
|
||||||
|
private static final String DEFAULT_INSTRUCTION =
|
||||||
|
"针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口";
|
||||||
|
|
||||||
static String getRequestTitle(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
static String getRequestTitle(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
||||||
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
||||||
@@ -22,7 +24,15 @@ final class HttpMessageFormatter {
|
|||||||
return method + " " + host + shortPath + q;
|
return method + " " + host + shortPath + q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String defaultInstruction() {
|
||||||
|
return DEFAULT_INSTRUCTION;
|
||||||
|
}
|
||||||
|
|
||||||
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
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);
|
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
||||||
String method = reqInfo.getMethod();
|
String method = reqInfo.getMethod();
|
||||||
String url = reqInfo.getUrl() != null ? reqInfo.getUrl().toString() : "(unknown)";
|
String url = reqInfo.getUrl() != null ? reqInfo.getUrl().toString() : "(unknown)";
|
||||||
@@ -53,8 +63,12 @@ final class HttpMessageFormatter {
|
|||||||
+ respBody;
|
+ respBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String prefix = (instruction == null || instruction.trim().isEmpty())
|
||||||
|
? DEFAULT_INSTRUCTION
|
||||||
|
: instruction.trim();
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
+ "针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口\n\n"
|
+ prefix + "\n\n"
|
||||||
+ "[Target]\n"
|
+ "[Target]\n"
|
||||||
+ method + " " + url + "\n\n"
|
+ method + " " + url + "\n\n"
|
||||||
+ "[Request]\n"
|
+ "[Request]\n"
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,403 @@
|
|||||||
|
name: "shodan_search"
|
||||||
|
command: "python3"
|
||||||
|
args:
|
||||||
|
- "-c"
|
||||||
|
- |
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
|
||||||
|
# ==================== Shodan配置 ====================
|
||||||
|
# 请在此处配置您的Shodan API Key
|
||||||
|
# 您也可以在环境变量中设置:SHODAN_API_KEY
|
||||||
|
# enable 默认为 false,需开启才能调用该MCP
|
||||||
|
SHODAN_API_KEY = "" # 请替换为您自己的Shodan API Key
|
||||||
|
# ==================================================
|
||||||
|
|
||||||
|
# Shodan API基础URL
|
||||||
|
base_url = "https://api.shodan.io"
|
||||||
|
|
||||||
|
# 解析参数(从JSON字符串或命令行参数)
|
||||||
|
def parse_args():
|
||||||
|
# 尝试从第一个参数读取JSON配置
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
arg1 = str(sys.argv[1])
|
||||||
|
config = json.loads(arg1)
|
||||||
|
if isinstance(config, dict):
|
||||||
|
return config
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 传统位置参数方式(向后兼容)
|
||||||
|
# 兼容两种序列:
|
||||||
|
# 1) query,page,facets,minify,fields,count_only,size
|
||||||
|
# 2) query,page,minify,fields,count_only,size (facets省略时执行器会压缩参数)
|
||||||
|
config = {}
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
config["query"] = str(sys.argv[1])
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
try:
|
||||||
|
config["page"] = int(sys.argv[2])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_bool_like(val):
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return True
|
||||||
|
if not isinstance(val, str):
|
||||||
|
return False
|
||||||
|
return val.strip().lower() in ("true", "false", "1", "0", "yes", "no")
|
||||||
|
|
||||||
|
remaining = [str(x) for x in sys.argv[3:]]
|
||||||
|
if remaining:
|
||||||
|
# facets 省略时,第一个剩余参数通常是 minify(布尔)
|
||||||
|
first_is_bool = is_bool_like(remaining[0])
|
||||||
|
idx = 0
|
||||||
|
if not first_is_bool:
|
||||||
|
config["facets"] = remaining[idx]
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if idx < len(remaining):
|
||||||
|
val = remaining[idx]
|
||||||
|
config["minify"] = val.lower() in ("true", "1", "yes")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if idx < len(remaining):
|
||||||
|
config["fields"] = remaining[idx]
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if idx < len(remaining):
|
||||||
|
val = remaining[idx]
|
||||||
|
config["count_only"] = val.lower() in ("true", "1", "yes")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if idx < len(remaining):
|
||||||
|
try:
|
||||||
|
config["size"] = int(remaining[idx])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def normalize_bool(value, default_value):
|
||||||
|
if value is None:
|
||||||
|
return default_value
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() in ("true", "1", "yes")
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = parse_args()
|
||||||
|
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
|
||||||
|
"type": "TypeError"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
api_key = os.getenv("SHODAN_API_KEY", SHODAN_API_KEY).strip()
|
||||||
|
query = str(config.get("query", "")).strip()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "缺少Shodan配置: api_key(Shodan API密钥)",
|
||||||
|
"required_config": ["api_key"],
|
||||||
|
"note": "请在YAML文件的SHODAN_API_KEY配置项中填写您的API密钥,或在环境变量SHODAN_API_KEY中设置。API密钥可在Shodan账户页面查看: https://account.shodan.io/"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "缺少必需参数: query(搜索查询语句)",
|
||||||
|
"required_params": ["query"],
|
||||||
|
"examples": [
|
||||||
|
"product:nginx",
|
||||||
|
"apache country:DE",
|
||||||
|
"port:22",
|
||||||
|
"ssl.cert.subject.cn:example.com",
|
||||||
|
"org:\"Amazon\" port:443"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
count_only = normalize_bool(config.get("count_only"), False)
|
||||||
|
minify = normalize_bool(config.get("minify"), True)
|
||||||
|
requested_size = config.get("size", None)
|
||||||
|
if requested_size is not None:
|
||||||
|
try:
|
||||||
|
requested_size = int(requested_size)
|
||||||
|
if requested_size <= 0:
|
||||||
|
requested_size = None
|
||||||
|
else:
|
||||||
|
# 防止单次请求过大导致额度和响应时间问题
|
||||||
|
requested_size = min(requested_size, 1000)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
requested_size = None
|
||||||
|
|
||||||
|
# 根据 count_only 选择搜索端点
|
||||||
|
endpoint = "/shodan/host/count" if count_only else "/shodan/host/search"
|
||||||
|
url = f"{base_url}{endpoint}"
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"key": api_key,
|
||||||
|
"query": query
|
||||||
|
}
|
||||||
|
|
||||||
|
# 可选参数 facets(search 和 count 都支持)
|
||||||
|
if "facets" in config and config["facets"]:
|
||||||
|
facets_value = str(config["facets"]).strip()
|
||||||
|
if facets_value:
|
||||||
|
params["facets"] = facets_value
|
||||||
|
|
||||||
|
# search 接口的可选参数
|
||||||
|
if not count_only:
|
||||||
|
if "page" in config and config["page"] is not None:
|
||||||
|
try:
|
||||||
|
page = int(config["page"])
|
||||||
|
if page > 0:
|
||||||
|
params["page"] = page
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
minify_effective = minify
|
||||||
|
|
||||||
|
if "fields" in config and config["fields"]:
|
||||||
|
fields_value = str(config["fields"]).strip()
|
||||||
|
if fields_value:
|
||||||
|
params["fields"] = fields_value
|
||||||
|
# Shodan API约束:fields 与 minify=true 互斥
|
||||||
|
minify_effective = False
|
||||||
|
|
||||||
|
params["minify"] = "true" if minify_effective else "false"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if count_only:
|
||||||
|
response = requests.get(url, params=params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
result_data = response.json()
|
||||||
|
|
||||||
|
if isinstance(result_data, dict) and result_data.get("error"):
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Shodan API错误: {result_data.get('error', '未知错误')}",
|
||||||
|
"suggestion": "请检查API密钥、查询语法和账户查询额度"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status": "success",
|
||||||
|
"mode": "count",
|
||||||
|
"query": query,
|
||||||
|
"total": result_data.get("total", 0),
|
||||||
|
"facets": result_data.get("facets", {}),
|
||||||
|
"size": requested_size,
|
||||||
|
"note": "count模式仅返回统计,不返回明细结果",
|
||||||
|
"message": "统计查询完成(未返回资产明细)"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
start_page = int(params.get("page", 1))
|
||||||
|
# Shodan search 每页固定最多100条
|
||||||
|
# 如果未指定 size,则保持原始行为(单页)
|
||||||
|
target_size = requested_size if requested_size else 100
|
||||||
|
pages_needed = 1 if not requested_size else max(1, int(math.ceil(target_size / 100.0)))
|
||||||
|
|
||||||
|
all_matches = []
|
||||||
|
last_result_data = {}
|
||||||
|
current_page = start_page
|
||||||
|
pages_fetched = 0
|
||||||
|
|
||||||
|
for _ in range(pages_needed):
|
||||||
|
page_params = dict(params)
|
||||||
|
page_params["page"] = current_page
|
||||||
|
|
||||||
|
response = requests.get(url, params=page_params, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
result_data = response.json()
|
||||||
|
last_result_data = result_data if isinstance(result_data, dict) else {}
|
||||||
|
pages_fetched += 1
|
||||||
|
|
||||||
|
if isinstance(last_result_data, dict) and last_result_data.get("error"):
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Shodan API错误: {last_result_data.get('error', '未知错误')}",
|
||||||
|
"suggestion": "请检查API密钥、查询语法和账户查询额度"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
page_matches = last_result_data.get("matches", []) if isinstance(last_result_data, dict) else []
|
||||||
|
if not page_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_matches.extend(page_matches)
|
||||||
|
if len(all_matches) >= target_size:
|
||||||
|
break
|
||||||
|
current_page += 1
|
||||||
|
|
||||||
|
matches = all_matches[:target_size]
|
||||||
|
output = {
|
||||||
|
"status": "success",
|
||||||
|
"mode": "search",
|
||||||
|
"query": query,
|
||||||
|
"page": start_page,
|
||||||
|
"size": target_size,
|
||||||
|
"pages_fetched": pages_fetched,
|
||||||
|
"total": last_result_data.get("total", 0),
|
||||||
|
"results_count": len(matches),
|
||||||
|
"facets": last_result_data.get("facets", {}),
|
||||||
|
"results": matches,
|
||||||
|
"message": f"成功获取 {len(matches)} 条结果"
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
response_body = ""
|
||||||
|
status_code = None
|
||||||
|
if hasattr(e, "response") and e.response is not None:
|
||||||
|
status_code = e.response.status_code
|
||||||
|
try:
|
||||||
|
response_body = e.response.text[:500]
|
||||||
|
except Exception:
|
||||||
|
response_body = ""
|
||||||
|
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"请求失败: {str(e)}",
|
||||||
|
"status_code": status_code,
|
||||||
|
"response": response_body,
|
||||||
|
"suggestion": "请检查网络连接、Shodan API状态、API密钥与查询额度"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"执行出错: {str(e)}",
|
||||||
|
"type": type(e).__name__
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
enabled: false
|
||||||
|
short_description: "Shodan网络空间搜索,支持search与count模式"
|
||||||
|
description: |
|
||||||
|
Shodan 资产搜索工具,基于官方 Developer API 实现,支持快速检索和统计分析。
|
||||||
|
|
||||||
|
**主要功能:**
|
||||||
|
- 使用 `/shodan/host/search` 进行资产搜索
|
||||||
|
- 使用 `/shodan/host/count` 进行无明细统计(节省查询信用)
|
||||||
|
- 支持按 `size` 控制返回条数(自动翻页聚合)
|
||||||
|
- 支持分页(page)
|
||||||
|
- 支持分面统计(facets)
|
||||||
|
- 支持结果字段裁剪(fields)
|
||||||
|
- 支持 `minify` 控制返回数据体积
|
||||||
|
|
||||||
|
**鉴权方式:**
|
||||||
|
- Query 参数使用 `key`
|
||||||
|
- 可在本文件中填写 `SHODAN_API_KEY`,或通过环境变量 `SHODAN_API_KEY` 注入
|
||||||
|
|
||||||
|
**查询语法示例:**
|
||||||
|
- `product:nginx`
|
||||||
|
- `apache country:DE`
|
||||||
|
- `port:22`
|
||||||
|
- `org:"Amazon" port:443`
|
||||||
|
- `ssl.cert.subject.cn:example.com`
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- 带过滤器的查询通常会消耗 query credits
|
||||||
|
- 翻页(超过第1页)会额外消耗额度
|
||||||
|
- `size` 大于 100 时会自动请求更多页(每页最多 100)
|
||||||
|
- `size` 最大限制为 1000(防止过量请求)
|
||||||
|
- `count_only=true` 使用统计接口,不返回 matches 明细
|
||||||
|
parameters:
|
||||||
|
- name: "query"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
Shodan 搜索语句(必需)。
|
||||||
|
|
||||||
|
支持 Shodan filter 语法(`filter:value`)与关键字组合。
|
||||||
|
示例:
|
||||||
|
- `product:nginx`
|
||||||
|
- `apache country:DE`
|
||||||
|
- `port:22`
|
||||||
|
- `org:"Amazon" port:443`
|
||||||
|
required: true
|
||||||
|
position: 1
|
||||||
|
format: "positional"
|
||||||
|
- name: "page"
|
||||||
|
type: "int"
|
||||||
|
description: |
|
||||||
|
页码(可选,仅 search 模式生效),从 1 开始,默认 1。
|
||||||
|
required: false
|
||||||
|
position: 2
|
||||||
|
format: "positional"
|
||||||
|
default: 1
|
||||||
|
- name: "facets"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
分面统计字段(可选)。
|
||||||
|
|
||||||
|
多个字段用英文逗号分隔,也可指定数量:
|
||||||
|
- `org,os`
|
||||||
|
- `country:20,org:10`
|
||||||
|
required: false
|
||||||
|
position: 3
|
||||||
|
format: "positional"
|
||||||
|
- name: "minify"
|
||||||
|
type: "bool"
|
||||||
|
description: |
|
||||||
|
是否精简返回字段(可选,仅 search 模式生效)。
|
||||||
|
默认 `true`。
|
||||||
|
required: false
|
||||||
|
position: 4
|
||||||
|
format: "positional"
|
||||||
|
default: true
|
||||||
|
- name: "fields"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
指定返回字段(可选,仅 search 模式生效)。
|
||||||
|
|
||||||
|
多个字段用英文逗号分隔,例如:
|
||||||
|
- `ip_str,port,org,hostnames,http.title`
|
||||||
|
- `tags,http.title,http.favicon.hash`
|
||||||
|
required: false
|
||||||
|
position: 5
|
||||||
|
format: "positional"
|
||||||
|
- name: "count_only"
|
||||||
|
type: "bool"
|
||||||
|
description: |
|
||||||
|
是否仅统计总数(可选)。
|
||||||
|
|
||||||
|
- `false`(默认):调用 `/shodan/host/search` 返回明细
|
||||||
|
- `true`:调用 `/shodan/host/count` 仅返回 total 和 facets
|
||||||
|
required: false
|
||||||
|
position: 6
|
||||||
|
format: "positional"
|
||||||
|
default: false
|
||||||
|
- name: "size"
|
||||||
|
type: "int"
|
||||||
|
description: |
|
||||||
|
返回结果数量(可选,仅 search 模式生效)。
|
||||||
|
|
||||||
|
- 支持 `10 / 20 / 100 / n`
|
||||||
|
- Shodan 单页最多 100,超过 100 时会自动翻页拼接
|
||||||
|
- 为避免额度和时延问题,最大值限制为 1000
|
||||||
|
- 未传时默认返回单页结果(最多 100 条)
|
||||||
|
required: false
|
||||||
|
position: 7
|
||||||
|
format: "positional"
|
||||||
+1109
-26
File diff suppressed because it is too large
Load Diff
@@ -251,9 +251,18 @@
|
|||||||
"clearHistory": "Clear history",
|
"clearHistory": "Clear history",
|
||||||
"cancelTask": "Cancel task",
|
"cancelTask": "Cancel task",
|
||||||
"viewConversation": "View conversation",
|
"viewConversation": "View conversation",
|
||||||
|
"retryTask": "Retry",
|
||||||
"conversationIdLabel": "Conversation ID",
|
"conversationIdLabel": "Conversation ID",
|
||||||
"statusPending": "Pending",
|
"statusPending": "Pending",
|
||||||
"statusPaused": "Paused",
|
"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)?",
|
"confirmCancelTasks": "Cancel {{n}} selected task(s)?",
|
||||||
"batchCancelResultPartial": "Batch cancel: {{success}} succeeded, {{fail}} failed",
|
"batchCancelResultPartial": "Batch cancel: {{success}} succeeded, {{fail}} failed",
|
||||||
"batchCancelResultSuccess": "Successfully cancelled {{n}} task(s)",
|
"batchCancelResultSuccess": "Successfully cancelled {{n}} task(s)",
|
||||||
@@ -276,6 +285,7 @@
|
|||||||
"deleteQueueConfirm": "Delete this batch queue? This cannot be undone.",
|
"deleteQueueConfirm": "Delete this batch queue? This cannot be undone.",
|
||||||
"deleteQueueFailed": "Failed to delete batch queue",
|
"deleteQueueFailed": "Failed to delete batch queue",
|
||||||
"batchQueueTitle": "Batch task queue",
|
"batchQueueTitle": "Batch task queue",
|
||||||
|
"batchQueueUntitled": "Untitled queue",
|
||||||
"resumeExecute": "Resume",
|
"resumeExecute": "Resume",
|
||||||
"taskIncomplete": "Task information incomplete",
|
"taskIncomplete": "Task information incomplete",
|
||||||
"cannotGetTaskMessageInput": "Cannot get task message input",
|
"cannotGetTaskMessageInput": "Cannot get task message input",
|
||||||
@@ -285,7 +295,7 @@
|
|||||||
"addTaskFailed": "Failed to add task",
|
"addTaskFailed": "Failed to add task",
|
||||||
"confirmDeleteTask": "Delete this task?\n\nTask: {{message}}\n\nThis cannot be undone.",
|
"confirmDeleteTask": "Delete this task?\n\nTask: {{message}}\n\nThis cannot be undone.",
|
||||||
"deleteTaskFailed": "Failed to delete task",
|
"deleteTaskFailed": "Failed to delete task",
|
||||||
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
"paginationShow": "Show {{start}}-{{end}} of {{total}} records",
|
||||||
"paginationPerPage": "Per page",
|
"paginationPerPage": "Per page",
|
||||||
"paginationFirst": "First",
|
"paginationFirst": "First",
|
||||||
"paginationPrev": "Previous",
|
"paginationPrev": "Previous",
|
||||||
@@ -498,6 +508,8 @@
|
|||||||
"toolSearchPlaceholder": "Enter tool name...",
|
"toolSearchPlaceholder": "Enter tool name...",
|
||||||
"statusFilter": "Status filter",
|
"statusFilter": "Status filter",
|
||||||
"filterAll": "All",
|
"filterAll": "All",
|
||||||
|
"filterEnabled": "Enabled",
|
||||||
|
"filterDisabled": "Disabled",
|
||||||
"selectedCount": "{{count}} selected",
|
"selectedCount": "{{count}} selected",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"deselectAll": "Deselect all",
|
"deselectAll": "Deselect all",
|
||||||
@@ -1494,6 +1506,21 @@
|
|||||||
"role": "Role",
|
"role": "Role",
|
||||||
"defaultRole": "Default",
|
"defaultRole": "Default",
|
||||||
"roleHint": "Select a role; all tasks will be executed using that role's configuration (prompt and tools).",
|
"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)",
|
"tasksList": "Task list (one task per line)",
|
||||||
"tasksListPlaceholder": "Enter task list, one 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",
|
"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",
|
||||||
@@ -1505,6 +1532,8 @@
|
|||||||
"title": "Batch queue details",
|
"title": "Batch queue details",
|
||||||
"addTask": "Add task",
|
"addTask": "Add task",
|
||||||
"startExecute": "Start",
|
"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",
|
"pauseQueue": "Pause queue",
|
||||||
"deleteQueue": "Delete queue",
|
"deleteQueue": "Delete queue",
|
||||||
"queueTitle": "Task title",
|
"queueTitle": "Task title",
|
||||||
@@ -1514,13 +1543,27 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"createdAt": "Created at",
|
"createdAt": "Created at",
|
||||||
"startedAt": "Started 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",
|
"completedAt": "Completed at",
|
||||||
"taskTotal": "Total tasks",
|
"taskTotal": "Total tasks",
|
||||||
"taskList": "Task list",
|
"taskList": "Task list",
|
||||||
"startLabel": "Start",
|
"startLabel": "Start",
|
||||||
"completeLabel": "Complete",
|
"completeLabel": "Complete",
|
||||||
"errorLabel": "Error",
|
"errorLabel": "Error",
|
||||||
"resultLabel": "Result"
|
"resultLabel": "Result",
|
||||||
|
"technicalDetails": "Technical details (ID, times, schedule)"
|
||||||
},
|
},
|
||||||
"editBatchTaskModal": {
|
"editBatchTaskModal": {
|
||||||
"title": "Edit task",
|
"title": "Edit task",
|
||||||
|
|||||||
@@ -251,9 +251,18 @@
|
|||||||
"clearHistory": "清空历史",
|
"clearHistory": "清空历史",
|
||||||
"cancelTask": "取消任务",
|
"cancelTask": "取消任务",
|
||||||
"viewConversation": "查看对话",
|
"viewConversation": "查看对话",
|
||||||
|
"retryTask": "重试",
|
||||||
"conversationIdLabel": "对话ID",
|
"conversationIdLabel": "对话ID",
|
||||||
"statusPending": "待执行",
|
"statusPending": "待执行",
|
||||||
"statusPaused": "已暂停",
|
"statusPaused": "已暂停",
|
||||||
|
"statusCronCycleIdle": "本轮已完成 · 定时循环中",
|
||||||
|
"statusCronRunning": "执行中 · 定时队列",
|
||||||
|
"cronNextRunLine": "下次执行:{{time}}",
|
||||||
|
"cronRoundDoneProgressHint": "定时队列:子任务已跑完,到点将自动下一轮",
|
||||||
|
"cronRunningProgressHint": "本轮执行中;下一整轮仍按 Cron 与「下次执行时间」排程",
|
||||||
|
"cronPendingScheduled": "Cron 已排程 · 下次 {{time}}",
|
||||||
|
"cronPendingProgressNote": "到点将自动开始;也可手动点「开始执行」立即跑一轮",
|
||||||
|
"cronRecurringCallout": "Cron 队列会在「下次执行时间」自动开始新一轮;关闭「允许 Cron 自动调度」即停止循环。",
|
||||||
"confirmCancelTasks": "确定要取消 {{n}} 个任务吗?",
|
"confirmCancelTasks": "确定要取消 {{n}} 个任务吗?",
|
||||||
"batchCancelResultPartial": "批量取消完成:成功 {{success}} 个,失败 {{fail}} 个",
|
"batchCancelResultPartial": "批量取消完成:成功 {{success}} 个,失败 {{fail}} 个",
|
||||||
"batchCancelResultSuccess": "成功取消 {{n}} 个任务",
|
"batchCancelResultSuccess": "成功取消 {{n}} 个任务",
|
||||||
@@ -276,6 +285,7 @@
|
|||||||
"deleteQueueConfirm": "确定要删除这个批量任务队列吗?此操作不可恢复。",
|
"deleteQueueConfirm": "确定要删除这个批量任务队列吗?此操作不可恢复。",
|
||||||
"deleteQueueFailed": "删除批量任务队列失败",
|
"deleteQueueFailed": "删除批量任务队列失败",
|
||||||
"batchQueueTitle": "批量任务队列",
|
"batchQueueTitle": "批量任务队列",
|
||||||
|
"batchQueueUntitled": "未命名队列",
|
||||||
"resumeExecute": "继续执行",
|
"resumeExecute": "继续执行",
|
||||||
"taskIncomplete": "任务信息不完整",
|
"taskIncomplete": "任务信息不完整",
|
||||||
"cannotGetTaskMessageInput": "无法获取任务消息输入框",
|
"cannotGetTaskMessageInput": "无法获取任务消息输入框",
|
||||||
@@ -285,7 +295,7 @@
|
|||||||
"addTaskFailed": "添加任务失败",
|
"addTaskFailed": "添加任务失败",
|
||||||
"confirmDeleteTask": "确定要删除这个任务吗?\n\n任务内容: {{message}}\n\n此操作不可恢复。",
|
"confirmDeleteTask": "确定要删除这个任务吗?\n\n任务内容: {{message}}\n\n此操作不可恢复。",
|
||||||
"deleteTaskFailed": "删除任务失败",
|
"deleteTaskFailed": "删除任务失败",
|
||||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
||||||
"paginationPerPage": "每页显示",
|
"paginationPerPage": "每页显示",
|
||||||
"paginationFirst": "首页",
|
"paginationFirst": "首页",
|
||||||
"paginationPrev": "上一页",
|
"paginationPrev": "上一页",
|
||||||
@@ -498,6 +508,8 @@
|
|||||||
"toolSearchPlaceholder": "输入工具名称...",
|
"toolSearchPlaceholder": "输入工具名称...",
|
||||||
"statusFilter": "状态筛选",
|
"statusFilter": "状态筛选",
|
||||||
"filterAll": "全部",
|
"filterAll": "全部",
|
||||||
|
"filterEnabled": "已启用",
|
||||||
|
"filterDisabled": "已停用",
|
||||||
"selectedCount": "已选择 {{count}} 项",
|
"selectedCount": "已选择 {{count}} 项",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"deselectAll": "全不选",
|
"deselectAll": "全不选",
|
||||||
@@ -1494,6 +1506,21 @@
|
|||||||
"role": "角色",
|
"role": "角色",
|
||||||
"defaultRole": "默认",
|
"defaultRole": "默认",
|
||||||
"roleHint": "选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。",
|
"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": "任务列表(每行一个任务)",
|
"tasksList": "任务列表(每行一个任务)",
|
||||||
"tasksListPlaceholder": "请输入任务列表,每行一个任务",
|
"tasksListPlaceholder": "请输入任务列表,每行一个任务",
|
||||||
"tasksListPlaceholderExample": "请输入任务列表,每行一个任务,例如:\n扫描 192.168.1.1 的开放端口\n检查 https://example.com 是否存在SQL注入\n枚举 example.com 的子域名",
|
"tasksListPlaceholderExample": "请输入任务列表,每行一个任务,例如:\n扫描 192.168.1.1 的开放端口\n检查 https://example.com 是否存在SQL注入\n枚举 example.com 的子域名",
|
||||||
@@ -1505,6 +1532,8 @@
|
|||||||
"title": "批量任务队列详情",
|
"title": "批量任务队列详情",
|
||||||
"addTask": "添加任务",
|
"addTask": "添加任务",
|
||||||
"startExecute": "开始执行",
|
"startExecute": "开始执行",
|
||||||
|
"startExecuteNow": "立即执行一轮",
|
||||||
|
"startExecuteNowConfirm": "这是 Cron 队列,点击后会立即执行当前这一轮,不会等待下次 Cron 时间。确定立即执行吗?",
|
||||||
"pauseQueue": "暂停队列",
|
"pauseQueue": "暂停队列",
|
||||||
"deleteQueue": "删除队列",
|
"deleteQueue": "删除队列",
|
||||||
"queueTitle": "任务标题",
|
"queueTitle": "任务标题",
|
||||||
@@ -1514,13 +1543,27 @@
|
|||||||
"status": "状态",
|
"status": "状态",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"startedAt": "开始时间",
|
"startedAt": "开始时间",
|
||||||
|
"nextRunAt": "下次执行时间",
|
||||||
|
"scheduleCronAuto": "允许 Cron 自动调度",
|
||||||
|
"scheduleCronAutoHint": "关闭后仅保留表达式配置,不会按时间自动跑;可随时手工点「开始执行」。",
|
||||||
|
"editSchedule": "修改调度",
|
||||||
|
"editScheduleTitle": "修改调度配置",
|
||||||
|
"editScheduleSuccess": "调度配置已更新",
|
||||||
|
"editScheduleError": "更新调度配置失败",
|
||||||
|
"editMetadata": "编辑信息",
|
||||||
|
"lastScheduleTriggerAt": "最近调度触发时间",
|
||||||
|
"lastScheduleError": "最近调度失败原因",
|
||||||
|
"lastRunError": "最近运行失败摘要",
|
||||||
|
"cronSchedulePausedBadge": "调度已暂停",
|
||||||
|
"scheduleToggleFailed": "更新调度开关失败",
|
||||||
"completedAt": "完成时间",
|
"completedAt": "完成时间",
|
||||||
"taskTotal": "任务总数",
|
"taskTotal": "任务总数",
|
||||||
"taskList": "任务列表",
|
"taskList": "任务列表",
|
||||||
"startLabel": "开始",
|
"startLabel": "开始",
|
||||||
"completeLabel": "完成",
|
"completeLabel": "完成",
|
||||||
"errorLabel": "错误",
|
"errorLabel": "错误",
|
||||||
"resultLabel": "结果"
|
"resultLabel": "结果",
|
||||||
|
"technicalDetails": "技术信息(ID / 时间 / 调度)"
|
||||||
},
|
},
|
||||||
"editBatchTaskModal": {
|
"editBatchTaskModal": {
|
||||||
"title": "编辑任务",
|
"title": "编辑任务",
|
||||||
|
|||||||
@@ -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) {
|
switch(pageId) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
if (typeof refreshDashboard === 'function') {
|
if (typeof refreshDashboard === 'function') {
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ async function loadConfig(loadTools = true) {
|
|||||||
currentConfig = await response.json();
|
currentConfig = await response.json();
|
||||||
|
|
||||||
// 填充OpenAI配置
|
// 填充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-api-key').value = currentConfig.openai.api_key || '';
|
||||||
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
|
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
|
||||||
document.getElementById('openai-model').value = currentConfig.openai.model || '';
|
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';
|
if (maMode) maMode.value = (ma.default_mode === 'multi') ? 'multi' : 'single';
|
||||||
const maRobot = document.getElementById('multi-agent-robot-use');
|
const maRobot = document.getElementById('multi-agent-robot-use');
|
||||||
if (maRobot) maRobot.checked = ma.robot_use_multi_agent === true;
|
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');
|
const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled');
|
||||||
@@ -275,8 +277,13 @@ async function loadConfig(loadTools = true) {
|
|||||||
// 工具搜索关键词
|
// 工具搜索关键词
|
||||||
let toolsSearchKeyword = '';
|
let toolsSearchKeyword = '';
|
||||||
|
|
||||||
|
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
|
||||||
|
let toolsStatusFilter = '';
|
||||||
|
|
||||||
// 加载工具列表(分页)
|
// 加载工具列表(分页)
|
||||||
async function loadToolsList(page = 1, searchKeyword = '') {
|
async function loadToolsList(page = 1, searchKeyword = '') {
|
||||||
|
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
||||||
|
if (window.i18nReady) await window.i18nReady;
|
||||||
const toolsList = document.getElementById('tools-list');
|
const toolsList = document.getElementById('tools-list');
|
||||||
|
|
||||||
// 显示加载状态
|
// 显示加载状态
|
||||||
@@ -294,6 +301,9 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
|||||||
if (searchKeyword) {
|
if (searchKeyword) {
|
||||||
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
||||||
}
|
}
|
||||||
|
if (toolsStatusFilter !== '') {
|
||||||
|
url += `&enabled=${toolsStatusFilter}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 使用较短的超时时间(10秒),避免长时间等待
|
// 使用较短的超时时间(10秒),避免长时间等待
|
||||||
const controller = new AbortController();
|
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() {
|
function renderToolsList() {
|
||||||
const toolsList = document.getElementById('tools-list');
|
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 apiKey = document.getElementById('openai-api-key').value.trim();
|
||||||
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||||
const model = document.getElementById('openai-model').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 wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||||
const config = {
|
const config = {
|
||||||
openai: {
|
openai: {
|
||||||
|
provider: provider,
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
model: model
|
model: model
|
||||||
@@ -820,7 +843,7 @@ async function applySettings() {
|
|||||||
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
||||||
default_mode: document.getElementById('multi-agent-default-mode')?.value === 'multi' ? 'multi' : 'single',
|
default_mode: document.getElementById('multi-agent-default-mode')?.value === 'multi' ? 'multi' : 'single',
|
||||||
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
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,
|
knowledge: knowledgeConfig,
|
||||||
robots: {
|
robots: {
|
||||||
@@ -964,6 +987,7 @@ async function testOpenAIConnection() {
|
|||||||
const btn = document.getElementById('test-openai-btn');
|
const btn = document.getElementById('test-openai-btn');
|
||||||
const resultEl = document.getElementById('test-openai-result');
|
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 baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||||
const apiKey = document.getElementById('openai-api-key').value.trim();
|
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||||
const model = document.getElementById('openai-model').value.trim();
|
const model = document.getElementById('openai-model').value.trim();
|
||||||
@@ -984,6 +1008,7 @@ async function testOpenAIConnection() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
provider: provider,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
model: model
|
model: model
|
||||||
@@ -1226,6 +1251,8 @@ async function fetchExternalMCPs() {
|
|||||||
// 加载外部MCP列表并渲染
|
// 加载外部MCP列表并渲染
|
||||||
async function loadExternalMCPs() {
|
async function loadExternalMCPs() {
|
||||||
try {
|
try {
|
||||||
|
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
||||||
|
if (window.i18nReady) await window.i18nReady;
|
||||||
const data = await fetchExternalMCPs();
|
const data = await fetchExternalMCPs();
|
||||||
renderExternalMCPList(data.servers || {});
|
renderExternalMCPList(data.servers || {});
|
||||||
renderExternalMCPStats(data.stats || {});
|
renderExternalMCPStats(data.stats || {});
|
||||||
|
|||||||
+532
-146
@@ -3,6 +3,69 @@ function _t(key, opts) {
|
|||||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 插值不转 HTML 实体(避免日期里的 / 变成 / 再被 escapeHtml 成乱码) */
|
||||||
|
function _tPlain(key, opts) {
|
||||||
|
if (typeof window.t !== 'function') return key;
|
||||||
|
const base = opts && typeof opts === 'object' ? opts : {};
|
||||||
|
const interp = base.interpolation && typeof base.interpolation === 'object' ? base.interpolation : {};
|
||||||
|
return window.t(key, {
|
||||||
|
...base,
|
||||||
|
interpolation: { escapeValue: false, ...interp }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cron 队列在「本轮 completed」等状态下的展示文案(底层 status 不变,仅 UI 强调循环调度) */
|
||||||
|
function getBatchQueueStatusPresentation(queue) {
|
||||||
|
const map = {
|
||||||
|
pending: { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||||
|
running: { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||||
|
paused: { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||||
|
completed: { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||||
|
cancelled: { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||||
|
};
|
||||||
|
const base = map[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
|
||||||
|
const cronOn = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||||||
|
const nextStr = queue.nextRunAt ? new Date(queue.nextRunAt).toLocaleString() : '';
|
||||||
|
const empty = { sublabel: null, progressNote: null, callout: null };
|
||||||
|
|
||||||
|
if (cronOn && queue.status === 'completed') {
|
||||||
|
return {
|
||||||
|
text: _t('tasks.statusCronCycleIdle'),
|
||||||
|
class: 'batch-queue-status-cron-cycle',
|
||||||
|
sublabel: nextStr ? _tPlain('tasks.cronNextRunLine', { time: nextStr }) : null,
|
||||||
|
progressNote: _t('tasks.cronRoundDoneProgressHint'),
|
||||||
|
callout: _t('tasks.cronRecurringCallout')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cronOn && queue.status === 'running') {
|
||||||
|
return {
|
||||||
|
text: _t('tasks.statusCronRunning'),
|
||||||
|
class: 'batch-queue-status-running batch-queue-cron-active',
|
||||||
|
sublabel: nextStr ? _tPlain('tasks.cronNextRunLine', { time: nextStr }) : null,
|
||||||
|
progressNote: _t('tasks.cronRunningProgressHint'),
|
||||||
|
callout: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cronOn && queue.status === 'pending' && nextStr) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...empty,
|
||||||
|
sublabel: _tPlain('tasks.cronPendingScheduled', { time: nextStr }),
|
||||||
|
progressNote: _t('tasks.cronPendingProgressNote')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...base, ...empty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 队列是否处于「可改子任务列表/文案」的空闲态(与后端 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转义函数(如果未定义)
|
// HTML转义函数(如果未定义)
|
||||||
if (typeof escapeHtml === 'undefined') {
|
if (typeof escapeHtml === 'undefined') {
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -725,6 +788,10 @@ async function showBatchImportModal() {
|
|||||||
const input = document.getElementById('batch-tasks-input');
|
const input = document.getElementById('batch-tasks-input');
|
||||||
const titleInput = document.getElementById('batch-queue-title');
|
const titleInput = document.getElementById('batch-queue-title');
|
||||||
const roleSelect = document.getElementById('batch-queue-role');
|
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) {
|
if (modal && input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (titleInput) {
|
if (titleInput) {
|
||||||
@@ -734,6 +801,19 @@ async function showBatchImportModal() {
|
|||||||
if (roleSelect) {
|
if (roleSelect) {
|
||||||
roleSelect.value = '';
|
roleSelect.value = '';
|
||||||
}
|
}
|
||||||
|
if (agentModeSelect) {
|
||||||
|
agentModeSelect.value = 'single';
|
||||||
|
}
|
||||||
|
if (scheduleModeSelect) {
|
||||||
|
scheduleModeSelect.value = 'manual';
|
||||||
|
}
|
||||||
|
if (cronExprInput) {
|
||||||
|
cronExprInput.value = '';
|
||||||
|
}
|
||||||
|
if (executeNowCheckbox) {
|
||||||
|
executeNowCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
handleBatchScheduleModeChange();
|
||||||
updateBatchImportStats('');
|
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) {
|
function updateBatchImportStats(text) {
|
||||||
const statsEl = document.getElementById('batch-import-stats');
|
const statsEl = document.getElementById('batch-import-stats');
|
||||||
@@ -807,6 +905,10 @@ async function createBatchQueue() {
|
|||||||
const input = document.getElementById('batch-tasks-input');
|
const input = document.getElementById('batch-tasks-input');
|
||||||
const titleInput = document.getElementById('batch-queue-title');
|
const titleInput = document.getElementById('batch-queue-title');
|
||||||
const roleSelect = document.getElementById('batch-queue-role');
|
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;
|
if (!input) return;
|
||||||
|
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
@@ -827,6 +929,18 @@ async function createBatchQueue() {
|
|||||||
|
|
||||||
// 获取角色(可选,空字符串表示默认角色)
|
// 获取角色(可选,空字符串表示默认角色)
|
||||||
const role = roleSelect ? roleSelect.value || '' : '';
|
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 {
|
try {
|
||||||
const response = await apiFetch('/api/batch-tasks', {
|
const response = await apiFetch('/api/batch-tasks', {
|
||||||
@@ -834,7 +948,7 @@ async function createBatchQueue() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ title, tasks, role }),
|
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr, executeNow }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -978,15 +1092,7 @@ function renderBatchQueues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = queues.map(queue => {
|
list.innerHTML = queues.map(queue => {
|
||||||
const statusMap = {
|
const pres = getBatchQueueStatusPresentation(queue);
|
||||||
'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 stats = {
|
const stats = {
|
||||||
@@ -1010,58 +1116,73 @@ function renderBatchQueues() {
|
|||||||
// 允许删除待执行、已完成或已取消状态的队列
|
// 允许删除待执行、已完成或已取消状态的队列
|
||||||
const canDelete = queue.status === 'pending' || queue.status === 'completed' || queue.status === 'cancelled';
|
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 loadedRoles = batchQueuesState.loadedRoles || [];
|
||||||
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
|
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
|
||||||
const roleName = queue.role && queue.role !== '' ? queue.role : _t('batchQueueDetailModal.defaultRole');
|
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 `
|
return `
|
||||||
<div class="batch-queue-item" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
<div class="batch-queue-item batch-queue-item--compact${cardMod}${noActionsClass}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||||||
<div class="batch-queue-header">
|
<div class="batch-queue-item__inner batch-queue-item__inner--grid">
|
||||||
<div class="batch-queue-info" style="flex: 1;">
|
<div class="batch-queue-item__lead">
|
||||||
${titleDisplay}
|
<div class="batch-queue-item__title-row">
|
||||||
${roleDisplay}
|
<span class="batch-queue-item__role-icon" aria-hidden="true">${escapeHtml(roleIcon)}</span>
|
||||||
<span class="batch-queue-status ${status.class}">${status.text}</span>
|
<div class="batch-queue-item__titles">${titleBlock}</div>
|
||||||
<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>
|
</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>
|
||||||
<div class="batch-queue-actions" style="display: flex; align-items: center; gap: 8px; margin-left: 12px;" onclick="event.stopPropagation();">
|
<div class="batch-queue-item__cluster">
|
||||||
${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__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>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// 渲染分页控件
|
// 渲染分页控件
|
||||||
renderBatchQueuesPagination();
|
renderBatchQueuesPagination();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染批量任务队列分页控件(参考Skills管理页面样式)
|
// 渲染批量任务队列分页控件(结构与样式对齐 MCP 监控 .monitor-pagination)
|
||||||
function renderBatchQueuesPagination() {
|
function renderBatchQueuesPagination() {
|
||||||
const paginationContainer = document.getElementById('batch-queues-pagination');
|
const paginationContainer = document.getElementById('batch-queues-pagination');
|
||||||
if (!paginationContainer) return;
|
if (!paginationContainer) return;
|
||||||
|
|
||||||
const { currentPage, pageSize, total, totalPages } = batchQueuesState;
|
const { currentPage, pageSize, total, totalPages } = batchQueuesState;
|
||||||
|
|
||||||
// 即使只有一页也显示分页信息(参考Skills样式)
|
// 即使只有一页也显示分页信息(与 MCP 监控一致)
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
paginationContainer.innerHTML = '';
|
paginationContainer.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
@@ -1071,7 +1192,7 @@ function renderBatchQueuesPagination() {
|
|||||||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||||
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||||||
|
|
||||||
let paginationHTML = '<div class="pagination">';
|
let paginationHTML = '<div class="monitor-pagination">';
|
||||||
|
|
||||||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||||||
paginationHTML += `
|
paginationHTML += `
|
||||||
@@ -1103,41 +1224,6 @@ function renderBatchQueuesPagination() {
|
|||||||
paginationHTML += '</div>';
|
paginationHTML += '</div>';
|
||||||
|
|
||||||
paginationContainer.innerHTML = paginationHTML;
|
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,6 +1284,8 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const queue = result.queue;
|
const queue = result.queue;
|
||||||
batchQueuesState.currentQueueId = queueId;
|
batchQueuesState.currentQueueId = queueId;
|
||||||
|
const pres = getBatchQueueStatusPresentation(queue);
|
||||||
|
const allowSubtaskMutation = batchQueueAllowsSubtaskMutation(queue);
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||||||
@@ -1207,7 +1295,7 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
// 更新按钮显示
|
// 更新按钮显示
|
||||||
const pauseBtn = document.getElementById('batch-queue-pause-btn');
|
const pauseBtn = document.getElementById('batch-queue-pause-btn');
|
||||||
if (addTaskBtn) {
|
if (addTaskBtn) {
|
||||||
addTaskBtn.style.display = queue.status === 'pending' ? 'inline-block' : 'none';
|
addTaskBtn.style.display = allowSubtaskMutation ? 'inline-block' : 'none';
|
||||||
}
|
}
|
||||||
if (startBtn) {
|
if (startBtn) {
|
||||||
// pending状态显示"开始执行",paused状态显示"继续执行"
|
// pending状态显示"开始执行",paused状态显示"继续执行"
|
||||||
@@ -1215,7 +1303,10 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
if (startBtn && queue.status === 'paused') {
|
if (startBtn && queue.status === 'paused') {
|
||||||
startBtn.textContent = _t('tasks.resumeExecute');
|
startBtn.textContent = _t('tasks.resumeExecute');
|
||||||
} else if (startBtn && queue.status === 'pending') {
|
} 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) {
|
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';
|
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 = {
|
const taskStatusMap = {
|
||||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-task-status-pending' },
|
'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' }
|
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-task-status-cancelled' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取角色信息(如果队列有角色配置)
|
let roleLineVal = '';
|
||||||
let roleDisplay = '';
|
|
||||||
if (queue.role && queue.role !== '') {
|
if (queue.role && queue.role !== '') {
|
||||||
// 如果有角色配置,尝试获取角色详细信息
|
|
||||||
let roleName = queue.role;
|
let roleName = queue.role;
|
||||||
let roleIcon = '👤';
|
let roleIcon = '\uD83D\uDC64';
|
||||||
// 从已加载的角色列表中查找角色图标
|
|
||||||
if (Array.isArray(loadedRoles) && loadedRoles.length > 0) {
|
if (Array.isArray(loadedRoles) && loadedRoles.length > 0) {
|
||||||
const role = loadedRoles.find(r => r.name === roleName);
|
const role = loadedRoles.find(r => r.name === roleName);
|
||||||
if (role && role.icon) {
|
if (role && role.icon) {
|
||||||
@@ -1262,61 +1341,99 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
const codePoint = parseInt(unicodeMatch[1], 16);
|
const codePoint = parseInt(unicodeMatch[1], 16);
|
||||||
icon = String.fromCodePoint(codePoint);
|
icon = String.fromCodePoint(codePoint);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 转换失败,使用默认图标
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleIcon = icon;
|
roleIcon = icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleDisplay = `<div class="detail-item">
|
roleLineVal = roleIcon + ' ' + escapeHtml(roleName);
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
|
||||||
<span class="detail-value">${roleIcon} ${escapeHtml(roleName)}</span>
|
|
||||||
</div>`;
|
|
||||||
} else {
|
} else {
|
||||||
// 默认角色
|
roleLineVal = '\uD83D\uDD35 ' + escapeHtml(_t('batchQueueDetailModal.defaultRole'));
|
||||||
roleDisplay = `<div class="detail-item">
|
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
|
||||||
<span class="detail-value">🔵 ` + _t('batchQueueDetailModal.defaultRole') + `</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
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 = `
|
content.innerHTML = `
|
||||||
<div class="batch-queue-detail-info">
|
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
|
||||||
${queue.title ? `<div class="detail-item">
|
<section class="batch-queue-detail-hero">
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.queueTitle') + `</span>
|
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||||||
<span class="detail-value">${escapeHtml(queue.title)}</span>
|
${pres.sublabel ? `<p class="batch-queue-detail-hero__sub">${escapeHtml(pres.sublabel)}</p>` : ''}
|
||||||
</div>` : ''}
|
${showProgressNoteInModal ? `<p class="batch-queue-detail-hero__note">${escapeHtml(pres.progressNote)}</p>` : ''}
|
||||||
${roleDisplay}
|
</section>
|
||||||
<div class="detail-item">
|
<section class="batch-queue-detail-kv">
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.queueId') + `</span>
|
<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>
|
||||||
<span class="detail-value"><code>${escapeHtml(queue.id)}</code></span>
|
<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>
|
||||||
<div class="detail-item">
|
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.role'))}</span><span class="bq-kv__v">${roleLineVal}</span></div>
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.status') + `</span>
|
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.agentMode'))}</span><span class="bq-kv__v">${escapeHtml(agentModeText)}</span></div>
|
||||||
<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('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>
|
||||||
<div class="detail-item">
|
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.taskTotal'))}</span><span class="bq-kv__v">${queue.tasks.length}</span></div>
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.createdAt') + `</span>
|
${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>` : ''}
|
||||||
<span class="detail-value">${new Date(queue.createdAt).toLocaleString()}</span>
|
</section>
|
||||||
</div>
|
${queue.lastScheduleError ? `<div class="bq-alert bq-alert--err"><strong>${escapeHtml(_t('batchQueueDetailModal.lastScheduleError'))}</strong><p>${escapeHtml(queue.lastScheduleError)}</p></div>` : ''}
|
||||||
${queue.startedAt ? `<div class="detail-item">
|
${queue.lastRunError ? `<div class="bq-alert bq-alert--err"><strong>${escapeHtml(_t('batchQueueDetailModal.lastRunError'))}</strong><p>${escapeHtml(queue.lastRunError)}</p></div>` : ''}
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.startedAt') + `</span>
|
${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>` : ''}
|
||||||
<span class="detail-value">${new Date(queue.startedAt).toLocaleString()}</span>
|
<details class="batch-queue-detail-tech">
|
||||||
</div>` : ''}
|
<summary class="batch-queue-detail-tech__sum">${escapeHtml(_t('batchQueueDetailModal.technicalDetails'))}</summary>
|
||||||
${queue.completedAt ? `<div class="detail-item">
|
<div class="batch-queue-detail-tech__body">
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.completedAt') + `</span>
|
<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>
|
||||||
<span class="detail-value">${new Date(queue.completedAt).toLocaleString()}</span>
|
<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>
|
||||||
</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>` : ''}
|
||||||
<div class="detail-item">
|
${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>` : ''}
|
||||||
<span class="detail-label">` + _t('batchQueueDetailModal.taskTotal') + `</span>
|
${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>` : ''}
|
||||||
<span class="detail-value">${queue.tasks.length}</span>
|
${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>
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="batch-queue-tasks-list">
|
<div class="batch-queue-tasks-list">
|
||||||
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
||||||
${queue.tasks.map((task, index) => {
|
${queue.tasks.map((task, index) => {
|
||||||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
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, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
||||||
return `
|
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}">
|
<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>
|
<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 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>` : ''}
|
${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>` : ''}
|
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</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>
|
</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';
|
modal.style.display = 'block';
|
||||||
|
|
||||||
// 如果队列正在运行,自动刷新
|
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
|
||||||
if (queue.status === 'running') {
|
if (queue.status === 'running') {
|
||||||
startBatchQueueRefresh(queueId);
|
startBatchQueueRefresh(queueId);
|
||||||
|
} else {
|
||||||
|
stopBatchQueueRefresh();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取队列详情失败:', error);
|
console.error('获取队列详情失败:', error);
|
||||||
@@ -1354,8 +1488,22 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
async function startBatchQueue() {
|
async function startBatchQueue() {
|
||||||
const queueId = batchQueuesState.currentQueueId;
|
const queueId = batchQueuesState.currentQueueId;
|
||||||
if (!queueId) return;
|
if (!queueId) return;
|
||||||
|
const btn = document.getElementById('batch-queue-start-btn');
|
||||||
|
if (btn) { btn.disabled = true; }
|
||||||
try {
|
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`, {
|
const response = await apiFetch(`/api/batch-tasks/${queueId}/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
@@ -1371,6 +1519,8 @@ async function startBatchQueue() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('启动批量任务失败:', error);
|
console.error('启动批量任务失败:', error);
|
||||||
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
|
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1382,7 +1532,8 @@ async function pauseBatchQueue() {
|
|||||||
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
|
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const btn = document.getElementById('batch-queue-pause-btn');
|
||||||
|
if (btn) { btn.disabled = true; }
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/pause`, {
|
const response = await apiFetch(`/api/batch-tasks/${queueId}/pause`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1399,6 +1550,8 @@ async function pauseBatchQueue() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('暂停批量任务失败:', error);
|
console.error('暂停批量任务失败:', error);
|
||||||
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
|
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1410,7 +1563,8 @@ async function deleteBatchQueue() {
|
|||||||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const btn = document.getElementById('batch-queue-delete-btn');
|
||||||
|
if (btn) { btn.disabled = true; }
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/api/batch-tasks/${queueId}`, {
|
const response = await apiFetch(`/api/batch-tasks/${queueId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -1426,6 +1580,8 @@ async function deleteBatchQueue() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除批量任务队列失败:', error);
|
console.error('删除批量任务队列失败:', error);
|
||||||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1477,6 +1633,17 @@ function startBatchQueueRefresh(queueId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
batchQueuesState.refreshInterval = setInterval(() => {
|
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) {
|
if (batchQueuesState.currentQueueId === queueId) {
|
||||||
showBatchQueueDetail(queueId);
|
showBatchQueueDetail(queueId);
|
||||||
refreshBatchQueues();
|
refreshBatchQueues();
|
||||||
@@ -1514,7 +1681,9 @@ function viewBatchTaskConversation(conversationId) {
|
|||||||
// 编辑批量任务的状态
|
// 编辑批量任务的状态
|
||||||
const editBatchTaskState = {
|
const editBatchTaskState = {
|
||||||
queueId: null,
|
queueId: null,
|
||||||
taskId: null
|
taskId: null,
|
||||||
|
_escHandler: null,
|
||||||
|
_saveHandler: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从元素获取任务信息并打开编辑模态框
|
// 从元素获取任务信息并打开编辑模态框
|
||||||
@@ -1565,24 +1734,30 @@ function editBatchTask(queueId, taskId, currentMessage) {
|
|||||||
messageInput.select();
|
messageInput.select();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// 清理旧的事件监听器(防止泄漏)
|
||||||
|
if (editBatchTaskState._escHandler) {
|
||||||
|
document.removeEventListener('keydown', editBatchTaskState._escHandler);
|
||||||
|
}
|
||||||
|
if (editBatchTaskState._saveHandler) {
|
||||||
|
messageInput.removeEventListener('keydown', editBatchTaskState._saveHandler);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加ESC键监听
|
// 添加ESC键监听
|
||||||
const handleKeyDown = (e) => {
|
editBatchTaskState._escHandler = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeEditBatchTaskModal();
|
closeEditBatchTaskModal();
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', editBatchTaskState._escHandler);
|
||||||
|
|
||||||
// 添加Enter+Ctrl/Cmd保存功能
|
// 添加Enter+Ctrl/Cmd保存功能
|
||||||
const handleKeyPress = (e) => {
|
editBatchTaskState._saveHandler = (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveBatchTask();
|
saveBatchTask();
|
||||||
document.removeEventListener('keydown', handleKeyPress);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
messageInput.addEventListener('keydown', handleKeyPress);
|
messageInput.addEventListener('keydown', editBatchTaskState._saveHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭编辑批量任务模态框
|
// 关闭编辑批量任务模态框
|
||||||
@@ -1591,6 +1766,18 @@ function closeEditBatchTaskModal() {
|
|||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'none';
|
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.queueId = null;
|
||||||
editBatchTaskState.taskId = null;
|
editBatchTaskState.taskId = null;
|
||||||
}
|
}
|
||||||
@@ -1671,28 +1858,46 @@ function showAddBatchTaskModal() {
|
|||||||
messageInput.focus();
|
messageInput.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// 清理旧的事件监听器
|
||||||
|
if (showAddBatchTaskModal._escHandler) {
|
||||||
|
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||||||
|
}
|
||||||
|
if (showAddBatchTaskModal._saveHandler && messageInput) {
|
||||||
|
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加ESC键监听
|
// 添加ESC键监听
|
||||||
const handleKeyDown = (e) => {
|
showAddBatchTaskModal._escHandler = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeAddBatchTaskModal();
|
closeAddBatchTaskModal();
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||||||
|
|
||||||
// 添加Enter+Ctrl/Cmd保存功能
|
// 添加Enter+Ctrl/Cmd保存功能
|
||||||
const handleKeyPress = (e) => {
|
showAddBatchTaskModal._saveHandler = (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveAddBatchTask();
|
saveAddBatchTask();
|
||||||
messageInput.removeEventListener('keydown', handleKeyPress);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
messageInput.addEventListener('keydown', handleKeyPress);
|
messageInput.addEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭添加批量任务模态框
|
// 关闭添加批量任务模态框
|
||||||
function closeAddBatchTaskModal() {
|
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 modal = document.getElementById('add-batch-task-modal');
|
||||||
const messageInput = document.getElementById('add-task-message');
|
const messageInput = document.getElementById('add-task-message');
|
||||||
if (modal) {
|
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.showBatchImportModal = showBatchImportModal;
|
||||||
window.closeBatchImportModal = closeBatchImportModal;
|
window.closeBatchImportModal = closeBatchImportModal;
|
||||||
@@ -1842,3 +2195,36 @@ window.closeAddBatchTaskModal = closeAddBatchTaskModal;
|
|||||||
window.saveAddBatchTask = saveAddBatchTask;
|
window.saveAddBatchTask = saveAddBatchTask;
|
||||||
window.deleteBatchTaskFromElement = deleteBatchTaskFromElement;
|
window.deleteBatchTaskFromElement = deleteBatchTaskFromElement;
|
||||||
window.deleteBatchQueueFromList = deleteBatchQueueFromList;
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,6 +121,13 @@
|
|||||||
ws.onopen = function () {
|
ws.onopen = function () {
|
||||||
if (tab.term) {
|
if (tab.term) {
|
||||||
tab.term.focus();
|
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) {
|
term.onData(function (data) {
|
||||||
// Ctrl+L:本地清屏,同时把 ^L 也发给后端
|
// Ctrl+L:本地清屏,同时把 ^L 也发给后端
|
||||||
if (data === '\x0c') {
|
if (data === '\x0c') {
|
||||||
@@ -235,6 +250,12 @@
|
|||||||
sendToWS(data);
|
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.term = term;
|
||||||
tab.fitAddon = fitAddon;
|
tab.fitAddon = fitAddon;
|
||||||
// 立即建立 WebSocket,让后端 PTY/Shell 马上启动并输出提示符;
|
// 立即建立 WebSocket,让后端 PTY/Shell 马上启动并输出提示符;
|
||||||
|
|||||||
+46
-13
@@ -725,6 +725,11 @@
|
|||||||
<div class="tools-actions">
|
<div class="tools-actions">
|
||||||
<button class="btn-secondary" onclick="selectAllTools()" data-i18n="mcp.selectAll">全选</button>
|
<button class="btn-secondary" onclick="selectAllTools()" data-i18n="mcp.selectAll">全选</button>
|
||||||
<button class="btn-secondary" onclick="deselectAllTools()" data-i18n="mcp.deselectAll">全不选</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">
|
<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()" />
|
<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>
|
<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-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>
|
<label>
|
||||||
<span data-i18n="tasksPage.statusFilter">状态筛选</span>
|
<span data-i18n="tasksPage.statusFilter">状态筛选</span>
|
||||||
<select id="batch-queues-status-filter" onchange="filterBatchQueues()">
|
<select id="batch-queues-status-filter" onchange="filterBatchQueues()">
|
||||||
@@ -1144,15 +1149,16 @@
|
|||||||
<option value="cancelled" data-i18n="tasksPage.statusCancelled">已取消</option>
|
<option value="cancelled" data-i18n="tasksPage.statusCancelled">已取消</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style="flex: 1; max-width: 300px;">
|
<label class="batch-queues-filters__search">
|
||||||
<span data-i18n="tasksPage.searchQueuePlaceholder">搜索队列ID、标题或创建时间</span>
|
<span data-i18n="tasksPage.searchQueuePlaceholder">搜索队列ID、标题或创建时间</span>
|
||||||
<input type="text" id="batch-queues-search" data-i18n="tasksPage.searchKeywordPlaceholder" data-i18n-attr="placeholder" placeholder="输入关键字搜索..."
|
<input type="text" id="batch-queues-search" data-i18n="tasksPage.searchKeywordPlaceholder" data-i18n-attr="placeholder" placeholder="输入关键字搜索..."
|
||||||
oninput="filterBatchQueues()">
|
oninput="filterBatchQueues()">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="batch-queues-list" class="batch-queues-list"></div>
|
<div class="batch-queues-board">
|
||||||
<!-- 分页控件 -->
|
<div id="batch-queues-list" class="batch-queues-list"></div>
|
||||||
<div id="batch-queues-pagination" class="pagination-container pagination-fixed"></div>
|
<div id="batch-queues-pagination" class="pagination-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1359,6 +1365,13 @@
|
|||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
||||||
<div class="settings-form">
|
<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">
|
<div class="form-group">
|
||||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
<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 />
|
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||||
@@ -1430,14 +1443,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotUseHint">需同时勾选「启用多代理」;调用量与成本更高。</small>
|
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotUseHint">需同时勾选「启用多代理」;调用量与成本更高。</small>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2322,6 +2327,34 @@ version: 1.0.0<br>
|
|||||||
</select>
|
</select>
|
||||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.roleHint">选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。</div>
|
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.roleHint">选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="batch-tasks-input"><span data-i18n="batchImportModal.tasksList">任务列表(每行一个任务)</span><span style="color: red;">*</span></label>
|
<label for="batch-tasks-input"><span data-i18n="batchImportModal.tasksList">任务列表(每行一个任务)</span><span style="color: red;">*</span></label>
|
||||||
<textarea id="batch-tasks-input" rows="15" data-i18n="batchImportModal.tasksListPlaceholderExample" data-i18n-attr="placeholder" placeholder="请输入任务列表,每行一个任务,例如: 扫描 192.168.1.1 的开放端口 检查 https://example.com 是否存在SQL注入 枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
|
<textarea id="batch-tasks-input" rows="15" data-i18n="batchImportModal.tasksListPlaceholderExample" data-i18n-attr="placeholder" placeholder="请输入任务列表,每行一个任务,例如: 扫描 192.168.1.1 的开放端口 检查 https://example.com 是否存在SQL注入 枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
|
||||||
|
|||||||
Reference in New Issue
Block a user