Compare commits

...

23 Commits

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:45:35 +08:00
公明 715240dc5e Add files via upload 2026-04-15 00:54:15 +08:00
公明 5f8b19e179 Add files via upload 2026-04-15 00:53:14 +08:00
公明 ea48f3d71b Add files via upload 2026-04-15 00:43:35 +08:00
公明 e3013aa230 Add files via upload 2026-04-15 00:39:23 +08:00
公明 1cf34797b8 Add files via upload 2026-04-15 00:38:07 +08:00
公明 62241e0e66 Add files via upload 2026-04-15 00:13:09 +08:00
公明 dda4edb952 Add files via upload 2026-04-15 00:08:35 +08:00
公明 5bf6317dcb Add files via upload 2026-04-14 19:30:39 +08:00
公明 9331fbfea1 Add files via upload 2026-04-14 19:28:17 +08:00
公明 b1ac985c28 Add files via upload 2026-04-14 19:06:52 +08:00
公明 4f4a725034 Add files via upload 2026-04-14 19:02:28 +08:00
公明 3e689a5dcb Add files via upload 2026-04-14 12:53:49 +08:00
公明 de18ae5b0f Add files via upload 2026-04-14 10:36:50 +08:00
公明 517906207a Update config.yaml 2026-04-14 10:31:19 +08:00
公明 7407d6822f Add files via upload 2026-04-14 10:30:40 +08:00
22 changed files with 2406 additions and 203 deletions
+3 -1
View File
@@ -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 # 模型名称(必填)
+5 -4
View File
@@ -9,8 +9,8 @@ toolchain go1.24.4
require (
github.com/bytedance/sonic v1.15.0
github.com/cloudwego/eino v0.8.4
github.com/cloudwego/eino-ext/components/model/openai v0.1.10
github.com/cloudwego/eino v0.8.8
github.com/cloudwego/eino-ext/components/model/openai v0.1.12
github.com/creack/pty v1.1.24
github.com/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1
@@ -21,6 +21,7 @@ require (
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1
go.uber.org/zap v1.26.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
@@ -33,7 +34,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
@@ -51,7 +52,7 @@ require (
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
+10
View File
@@ -22,10 +22,16 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.8.4 h1:aFKJK82MmPR6dm5y5J7IXivYSvh4HkcXwf18j6vyhmk=
github.com/cloudwego/eino v0.8.4/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino v0.8.8 h1:64NuheQBmxOXe/28Tm85rkBkxXMB5ZhjSu/j0RDFyZU=
github.com/cloudwego/eino v0.8.8/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino-ext/components/model/openai v0.1.10 h1:zVkU4rZUUUUAPEXOGs98n8nsT/NZvQ9zWY0B9h2US7k=
github.com/cloudwego/eino-ext/components/model/openai v0.1.10/go.mod h1:smEeTKXe8uz+HDUBQn0yZhpx7mmOUKFQyguLfjAQ57I=
github.com/cloudwego/eino-ext/components/model/openai v0.1.12 h1:vcwNXeT7bpaXMNwUhtcHZwMYY8II2jAihuooyivmEZ0=
github.com/cloudwego/eino-ext/components/model/openai v0.1.12/go.mod h1:ve/+/hLZMvxD5AieQ355xHIFhAZVlsG4rdwTnE16aQU=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 h1:yOZII6VYaL00CVZYba+HUixFygsW0Xz/1QjQ5htj1Ls=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -113,6 +119,8 @@ github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
@@ -136,6 +144,8 @@ github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/Q
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+2
View File
@@ -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)
+1
View File
@@ -129,6 +129,7 @@ type MCPConfig struct {
}
type OpenAIConfig struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude"claude 时自动桥接为 Anthropic Messages API
APIKey string `yaml:"api_key" json:"api_key"`
BaseURL string `yaml:"base_url" json:"base_url"`
Model string `yaml:"model" json:"model"`
+13 -1
View File
@@ -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 {
+90 -2
View File
@@ -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")
+165 -64
View File
@@ -9,8 +9,35 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// 批量任务状态常量
const (
BatchQueueStatusPending = "pending"
BatchQueueStatusRunning = "running"
BatchQueueStatusPaused = "paused"
BatchQueueStatusCompleted = "completed"
BatchQueueStatusCancelled = "cancelled"
BatchTaskStatusPending = "pending"
BatchTaskStatusRunning = "running"
BatchTaskStatusCompleted = "completed"
BatchTaskStatusFailed = "failed"
BatchTaskStatusCancelled = "cancelled"
// MaxBatchTasksPerQueue 单个队列最大任务数
MaxBatchTasksPerQueue = 10000
// MaxBatchQueueTitleLen 队列标题最大长度
MaxBatchQueueTitleLen = 200
// MaxBatchQueueRoleLen 角色名最大长度
MaxBatchQueueRoleLen = 100
)
// BatchTask 批量任务项
@@ -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))
}
}
+243 -10
View File
@@ -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_idCron 队列若需按表达式自动触发下一轮还需保持调度开关开启可用 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_startqueue_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_startqueue_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
View File
@@ -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)
+25 -3
View File
@@ -403,6 +403,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "角色名称(可选)",
},
"agentMode": map[string]interface{}{
"type": "string",
"description": "代理模式(single | multi",
"enum": []string{"single", "multi"},
},
"scheduleMode": map[string]interface{}{
"type": "string",
"description": "调度方式(manual | cron",
"enum": []string{"manual", "cron"},
},
"cronExpr": map[string]interface{}{
"type": "string",
"description": "Cron 表达式(scheduleMode=cron 时必填)",
},
"executeNow": map[string]interface{}{
"type": "boolean",
"description": "是否创建后立即执行(默认 false)",
},
},
},
"BatchQueue": map[string]interface{}{
@@ -1540,9 +1558,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"message": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
},
"required": []string{"message"},
@@ -1711,6 +1729,10 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"queue": map[string]interface{}{
"$ref": "#/components/schemas/BatchQueue",
},
"started": map[string]interface{}{
"type": "boolean",
"description": "是否已立即启动执行",
},
},
},
},
+6
View File
@@ -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,
+4
View File
@@ -19,6 +19,7 @@ import (
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
@@ -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
+9
View File
@@ -64,6 +64,9 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
if strings.TrimSpace(c.config.APIKey) == "" {
return fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return c.claudeChatCompletion(ctx, payload, out)
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
@@ -156,6 +159,9 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
if strings.TrimSpace(c.config.APIKey) == "" {
return "", fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return c.claudeChatCompletionStream(ctx, payload, onDelta)
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
@@ -294,6 +300,9 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
if strings.TrimSpace(c.config.APIKey) == "" {
return "", nil, "", fmt.Errorf("openai api key is empty")
}
if c.isClaude() {
return c.claudeChatCompletionStreamWithToolCalls(ctx, payload, onContentDelta)
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
+294 -9
View File
@@ -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;
+13
View File
@@ -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",
+13
View File
@@ -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": "最近运行失败摘要",
+3 -1
View File
@@ -232,7 +232,9 @@ function showSubmenuPopup(navItem, menuId) {
}
// 初始化页面
function initPage(pageId) {
async function initPage(pageId) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
if (window.i18nReady) await window.i18nReady;
switch(pageId) {
case 'dashboard':
if (typeof refreshDashboard === 'function') {
+30 -1
View File
@@ -102,6 +102,10 @@ async function loadConfig(loadTools = true) {
currentConfig = await response.json();
// 填充OpenAI配置
const providerEl = document.getElementById('openai-provider');
if (providerEl) {
providerEl.value = currentConfig.openai.provider || 'openai';
}
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
document.getElementById('openai-model').value = currentConfig.openai.model || '';
@@ -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
View File
@@ -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,否则会把 && 显示成 &amp;...(看起来像“变形/乱码”)
@@ -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, "&#39;").replace(/"/g, "&quot;").replace(/\n/g, "\\n");
return `
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
@@ -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 () {
+21 -2
View File
@@ -725,6 +725,11 @@
<div class="tools-actions">
<button class="btn-secondary" onclick="selectAllTools()" data-i18n="mcp.selectAll">全选</button>
<button class="btn-secondary" onclick="deselectAllTools()" data-i18n="mcp.deselectAll">全不选</button>
<div class="tools-status-filter">
<button class="btn-filter active" data-filter="" onclick="filterToolsByStatus('')" data-i18n="mcp.filterAll">全部</button>
<button class="btn-filter" data-filter="true" onclick="filterToolsByStatus('true')" data-i18n="mcp.filterEnabled">已启用</button>
<button class="btn-filter" data-filter="false" onclick="filterToolsByStatus('false')" data-i18n="mcp.filterDisabled">已停用</button>
</div>
<div class="search-box">
<input type="text" id="tools-search" data-i18n="mcp.toolSearchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..." onkeypress="handleSearchKeyPress(event)" oninput="if(this.value.trim() === '') clearSearch()" />
<button class="btn-search" onclick="searchTools()" data-i18n="common.search" data-i18n-attr="title" title="搜索">🔍</button>
@@ -1132,7 +1137,7 @@
<!-- 批量任务队列列表 -->
<div class="batch-queues-section" id="batch-queues-section" style="display: none;">
<!-- 筛选控件 -->
<div class="batch-queues-filters tasks-filters">
<div class="batch-queues-filters tasks-filters batch-queues-filters--compact">
<label>
<span data-i18n="tasksPage.statusFilter">状态筛选</span>
<select id="batch-queues-status-filter" onchange="filterBatchQueues()">
@@ -1144,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="请输入任务列表,每行一个任务,例如:&#10;扫描 192.168.1.1 的开放端口&#10;检查 https://example.com 是否存在SQL注入&#10;枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>