Compare commits

...

6 Commits

Author SHA1 Message Date
公明 fe625010eb Update config.yaml 2026-05-07 17:04:39 +08:00
公明 40cd0293b5 Add files via upload 2026-05-07 17:04:14 +08:00
公明 b62dc1f326 Add files via upload 2026-05-07 17:02:26 +08:00
公明 6d180c814d Add files via upload 2026-05-07 17:01:15 +08:00
公明 e68d3a3d23 Add files via upload 2026-05-07 16:58:54 +08:00
公明 699b9181e6 Add files via upload 2026-05-07 16:57:17 +08:00
20 changed files with 1154 additions and 164 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.3" version: "v1.6.4"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+3 -1
View File
@@ -1514,7 +1514,9 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常 // 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
if err != nil { if err != nil {
detail := err.Error() detail := err.Error()
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) {
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
} else if errors.Is(err, context.DeadlineExceeded) {
min := 10 min := 10
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 { if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
min = a.agentConfig.ToolTimeoutMinutes min = a.agentConfig.ToolTimeoutMinutes
+1
View File
@@ -757,6 +757,7 @@ func setupRoutes(
// 监控 // 监控
protected.GET("/monitor", monitorHandler.Monitor) protected.GET("/monitor", monitorHandler.Monitor)
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution) protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
protected.POST("/monitor/execution/:id/cancel", monitorHandler.CancelExecution)
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames) protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution) protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions) protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
+24 -4
View File
@@ -1717,6 +1717,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) { func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
var req struct { var req struct {
ConversationID string `json:"conversationId" binding:"required"` ConversationID string `json:"conversationId" binding:"required"`
Reason string `json:"reason,omitempty"`
ContinueAfter bool `json:"continueAfter,omitempty"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -1724,7 +1726,23 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
return return
} }
ok, err := h.tasks.CancelTask(req.ConversationID, ErrTaskCancelled) if req.ContinueAfter && strings.TrimSpace(req.Reason) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "continueAfter 为 true 时必须提供非空的 reason(中断说明)"})
return
}
var cause error = ErrTaskCancelled
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
if req.ContinueAfter {
if !h.tasks.SetInterruptContinueReason(req.ConversationID, req.Reason) {
c.JSON(http.StatusNotFound, gin.H{"error": "未找到正在执行的任务,无法提交中断说明"})
return
}
cause = ErrUserInterruptContinue
msg = "已提交中断说明,当前步骤结束后将写入对话并继续迭代。"
}
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
if err != nil { if err != nil {
h.logger.Error("取消任务失败", zap.Error(err)) h.logger.Error("取消任务失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -1737,9 +1755,11 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "cancelling", "status": "cancelling",
"conversationId": req.ConversationID, "conversationId": req.ConversationID,
"message": "已提交取消请求,任务将在当前步骤完成后停止。", "message": msg,
"continueAfter": req.ContinueAfter,
"interruptWithNote": req.ContinueAfter,
}) })
} }
+108 -42
View File
@@ -43,8 +43,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var sseWriteMu sync.Mutex var sseWriteMu sync.Mutex
var ssePublishConversationID string var ssePublishConversationID string
sendEvent := func(eventType, message string, data interface{}) { sendEvent := func(eventType, message string, data interface{}) {
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) { if eventType == "error" && baseCtx != nil {
return cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) || errors.Is(cause, ErrUserInterruptContinue) {
return
}
} }
ev := StreamEvent{Type: eventType, Message: message, Data: data} ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, errMarshal := json.Marshal(ev) b, errMarshal := json.Marshal(ev)
@@ -114,33 +117,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
} }
var cancelWithCause context.CancelCauseFunc var cancelWithCause context.CancelCauseFunc
baseCtx, cancelWithCause = context.WithCancelCause(context.Background()) firstRun := true
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute) curFinalMessage := prep.FinalMessage
defer timeoutCancel() curHistory := prep.History
defer cancelWithCause(nil) roleTools := prep.RoleTools
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
if errors.Is(err, ErrTaskAlreadyRunning) {
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
sendEvent("error", errorMsg, map[string]interface{}{
"conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
taskStatus := "completed" taskStatus := "completed"
defer h.tasks.FinishTask(conversationID, taskStatus) defer h.tasks.FinishTask(conversationID, taskStatus)
@@ -161,22 +141,108 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
return return
} }
result, runErr := multiagent.RunEinoSingleChatModelAgent( var result *multiagent.RunResult
taskCtx, var runErr error
h.config,
&h.config.MultiAgent, for {
h.agent, baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.logger, taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
conversationID,
prep.FinalMessage, if firstRun {
prep.History, if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
prep.RoleTools, var errorMsg string
progressCallback, if errors.Is(err, ErrTaskAlreadyRunning) {
) errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
sendEvent("error", errorMsg, map[string]interface{}{
"conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
firstRun = false
} else {
if err := h.tasks.ResetTaskCancelForContinue(conversationID, cancelWithCause); err != nil {
h.logger.Error("续跑任务时重置 cancel 失败", zap.Error(err))
taskStatus = "failed"
sendEvent("error", err.Error(), nil)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
}
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
conversationID,
curFinalMessage,
curHistory,
roleTools,
progressCallback,
)
timeoutCancel()
if runErr == nil {
break
}
if runErr != nil {
h.persistEinoAgentTraceForResume(conversationID, result) h.persistEinoAgentTraceForResume(conversationID, result)
cause := context.Cause(baseCtx) cause := context.Cause(baseCtx)
if errors.Is(cause, ErrUserInterruptContinue) {
reason := h.tasks.TakeInterruptContinueReason(conversationID)
prepNext, perr := h.prepareSessionAfterUserInterrupt(conversationID, assistantMessageID, reason, roleTools)
if perr != nil {
h.logger.Error("准备中断后续跑失败", zap.Error(perr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "中断后续跑失败: " + perr.Error()
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
assistantMessageID = prepNext.AssistantMessageID
curFinalMessage = prepNext.FinalMessage
curHistory = prepNext.History
if prepNext.UserMessageID != "" {
sendEvent("message_saved", "", map[string]interface{}{
"conversationId": conversationID,
"userMessageId": prepNext.UserMessageID,
})
}
sendEvent("user_interrupt_continue", reason, map[string]interface{}{
"conversationId": conversationID,
"reason": reason,
"messageId": assistantMessageID,
})
sendEvent("progress", "已接收中断说明,继续迭代...", map[string]interface{}{
"conversationId": conversationID,
})
continue
}
if errors.Is(cause, ErrTaskCancelled) { if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled" taskStatus = "cancelled"
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
+36 -2
View File
@@ -1,6 +1,9 @@
package handler package handler
import ( import (
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -245,6 +248,37 @@ func (h *MonitorHandler) GetExecution(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"}) c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
} }
// CancelExecution 手动取消进行中的 MCP 工具调用(仅取消该次 tools/call 的上下文,不停止整条 Agent / 迭代任务)
// 请求体可选 JSON{ "note": "用户说明" },将与工具已返回输出合并交给模型(含「用户终止说明」标题块,与命令行原文区分)。
func (h *MonitorHandler) CancelExecution(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID不能为空"})
return
}
note := ""
dec := json.NewDecoder(c.Request.Body)
var body struct {
Note string `json:"note"`
}
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON,例如 {\"note\":\"说明\"},可为空对象"})
return
}
note = strings.TrimSpace(body.Note)
if h.mcpServer.CancelToolExecutionWithNote(id, note) {
h.logger.Info("已请求取消 MCP 工具执行", zap.String("executionId", id), zap.String("source", "internal"), zap.Bool("hasNote", note != ""))
c.JSON(http.StatusOK, gin.H{"message": "已发送终止信号", "executionId": id})
return
}
if h.externalMCPMgr != nil && h.externalMCPMgr.CancelToolExecutionWithNote(id, note) {
h.logger.Info("已请求取消 MCP 工具执行", zap.String("executionId", id), zap.String("source", "external"), zap.Bool("hasNote", note != ""))
c.JSON(http.StatusOK, gin.H{"message": "已发送终止信号", "executionId": id})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "未找到进行中的工具执行,或该任务已结束"})
}
// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求) // BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求)
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) { func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
var req struct { var req struct {
@@ -317,7 +351,7 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
totalCalls := 1 totalCalls := 1
successCalls := 0 successCalls := 0
failedCalls := 0 failedCalls := 0
if exec.Status == "failed" { if exec.Status == "failed" || exec.Status == "cancelled" {
failedCalls = 1 failedCalls = 1
} else if exec.Status == "completed" { } else if exec.Status == "completed" {
successCalls = 1 successCalls = 1
@@ -381,7 +415,7 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
stats := toolStats[exec.ToolName] stats := toolStats[exec.ToolName]
stats.totalCalls++ stats.totalCalls++
if exec.Status == "failed" { if exec.Status == "failed" || exec.Status == "cancelled" {
stats.failedCalls++ stats.failedCalls++
} else if exec.Status == "completed" { } else if exec.Status == "completed" {
stats.successCalls++ stats.successCalls++
+112 -44
View File
@@ -60,8 +60,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
sendEvent := func(eventType, message string, data interface{}) { sendEvent := func(eventType, message string, data interface{}) {
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。 // 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。 // 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) { if eventType == "error" && baseCtx != nil {
return cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) || errors.Is(cause, ErrUserInterruptContinue) {
return
}
} }
ev := StreamEvent{Type: eventType, Message: message, Data: data} ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, errMarshal := json.Marshal(ev) b, errMarshal := json.Marshal(ev)
@@ -130,33 +133,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}) })
} }
baseCtx, cancelWithCause := context.WithCancelCause(context.Background()) var cancelWithCause context.CancelCauseFunc
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute) firstRun := true
defer timeoutCancel() curFinalMessage := prep.FinalMessage
defer cancelWithCause(nil) curHistory := prep.History
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent) roleTools := prep.RoleTools
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) { orch := strings.TrimSpace(req.Orchestration)
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
if errors.Is(err, ErrTaskAlreadyRunning) {
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
sendEvent("error", errorMsg, map[string]interface{}{
"conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
taskStatus := "completed" taskStatus := "completed"
defer h.tasks.FinishTask(conversationID, taskStatus) defer h.tasks.FinishTask(conversationID, taskStatus)
@@ -169,24 +151,110 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
go sseKeepalive(c, stopKeepalive, &sseWriteMu) go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive) defer close(stopKeepalive)
result, runErr := multiagent.RunDeepAgent( var result *multiagent.RunResult
taskCtx, var runErr error
h.config,
&h.config.MultiAgent, for {
h.agent, baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.logger, taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
conversationID,
prep.FinalMessage, if firstRun {
prep.History, if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
prep.RoleTools, var errorMsg string
progressCallback, if errors.Is(err, ErrTaskAlreadyRunning) {
h.agentsMarkdownDir, errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
strings.TrimSpace(req.Orchestration), sendEvent("error", errorMsg, map[string]interface{}{
) "conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
firstRun = false
} else {
if err := h.tasks.ResetTaskCancelForContinue(conversationID, cancelWithCause); err != nil {
h.logger.Error("续跑任务时重置 cancel 失败", zap.Error(err))
taskStatus = "failed"
sendEvent("error", err.Error(), nil)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
}
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
result, runErr = multiagent.RunDeepAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
conversationID,
curFinalMessage,
curHistory,
roleTools,
progressCallback,
h.agentsMarkdownDir,
orch,
)
timeoutCancel()
if runErr == nil {
break
}
if runErr != nil {
h.persistEinoAgentTraceForResume(conversationID, result) h.persistEinoAgentTraceForResume(conversationID, result)
cause := context.Cause(baseCtx) cause := context.Cause(baseCtx)
if errors.Is(cause, ErrUserInterruptContinue) {
reason := h.tasks.TakeInterruptContinueReason(conversationID)
prepNext, perr := h.prepareSessionAfterUserInterrupt(conversationID, assistantMessageID, reason, roleTools)
if perr != nil {
h.logger.Error("准备中断后续跑失败", zap.Error(perr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "中断后续跑失败: " + perr.Error()
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
assistantMessageID = prepNext.AssistantMessageID
curFinalMessage = prepNext.FinalMessage
curHistory = prepNext.History
if prepNext.UserMessageID != "" {
sendEvent("message_saved", "", map[string]interface{}{
"conversationId": conversationID,
"userMessageId": prepNext.UserMessageID,
})
}
sendEvent("user_interrupt_continue", reason, map[string]interface{}{
"conversationId": conversationID,
"reason": reason,
"messageId": assistantMessageID,
})
sendEvent("progress", "已接收中断说明,继续迭代...", map[string]interface{}{
"conversationId": conversationID,
})
continue
}
if errors.Is(cause, ErrTaskCancelled) { if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled" taskStatus = "cancelled"
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
+62
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
@@ -142,3 +143,64 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
UserMessageID: userMessageID, UserMessageID: userMessageID,
}, nil }, nil
} }
// prepareSessionAfterUserInterrupt 用户「中断并说明」后:结束当前助手占位、写入用户说明、新建助手占位,并生成下一轮 Run 所需的 History + FinalMessage。
func (h *AgentHandler) prepareSessionAfterUserInterrupt(conversationID, prevAssistantMessageID, reason string, roleTools []string) (*multiAgentPrepared, error) {
if strings.TrimSpace(conversationID) == "" {
return nil, fmt.Errorf("conversationId 为空")
}
if _, err := h.db.GetConversation(conversationID); err != nil {
return nil, fmt.Errorf("对话不存在")
}
note := "(已根据用户说明中断当前步骤,正在继续迭代。)"
if prevAssistantMessageID != "" {
if _, err := h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", note, time.Now(), prevAssistantMessageID); err != nil {
return nil, fmt.Errorf("更新助手消息失败: %w", err)
}
r := strings.TrimSpace(reason)
detail := "用户中断并说明"
if r != "" {
detail += "" + r
}
_ = h.db.AddProcessDetail(prevAssistantMessageID, conversationID, "user_interrupt", detail, map[string]interface{}{
"reason": r,
})
}
userContent := fmt.Sprintf("【用户中断说明】%s\n\n请根据以上说明调整并继续任务。", strings.TrimSpace(reason))
if strings.TrimSpace(reason) == "" {
userContent = "【用户中断说明】(未填写具体原因)\n\n请根据情况调整并继续任务。"
}
userMsgRow, err := h.db.AddMessage(conversationID, "user", userContent, nil)
if err != nil {
return nil, fmt.Errorf("保存用户消息失败: %w", err)
}
assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
if err != nil || assistantMsg == nil {
return nil, fmt.Errorf("创建助手占位失败: %w", err)
}
msgs, err := h.db.GetMessages(conversationID)
if err != nil || len(msgs) < 2 {
return nil, fmt.Errorf("读取消息历史失败或消息不足")
}
histMsgs := msgs[:len(msgs)-2]
agentHistory := make([]agent.ChatMessage, 0, len(histMsgs))
for _, msg := range histMsgs {
agentHistory = append(agentHistory, agent.ChatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
userMessageID := ""
if userMsgRow != nil {
userMessageID = userMsgRow.ID
}
return &multiAgentPrepared{
ConversationID: conversationID,
CreatedNew: false,
History: agentHistory,
FinalMessage: userContent,
RoleTools: roleTools,
AssistantMessageID: assistantMsg.ID,
UserMessageID: userMessageID,
}, nil
}
+57
View File
@@ -461,6 +461,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string", "type": "string",
"description": "对话ID", "description": "对话ID",
}, },
"reason": map[string]interface{}{
"type": "string",
"description": "中断说明;与 continueAfter 同时为真时必填,将写入对话并由同一会话流式迭代继续",
},
"continueAfter": map[string]interface{}{
"type": "boolean",
"description": "为 true 时取消当前运行步骤并注入 reason 后继续迭代(非彻底停止)",
},
}, },
}, },
"AgentTask": map[string]interface{}{ "AgentTask": map[string]interface{}{
@@ -3318,6 +3326,55 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
}, },
}, },
"/api/monitor/execution/{id}/cancel": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"监控"},
"summary": "取消进行中的工具执行",
"description": "对当前进程内正在执行的 MCP 工具调用发送 context 取消信号;上层对话/多步任务可继续。若执行已结束或未在本进程内运行则返回 404。",
"operationId": "cancelExecution",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "执行ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"requestBody": map[string]interface{}{
"required": false,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"note": map[string]interface{}{
"type": "string",
"description": "可选。非空时与工具已返回输出合并交给大模型,并带有「用户终止说明」标题块以便与命令行原文区分",
},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "已发送终止信号",
},
"400": map[string]interface{}{
"description": "请求体不是合法 JSON",
},
"404": map[string]interface{}{
"description": "未找到进行中的工具执行",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/monitor/executions": map[string]interface{}{ "/api/monitor/executions": map[string]interface{}{
"delete": map[string]interface{}{ "delete": map[string]interface{}{
"tags": []string{"监控"}, "tags": []string{"监控"},
+50
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -10,6 +11,9 @@ import (
// ErrTaskCancelled 用户取消任务的错误 // ErrTaskCancelled 用户取消任务的错误
var ErrTaskCancelled = errors.New("agent task cancelled by user") var ErrTaskCancelled = errors.New("agent task cancelled by user")
// ErrUserInterruptContinue 用户在进度条上「中断并说明」:取消当前运行步骤,将说明写入对话并继续迭代(与 ErrTaskCancelled 区分)
var ErrUserInterruptContinue = errors.New("user interrupt with continue")
// ErrTaskAlreadyRunning 会话已有任务正在执行 // ErrTaskAlreadyRunning 会话已有任务正在执行
var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation") var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation")
@@ -21,6 +25,9 @@ type AgentTask struct {
Status string `json:"status"` Status string `json:"status"`
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务 CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
// InterruptContinueReason 由 /api/agent-loop/cancel 在 continueAfter 时写入,Run 返回后由 handler 取出并清空
InterruptContinueReason string `json:"-"`
cancel func(error) cancel func(error)
} }
@@ -140,6 +147,49 @@ func (m *AgentTaskManager) StartTask(conversationID, message string, cancel cont
return task, nil return task, nil
} }
// SetInterruptContinueReason 在发起 ErrUserInterruptContinue 取消前写入用户说明(须任务仍存在)。
func (m *AgentTaskManager) SetInterruptContinueReason(conversationID, reason string) bool {
m.mu.Lock()
defer m.mu.Unlock()
task, ok := m.tasks[conversationID]
if !ok {
return false
}
task.InterruptContinueReason = strings.TrimSpace(reason)
return true
}
// TakeInterruptContinueReason 取出并清空用户中断说明。
func (m *AgentTaskManager) TakeInterruptContinueReason(conversationID string) string {
m.mu.Lock()
defer m.mu.Unlock()
task, ok := m.tasks[conversationID]
if !ok {
return ""
}
r := task.InterruptContinueReason
task.InterruptContinueReason = ""
return r
}
// ResetTaskCancelForContinue 在一次「中断并继续」后恢复任务为 running 并绑定新的 cancel(同一会话同一条 HTTP 流内续跑)。
func (m *AgentTaskManager) ResetTaskCancelForContinue(conversationID string, cancel context.CancelCauseFunc) error {
m.mu.Lock()
defer m.mu.Unlock()
task, ok := m.tasks[conversationID]
if !ok {
return errors.New("no active task")
}
task.cancel = func(err error) {
if cancel != nil {
cancel(err)
}
}
task.Status = "running"
task.CancellingAt = time.Time{}
return nil
}
// CancelTask 取消指定会话的任务。若任务已在取消中,仍返回 (true, nil) 以便接口幂等、前端不报错。 // CancelTask 取消指定会话的任务。若任务已在取消中,仍返回 (true, nil) 以便接口幂等、前端不报错。
func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool, error) { func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool, error) {
m.mu.Lock() m.mu.Lock()
+117 -18
View File
@@ -32,6 +32,8 @@ type ExternalMCPManager struct {
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成 refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积 refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex mu sync.RWMutex
runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string
} }
// NewExternalMCPManager 创建外部MCP管理器 // NewExternalMCPManager 创建外部MCP管理器
@@ -42,16 +44,18 @@ func NewExternalMCPManager(logger *zap.Logger) *ExternalMCPManager {
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储) // NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager { func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
manager := &ExternalMCPManager{ manager := &ExternalMCPManager{
clients: make(map[string]ExternalMCPClient), clients: make(map[string]ExternalMCPClient),
configs: make(map[string]config.ExternalMCPServerConfig), configs: make(map[string]config.ExternalMCPServerConfig),
logger: logger, logger: logger,
storage: storage, storage: storage,
executions: make(map[string]*ToolExecution), executions: make(map[string]*ToolExecution),
stats: make(map[string]*ToolStats), stats: make(map[string]*ToolStats),
errors: make(map[string]string), errors: make(map[string]string),
toolCounts: make(map[string]int), toolCounts: make(map[string]int),
toolCache: make(map[string][]Tool), toolCache: make(map[string][]Tool),
stopRefresh: make(chan struct{}), stopRefresh: make(chan struct{}),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
} }
// 启动后台刷新工具数量的goroutine // 启动后台刷新工具数量的goroutine
manager.startToolCountRefresh() manager.startToolCountRefresh()
@@ -452,8 +456,16 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
} }
} }
execCtx, runCancel := context.WithCancel(ctx)
m.registerRunningCancel(executionID, runCancel)
defer func() {
runCancel()
m.unregisterRunningCancel(executionID)
}()
// 调用工具 // 调用工具
result, err := client.CallTool(ctx, actualToolName, args) result, err := client.CallTool(execCtx, actualToolName, args)
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
// 更新执行记录 // 更新执行记录
m.mu.Lock() m.mu.Lock()
@@ -462,16 +474,23 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
execution.Duration = now.Sub(execution.StartTime) execution.Duration = now.Sub(execution.StartTime)
if err != nil { if err != nil {
execution.Status = "failed" st, msg := executionStatusAndMessage(err)
execution.Error = err.Error() execution.Status = st
execution.Error = msg
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
execution.Status = "failed" if cancelledWithUserNote {
if len(result.Content) > 0 { execution.Status = "cancelled"
execution.Error = result.Content[0].Text execution.Error = ""
execution.Result = result
} else { } else {
execution.Error = "工具执行返回错误结果" execution.Status = "failed"
if len(result.Content) > 0 {
execution.Error = result.Content[0].Text
} else {
execution.Error = "工具执行返回错误结果"
}
execution.Result = result
} }
execution.Result = result
} else { } else {
execution.Status = "completed" execution.Status = "completed"
if result == nil { if result == nil {
@@ -509,6 +528,50 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return result, executionID, nil return result, executionID, nil
} }
func (m *ExternalMCPManager) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
note := strings.TrimSpace(m.readAbortUserNote(executionID))
if note == "" {
return false
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if !hasErr && !hasRes {
return false
}
_ = m.takeAbortUserNote(executionID)
partial := ""
if hasRes {
partial = ToolResultPlainText(*result)
}
if partial == "" && hasErr {
partial = (*err).Error()
}
merged := MergePartialToolOutputAndAbortNote(partial, note)
*err = nil
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
return true
}
func (m *ExternalMCPManager) readAbortUserNote(id string) string {
m.mu.Lock()
defer m.mu.Unlock()
if m.abortUserNotes == nil {
return ""
}
return m.abortUserNotes[id]
}
func (m *ExternalMCPManager) takeAbortUserNote(id string) string {
m.mu.Lock()
defer m.mu.Unlock()
if m.abortUserNotes == nil {
return ""
}
n := m.abortUserNotes[id]
delete(m.abortUserNotes, id)
return n
}
// cleanupOldExecutions 清理旧的执行记录(保持内存中的记录数量在限制内) // cleanupOldExecutions 清理旧的执行记录(保持内存中的记录数量在限制内)
func (m *ExternalMCPManager) cleanupOldExecutions() { func (m *ExternalMCPManager) cleanupOldExecutions() {
const maxExecutionsInMemory = 1000 const maxExecutionsInMemory = 1000
@@ -562,6 +625,42 @@ func (m *ExternalMCPManager) GetExecution(id string) (*ToolExecution, bool) {
return nil, false return nil, false
} }
func (m *ExternalMCPManager) registerRunningCancel(id string, cancel context.CancelFunc) {
m.mu.Lock()
m.runningCancels[id] = cancel
m.mu.Unlock()
}
func (m *ExternalMCPManager) unregisterRunningCancel(id string) {
m.mu.Lock()
delete(m.runningCancels, id)
m.mu.Unlock()
}
// CancelToolExecutionWithNote 取消外部 MCP 工具;note 非空时与已返回输出合并后交给模型。
func (m *ExternalMCPManager) CancelToolExecutionWithNote(id string, note string) bool {
m.mu.Lock()
cancel, ok := m.runningCancels[id]
if !ok || cancel == nil {
m.mu.Unlock()
return false
}
if strings.TrimSpace(note) != "" {
if m.abortUserNotes == nil {
m.abortUserNotes = make(map[string]string)
}
m.abortUserNotes[id] = strings.TrimSpace(note)
}
m.mu.Unlock()
cancel()
return true
}
// CancelToolExecution 取消正在执行的外部 MCP 工具(无用户说明)。
func (m *ExternalMCPManager) CancelToolExecution(id string) bool {
return m.CancelToolExecutionWithNote(id, "")
}
// updateStats 更新统计信息 // updateStats 更新统计信息
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) { func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
now := time.Now() now := time.Now()
+153 -22
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -40,6 +41,9 @@ type Server struct {
logger *zap.Logger logger *zap.Logger
maxExecutionsInMemory int // 内存中最大执行记录数 maxExecutionsInMemory int // 内存中最大执行记录数
sseClients map[string]*sseClient sseClients map[string]*sseClient
runningCancels map[string]context.CancelFunc
runningCancelsMu sync.Mutex
abortUserNotes map[string]string // 监控页终止时附带的用户说明,与 executionID 对应
} }
type sseClient struct { type sseClient struct {
@@ -50,6 +54,13 @@ type sseClient struct {
// ToolHandler 工具处理函数 // ToolHandler 工具处理函数
type ToolHandler func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) type ToolHandler func(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
func executionStatusAndMessage(err error) (status string, errMsg string) {
if errors.Is(err, context.Canceled) {
return "cancelled", "已手动终止(MCP 监控)"
}
return "failed", err.Error()
}
// NewServer 创建新的MCP服务器 // NewServer 创建新的MCP服务器
func NewServer(logger *zap.Logger) *Server { func NewServer(logger *zap.Logger) *Server {
return NewServerWithStorage(logger, nil) return NewServerWithStorage(logger, nil)
@@ -68,6 +79,8 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
logger: logger, logger: logger,
maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录 maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录
sseClients: make(map[string]*sseClient), sseClients: make(map[string]*sseClient),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
} }
// 初始化默认提示词和资源 // 初始化默认提示词和资源
@@ -444,15 +457,22 @@ func (s *Server) handleCallTool(msg *Message) *Message {
} }
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) baseCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() defer timeoutCancel()
execCtx, runCancel := context.WithCancel(baseCtx)
s.registerRunningCancel(executionID, runCancel)
defer func() {
runCancel()
s.unregisterRunningCancel(executionID)
}()
s.logger.Info("开始执行工具", s.logger.Info("开始执行工具",
zap.String("toolName", req.Name), zap.String("toolName", req.Name),
zap.Any("arguments", req.Arguments), zap.Any("arguments", req.Arguments),
) )
result, err := handler(ctx, req.Arguments) result, err := handler(execCtx, req.Arguments)
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
now := time.Now() now := time.Now()
var failed bool var failed bool
var finalResult *ToolResult var finalResult *ToolResult
@@ -462,18 +482,26 @@ func (s *Server) handleCallTool(msg *Message) *Message {
execution.Duration = now.Sub(execution.StartTime) execution.Duration = now.Sub(execution.StartTime)
if err != nil { if err != nil {
execution.Status = "failed" st, msg := executionStatusAndMessage(err)
execution.Error = err.Error() execution.Status = st
execution.Error = msg
failed = true failed = true
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
execution.Status = "failed" if cancelledWithUserNote {
if len(result.Content) > 0 { execution.Status = "cancelled"
execution.Error = result.Content[0].Text execution.Error = ""
execution.Result = result
failed = true
} else { } else {
execution.Error = "工具执行返回错误结果" execution.Status = "failed"
if len(result.Content) > 0 {
execution.Error = result.Content[0].Text
} else {
execution.Error = "工具执行返回错误结果"
}
execution.Result = result
failed = true
} }
execution.Result = result
failed = true
} else { } else {
execution.Status = "completed" execution.Status = "completed"
if result == nil { if result == nil {
@@ -510,9 +538,13 @@ func (s *Server) handleCallTool(msg *Message) *Message {
zap.Error(err), zap.Error(err),
) )
errText := fmt.Sprintf("工具执行失败: %v", err)
if errors.Is(err, context.Canceled) {
errText = "工具执行已手动终止(MCP 监控)。后续编排步骤可继续。"
}
errorResult, _ := json.Marshal(CallToolResponse{ errorResult, _ := json.Marshal(CallToolResponse{
Content: []Content{ Content: []Content{
{Type: "text", Text: fmt.Sprintf("工具执行失败: %v", err)}, {Type: "text", Text: errText},
}, },
IsError: true, IsError: true,
}) })
@@ -769,7 +801,15 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
} }
} }
result, err := handler(ctx, args) execCtx, runCancel := context.WithCancel(ctx)
s.registerRunningCancel(executionID, runCancel)
defer func() {
runCancel()
s.unregisterRunningCancel(executionID)
}()
result, err := handler(execCtx, args)
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
s.mu.Lock() s.mu.Lock()
now := time.Now() now := time.Now()
@@ -779,19 +819,28 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
var finalResult *ToolResult var finalResult *ToolResult
if err != nil { if err != nil {
execution.Status = "failed" st, msg := executionStatusAndMessage(err)
execution.Error = err.Error() execution.Status = st
execution.Error = msg
failed = true failed = true
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
execution.Status = "failed" if cancelledWithUserNote {
if len(result.Content) > 0 { execution.Status = "cancelled"
execution.Error = result.Content[0].Text execution.Error = ""
execution.Result = result
failed = true
finalResult = result
} else { } else {
execution.Error = "工具执行返回错误结果" execution.Status = "failed"
if len(result.Content) > 0 {
execution.Error = result.Content[0].Text
} else {
execution.Error = "工具执行返回错误结果"
}
execution.Result = result
failed = true
finalResult = result
} }
execution.Result = result
failed = true
finalResult = result
} else { } else {
execution.Status = "completed" execution.Status = "completed"
if result == nil { if result == nil {
@@ -869,6 +918,88 @@ func (s *Server) cleanupOldExecutions() {
) )
} }
func (s *Server) registerRunningCancel(id string, cancel context.CancelFunc) {
s.runningCancelsMu.Lock()
s.runningCancels[id] = cancel
s.runningCancelsMu.Unlock()
}
func (s *Server) unregisterRunningCancel(id string) {
s.runningCancelsMu.Lock()
delete(s.runningCancels, id)
s.runningCancelsMu.Unlock()
}
func (s *Server) readAbortUserNote(id string) string {
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
if s.abortUserNotes == nil {
return ""
}
return s.abortUserNotes[id]
}
func (s *Server) takeAbortUserNote(id string) string {
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
if s.abortUserNotes == nil {
return ""
}
n := s.abortUserNotes[id]
delete(s.abortUserNotes, id)
return n
}
// applyAbortUserNoteToCancelledToolResult 监控页「终止并填写说明」时合并「工具已输出 + 用户说明」交给模型。
// exec 等工具会把失败写在 *ToolResult 里并返回 err==nil,若仅在 err!=nil 时合并会漏掉说明,甚至误 clear 掉 note。
func (s *Server) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
note := strings.TrimSpace(s.readAbortUserNote(executionID))
if note == "" {
return false
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if !hasErr && !hasRes {
return false
}
_ = s.takeAbortUserNote(executionID)
partial := ""
if hasRes {
partial = ToolResultPlainText(*result)
}
if partial == "" && hasErr {
partial = (*err).Error()
}
merged := MergePartialToolOutputAndAbortNote(partial, note)
*err = nil
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
return true
}
// CancelToolExecutionWithNote 取消内部工具;note 非空时与工具已返回文本合并后交给上层模型。
func (s *Server) CancelToolExecutionWithNote(id string, note string) bool {
s.runningCancelsMu.Lock()
cancel, ok := s.runningCancels[id]
if !ok || cancel == nil {
s.runningCancelsMu.Unlock()
return false
}
if strings.TrimSpace(note) != "" {
if s.abortUserNotes == nil {
s.abortUserNotes = make(map[string]string)
}
s.abortUserNotes[id] = strings.TrimSpace(note)
}
s.runningCancelsMu.Unlock()
cancel()
return true
}
// CancelToolExecution 取消正在执行的内部工具调用(无用户说明)。
func (s *Server) CancelToolExecution(id string) bool {
return s.CancelToolExecutionWithNote(id, "")
}
// initDefaultPrompts 初始化默认提示词模板 // initDefaultPrompts 初始化默认提示词模板
func (s *Server) initDefaultPrompts() { func (s *Server) initDefaultPrompts() {
s.mu.Lock() s.mu.Lock()
+35 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -192,7 +193,7 @@ type ToolExecution struct {
ID string `json:"id"` ID string `json:"id"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Arguments map[string]interface{} `json:"arguments"` Arguments map[string]interface{} `json:"arguments"`
Status string `json:"status"` // pending, running, completed, failed Status string `json:"status"` // pending, running, completed, failed, cancelled
Result *ToolResult `json:"result,omitempty"` Result *ToolResult `json:"result,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
@@ -293,3 +294,36 @@ type SamplingContent struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
// ToolResultPlainText 拼接工具结果中的文本(手动终止时作为「工具原始输出」)。
func ToolResultPlainText(r *ToolResult) string {
if r == nil || len(r.Content) == 0 {
return ""
}
var b strings.Builder
for _, c := range r.Content {
b.WriteString(c.Text)
}
return strings.TrimSpace(b.String())
}
// AbortNoteBannerForModel 标出后续文本来自「用户手动终止工具时在弹窗中填写」,避免与 stdout/stderr 混淆。
const AbortNoteBannerForModel = "---\n" +
"【用户终止说明|USER INTERRUPT NOTE】\n" +
"(以下由操作者填写,用于指示模型如何继续;不是工具原始输出。)\n" +
"Written by the operator when stopping this tool; not raw tool output.\n" +
"---"
// MergePartialToolOutputAndAbortNote 格式:工具原始输出 + 醒目标题 + 用户终止说明(无说明则原样返回 partial)。
func MergePartialToolOutputAndAbortNote(partial, userNote string) string {
partial = strings.TrimSpace(partial)
userNote = strings.TrimSpace(userNote)
if userNote == "" {
return partial
}
section := AbortNoteBannerForModel + "\n" + userNote
if partial == "" {
return section
}
return partial + "\n\n" + section
}
+28
View File
@@ -3196,6 +3196,12 @@ header {
border-color: rgba(220, 53, 69, 0.3); border-color: rgba(220, 53, 69, 0.3);
} }
.status-chip.status-cancelled {
background: rgba(108, 117, 125, 0.12);
color: var(--text-secondary, #6c757d);
border-color: rgba(108, 117, 125, 0.35);
}
.status-chip.status-pending, .status-chip.status-pending,
.status-chip.status-unknown { .status-chip.status-unknown {
background: rgba(255, 193, 7, 0.12); background: rgba(255, 193, 7, 0.12);
@@ -3203,6 +3209,18 @@ header {
border-color: rgba(255, 193, 7, 0.3); border-color: rgba(255, 193, 7, 0.3);
} }
.detail-abort-hint {
font-size: 0.875rem;
opacity: 0.88;
margin: 0 0 10px;
line-height: 1.45;
}
.detail-abort-section .btn-monitor-abort {
border-color: rgba(253, 126, 20, 0.55);
color: #fd7e14;
}
.detail-code-card { .detail-code-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px dashed rgba(0, 0, 0, 0.06); border: 1px dashed rgba(0, 0, 0, 0.06);
@@ -5517,6 +5535,16 @@ header {
color: var(--error-color); color: var(--error-color);
} }
.monitor-status-chip.cancelled {
background: rgba(108, 117, 125, 0.15);
color: var(--text-muted, #6c757d);
}
.monitor-execution-actions .btn-monitor-abort {
border-color: rgba(253, 126, 20, 0.55);
color: #fd7e14;
}
.monitor-execution-actions { .monitor-execution-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+26
View File
@@ -394,6 +394,16 @@
"tasks": { "tasks": {
"title": "Task Management", "title": "Task Management",
"stopTask": "Stop task", "stopTask": "Stop task",
"interruptModalTitle": "Interrupt current step",
"interruptReasonLabel": "Interrupt note",
"interruptModalHint": "Your note is saved as a user message and the agent continues in the same stream. Use \"Stop completely\" to end the task.",
"interruptReasonPlaceholder": "e.g. Tool is too slow—skip and summarize…",
"interruptReasonRequired": "Please enter a short note so the model can continue accordingly.",
"interruptSubmitting": "Submitting...",
"interruptConfirmContinue": "Interrupt & continue",
"interruptHardStop": "Stop completely",
"interruptModalClose": "Close",
"userInterruptTimelineTitle": "User interrupt note (continuing)",
"collapseDetail": "Collapse details", "collapseDetail": "Collapse details",
"newTask": "New task", "newTask": "New task",
"autoRefresh": "Auto refresh", "autoRefresh": "Auto refresh",
@@ -1260,6 +1270,8 @@
"statusCompleted": "Completed", "statusCompleted": "Completed",
"statusRunning": "Running", "statusRunning": "Running",
"statusFailed": "Failed", "statusFailed": "Failed",
"statusCancelled": "Cancelled",
"terminateExecution": "Stop",
"loading": "Loading...", "loading": "Loading...",
"noStatsData": "No statistical data", "noStatsData": "No statistical data",
"noExecutions": "No execution records", "noExecutions": "No execution records",
@@ -1727,8 +1739,22 @@
"statusRunning": "Running", "statusRunning": "Running",
"statusCompleted": "Completed", "statusCompleted": "Completed",
"statusFailed": "Failed", "statusFailed": "Failed",
"statusCancelled": "Cancelled",
"unknown": "Unknown", "unknown": "Unknown",
"getDetailFailed": "Failed to get details", "getDetailFailed": "Failed to get details",
"runningNoResponseYet": "No output yet; the tool may still be running. If it hangs, use \"Stop tool\" below to end this call only.",
"abortTitle": "Execution control",
"abortHint": "Stops only this tool call. The conversation / multi-step task continues (unlike stopping the whole task).",
"abortBtn": "Stop tool",
"abortConfirm": "Stop this tool call? The overall conversation or iterative task will not be cancelled.",
"abortSuccess": "Cancellation requested; status will update when the tool returns.",
"abortFailed": "Failed to stop tool",
"abortNoteModalTitle": "Stop tool with a note",
"abortNoteModalHint": "Optional: why you stopped or how the model should continue. The model sees any tool output first, then a labeled block (USER INTERRUPT NOTE — not raw tool output), then your text. Leave empty for a plain stop.",
"abortNoteLabel": "Note (optional)",
"abortNotePlaceholder": "e.g. Output is enough—skip waiting and continue…",
"abortNoteSubmit": "Stop tool",
"abortNoteClose": "Cancel",
"execSuccessNoContent": "Execution succeeded with no displayable content.", "execSuccessNoContent": "Execution succeeded with no displayable content.",
"time": "Time", "time": "Time",
"executionId": "Execution ID", "executionId": "Execution ID",
+26
View File
@@ -383,6 +383,16 @@
"tasks": { "tasks": {
"title": "任务管理", "title": "任务管理",
"stopTask": "停止任务", "stopTask": "停止任务",
"interruptModalTitle": "中断当前步骤",
"interruptReasonLabel": "中断说明",
"interruptModalHint": "填写说明后将作为一条用户消息写入对话,智能体在同一会话内继续迭代。若只需完全停止任务,请点「彻底停止」。",
"interruptReasonPlaceholder": "例如:工具耗时过长,请先跳过并总结当前结果…",
"interruptReasonRequired": "请填写中断说明,以便模型根据你的意图继续。",
"interruptSubmitting": "提交中...",
"interruptConfirmContinue": "中断并继续",
"interruptHardStop": "彻底停止",
"interruptModalClose": "关闭",
"userInterruptTimelineTitle": "用户中断说明(继续迭代)",
"collapseDetail": "收起详情", "collapseDetail": "收起详情",
"newTask": "新建任务", "newTask": "新建任务",
"autoRefresh": "自动刷新", "autoRefresh": "自动刷新",
@@ -1249,6 +1259,8 @@
"statusCompleted": "已完成", "statusCompleted": "已完成",
"statusRunning": "执行中", "statusRunning": "执行中",
"statusFailed": "失败", "statusFailed": "失败",
"statusCancelled": "已终止",
"terminateExecution": "终止",
"loading": "加载中...", "loading": "加载中...",
"noStatsData": "暂无统计数据", "noStatsData": "暂无统计数据",
"noExecutions": "暂无执行记录", "noExecutions": "暂无执行记录",
@@ -1716,8 +1728,22 @@
"statusRunning": "执行中", "statusRunning": "执行中",
"statusCompleted": "已完成", "statusCompleted": "已完成",
"statusFailed": "失败", "statusFailed": "失败",
"statusCancelled": "已终止",
"unknown": "未知", "unknown": "未知",
"getDetailFailed": "获取详情失败", "getDetailFailed": "获取详情失败",
"runningNoResponseYet": "尚无返回,工具可能仍在执行。若长时间无响应,可使用下方「终止工具」结束本次调用。",
"abortTitle": "运行控制",
"abortHint": "仅中断当前这一次工具调用;对话与多步迭代任务会继续,不会等同于「停止任务」。",
"abortBtn": "终止工具",
"abortConfirm": "确定终止此次工具调用?整条对话或迭代任务不会因此停止。",
"abortSuccess": "已发送终止请求,工具返回后状态将更新。",
"abortFailed": "终止失败",
"abortNoteModalTitle": "终止工具并补充说明",
"abortNoteModalHint": "可选:说明为何终止或希望模型如何继续。提交后模型会先看到工具已输出内容(若有),再看到带「用户终止说明」标题的独立区块(中英标注,与命令行原文区分),最后是您的文字。留空则与原先仅终止一致。",
"abortNoteLabel": "终止说明(可选)",
"abortNotePlaceholder": "例如:输出已够判断,请停止等待并继续下一步…",
"abortNoteSubmit": "提交终止",
"abortNoteClose": "取消",
"execSuccessNoContent": "执行成功,未返回可展示的文本内容。", "execSuccessNoContent": "执行成功,未返回可展示的文本内容。",
"time": "时间", "time": "时间",
"executionId": "执行 ID", "executionId": "执行 ID",
+5 -4
View File
@@ -306,12 +306,13 @@ async function bootstrapApp() {
// 通用工具函数 // 通用工具函数
function getStatusText(status) { function getStatusText(status) {
const s = (status && String(status).toLowerCase()) || '';
if (typeof window.t !== 'function') { if (typeof window.t !== 'function') {
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' }; const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败', cancelled: '已终止' };
return fallback[status] || status; return fallback[s] || status;
} }
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' }; const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled' };
const key = keyMap[status]; const key = keyMap[s];
return key ? window.t(key) : status; return key ? window.t(key) : status;
} }
+113 -1
View File
@@ -2446,7 +2446,24 @@ async function showMCPDetail(executionId) {
} }
} }
} else { } else {
responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据'; if (normalizedStatus === 'running') {
responseElement.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.runningNoResponseYet') : '尚无返回,工具可能仍在执行。若长时间无响应,可在下方终止本次调用。';
} else {
responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据';
}
}
const abortSection = document.getElementById('detail-abort-section');
const abortBtn = document.getElementById('detail-abort-btn');
if (abortSection && abortBtn) {
if (normalizedStatus === 'running') {
abortSection.style.display = 'block';
abortBtn.dataset.execId = exec.id || '';
abortBtn.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.abortBtn') : '终止工具';
} else {
abortSection.style.display = 'none';
delete abortBtn.dataset.execId;
}
} }
// 显示模态框 // 显示模态框
@@ -2464,6 +2481,101 @@ function closeMCPDetail() {
document.getElementById('mcp-detail-modal').style.display = 'none'; document.getElementById('mcp-detail-modal').style.display = 'none';
} }
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
async function abortMCPToolExecutionFromDetail() {
const btn = document.getElementById('detail-abort-btn');
const id = btn && btn.dataset.execId;
if (!id) {
return;
}
await cancelMCPToolExecution(id, { refreshDetail: true });
}
/**
* 打开 MCP 工具终止弹窗说明会经服务端加上用户终止说明标题块后与工具输出合并给模型
* @param {string} executionId
* @param {{ refreshDetail?: boolean }} [options]
*/
function openMcpToolAbortModal(executionId, options = {}) {
window.__mcpToolAbortContext = { executionId: executionId, options: options || {} };
const ta = document.getElementById('mcp-tool-abort-note');
if (ta) {
ta.value = '';
}
const m = document.getElementById('mcp-tool-abort-modal');
if (m) {
m.style.display = 'block';
}
}
function closeMcpToolAbortModal() {
window.__mcpToolAbortContext = null;
const m = document.getElementById('mcp-tool-abort-modal');
if (m) {
m.style.display = 'none';
}
}
async function submitMcpToolAbortModal() {
const ctx = window.__mcpToolAbortContext;
if (!ctx || !ctx.executionId) {
closeMcpToolAbortModal();
return;
}
const note = (document.getElementById('mcp-tool-abort-note') && document.getElementById('mcp-tool-abort-note').value || '').trim();
const executionId = ctx.executionId;
const options = ctx.options || {};
closeMcpToolAbortModal();
await cancelMCPToolExecutionSubmit(executionId, note, options);
}
/**
* 提交终止请求body: { note }
* @param {string} executionId
* @param {string} userNote
* @param {{ refreshDetail?: boolean }} [options]
*/
async function cancelMCPToolExecutionSubmit(executionId, userNote, options = {}) {
if (!executionId) {
return;
}
try {
const res = await apiFetch(`/api/monitor/execution/${encodeURIComponent(executionId)}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: userNote || '' }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || body.message || res.statusText);
}
const okMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortSuccess') : '已发送终止请求';
alert(okMsg);
if (options.refreshDetail && typeof showMCPDetail === 'function') {
await showMCPDetail(executionId);
}
if (typeof refreshMonitorPanel === 'function') {
const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page) ? monitorState.pagination.page : 1;
await refreshMonitorPanel(page);
}
} catch (e) {
const failMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortFailed') : '终止失败';
alert(failMsg + ': ' + (e && e.message ? e.message : String(e)));
}
}
/**
* 取消单次 MCP 工具执行监控页终止弹出说明框后提交仅取消该次 tools/call不停止整条对话/迭代任务
* @param {string} executionId
* @param {{ refreshDetail?: boolean }} [options]
*/
async function cancelMCPToolExecution(executionId, options = {}) {
if (!executionId) {
return;
}
openMcpToolAbortModal(executionId, options);
}
// 复制详情面板中的内容 // 复制详情面板中的内容
function copyDetailBlock(elementId, triggerBtn = null) { function copyDetailBlock(elementId, triggerBtn = null) {
const target = document.getElementById(elementId); const target = document.getElementById(elementId);
+146 -24
View File
@@ -1,4 +1,6 @@
const progressTaskState = new Map(); const progressTaskState = new Map();
/** @type {{ progressId: string, conversationId: string } | null} */
let userInterruptModalPending = null;
let activeTaskInterval = null; let activeTaskInterval = null;
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次 const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']); const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
@@ -410,6 +412,128 @@ async function requestCancel(conversationId) {
return result; return result;
} }
/** 用户填写说明后中断当前步骤,由后端写入对话并继续同一条流式迭代 */
async function requestCancelWithContinue(conversationId, reason) {
const response = await apiFetch('/api/agent-loop/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId,
reason: reason || '',
continueAfter: true,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败'));
}
return result;
}
function openUserInterruptModal(progressId, conversationId) {
userInterruptModalPending = { progressId, conversationId };
const ta = document.getElementById('user-interrupt-reason');
if (ta) {
ta.value = '';
}
const m = document.getElementById('user-interrupt-modal');
if (m) {
m.style.display = 'block';
}
}
function closeUserInterruptModal() {
userInterruptModalPending = null;
const m = document.getElementById('user-interrupt-modal');
if (m) {
m.style.display = 'none';
}
}
async function submitUserInterruptContinue() {
if (!userInterruptModalPending) {
return;
}
const reason = (document.getElementById('user-interrupt-reason') && document.getElementById('user-interrupt-reason').value || '').trim();
if (!reason) {
alert(typeof window.t === 'function' ? window.t('tasks.interruptReasonRequired') : '请填写中断说明');
return;
}
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
try {
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...';
}
await requestCancelWithContinue(conversationId, reason);
loadActiveTasks();
} catch (error) {
console.error('中断并继续失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '操作失败') + ': ' + error.message);
} finally {
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
}
}
async function submitUserInterruptHardCancel() {
if (!userInterruptModalPending) {
return;
}
const { progressId } = userInterruptModalPending;
closeUserInterruptModal();
await performHardCancelProgressTask(progressId);
}
/** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
if (!state || !state.conversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
stopBtn.disabled = false;
}, 1500);
}
alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。');
return;
}
if (state.cancelling) {
return;
}
markProgressCancelling(progressId);
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
}
try {
await requestCancel(state.conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
const currentState = progressTaskState.get(progressId);
if (currentState) {
currentState.cancelling = false;
}
}
}
function addProgressMessage() { function addProgressMessage() {
const messagesDiv = document.getElementById('chat-messages'); const messagesDiv = document.getElementById('chat-messages');
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
@@ -737,7 +861,7 @@ function toggleProcessDetails(progressId, assistantMessageId) {
} }
} }
// 停止当前进度对应的任务 // 停止当前进度:弹出「中断并说明 / 彻底停止」
async function cancelProgressTask(progressId) { async function cancelProgressTask(progressId) {
const state = progressTaskState.get(progressId); const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`); const stopBtn = document.getElementById(`${progressId}-stop-btn`);
@@ -757,27 +881,7 @@ async function cancelProgressTask(progressId) {
return; return;
} }
markProgressCancelling(progressId); openUserInterruptModal(progressId, state.conversationId);
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
}
try {
await requestCancel(state.conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
const currentState = progressTaskState.get(progressId);
if (currentState) {
currentState.cancelling = false;
}
}
} }
// 将进度消息转换为可折叠的详情组件 // 将进度消息转换为可折叠的详情组件
@@ -1414,6 +1518,18 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
} }
case 'user_interrupt_continue': {
const d = event.data || {};
const reason = (d.reason != null && String(d.reason).trim() !== '') ? String(d.reason).trim() : (event.message || '');
const timelineTitle = typeof window.t === 'function' ? window.t('tasks.userInterruptTimelineTitle') : '用户中断说明(继续迭代)';
addTimelineItem(timeline, 'user_interrupt', {
title: '✋ ' + timelineTitle,
message: reason,
data: d,
});
break;
}
case 'progress': case 'progress':
const progressTitle = document.querySelector(`#${progressId} .progress-title`); const progressTitle = document.querySelector(`#${progressId} .progress-title`);
if (progressTitle) { if (progressTitle) {
@@ -2777,7 +2893,8 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情'; const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除'; const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录'; const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' }; const terminateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.terminateExecution') : '终止';
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed', cancelled: 'statusCancelled' };
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined; const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rows = executions const rows = executions
.map(exec => { .map(exec => {
@@ -2788,7 +2905,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel; const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
const duration = formatExecutionDuration(exec.startTime, exec.endTime); const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(exec.toolName || unknownToolLabel); const toolName = escapeHtml(exec.toolName || unknownToolLabel);
const executionId = escapeHtml(exec.id || ''); const rawExecId = exec.id || '';
const executionId = escapeHtml(rawExecId);
const terminateBtn = status === 'running'
? `<button type="button" class="btn-secondary btn-monitor-abort" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>`
: '';
return ` return `
<tr> <tr>
<td> <td>
@@ -2801,6 +2922,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<td> <td>
<div class="monitor-execution-actions"> <div class="monitor-execution-actions">
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button> <button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
${terminateBtn}
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button> <button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
</div> </div>
</td> </td>
+51
View File
@@ -1053,6 +1053,7 @@
<option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option> <option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option>
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option> <option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option> <option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
<option value="cancelled" data-i18n="mcpMonitor.statusCancelled">已终止</option>
</select> </select>
</label> </label>
</div> </div>
@@ -2449,6 +2450,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="detail-section detail-abort-section" id="detail-abort-section" style="display: none;">
<div class="detail-section-header">
<h3 data-i18n="mcpDetailModal.abortTitle">运行控制</h3>
</div>
<p class="detail-abort-hint" data-i18n="mcpDetailModal.abortHint">仅中断当前工具调用;对话与多步任务会继续。</p>
<button type="button" class="btn-secondary btn-monitor-abort" id="detail-abort-btn" onclick="abortMCPToolExecutionFromDetail()">终止工具</button>
</div>
<div class="detail-section"> <div class="detail-section">
<div class="detail-section-header"> <div class="detail-section-header">
<h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3> <h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3>
@@ -2489,6 +2497,49 @@
</div> </div>
</div> </div>
<!-- 用户中断并说明(继续迭代) -->
<div id="user-interrupt-modal" class="modal">
<div class="modal-content" style="max-width: 520px;">
<div class="modal-header">
<h2 data-i18n="tasks.interruptModalTitle">中断当前步骤</h2>
<span class="modal-close" onclick="closeUserInterruptModal()">&times;</span>
</div>
<div class="modal-body">
<p class="detail-abort-hint" data-i18n="tasks.interruptModalHint">填写说明后将写入对话并由智能体继续迭代。</p>
<div class="form-group">
<label for="user-interrupt-reason"><span data-i18n="tasks.interruptReasonLabel">中断说明</span></label>
<textarea id="user-interrupt-reason" class="form-control" rows="4" data-i18n="tasks.interruptReasonPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
</div>
<div class="form-actions" style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn-secondary" onclick="closeUserInterruptModal()" data-i18n="tasks.interruptModalClose">关闭</button>
<button type="button" class="btn-secondary btn-delete" onclick="submitUserInterruptHardCancel()" data-i18n="tasks.interruptHardStop">彻底停止</button>
<button type="button" class="btn-primary" onclick="submitUserInterruptContinue()" data-i18n="tasks.interruptConfirmContinue">中断并继续</button>
</div>
</div>
</div>
</div>
<!-- MCP 工具终止:可填写给模型的说明 -->
<div id="mcp-tool-abort-modal" class="modal">
<div class="modal-content" style="max-width: 520px;">
<div class="modal-header">
<h2 data-i18n="mcpDetailModal.abortNoteModalTitle">终止工具并补充说明</h2>
<span class="modal-close" onclick="closeMcpToolAbortModal()">&times;</span>
</div>
<div class="modal-body">
<p class="detail-abort-hint" data-i18n="mcpDetailModal.abortNoteModalHint">可选说明。</p>
<div class="form-group">
<label for="mcp-tool-abort-note"><span data-i18n="mcpDetailModal.abortNoteLabel">终止说明(可选)</span></label>
<textarea id="mcp-tool-abort-note" class="form-control" rows="4" data-i18n="mcpDetailModal.abortNotePlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
</div>
<div class="form-actions" style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn-secondary" onclick="closeMcpToolAbortModal()" data-i18n="mcpDetailModal.abortNoteClose">取消</button>
<button type="button" class="btn-primary" onclick="submitMcpToolAbortModal()" data-i18n="mcpDetailModal.abortNoteSubmit">提交终止</button>
</div>
</div>
</div>
</div>
<!-- 外部MCP配置模态框 --> <!-- 外部MCP配置模态框 -->
<div id="external-mcp-modal" class="modal"> <div id="external-mcp-modal" class="modal">
<div class="modal-content" style="max-width: 900px;"> <div class="modal-content" style="max-width: 900px;">