mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 21:23:29 +02:00
Compare commits
23 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 |
+3
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.4.15"
|
||||
version: "v1.4.18"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -34,7 +34,9 @@ log:
|
||||
# - DeepSeek: https://api.deepseek.com/v1
|
||||
# - 其他兼容 OpenAI 协议的 API
|
||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
||||
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
openai:
|
||||
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
||||
api_key: sk-xxxxxx # API 密钥(必填)
|
||||
model: qwen3-max # 模型名称(必填)
|
||||
|
||||
@@ -9,8 +9,8 @@ toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/cloudwego/eino v0.8.4
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.10
|
||||
github.com/cloudwego/eino v0.8.8
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.12
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/eino-contrib/jsonschema v1.0.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
@@ -21,6 +21,7 @@ require (
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -33,7 +34,7 @@ require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
@@ -51,7 +52,7 @@ require (
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
|
||||
@@ -22,10 +22,16 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.8.4 h1:aFKJK82MmPR6dm5y5J7IXivYSvh4HkcXwf18j6vyhmk=
|
||||
github.com/cloudwego/eino v0.8.4/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
|
||||
github.com/cloudwego/eino v0.8.8 h1:64NuheQBmxOXe/28Tm85rkBkxXMB5ZhjSu/j0RDFyZU=
|
||||
github.com/cloudwego/eino v0.8.8/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.10 h1:zVkU4rZUUUUAPEXOGs98n8nsT/NZvQ9zWY0B9h2US7k=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.10/go.mod h1:smEeTKXe8uz+HDUBQn0yZhpx7mmOUKFQyguLfjAQ57I=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.12 h1:vcwNXeT7bpaXMNwUhtcHZwMYY8II2jAihuooyivmEZ0=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.12/go.mod h1:ve/+/hLZMvxD5AieQ355xHIFhAZVlsG4rdwTnE16aQU=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 h1:yOZII6VYaL00CVZYba+HUixFygsW0Xz/1QjQ5htj1Ls=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -113,6 +119,8 @@ github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
|
||||
@@ -136,6 +144,8 @@ github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/Q
|
||||
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
|
||||
@@ -659,6 +659,8 @@ func setupRoutes(
|
||||
protected.GET("/batch-tasks/:queueId", agentHandler.GetBatchQueue)
|
||||
protected.POST("/batch-tasks/:queueId/start", agentHandler.StartBatchQueue)
|
||||
protected.POST("/batch-tasks/:queueId/pause", agentHandler.PauseBatchQueue)
|
||||
protected.PUT("/batch-tasks/:queueId/metadata", agentHandler.UpdateBatchQueueMetadata)
|
||||
protected.PUT("/batch-tasks/:queueId/schedule", agentHandler.UpdateBatchQueueSchedule)
|
||||
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
|
||||
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
|
||||
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
||||
|
||||
@@ -129,6 +129,7 @@ type MCPConfig struct {
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude",claude 时自动桥接为 Anthropic Messages API
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
|
||||
@@ -352,6 +352,18 @@ func (db *DB) UpdateBatchQueueCurrentIndex(queueID string, currentIndex int) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueMetadata 更新批量任务队列标题和角色
|
||||
func (db *DB) UpdateBatchQueueMetadata(queueID, title, role string) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET title = ?, role = ? WHERE id = ?",
|
||||
title, role, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列元数据失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueSchedule 更新批量任务队列调度相关信息
|
||||
func (db *DB) UpdateBatchQueueSchedule(queueID, scheduleMode, cronExpr string, nextRunAt *time.Time) error {
|
||||
var nextRunAtValue interface{}
|
||||
@@ -435,7 +447,7 @@ func (db *DB) ResetBatchQueueForRerun(queueID string) error {
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, current_index = 0, started_at = NULL, completed_at = NULL WHERE id = ?",
|
||||
"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 {
|
||||
|
||||
@@ -89,7 +89,7 @@ type AgentHandler struct {
|
||||
|
||||
// NewAgentHandler 创建新的Agent处理器
|
||||
func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, logger *zap.Logger) *AgentHandler {
|
||||
batchTaskManager := NewBatchTaskManager()
|
||||
batchTaskManager := NewBatchTaskManager(logger)
|
||||
batchTaskManager.SetDB(db)
|
||||
|
||||
// 从数据库加载所有批量任务队列
|
||||
@@ -1589,6 +1589,7 @@ type BatchTaskRequest struct {
|
||||
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 {
|
||||
@@ -1649,10 +1650,31 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
|
||||
nextRunAt = &next
|
||||
}
|
||||
|
||||
queue := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, agentMode, scheduleMode, cronExpr, nextRunAt, validTasks)
|
||||
queue, createErr := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, agentMode, scheduleMode, cronExpr, nextRunAt, validTasks)
|
||||
if createErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": createErr.Error()})
|
||||
return
|
||||
}
|
||||
started := false
|
||||
if req.ExecuteNow {
|
||||
ok, err := h.startBatchQueueExecution(queue.ID, false)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "queueId": queue.ID})
|
||||
return
|
||||
}
|
||||
started = true
|
||||
if refreshed, exists := h.batchTaskManager.GetBatchQueue(queue.ID); exists {
|
||||
queue = refreshed
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"queueId": queue.ID,
|
||||
"queue": queue,
|
||||
"started": started,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1703,6 +1725,11 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
// 防止恶意大 offset 导致 DB 性能问题
|
||||
const maxOffset = 100000
|
||||
if offset > maxOffset {
|
||||
offset = maxOffset
|
||||
}
|
||||
|
||||
// 默认status为"all"
|
||||
if status == "" {
|
||||
@@ -1765,6 +1792,67 @@ func (h *AgentHandler) PauseBatchQueue(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"})
|
||||
}
|
||||
|
||||
// UpdateBatchQueueMetadata 修改批量任务队列的标题和角色
|
||||
func (h *AgentHandler) UpdateBatchQueueMetadata(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.batchTaskManager.UpdateQueueMetadata(queueID, req.Title, req.Role); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, _ := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
c.JSON(http.StatusOK, gin.H{"queue": updated})
|
||||
}
|
||||
|
||||
// UpdateBatchQueueSchedule 修改批量任务队列的调度配置(scheduleMode / cronExpr)
|
||||
func (h *AgentHandler) UpdateBatchQueueSchedule(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||
return
|
||||
}
|
||||
// 仅在非 running 状态下允许修改调度
|
||||
if queue.Status == "running" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "队列正在运行中,无法修改调度配置"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ScheduleMode string `json:"scheduleMode"`
|
||||
CronExpr string `json:"cronExpr"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
scheduleMode := normalizeBatchQueueScheduleMode(req.ScheduleMode)
|
||||
cronExpr := strings.TrimSpace(req.CronExpr)
|
||||
var nextRunAt *time.Time
|
||||
if scheduleMode == "cron" {
|
||||
if cronExpr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "启用 Cron 调度时,调度表达式不能为空"})
|
||||
return
|
||||
}
|
||||
schedule, err := h.batchCronParser.Parse(cronExpr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 Cron 表达式: " + err.Error()})
|
||||
return
|
||||
}
|
||||
next := schedule.Next(time.Now())
|
||||
nextRunAt = &next
|
||||
}
|
||||
h.batchTaskManager.UpdateQueueSchedule(queueID, scheduleMode, cronExpr, nextRunAt)
|
||||
updated, _ := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
c.JSON(http.StatusOK, gin.H{"queue": updated})
|
||||
}
|
||||
|
||||
// SetBatchQueueScheduleEnabled 开启/关闭 Cron 自动调度(手工执行不受影响)
|
||||
func (h *AgentHandler) SetBatchQueueScheduleEnabled(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
|
||||
@@ -9,8 +9,35 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 批量任务状态常量
|
||||
const (
|
||||
BatchQueueStatusPending = "pending"
|
||||
BatchQueueStatusRunning = "running"
|
||||
BatchQueueStatusPaused = "paused"
|
||||
BatchQueueStatusCompleted = "completed"
|
||||
BatchQueueStatusCancelled = "cancelled"
|
||||
|
||||
BatchTaskStatusPending = "pending"
|
||||
BatchTaskStatusRunning = "running"
|
||||
BatchTaskStatusCompleted = "completed"
|
||||
BatchTaskStatusFailed = "failed"
|
||||
BatchTaskStatusCancelled = "cancelled"
|
||||
|
||||
// MaxBatchTasksPerQueue 单个队列最大任务数
|
||||
MaxBatchTasksPerQueue = 10000
|
||||
|
||||
// MaxBatchQueueTitleLen 队列标题最大长度
|
||||
MaxBatchQueueTitleLen = 200
|
||||
|
||||
// MaxBatchQueueRoleLen 角色名最大长度
|
||||
MaxBatchQueueRoleLen = 100
|
||||
)
|
||||
|
||||
// BatchTask 批量任务项
|
||||
@@ -50,14 +77,19 @@ type BatchTaskQueue struct {
|
||||
// BatchTaskManager 批量任务管理器
|
||||
type BatchTaskManager struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
queues map[string]*BatchTaskQueue
|
||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBatchTaskManager 创建批量任务管理器
|
||||
func NewBatchTaskManager() *BatchTaskManager {
|
||||
func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &BatchTaskManager{
|
||||
logger: logger,
|
||||
queues: make(map[string]*BatchTaskQueue),
|
||||
taskCancels: make(map[string]context.CancelFunc),
|
||||
}
|
||||
@@ -75,7 +107,18 @@ func (m *BatchTaskManager) CreateBatchQueue(
|
||||
title, role, agentMode, scheduleMode, cronExpr string,
|
||||
nextRunAt *time.Time,
|
||||
tasks []string,
|
||||
) *BatchTaskQueue {
|
||||
) (*BatchTaskQueue, error) {
|
||||
// 输入校验
|
||||
if utf8.RuneCountInString(title) > MaxBatchQueueTitleLen {
|
||||
return nil, fmt.Errorf("标题不能超过 %d 个字符", MaxBatchQueueTitleLen)
|
||||
}
|
||||
if utf8.RuneCountInString(role) > MaxBatchQueueRoleLen {
|
||||
return nil, fmt.Errorf("角色名不能超过 %d 个字符", MaxBatchQueueRoleLen)
|
||||
}
|
||||
if len(tasks) > MaxBatchTasksPerQueue {
|
||||
return nil, fmt.Errorf("单个队列最多 %d 条任务", MaxBatchTasksPerQueue)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -90,7 +133,7 @@ func (m *BatchTaskManager) CreateBatchQueue(
|
||||
NextRunAt: nextRunAt,
|
||||
ScheduleEnabled: true,
|
||||
Tasks: make([]*BatchTask, 0, len(tasks)),
|
||||
Status: "pending",
|
||||
Status: BatchQueueStatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
CurrentIndex: 0,
|
||||
}
|
||||
@@ -110,7 +153,7 @@ func (m *BatchTaskManager) CreateBatchQueue(
|
||||
task := &BatchTask{
|
||||
ID: taskID,
|
||||
Message: message,
|
||||
Status: "pending",
|
||||
Status: BatchTaskStatusPending,
|
||||
}
|
||||
queue.Tasks = append(queue.Tasks, task)
|
||||
dbTasks = append(dbTasks, map[string]interface{}{
|
||||
@@ -131,13 +174,12 @@ func (m *BatchTaskManager) CreateBatchQueue(
|
||||
queue.NextRunAt,
|
||||
dbTasks,
|
||||
); err != nil {
|
||||
// 如果数据库保存失败,记录错误但继续(使用内存缓存)
|
||||
// 这里可以添加日志记录
|
||||
m.logger.Warn("batch queue DB create failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
m.queues[queueID] = queue
|
||||
return queue
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// GetBatchQueue 获取批量任务队列
|
||||
@@ -512,10 +554,10 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
|
||||
task.ConversationID = conversationID
|
||||
}
|
||||
now := time.Now()
|
||||
if status == "running" && task.StartedAt == nil {
|
||||
if status == BatchTaskStatusRunning && task.StartedAt == nil {
|
||||
task.StartedAt = &now
|
||||
}
|
||||
if status == "completed" || status == "failed" || status == "cancelled" {
|
||||
if status == BatchTaskStatusCompleted || status == BatchTaskStatusFailed || status == BatchTaskStatusCancelled {
|
||||
task.CompletedAt = &now
|
||||
}
|
||||
break
|
||||
@@ -525,7 +567,7 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
|
||||
// 同步到数据库
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
m.logger.Warn("batch task DB status update failed", zap.String("queueId", queueID), zap.String("taskId", taskID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,17 +584,17 @@ func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
||||
|
||||
queue.Status = status
|
||||
now := time.Now()
|
||||
if status == "running" && queue.StartedAt == nil {
|
||||
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
|
||||
queue.StartedAt = &now
|
||||
}
|
||||
if status == "completed" || status == "cancelled" {
|
||||
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
|
||||
queue.CompletedAt = &now
|
||||
}
|
||||
|
||||
// 同步到数据库
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
m.logger.Warn("batch queue DB status update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,11 +620,42 @@ func (m *BatchTaskManager) UpdateQueueSchedule(queueID, scheduleMode, cronExpr s
|
||||
|
||||
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()
|
||||
@@ -656,13 +729,15 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
queue.Status = "pending"
|
||||
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 = "pending"
|
||||
task.Status = BatchTaskStatusPending
|
||||
task.ConversationID = ""
|
||||
task.StartedAt = nil
|
||||
task.CompletedAt = nil
|
||||
@@ -678,7 +753,7 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateTaskMessage 更新任务消息(仅限待执行状态)
|
||||
// UpdateTaskMessage 更新任务消息(队列空闲时可改;任务需非 running)
|
||||
func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -688,17 +763,15 @@ func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) er
|
||||
return fmt.Errorf("队列不存在")
|
||||
}
|
||||
|
||||
// 检查队列状态,只有待执行状态的队列才能编辑任务
|
||||
if queue.Status != "pending" {
|
||||
return fmt.Errorf("只有待执行状态的队列才能编辑任务")
|
||||
if !queueAllowsTaskListMutationLocked(queue) {
|
||||
return fmt.Errorf("队列正在执行或未就绪,无法编辑任务")
|
||||
}
|
||||
|
||||
// 查找并更新任务
|
||||
for _, task := range queue.Tasks {
|
||||
if task.ID == taskID {
|
||||
// 只有待执行状态的任务才能编辑
|
||||
if task.Status != "pending" {
|
||||
return fmt.Errorf("只有待执行状态的任务才能编辑")
|
||||
if task.Status == BatchTaskStatusRunning {
|
||||
return fmt.Errorf("执行中的任务不能编辑")
|
||||
}
|
||||
task.Message = message
|
||||
|
||||
@@ -715,7 +788,7 @@ func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) er
|
||||
return fmt.Errorf("任务不存在")
|
||||
}
|
||||
|
||||
// AddTaskToQueue 添加任务到队列(仅限待执行状态)
|
||||
// AddTaskToQueue 添加任务到队列(队列空闲时可添加:含 cron 本轮 completed、手动暂停后等)
|
||||
func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -725,9 +798,8 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
||||
return nil, fmt.Errorf("队列不存在")
|
||||
}
|
||||
|
||||
// 检查队列状态,只有待执行状态的队列才能添加任务
|
||||
if queue.Status != "pending" {
|
||||
return nil, fmt.Errorf("只有待执行状态的队列才能添加任务")
|
||||
if !queueAllowsTaskListMutationLocked(queue) {
|
||||
return nil, fmt.Errorf("队列正在执行或未就绪,无法添加任务")
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
@@ -739,7 +811,7 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
||||
task := &BatchTask{
|
||||
ID: taskID,
|
||||
Message: message,
|
||||
Status: "pending",
|
||||
Status: BatchTaskStatusPending,
|
||||
}
|
||||
|
||||
// 添加到内存队列
|
||||
@@ -757,7 +829,7 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// DeleteTask 删除任务(仅限待执行状态)
|
||||
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
|
||||
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -767,18 +839,16 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||
return fmt.Errorf("队列不存在")
|
||||
}
|
||||
|
||||
// 检查队列状态,只有待执行状态的队列才能删除任务
|
||||
if queue.Status != "pending" {
|
||||
return fmt.Errorf("只有待执行状态的队列才能删除任务")
|
||||
if !queueAllowsTaskListMutationLocked(queue) {
|
||||
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
|
||||
}
|
||||
|
||||
// 查找并删除任务
|
||||
taskIndex := -1
|
||||
for i, task := range queue.Tasks {
|
||||
if task.ID == taskID {
|
||||
// 只有待执行状态的任务才能删除
|
||||
if task.Status != "pending" {
|
||||
return fmt.Errorf("只有待执行状态的任务才能删除")
|
||||
if task.Status == BatchTaskStatusRunning {
|
||||
return fmt.Errorf("执行中的任务不能删除")
|
||||
}
|
||||
taskIndex = i
|
||||
break
|
||||
@@ -804,10 +874,41 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func queueHasRunningTaskLocked(queue *BatchTaskQueue) bool {
|
||||
if queue == nil {
|
||||
return false
|
||||
}
|
||||
for _, t := range queue.Tasks {
|
||||
if t != nil && t.Status == BatchTaskStatusRunning {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queueAllowsTaskListMutationLocked 是否允许增删改子任务文案/列表(必须在持有 BatchTaskManager.mu 下调用)
|
||||
func queueAllowsTaskListMutationLocked(queue *BatchTaskQueue) bool {
|
||||
if queue == nil {
|
||||
return false
|
||||
}
|
||||
if queue.Status == BatchQueueStatusRunning {
|
||||
return false
|
||||
}
|
||||
if queueHasRunningTaskLocked(queue) {
|
||||
return false
|
||||
}
|
||||
switch queue.Status {
|
||||
case BatchQueueStatusPending, BatchQueueStatusPaused, BatchQueueStatusCompleted, BatchQueueStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetNextTask 获取下一个待执行的任务
|
||||
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
@@ -816,7 +917,7 @@ func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
||||
|
||||
for i := queue.CurrentIndex; i < len(queue.Tasks); i++ {
|
||||
task := queue.Tasks[i]
|
||||
if task.Status == "pending" {
|
||||
if task.Status == BatchTaskStatusPending {
|
||||
queue.CurrentIndex = i
|
||||
return task, true
|
||||
}
|
||||
@@ -840,7 +941,7 @@ func (m *BatchTaskManager) MoveToNextTask(queueID string) {
|
||||
// 同步到数据库
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueCurrentIndex(queueID, queue.CurrentIndex); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
m.logger.Warn("batch queue DB index update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -859,19 +960,18 @@ func (m *BatchTaskManager) SetTaskCancel(queueID string, cancel context.CancelFu
|
||||
// PauseQueue 暂停队列
|
||||
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
if queue.Status != "running" {
|
||||
m.mu.Unlock()
|
||||
if queue.Status != BatchQueueStatusRunning {
|
||||
return false
|
||||
}
|
||||
|
||||
queue.Status = "paused"
|
||||
queue.Status = BatchQueueStatusPaused
|
||||
|
||||
// 取消当前正在执行的任务(通过取消context)
|
||||
if cancel, exists := m.taskCancels[queueID]; exists {
|
||||
@@ -879,12 +979,10 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
delete(m.taskCancels, queueID)
|
||||
}
|
||||
|
||||
m.mu.Unlock()
|
||||
|
||||
// 同步队列状态到数据库
|
||||
// 同步队列状态到数据库(在锁内完成,避免竞态)
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, "paused"); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusPaused); err != nil {
|
||||
m.logger.Warn("batch queue DB pause update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,30 +992,30 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
// CancelQueue 取消队列(保留此方法以保持向后兼容,但建议使用PauseQueue)
|
||||
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
if queue.Status == "completed" || queue.Status == "cancelled" {
|
||||
m.mu.Unlock()
|
||||
if queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled {
|
||||
return false
|
||||
}
|
||||
|
||||
queue.Status = "cancelled"
|
||||
queue.Status = BatchQueueStatusCancelled
|
||||
now := time.Now()
|
||||
queue.CompletedAt = &now
|
||||
|
||||
// 取消所有待执行的任务
|
||||
for _, task := range queue.Tasks {
|
||||
if task.Status == "pending" {
|
||||
task.Status = "cancelled"
|
||||
if task.Status == BatchTaskStatusPending {
|
||||
task.Status = BatchTaskStatusCancelled
|
||||
task.CompletedAt = &now
|
||||
// 同步到数据库
|
||||
if m.db != nil {
|
||||
m.db.UpdateBatchTaskStatus(queueID, task.ID, "cancelled", "", "", "")
|
||||
if err := m.db.UpdateBatchTaskStatus(queueID, task.ID, BatchTaskStatusCancelled, "", "", ""); err != nil {
|
||||
m.logger.Warn("batch task DB cancel update failed", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -928,35 +1026,38 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||
delete(m.taskCancels, queueID)
|
||||
}
|
||||
|
||||
m.mu.Unlock()
|
||||
|
||||
// 同步队列状态到数据库
|
||||
// 同步队列状态到数据库(在锁内完成)
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, "cancelled"); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusCancelled); err != nil {
|
||||
m.logger.Warn("batch queue DB cancel update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteQueue 删除队列
|
||||
// DeleteQueue 删除队列(运行中的队列不允许删除)
|
||||
func (m *BatchTaskManager) DeleteQueue(queueID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, exists := m.queues[queueID]
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// 运行中的队列不允许删除,防止孤儿协程和数据丢失
|
||||
if queue.Status == BatchQueueStatusRunning {
|
||||
return false
|
||||
}
|
||||
|
||||
// 清理取消函数
|
||||
delete(m.taskCancels, queueID)
|
||||
|
||||
// 从数据库删除
|
||||
if m.db != nil {
|
||||
if err := m.db.DeleteBatchQueue(queueID); err != nil {
|
||||
// 记录错误但继续(使用内存缓存)
|
||||
m.logger.Warn("batch queue DB delete failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
// --- list ---
|
||||
reg(mcp.Tool{
|
||||
Name: builtin.ToolBatchTaskList,
|
||||
Description: "列出批量任务队列,支持按状态筛选与关键字搜索。用于查看队列 id、状态、进度及 Cron 配置等。",
|
||||
Description: "列出批量任务队列(精简摘要,省上下文)。含队列元数据、子任务 id/status/截断后的 message、各状态计数。完整子任务(含 result/error/conversationId/时间等)请用 batch_task_get(queue_id)。",
|
||||
ShortDescription: "列出批量任务队列",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -69,6 +69,9 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
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
|
||||
@@ -77,8 +80,15 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
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": queues,
|
||||
"queues": slim,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
@@ -120,8 +130,8 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
Name: builtin.ToolBatchTaskCreate,
|
||||
Description: `创建新的批量任务队列。任务列表使用 tasks(字符串数组)或 tasks_text(多行,每行一条)。
|
||||
agent_mode: single(默认)或 multi(需系统启用多代理)。schedule_mode: manual(默认)或 cron;为 cron 时必须提供 cron_expr(如 "0 */6 * * *")。
|
||||
重要:创建成功后队列处于 pending,不会自动开始跑子任务。若要立即执行或手工开跑,必须再调用工具 batch_task_start(传入返回的 queue_id)。Cron 队列若需按表达式自动触发下一轮,还需保持调度开关开启(可用 batch_task_schedule_enabled)。`,
|
||||
ShortDescription: "创建批量任务队列(创建后需 batch_task_start 才会执行)",
|
||||
默认创建后不会立即执行。可通过 execute_now=true 在创建后立即启动;也可后续调用 batch_task_start 手工启动。Cron 队列若需按表达式自动触发下一轮,还需保持调度开关开启(可用 batch_task_schedule_enabled)。`,
|
||||
ShortDescription: "创建批量任务队列(可选立即执行)",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@@ -154,7 +164,11 @@ agent_mode: single(默认)或 multi(需系统启用多代理)。schedule
|
||||
},
|
||||
"cron_expr": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "schedule_mode 为 cron 时必填",
|
||||
"description": "schedule_mode 为 cron 时必填。标准 5 段格式:分钟 小时 日 月 星期,例如 \"0 */6 * * *\"(每6小时)、\"30 2 * * 1-5\"(工作日凌晨2:30)",
|
||||
},
|
||||
"execute_now": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "是否创建后立即执行,默认 false",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -180,12 +194,40 @@ agent_mode: single(默认)或 multi(需系统启用多代理)。schedule
|
||||
n := sch.Next(time.Now())
|
||||
nextRunAt = &n
|
||||
}
|
||||
queue := h.batchTaskManager.CreateBatchQueue(title, role, agentMode, scheduleMode, cronExpr, nextRunAt, tasks)
|
||||
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,
|
||||
"reminder": "队列已创建,当前为 pending。需要开始执行时请调用 MCP工具 batch_task_start(queue_id 同上)。Cron 自动调度需 schedule_enabled 为 true,可用 batch_task_schedule_enabled。",
|
||||
"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。"
|
||||
}(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -275,6 +317,101 @@ agent_mode: single(默认)或 multi(需系统启用多代理)。schedule
|
||||
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,
|
||||
@@ -420,7 +557,103 @@ agent_mode: single(默认)或 multi(需系统启用多代理)。schedule
|
||||
return batchMCPJSONResult(queue)
|
||||
})
|
||||
|
||||
logger.Info("批量任务 MCP 工具已注册", zap.Int("count", 10))
|
||||
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 {
|
||||
|
||||
+61
-55
@@ -3,9 +3,7 @@ package handler
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -18,6 +16,7 @@ import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/knowledge"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -325,6 +324,17 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
searchTermLower = strings.ToLower(searchTerm)
|
||||
}
|
||||
|
||||
// 解析状态筛选参数: "true" = 仅已启用, "false" = 仅已停用, "" = 全部
|
||||
enabledFilter := c.Query("enabled")
|
||||
var filterEnabled *bool
|
||||
if enabledFilter == "true" {
|
||||
v := true
|
||||
filterEnabled = &v
|
||||
} else if enabledFilter == "false" {
|
||||
v := false
|
||||
filterEnabled = &v
|
||||
}
|
||||
|
||||
// 解析角色参数,用于过滤工具并标注启用状态
|
||||
roleName := c.Query("role")
|
||||
var roleToolsSet map[string]bool // 角色配置的工具集合
|
||||
@@ -388,6 +398,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
allTools = append(allTools, toolInfo)
|
||||
}
|
||||
|
||||
@@ -444,6 +459,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
allTools = append(allTools, toolInfo)
|
||||
}
|
||||
}
|
||||
@@ -486,6 +506,11 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if filterEnabled != nil && toolInfo.Enabled != *filterEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
allTools = append(allTools, toolInfo)
|
||||
}
|
||||
}
|
||||
@@ -769,9 +794,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// TestOpenAIRequest 测试OpenAI连接请求
|
||||
type TestOpenAIRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// TestOpenAI 测试OpenAI API连接是否可用
|
||||
@@ -793,7 +819,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(req.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 请求
|
||||
@@ -805,57 +835,19 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
"max_tokens": 5,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造请求失败"})
|
||||
return
|
||||
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
|
||||
tmpCfg := &config.OpenAIConfig{
|
||||
Provider: req.Provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: strings.TrimSpace(req.APIKey),
|
||||
Model: req.Model,
|
||||
}
|
||||
client := openai.NewClient(tmpCfg, nil, h.logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造HTTP请求失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey))
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "连接失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 尝试提取错误信息
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
errMsg := string(respBody)
|
||||
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
|
||||
errMsg = errResp.Error.Message
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", resp.StatusCode, errMsg),
|
||||
"status_code": resp.StatusCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析响应并严格验证是否为有效的 chat completion 响应
|
||||
var chatResp struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -867,10 +859,21 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
} `json:"message"`
|
||||
} `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{
|
||||
"success": false,
|
||||
"error": "API 响应不是有效的 JSON,请检查 Base URL 是否正确",
|
||||
"error": "连接失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -879,14 +882,14 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
if len(chatResp.Choices) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确(通常以 /v1 结尾)",
|
||||
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确",
|
||||
})
|
||||
return
|
||||
}
|
||||
if chatResp.ID == "" && chatResp.Model == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": "API 响应格式不符合 OpenAI 规范,请检查 Base URL 是否正确",
|
||||
"error": "API 响应格式不符合预期,请检查 Base URL 是否正确",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1246,6 +1249,9 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
|
||||
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||
root := doc.Content[0]
|
||||
openaiNode := ensureMap(root, "openai")
|
||||
if cfg.Provider != "" {
|
||||
setStringInMap(openaiNode, "provider", cfg.Provider)
|
||||
}
|
||||
setStringInMap(openaiNode, "api_key", cfg.APIKey)
|
||||
setStringInMap(openaiNode, "base_url", cfg.BaseURL)
|
||||
setStringInMap(openaiNode, "model", cfg.Model)
|
||||
|
||||
@@ -403,6 +403,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"description": "角色名称(可选)",
|
||||
},
|
||||
"agentMode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "代理模式(single | multi)",
|
||||
"enum": []string{"single", "multi"},
|
||||
},
|
||||
"scheduleMode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "调度方式(manual | cron)",
|
||||
"enum": []string{"manual", "cron"},
|
||||
},
|
||||
"cronExpr": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Cron 表达式(scheduleMode=cron 时必填)",
|
||||
},
|
||||
"executeNow": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "是否创建后立即执行(默认 false)",
|
||||
},
|
||||
},
|
||||
},
|
||||
"BatchQueue": map[string]interface{}{
|
||||
@@ -1540,9 +1558,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"message": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"message": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"message"},
|
||||
@@ -1711,6 +1729,10 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"queue": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/BatchQueue",
|
||||
},
|
||||
"started": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "是否已立即启动执行",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,6 +34,8 @@ const (
|
||||
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"
|
||||
@@ -63,6 +65,8 @@ func IsBuiltinTool(toolName string) bool {
|
||||
ToolBatchTaskStart,
|
||||
ToolBatchTaskPause,
|
||||
ToolBatchTaskDelete,
|
||||
ToolBatchTaskUpdateMetadata,
|
||||
ToolBatchTaskUpdateSchedule,
|
||||
ToolBatchTaskScheduleEnabled,
|
||||
ToolBatchTaskAdd,
|
||||
ToolBatchTaskUpdate,
|
||||
@@ -96,6 +100,8 @@ func GetAllBuiltinTools() []string {
|
||||
ToolBatchTaskStart,
|
||||
ToolBatchTaskPause,
|
||||
ToolBatchTaskDelete,
|
||||
ToolBatchTaskUpdateMetadata,
|
||||
ToolBatchTaskUpdateSchedule,
|
||||
ToolBatchTaskScheduleEnabled,
|
||||
ToolBatchTaskAdd,
|
||||
ToolBatchTaskUpdate,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -141,6 +142,9 @@ func RunDeepAgent(
|
||||
},
|
||||
}
|
||||
|
||||
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
|
||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||
|
||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||
APIKey: appCfg.OpenAI.APIKey,
|
||||
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
|
||||
|
||||
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) == "" {
|
||||
return fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return c.claudeChatCompletion(ctx, payload, out)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
@@ -156,6 +159,9 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||
return "", fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return c.claudeChatCompletionStream(ctx, payload, onDelta)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
@@ -294,6 +300,9 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||
return "", nil, "", fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return c.claudeChatCompletionStreamWithToolCalls(ctx, payload, onContentDelta)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
|
||||
+294
-9
@@ -3621,7 +3621,7 @@ header {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group input:not([type="checkbox"]):not([type="radio"]),
|
||||
.form-group select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -3644,30 +3644,43 @@ header {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:hover,
|
||||
.form-group input:not([type="checkbox"]):not([type="radio"]):hover,
|
||||
.form-group select:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group input:not([type="checkbox"]):not([type="radio"]).error,
|
||||
.form-group select.error {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error:focus,
|
||||
.form-group input:not([type="checkbox"]):not([type="radio"]).error:focus,
|
||||
.form-group select.error:focus {
|
||||
border-color: var(--error-color);
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2);
|
||||
}
|
||||
|
||||
.batch-execute-now-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-execute-now-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 现代化复选框样式 */
|
||||
.checkbox-label {
|
||||
display: flex !important;
|
||||
@@ -3826,6 +3839,41 @@ header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tools-status-filter {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tools-status-filter .btn-filter {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tools-status-filter .btn-filter:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tools-status-filter .btn-filter:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tools-status-filter .btn-filter.active {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8160,6 +8208,21 @@ header {
|
||||
.tasks-filters select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact select {
|
||||
min-width: 120px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.batch-queues-filters__search {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.tasks-batch-actions {
|
||||
flex-direction: column;
|
||||
@@ -8210,8 +8273,10 @@ header {
|
||||
|
||||
.batch-queues-board .batch-queues-list {
|
||||
margin-bottom: 0;
|
||||
padding: 12px 16px;
|
||||
padding: 14px 16px;
|
||||
box-sizing: border-box;
|
||||
/* 与卡片底色区分,flex gap 才能被看见,避免「白贴白」连成一片 */
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.batch-queues-board #batch-queues-pagination {
|
||||
@@ -8233,6 +8298,45 @@ header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 任务管理 · 筛选条:标签与控件同一行,降低占用高度 */
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact label {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact label > span {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
max-width: 9em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact select {
|
||||
min-width: 120px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.batch-queues-filters.tasks-filters.batch-queues-filters--compact input[type="text"] {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queues-filters__search {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.batch-queues-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -8251,15 +8355,15 @@ header {
|
||||
.batch-queues-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.batch-queue-item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 16px 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
@@ -8286,6 +8390,148 @@ header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 任务管理 · 队列卡片:单行主网格 + 进度列内统计,降低高度 */
|
||||
.batch-queue-item__inner--grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(128px, auto) minmax(88px, 14%) 44px;
|
||||
grid-template-rows: auto;
|
||||
grid-template-areas: "lead cluster progress actions";
|
||||
column-gap: 22px;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__lead {
|
||||
grid-area: lead;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__role-icon {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.batch-queue-item__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__inner--grid .batch-queue-card-title {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.batch-queue-item__cluster {
|
||||
grid-area: cluster;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
text-align: right;
|
||||
max-width: min(100%, 360px);
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* 状态徽章与百分比同一行,用 gap 明确分隔(避免视觉上「贴住」) */
|
||||
.batch-queue-item__status-inline {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px 20px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-queue-item__progress-col {
|
||||
grid-area: progress;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.batch-queue-item__actions-col {
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
align-self: center;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.batch-queue-item__idline--lead {
|
||||
display: block;
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, var(--text-secondary));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.batch-queue-item__idline--lead code {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.batch-queue-item__inner--grid .batch-queue-item__config {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.batch-queue-item__inner--grid .batch-queue-item__sublabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-queue-item__inner--grid .batch-queue-item__pct {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-queue-progress-bar--card-row {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.batch-queue-item--no-actions .batch-queue-item__inner--grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(128px, auto) minmax(88px, 16%);
|
||||
grid-template-areas: "lead cluster progress";
|
||||
}
|
||||
|
||||
.batch-queue-item--no-actions .batch-queue-item__actions-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.batch-queue-item__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -9323,6 +9569,45 @@ header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.batch-queue-item__inner--grid {
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"lead actions"
|
||||
"cluster cluster"
|
||||
"progress progress";
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.batch-queue-item--no-actions .batch-queue-item__inner--grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"lead"
|
||||
"cluster"
|
||||
"progress";
|
||||
}
|
||||
|
||||
.batch-queue-item__cluster {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.batch-queue-item__status-inline {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.batch-queue-item__inner--grid .batch-queue-item__pct {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.batch-queue-item__idline--lead {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.batch-queue-item__top {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"clearHistory": "Clear history",
|
||||
"cancelTask": "Cancel task",
|
||||
"viewConversation": "View conversation",
|
||||
"retryTask": "Retry",
|
||||
"conversationIdLabel": "Conversation ID",
|
||||
"statusPending": "Pending",
|
||||
"statusPaused": "Paused",
|
||||
@@ -507,6 +508,8 @@
|
||||
"toolSearchPlaceholder": "Enter tool name...",
|
||||
"statusFilter": "Status filter",
|
||||
"filterAll": "All",
|
||||
"filterEnabled": "Enabled",
|
||||
"filterDisabled": "Disabled",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
@@ -1515,6 +1518,9 @@
|
||||
"cronExprPlaceholder": "e.g. 0 */2 * * * (run every 2 hours)",
|
||||
"cronExprHint": "Use standard 5-field Cron: minute hour day month weekday. Example: `0 2 * * *` runs at 02:00 daily.",
|
||||
"cronExprRequired": "Please fill in a Cron expression when Cron schedule is selected",
|
||||
"cronExprInvalid": "Invalid Cron expression format. Must have 5 fields (minute hour day month weekday), e.g.: 0 */2 * * *",
|
||||
"executeNow": "Run immediately after creation",
|
||||
"executeNowHint": "Default is off. When disabled, the queue stays pending and can be started manually later.",
|
||||
"tasksList": "Task list (one task per line)",
|
||||
"tasksListPlaceholder": "Enter task list, one per line",
|
||||
"tasksListPlaceholderExample": "Enter task list, one per line, for example:\nScan open ports of 192.168.1.1\nCheck if https://example.com has SQL injection\nEnumerate subdomains of example.com",
|
||||
@@ -1526,6 +1532,8 @@
|
||||
"title": "Batch queue details",
|
||||
"addTask": "Add task",
|
||||
"startExecute": "Start",
|
||||
"startExecuteNow": "Run now (one round)",
|
||||
"startExecuteNowConfirm": "This is a Cron queue. Clicking Start will run the current round immediately instead of waiting for the next Cron time. Continue?",
|
||||
"pauseQueue": "Pause queue",
|
||||
"deleteQueue": "Delete queue",
|
||||
"queueTitle": "Task title",
|
||||
@@ -1538,6 +1546,11 @@
|
||||
"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",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"clearHistory": "清空历史",
|
||||
"cancelTask": "取消任务",
|
||||
"viewConversation": "查看对话",
|
||||
"retryTask": "重试",
|
||||
"conversationIdLabel": "对话ID",
|
||||
"statusPending": "待执行",
|
||||
"statusPaused": "已暂停",
|
||||
@@ -507,6 +508,8 @@
|
||||
"toolSearchPlaceholder": "输入工具名称...",
|
||||
"statusFilter": "状态筛选",
|
||||
"filterAll": "全部",
|
||||
"filterEnabled": "已启用",
|
||||
"filterDisabled": "已停用",
|
||||
"selectedCount": "已选择 {{count}} 项",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "全不选",
|
||||
@@ -1515,6 +1518,9 @@
|
||||
"cronExprPlaceholder": "例如:0 */2 * * *(每2小时执行一次)",
|
||||
"cronExprHint": "采用标准 5 段 Cron:分 时 日 月 周,例如 `0 2 * * *` 表示每天 02:00 执行。",
|
||||
"cronExprRequired": "请选择 Cron 调度后填写 Cron 表达式",
|
||||
"cronExprInvalid": "Cron 表达式格式错误,需要 5 段(分 时 日 月 周),例如:0 */2 * * *",
|
||||
"executeNow": "创建后立即执行",
|
||||
"executeNowHint": "默认不立即执行;关闭后队列保持待执行,可在需要时手动开始。",
|
||||
"tasksList": "任务列表(每行一个任务)",
|
||||
"tasksListPlaceholder": "请输入任务列表,每行一个任务",
|
||||
"tasksListPlaceholderExample": "请输入任务列表,每行一个任务,例如:\n扫描 192.168.1.1 的开放端口\n检查 https://example.com 是否存在SQL注入\n枚举 example.com 的子域名",
|
||||
@@ -1526,6 +1532,8 @@
|
||||
"title": "批量任务队列详情",
|
||||
"addTask": "添加任务",
|
||||
"startExecute": "开始执行",
|
||||
"startExecuteNow": "立即执行一轮",
|
||||
"startExecuteNowConfirm": "这是 Cron 队列,点击后会立即执行当前这一轮,不会等待下次 Cron 时间。确定立即执行吗?",
|
||||
"pauseQueue": "暂停队列",
|
||||
"deleteQueue": "删除队列",
|
||||
"queueTitle": "任务标题",
|
||||
@@ -1538,6 +1546,11 @@
|
||||
"nextRunAt": "下次执行时间",
|
||||
"scheduleCronAuto": "允许 Cron 自动调度",
|
||||
"scheduleCronAutoHint": "关闭后仅保留表达式配置,不会按时间自动跑;可随时手工点「开始执行」。",
|
||||
"editSchedule": "修改调度",
|
||||
"editScheduleTitle": "修改调度配置",
|
||||
"editScheduleSuccess": "调度配置已更新",
|
||||
"editScheduleError": "更新调度配置失败",
|
||||
"editMetadata": "编辑信息",
|
||||
"lastScheduleTriggerAt": "最近调度触发时间",
|
||||
"lastScheduleError": "最近调度失败原因",
|
||||
"lastRunError": "最近运行失败摘要",
|
||||
|
||||
@@ -232,7 +232,9 @@ function showSubmenuPopup(navItem, menuId) {
|
||||
}
|
||||
|
||||
// 初始化页面
|
||||
function initPage(pageId) {
|
||||
async function initPage(pageId) {
|
||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
|
||||
if (window.i18nReady) await window.i18nReady;
|
||||
switch(pageId) {
|
||||
case 'dashboard':
|
||||
if (typeof refreshDashboard === 'function') {
|
||||
|
||||
@@ -102,6 +102,10 @@ async function loadConfig(loadTools = true) {
|
||||
currentConfig = await response.json();
|
||||
|
||||
// 填充OpenAI配置
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
if (providerEl) {
|
||||
providerEl.value = currentConfig.openai.provider || 'openai';
|
||||
}
|
||||
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
|
||||
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
|
||||
document.getElementById('openai-model').value = currentConfig.openai.model || '';
|
||||
@@ -273,10 +277,15 @@ async function loadConfig(loadTools = true) {
|
||||
// 工具搜索关键词
|
||||
let toolsSearchKeyword = '';
|
||||
|
||||
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
|
||||
let toolsStatusFilter = '';
|
||||
|
||||
// 加载工具列表(分页)
|
||||
async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
||||
if (window.i18nReady) await window.i18nReady;
|
||||
const toolsList = document.getElementById('tools-list');
|
||||
|
||||
|
||||
// 显示加载状态
|
||||
if (toolsList) {
|
||||
// 清空整个容器,包括可能存在的分页控件
|
||||
@@ -292,6 +301,9 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
if (searchKeyword) {
|
||||
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
||||
}
|
||||
if (toolsStatusFilter !== '') {
|
||||
url += `&enabled=${toolsStatusFilter}`;
|
||||
}
|
||||
|
||||
// 使用较短的超时时间(10秒),避免长时间等待
|
||||
const controller = new AbortController();
|
||||
@@ -387,6 +399,17 @@ function handleSearchKeyPress(event) {
|
||||
}
|
||||
}
|
||||
|
||||
// 按状态筛选工具
|
||||
function filterToolsByStatus(status) {
|
||||
toolsStatusFilter = status;
|
||||
// 更新按钮激活状态
|
||||
document.querySelectorAll('.tools-status-filter .btn-filter').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === status);
|
||||
});
|
||||
// 重置到第一页并重新加载
|
||||
loadToolsList(1, toolsSearchKeyword);
|
||||
}
|
||||
|
||||
// 渲染工具列表
|
||||
function renderToolsList() {
|
||||
const toolsList = document.getElementById('tools-list');
|
||||
@@ -734,6 +757,7 @@ async function applySettings() {
|
||||
});
|
||||
|
||||
// 验证必填字段
|
||||
const provider = document.getElementById('openai-provider')?.value || 'openai';
|
||||
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||
const model = document.getElementById('openai-model').value.trim();
|
||||
@@ -802,6 +826,7 @@ async function applySettings() {
|
||||
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||
const config = {
|
||||
openai: {
|
||||
provider: provider,
|
||||
api_key: apiKey,
|
||||
base_url: baseUrl,
|
||||
model: model
|
||||
@@ -962,6 +987,7 @@ async function testOpenAIConnection() {
|
||||
const btn = document.getElementById('test-openai-btn');
|
||||
const resultEl = document.getElementById('test-openai-result');
|
||||
|
||||
const provider = document.getElementById('openai-provider')?.value || 'openai';
|
||||
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||
const model = document.getElementById('openai-model').value.trim();
|
||||
@@ -982,6 +1008,7 @@ async function testOpenAIConnection() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: provider,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: model
|
||||
@@ -1224,6 +1251,8 @@ async function fetchExternalMCPs() {
|
||||
// 加载外部MCP列表并渲染
|
||||
async function loadExternalMCPs() {
|
||||
try {
|
||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
||||
if (window.i18nReady) await window.i18nReady;
|
||||
const data = await fetchExternalMCPs();
|
||||
renderExternalMCPList(data.servers || {});
|
||||
renderExternalMCPStats(data.stats || {});
|
||||
|
||||
+322
-50
@@ -57,6 +57,15 @@ function getBatchQueueStatusPresentation(queue) {
|
||||
return { ...base, ...empty };
|
||||
}
|
||||
|
||||
/** 队列是否处于「可改子任务列表/文案」的空闲态(与后端 batch_task_manager.queueAllowsTaskListMutationLocked 对齐) */
|
||||
function batchQueueAllowsSubtaskMutation(queue) {
|
||||
if (!queue) return false;
|
||||
if (queue.status === 'running') return false;
|
||||
const hasRunningSubtask = Array.isArray(queue.tasks) && queue.tasks.some(t => t && t.status === 'running');
|
||||
if (hasRunningSubtask) return false;
|
||||
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
||||
}
|
||||
|
||||
// HTML转义函数(如果未定义)
|
||||
if (typeof escapeHtml === 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
@@ -782,6 +791,7 @@ async function showBatchImportModal() {
|
||||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
const executeNowCheckbox = document.getElementById('batch-queue-execute-now');
|
||||
if (modal && input) {
|
||||
input.value = '';
|
||||
if (titleInput) {
|
||||
@@ -800,6 +810,9 @@ async function showBatchImportModal() {
|
||||
if (cronExprInput) {
|
||||
cronExprInput.value = '';
|
||||
}
|
||||
if (executeNowCheckbox) {
|
||||
executeNowCheckbox.checked = false;
|
||||
}
|
||||
handleBatchScheduleModeChange();
|
||||
updateBatchImportStats('');
|
||||
|
||||
@@ -895,6 +908,7 @@ async function createBatchQueue() {
|
||||
const agentModeSelect = document.getElementById('batch-queue-agent-mode');
|
||||
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
|
||||
const cronExprInput = document.getElementById('batch-queue-cron-expr');
|
||||
const executeNowCheckbox = document.getElementById('batch-queue-execute-now');
|
||||
if (!input) return;
|
||||
|
||||
const text = input.value.trim();
|
||||
@@ -918,18 +932,23 @@ async function createBatchQueue() {
|
||||
const agentMode = agentModeSelect ? (agentModeSelect.value === 'multi' ? 'multi' : 'single') : 'single';
|
||||
const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual';
|
||||
const cronExpr = cronExprInput ? cronExprInput.value.trim() : '';
|
||||
const executeNow = executeNowCheckbox ? !!executeNowCheckbox.checked : false;
|
||||
if (scheduleMode === 'cron' && !cronExpr) {
|
||||
alert(_t('batchImportModal.cronExprRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleMode === 'cron' && !/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(cronExpr)) {
|
||||
alert(_t('batchImportModal.cronExprInvalid') || 'Cron 表达式格式错误,需要 5 段(分 时 日 月 周)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/batch-tasks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr }),
|
||||
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr, executeNow }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1118,34 +1137,34 @@ function renderBatchQueues() {
|
||||
? `<h4 class="batch-queue-card-title">${escapeHtml(queue.title)}</h4>`
|
||||
: `<h4 class="batch-queue-card-title batch-queue-card-title--muted">${escapeHtml(_t('tasks.batchQueueUntitled'))}</h4>`;
|
||||
const doneCount = stats.completed + stats.failed + stats.cancelled;
|
||||
const statsCompact = `<span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.totalLabel'))}\u00a0${stats.total}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.pendingLabel'))}\u00a0${stats.pending}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.runningLabel'))}\u00a0${stats.running}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item batch-queue-statsline__item--ok">${escapeHtml(_t('tasks.completedLabel'))}\u00a0${stats.completed}</span><span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item batch-queue-statsline__item--err">${escapeHtml(_t('tasks.failedLabel'))}\u00a0${stats.failed}</span>${stats.cancelled > 0 ? `<span class="batch-queue-statsline__sep">\u00b7</span><span class="batch-queue-statsline__item">${escapeHtml(_t('tasks.cancelledLabel'))}\u00a0${stats.cancelled}</span>` : ''}`;
|
||||
|
||||
const noActionsClass = canDelete ? '' : ' batch-queue-item--no-actions';
|
||||
return `
|
||||
<div class="batch-queue-item batch-queue-item--compact${cardMod}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||||
<div class="batch-queue-item__inner">
|
||||
<div class="batch-queue-item__top">
|
||||
<div class="batch-queue-item__title-col">
|
||||
${titleBlock}
|
||||
<p class="batch-queue-item__config">${configLine}${cronPausedNote}</p>
|
||||
<p class="batch-queue-item__idline"><code title="${escapeHtml(queue.id)}">${shortId}</code><span class="batch-queue-item__idsep">\u00b7</span><span>${escapeHtml(_t('tasks.createdTimeLabel'))}\u00a0${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></p>
|
||||
</div>
|
||||
<div class="batch-queue-item__top-actions" onclick="event.stopPropagation();">
|
||||
${canDelete ? `<button type="button" class="batch-queue-icon-btn" onclick="deleteBatchQueueFromList('${queue.id}')" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
|
||||
<div class="batch-queue-item batch-queue-item--compact${cardMod}${noActionsClass}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||||
<div class="batch-queue-item__inner batch-queue-item__inner--grid">
|
||||
<div class="batch-queue-item__lead">
|
||||
<div class="batch-queue-item__title-row">
|
||||
<span class="batch-queue-item__role-icon" aria-hidden="true">${escapeHtml(roleIcon)}</span>
|
||||
<div class="batch-queue-item__titles">${titleBlock}</div>
|
||||
</div>
|
||||
<p class="batch-queue-item__config">${configLine}${cronPausedNote}</p>
|
||||
<p class="batch-queue-item__idline batch-queue-item__idline--lead"><code title="${escapeHtml(queue.id)}">${shortId}</code><span class="batch-queue-item__idsep">\u00b7</span><span>${escapeHtml(_t('tasks.createdTimeLabel'))}\u00a0${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></p>
|
||||
</div>
|
||||
<div class="batch-queue-item__mid">
|
||||
<div class="batch-queue-item__mid-left">
|
||||
<div class="batch-queue-item__cluster">
|
||||
<div class="batch-queue-item__status-inline">
|
||||
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||||
${pres.sublabel ? `<span class="batch-queue-item__sublabel">${escapeHtml(pres.sublabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="batch-queue-item__mid-right">
|
||||
<div class="batch-queue-progress-bar batch-queue-progress-bar--card batch-queue-progress-bar--list">
|
||||
<div class="batch-queue-progress-fill${progressFillMod}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
<span class="batch-queue-item__pct">${progress}%\u00a0<span class="batch-queue-item__pct-frac">(${doneCount}/${stats.total})</span></span>
|
||||
</div>
|
||||
${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 class="batch-queue-statsline" aria-label="${escapeHtml(_t('tasks.batchQueueTitle'))}">${statsCompact}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1266,6 +1285,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
const queue = result.queue;
|
||||
batchQueuesState.currentQueueId = queueId;
|
||||
const pres = getBatchQueueStatusPresentation(queue);
|
||||
const allowSubtaskMutation = batchQueueAllowsSubtaskMutation(queue);
|
||||
|
||||
if (title) {
|
||||
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||||
@@ -1275,7 +1295,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
// 更新按钮显示
|
||||
const pauseBtn = document.getElementById('batch-queue-pause-btn');
|
||||
if (addTaskBtn) {
|
||||
addTaskBtn.style.display = queue.status === 'pending' ? 'inline-block' : 'none';
|
||||
addTaskBtn.style.display = allowSubtaskMutation ? 'inline-block' : 'none';
|
||||
}
|
||||
if (startBtn) {
|
||||
// pending状态显示"开始执行",paused状态显示"继续执行"
|
||||
@@ -1283,7 +1303,10 @@ async function showBatchQueueDetail(queueId) {
|
||||
if (startBtn && queue.status === 'paused') {
|
||||
startBtn.textContent = _t('tasks.resumeExecute');
|
||||
} else if (startBtn && queue.status === 'pending') {
|
||||
startBtn.textContent = _t('batchQueueDetailModal.startExecute');
|
||||
const isCronPending = queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||||
startBtn.textContent = isCronPending
|
||||
? _t('batchQueueDetailModal.startExecuteNow')
|
||||
: _t('batchQueueDetailModal.startExecute');
|
||||
}
|
||||
}
|
||||
if (pauseBtn) {
|
||||
@@ -1339,19 +1362,55 @@ async function showBatchQueueDetail(queueId) {
|
||||
const tasksList = content.querySelector('.batch-queue-tasks-list');
|
||||
const savedModalBodyScrollTop = modalBody ? modalBody.scrollTop : 0;
|
||||
const savedTasksListScrollTop = tasksList ? tasksList.scrollTop : 0;
|
||||
const prevTechDetails = content.querySelector('details.batch-queue-detail-tech');
|
||||
const prevLayout = content.querySelector('.batch-queue-detail-layout[data-bq-detail-for]');
|
||||
const prevDetailFor = prevLayout ? prevLayout.getAttribute('data-bq-detail-for') : null;
|
||||
const sameQueueAsBefore = prevDetailFor === queue.id;
|
||||
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="batch-queue-detail-layout">
|
||||
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
|
||||
<section class="batch-queue-detail-hero">
|
||||
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
|
||||
${pres.sublabel ? `<p class="batch-queue-detail-hero__sub">${escapeHtml(pres.sublabel)}</p>` : ''}
|
||||
${showProgressNoteInModal ? `<p class="batch-queue-detail-hero__note">${escapeHtml(pres.progressNote)}</p>` : ''}
|
||||
</section>
|
||||
<section class="batch-queue-detail-kv">
|
||||
${queue.title ? `<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</span><span class="bq-kv__v">${escapeHtml(queue.title)}</span></div>` : ''}
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</span><span class="bq-kv__v">${escapeHtml(queue.title || _t('tasks.batchQueueUntitled'))}${allowSubtaskMutation ? ` <button class="btn-secondary btn-small" onclick="showEditMetadataInline()" style="margin-left:8px;padding:1px 8px;font-size:12px;">${escapeHtml(_t('common.edit'))}</button>` : ''}</span></div>
|
||||
<div class="bq-kv bq-kv--block" id="bq-edit-metadata-row" style="display:none;">
|
||||
<span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.editMetadata') || '编辑信息')}</span>
|
||||
<span class="bq-kv__v bq-kv__v--control">
|
||||
<label style="font-size:12px;margin-right:4px;">${escapeHtml(_t('batchQueueDetailModal.queueTitle'))}</label>
|
||||
<input type="text" id="bq-edit-title" value="${escapeHtml(queue.title || '')}" placeholder="${escapeHtml(_t('batchImportModal.queueTitleHint') || '')}" style="padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;width:160px;" />
|
||||
<label style="font-size:12px;margin-left:12px;margin-right:4px;">${escapeHtml(_t('batchQueueDetailModal.role'))}</label>
|
||||
<select id="bq-edit-role" style="padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;min-width:120px;max-width:200px;">
|
||||
<option value="">${escapeHtml(_t('batchImportModal.defaultRole'))}</option>
|
||||
${(() => {
|
||||
const roles = (Array.isArray(loadedRoles) ? loadedRoles : []).filter(r => r.name !== '默认' && r.enabled !== false).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'zh-CN'));
|
||||
const currentInList = !queue.role || queue.role === '' || roles.some(r => r.name === queue.role);
|
||||
const orphan = !currentInList ? `<option value="${escapeHtml(queue.role)}" selected>${escapeHtml(queue.role)} (${escapeHtml(_t('batchQueueDetailModal.roleNotFound') || '已移除')})</option>` : '';
|
||||
return orphan + roles.map(r => `<option value="${escapeHtml(r.name)}" ${r.name === (queue.role || '') ? 'selected' : ''}>${escapeHtml(r.name)}</option>`).join('');
|
||||
})()}
|
||||
</select>
|
||||
<button class="btn-primary btn-small" onclick="saveEditMetadata()" style="margin-left:8px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.save'))}</button>
|
||||
<button class="btn-secondary btn-small" onclick="hideEditMetadataInline()" style="margin-left:4px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.cancel'))}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.role'))}</span><span class="bq-kv__v">${roleLineVal}</span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.agentMode'))}</span><span class="bq-kv__v">${escapeHtml(agentModeText)}</span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.scheduleMode'))}</span><span class="bq-kv__v">${scheduleDetail}</span></div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchImportModal.scheduleMode'))}</span><span class="bq-kv__v">${scheduleDetail}${allowSubtaskMutation ? ` <button class="btn-secondary btn-small" onclick="showEditScheduleInline()" style="margin-left:8px;padding:1px 8px;font-size:12px;">${escapeHtml(_t('batchQueueDetailModal.editSchedule'))}</button>` : ''}</span></div>
|
||||
<div class="bq-kv bq-kv--block" id="bq-edit-schedule-row" style="display:none;">
|
||||
<span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.editScheduleTitle'))}</span>
|
||||
<span class="bq-kv__v bq-kv__v--control">
|
||||
<select id="bq-edit-schedule-mode" onchange="toggleEditScheduleCronInput()" style="padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;">
|
||||
<option value="manual" ${queue.scheduleMode !== 'cron' ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeManual'))}</option>
|
||||
<option value="cron" ${queue.scheduleMode === 'cron' ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeCron'))}</option>
|
||||
</select>
|
||||
<input type="text" id="bq-edit-cron-expr" value="${escapeHtml(queue.cronExpr || '')}" placeholder="${_t('batchImportModal.cronExprPlaceholder', { interpolation: { escapeValue: false } })}" style="margin-left:8px;padding:4px 8px;border-radius:4px;border:1px solid #d0d0d0;font-size:13px;width:220px;${queue.scheduleMode !== 'cron' ? 'display:none;' : ''}" />
|
||||
<button class="btn-primary btn-small" onclick="saveEditSchedule()" style="margin-left:8px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.save'))}</button>
|
||||
<button class="btn-secondary btn-small" onclick="hideEditScheduleInline()" style="margin-left:4px;padding:2px 12px;font-size:12px;">${escapeHtml(_t('common.cancel'))}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bq-kv"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.taskTotal'))}</span><span class="bq-kv__v">${queue.tasks.length}</span></div>
|
||||
${queue.scheduleMode === 'cron' ? `<div class="bq-kv bq-kv--block"><span class="bq-kv__k">${escapeHtml(_t('batchQueueDetailModal.scheduleCronAuto'))}</span><span class="bq-kv__v bq-kv__v--control"><label class="bq-cron-toggle"><input type="checkbox" ${queue.scheduleEnabled !== false ? 'checked' : ''} onchange="updateBatchQueueScheduleEnabled(this.checked)" /><span class="bq-cron-toggle__hint">${escapeHtml(_t('batchQueueDetailModal.scheduleCronAutoHint'))}</span></label></span></div>` : ''}
|
||||
</section>
|
||||
@@ -1374,7 +1433,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
||||
${queue.tasks.map((task, index) => {
|
||||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
||||
const canEdit = queue.status === 'pending' && task.status === 'pending';
|
||||
const canEdit = allowSubtaskMutation && task.status !== 'running';
|
||||
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
||||
return `
|
||||
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
|
||||
@@ -1384,6 +1443,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
|
||||
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
|
||||
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
|
||||
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
</div>
|
||||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
||||
@@ -1405,11 +1465,18 @@ async function showBatchQueueDetail(queueId) {
|
||||
newTasksList.scrollTop = savedTasksListScrollTop;
|
||||
}
|
||||
|
||||
const newTechDetails = content.querySelector('details.batch-queue-detail-tech');
|
||||
if (newTechDetails && savedTechDetailsOpen) {
|
||||
newTechDetails.open = true;
|
||||
}
|
||||
|
||||
modal.style.display = 'block';
|
||||
|
||||
// 如果队列正在运行,自动刷新
|
||||
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
|
||||
if (queue.status === 'running') {
|
||||
startBatchQueueRefresh(queueId);
|
||||
} else {
|
||||
stopBatchQueueRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取队列详情失败:', error);
|
||||
@@ -1421,8 +1488,22 @@ async function showBatchQueueDetail(queueId) {
|
||||
async function startBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
const btn = document.getElementById('batch-queue-start-btn');
|
||||
if (btn) { btn.disabled = true; }
|
||||
try {
|
||||
// Cron 队列点击“开始执行”会立即运行一轮,这里二次确认避免误触
|
||||
const queueResponse = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
if (!queueResponse.ok) {
|
||||
throw new Error(_t('tasks.getQueueDetailFailed'));
|
||||
}
|
||||
const queueResult = await queueResponse.json();
|
||||
const queue = queueResult && queueResult.queue ? queueResult.queue : null;
|
||||
const isCronPending = queue && queue.status === 'pending' && queue.scheduleMode === 'cron' && queue.scheduleEnabled !== false;
|
||||
if (isCronPending) {
|
||||
const okNow = confirm(_t('batchQueueDetailModal.startExecuteNowConfirm'));
|
||||
if (!okNow) return;
|
||||
}
|
||||
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -1438,6 +1519,8 @@ async function startBatchQueue() {
|
||||
} catch (error) {
|
||||
console.error('启动批量任务失败:', error);
|
||||
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1445,11 +1528,12 @@ async function startBatchQueue() {
|
||||
async function pauseBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
|
||||
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('batch-queue-pause-btn');
|
||||
if (btn) { btn.disabled = true; }
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/pause`, {
|
||||
method: 'POST',
|
||||
@@ -1466,6 +1550,8 @@ async function pauseBatchQueue() {
|
||||
} catch (error) {
|
||||
console.error('暂停批量任务失败:', error);
|
||||
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1473,11 +1559,12 @@ async function pauseBatchQueue() {
|
||||
async function deleteBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
|
||||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('batch-queue-delete-btn');
|
||||
if (btn) { btn.disabled = true; }
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1493,6 +1580,8 @@ async function deleteBatchQueue() {
|
||||
} catch (error) {
|
||||
console.error('删除批量任务队列失败:', error);
|
||||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1542,8 +1631,19 @@ function startBatchQueueRefresh(queueId) {
|
||||
if (batchQueuesState.refreshInterval) {
|
||||
clearInterval(batchQueuesState.refreshInterval);
|
||||
}
|
||||
|
||||
|
||||
batchQueuesState.refreshInterval = setInterval(() => {
|
||||
// 如果编辑或添加任务的模态框正在打开,跳过本次刷新防止丢失编辑内容
|
||||
const editModal = document.getElementById('edit-batch-task-modal');
|
||||
const addModal = document.getElementById('add-batch-task-modal');
|
||||
const editScheduleRow = document.getElementById('bq-edit-schedule-row');
|
||||
const editMetadataRow = document.getElementById('bq-edit-metadata-row');
|
||||
if ((editModal && editModal.style.display === 'block') ||
|
||||
(addModal && addModal.style.display === 'block') ||
|
||||
(editScheduleRow && editScheduleRow.style.display !== 'none') ||
|
||||
(editMetadataRow && editMetadataRow.style.display !== 'none')) {
|
||||
return;
|
||||
}
|
||||
if (batchQueuesState.currentQueueId === queueId) {
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
@@ -1581,7 +1681,9 @@ function viewBatchTaskConversation(conversationId) {
|
||||
// 编辑批量任务的状态
|
||||
const editBatchTaskState = {
|
||||
queueId: null,
|
||||
taskId: null
|
||||
taskId: null,
|
||||
_escHandler: null,
|
||||
_saveHandler: null
|
||||
};
|
||||
|
||||
// 从元素获取任务信息并打开编辑模态框
|
||||
@@ -1632,24 +1734,30 @@ function editBatchTask(queueId, taskId, currentMessage) {
|
||||
messageInput.select();
|
||||
}, 100);
|
||||
|
||||
// 清理旧的事件监听器(防止泄漏)
|
||||
if (editBatchTaskState._escHandler) {
|
||||
document.removeEventListener('keydown', editBatchTaskState._escHandler);
|
||||
}
|
||||
if (editBatchTaskState._saveHandler) {
|
||||
messageInput.removeEventListener('keydown', editBatchTaskState._saveHandler);
|
||||
}
|
||||
|
||||
// 添加ESC键监听
|
||||
const handleKeyDown = (e) => {
|
||||
editBatchTaskState._escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeEditBatchTaskModal();
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
document.addEventListener('keydown', editBatchTaskState._escHandler);
|
||||
|
||||
// 添加Enter+Ctrl/Cmd保存功能
|
||||
const handleKeyPress = (e) => {
|
||||
editBatchTaskState._saveHandler = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveBatchTask();
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
messageInput.addEventListener('keydown', handleKeyPress);
|
||||
messageInput.addEventListener('keydown', editBatchTaskState._saveHandler);
|
||||
}
|
||||
|
||||
// 关闭编辑批量任务模态框
|
||||
@@ -1658,6 +1766,18 @@ function closeEditBatchTaskModal() {
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
// 清理事件监听器
|
||||
if (editBatchTaskState._escHandler) {
|
||||
document.removeEventListener('keydown', editBatchTaskState._escHandler);
|
||||
editBatchTaskState._escHandler = null;
|
||||
}
|
||||
if (editBatchTaskState._saveHandler) {
|
||||
const messageInput = document.getElementById('edit-task-message');
|
||||
if (messageInput) {
|
||||
messageInput.removeEventListener('keydown', editBatchTaskState._saveHandler);
|
||||
}
|
||||
editBatchTaskState._saveHandler = null;
|
||||
}
|
||||
editBatchTaskState.queueId = null;
|
||||
editBatchTaskState.taskId = null;
|
||||
}
|
||||
@@ -1738,28 +1858,46 @@ function showAddBatchTaskModal() {
|
||||
messageInput.focus();
|
||||
}, 100);
|
||||
|
||||
// 清理旧的事件监听器
|
||||
if (showAddBatchTaskModal._escHandler) {
|
||||
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||||
}
|
||||
if (showAddBatchTaskModal._saveHandler && messageInput) {
|
||||
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||||
}
|
||||
|
||||
// 添加ESC键监听
|
||||
const handleKeyDown = (e) => {
|
||||
showAddBatchTaskModal._escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeAddBatchTaskModal();
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
document.addEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||||
|
||||
// 添加Enter+Ctrl/Cmd保存功能
|
||||
const handleKeyPress = (e) => {
|
||||
showAddBatchTaskModal._saveHandler = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveAddBatchTask();
|
||||
messageInput.removeEventListener('keydown', handleKeyPress);
|
||||
}
|
||||
};
|
||||
messageInput.addEventListener('keydown', handleKeyPress);
|
||||
messageInput.addEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||||
}
|
||||
|
||||
// 关闭添加批量任务模态框
|
||||
function closeAddBatchTaskModal() {
|
||||
// 清理事件监听器
|
||||
if (showAddBatchTaskModal._escHandler) {
|
||||
document.removeEventListener('keydown', showAddBatchTaskModal._escHandler);
|
||||
showAddBatchTaskModal._escHandler = null;
|
||||
}
|
||||
if (showAddBatchTaskModal._saveHandler) {
|
||||
const messageInput = document.getElementById('add-task-message');
|
||||
if (messageInput) {
|
||||
messageInput.removeEventListener('keydown', showAddBatchTaskModal._saveHandler);
|
||||
}
|
||||
showAddBatchTaskModal._saveHandler = null;
|
||||
}
|
||||
const modal = document.getElementById('add-batch-task-modal');
|
||||
const messageInput = document.getElementById('add-task-message');
|
||||
if (modal) {
|
||||
@@ -1908,6 +2046,132 @@ async function updateBatchQueueScheduleEnabled(enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 元数据(标题/角色)内联编辑 ---
|
||||
function showEditMetadataInline() {
|
||||
// 关闭调度编辑行(互斥)
|
||||
const schedRow = document.getElementById('bq-edit-schedule-row');
|
||||
if (schedRow) schedRow.style.display = 'none';
|
||||
const row = document.getElementById('bq-edit-metadata-row');
|
||||
if (row) row.style.display = '';
|
||||
}
|
||||
function hideEditMetadataInline() {
|
||||
const row = document.getElementById('bq-edit-metadata-row');
|
||||
if (row) row.style.display = 'none';
|
||||
}
|
||||
async function saveEditMetadata() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
const titleInput = document.getElementById('bq-edit-title');
|
||||
const roleInput = document.getElementById('bq-edit-role');
|
||||
const title = titleInput ? titleInput.value.trim() : '';
|
||||
const role = roleInput ? roleInput.value.trim() : '';
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, role }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||||
}
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 重试失败任务 ---
|
||||
async function retryBatchTask(queueId, taskId) {
|
||||
if (!queueId || !taskId) return;
|
||||
try {
|
||||
// 获取任务消息
|
||||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
|
||||
const detail = await detailResp.json();
|
||||
const task = detail.queue.tasks.find(t => t.id === taskId);
|
||||
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
|
||||
const message = task.message;
|
||||
|
||||
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
|
||||
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!addResp.ok) {
|
||||
const r = await addResp.json().catch(() => ({}));
|
||||
throw new Error(r.error || _t('tasks.addTaskFailed'));
|
||||
}
|
||||
// 新任务添加成功后才删除旧任务
|
||||
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
|
||||
if (!delResp.ok) {
|
||||
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
|
||||
console.warn('删除旧任务失败,但新任务已添加');
|
||||
}
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
} catch (e) {
|
||||
console.error('重试任务失败:', e);
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 调度配置内联编辑 ---
|
||||
function showEditScheduleInline() {
|
||||
// 关闭元数据编辑行(互斥)
|
||||
const metaRow = document.getElementById('bq-edit-metadata-row');
|
||||
if (metaRow) metaRow.style.display = 'none';
|
||||
const row = document.getElementById('bq-edit-schedule-row');
|
||||
if (row) row.style.display = '';
|
||||
}
|
||||
function hideEditScheduleInline() {
|
||||
const row = document.getElementById('bq-edit-schedule-row');
|
||||
if (row) row.style.display = 'none';
|
||||
}
|
||||
function toggleEditScheduleCronInput() {
|
||||
const modeSelect = document.getElementById('bq-edit-schedule-mode');
|
||||
const cronInput = document.getElementById('bq-edit-cron-expr');
|
||||
if (modeSelect && cronInput) {
|
||||
cronInput.style.display = modeSelect.value === 'cron' ? '' : 'none';
|
||||
}
|
||||
}
|
||||
async function saveEditSchedule() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
const modeSelect = document.getElementById('bq-edit-schedule-mode');
|
||||
const cronInput = document.getElementById('bq-edit-cron-expr');
|
||||
if (!modeSelect) return;
|
||||
const scheduleMode = modeSelect.value;
|
||||
const cronExpr = cronInput ? cronInput.value.trim() : '';
|
||||
if (scheduleMode === 'cron' && !cronExpr) {
|
||||
alert(_t('batchImportModal.cronExprRequired'));
|
||||
return;
|
||||
}
|
||||
if (scheduleMode === 'cron' && !/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(cronExpr)) {
|
||||
alert(_t('batchImportModal.cronExprInvalid') || 'Cron 表达式格式错误,需要 5 段(分 时 日 月 周)');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scheduleMode, cronExpr }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || _t('batchQueueDetailModal.editScheduleError'));
|
||||
}
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(_t('batchQueueDetailModal.editScheduleError') + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数
|
||||
window.showBatchImportModal = showBatchImportModal;
|
||||
window.closeBatchImportModal = closeBatchImportModal;
|
||||
@@ -1933,6 +2197,14 @@ window.deleteBatchTaskFromElement = deleteBatchTaskFromElement;
|
||||
window.deleteBatchQueueFromList = deleteBatchQueueFromList;
|
||||
window.handleBatchScheduleModeChange = handleBatchScheduleModeChange;
|
||||
window.updateBatchQueueScheduleEnabled = updateBatchQueueScheduleEnabled;
|
||||
window.showEditMetadataInline = showEditMetadataInline;
|
||||
window.hideEditMetadataInline = hideEditMetadataInline;
|
||||
window.saveEditMetadata = saveEditMetadata;
|
||||
window.retryBatchTask = retryBatchTask;
|
||||
window.showEditScheduleInline = showEditScheduleInline;
|
||||
window.hideEditScheduleInline = hideEditScheduleInline;
|
||||
window.toggleEditScheduleCronInput = toggleEditScheduleCronInput;
|
||||
window.saveEditSchedule = saveEditSchedule;
|
||||
|
||||
// 语言切换后,列表/分页/详情弹窗由 JS 渲染的文案需用当前语言重绘(applyTranslations 不会处理 innerHTML 内容)
|
||||
document.addEventListener('languagechange', function () {
|
||||
|
||||
@@ -725,6 +725,11 @@
|
||||
<div class="tools-actions">
|
||||
<button class="btn-secondary" onclick="selectAllTools()" data-i18n="mcp.selectAll">全选</button>
|
||||
<button class="btn-secondary" onclick="deselectAllTools()" data-i18n="mcp.deselectAll">全不选</button>
|
||||
<div class="tools-status-filter">
|
||||
<button class="btn-filter active" data-filter="" onclick="filterToolsByStatus('')" data-i18n="mcp.filterAll">全部</button>
|
||||
<button class="btn-filter" data-filter="true" onclick="filterToolsByStatus('true')" data-i18n="mcp.filterEnabled">已启用</button>
|
||||
<button class="btn-filter" data-filter="false" onclick="filterToolsByStatus('false')" data-i18n="mcp.filterDisabled">已停用</button>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="tools-search" data-i18n="mcp.toolSearchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..." onkeypress="handleSearchKeyPress(event)" oninput="if(this.value.trim() === '') clearSearch()" />
|
||||
<button class="btn-search" onclick="searchTools()" data-i18n="common.search" data-i18n-attr="title" title="搜索">🔍</button>
|
||||
@@ -1132,7 +1137,7 @@
|
||||
<!-- 批量任务队列列表 -->
|
||||
<div class="batch-queues-section" id="batch-queues-section" style="display: none;">
|
||||
<!-- 筛选控件 -->
|
||||
<div class="batch-queues-filters tasks-filters">
|
||||
<div class="batch-queues-filters tasks-filters batch-queues-filters--compact">
|
||||
<label>
|
||||
<span data-i18n="tasksPage.statusFilter">状态筛选</span>
|
||||
<select id="batch-queues-status-filter" onchange="filterBatchQueues()">
|
||||
@@ -1144,7 +1149,7 @@
|
||||
<option value="cancelled" data-i18n="tasksPage.statusCancelled">已取消</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="flex: 1; max-width: 300px;">
|
||||
<label class="batch-queues-filters__search">
|
||||
<span data-i18n="tasksPage.searchQueuePlaceholder">搜索队列ID、标题或创建时间</span>
|
||||
<input type="text" id="batch-queues-search" data-i18n="tasksPage.searchKeywordPlaceholder" data-i18n-attr="placeholder" placeholder="输入关键字搜索..."
|
||||
oninput="filterBatchQueues()">
|
||||
@@ -1360,6 +1365,13 @@
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="openai-provider">API 提供商</label>
|
||||
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="openai">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
||||
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||
@@ -2336,6 +2348,13 @@ version: 1.0.0<br>
|
||||
<input type="text" id="batch-queue-cron-expr" data-i18n="batchImportModal.cronExprPlaceholder" data-i18n-attr="placeholder" placeholder="例如:0 */2 * * *(每2小时执行一次)" />
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.cronExprHint">采用标准 5 段 Cron:分 时 日 月 周,例如 `0 2 * * *` 表示每天 02:00 执行。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-execute-now" class="batch-execute-now-label">
|
||||
<input type="checkbox" id="batch-queue-execute-now" />
|
||||
<span data-i18n="batchImportModal.executeNow">创建后立即执行</span>
|
||||
</label>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.executeNowHint">默认不立即执行;关闭后队列保持待执行,可在需要时手动开始。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-tasks-input"><span data-i18n="batchImportModal.tasksList">任务列表(每行一个任务)</span><span style="color: red;">*</span></label>
|
||||
<textarea id="batch-tasks-input" rows="15" data-i18n="batchImportModal.tasksListPlaceholderExample" data-i18n-attr="placeholder" placeholder="请输入任务列表,每行一个任务,例如: 扫描 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