mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cd864c5ca | |||
| e34faff001 | |||
| fa09796ddd | |||
| 1ab7e98f56 | |||
| 0743086873 | |||
| a1ceb9c108 | |||
| 9ddea33dab | |||
| e948940b18 | |||
| 94bbbf87bf | |||
| 4f09ffbaaa | |||
| 6d77081b2b | |||
| 99ccb07ec9 | |||
| 1130fdbfa4 | |||
| 84f4da4d1d | |||
| 34dae98329 | |||
| 3ee7d64b09 | |||
| 22a3aa1531 | |||
| 8ad61906fa | |||
| 487522707f | |||
| fe625010eb | |||
| 40cd0293b5 | |||
| b62dc1f326 | |||
| 6d180c814d | |||
| e68d3a3d23 | |||
| 699b9181e6 | |||
| 7b9070f106 | |||
| 5a31b69245 | |||
| 104a6e30d5 | |||
| 80c4299dbb | |||
| debe967272 |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.2"
|
version: "v1.6.5"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 832 KiB After Width: | Height: | Size: 726 KiB |
+20
-1
@@ -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
|
||||||
@@ -1903,9 +1905,26 @@ func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationI
|
|||||||
a.currentConversationID = prev
|
a.currentConversationID = prev
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
ctx = withAgentConversationID(ctx, conversationID)
|
||||||
return a.executeToolViaMCP(ctx, toolName, args)
|
return a.executeToolViaMCP(ctx, toolName, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
|
||||||
|
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
note = strings.TrimSpace(note)
|
||||||
|
if executionID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.mcpServer != nil && a.mcpServer.CancelToolExecutionWithNote(executionID, note) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a.externalMCPMgr != nil && a.externalMCPMgr.CancelToolExecutionWithNote(executionID, note) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
|
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
|
||||||
func extractQuotedToolName(errMsg string) string {
|
func extractQuotedToolName(errMsg string) string {
|
||||||
start := strings.Index(errMsg, "\"")
|
start := strings.Index(errMsg, "\"")
|
||||||
|
|||||||
+3
-2
@@ -599,12 +599,12 @@ func (a *App) startRobotConnections() {
|
|||||||
if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" {
|
if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
a.larkCancel = cancel
|
a.larkCancel = cancel
|
||||||
go robot.StartLark(ctx, cfg.Robots.Lark, a.robotHandler, a.logger.Logger)
|
go robot.StartLark(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||||
}
|
}
|
||||||
if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" {
|
if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
a.dingCancel = cancel
|
a.dingCancel = cancel
|
||||||
go robot.StartDing(ctx, cfg.Robots.Dingtalk, a.robotHandler, a.logger.Logger)
|
go robot.StartDing(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -811,8 +811,8 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) (
|
|||||||
"content": prompt,
|
"content": prompt,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"max_tokens": 8000,
|
"max_completion_tokens": 80000,
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResponse struct {
|
var apiResponse struct {
|
||||||
|
|||||||
@@ -275,11 +275,25 @@ type MultiAgentAPIUpdate struct {
|
|||||||
|
|
||||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||||
type RobotsConfig struct {
|
type RobotsConfig struct {
|
||||||
|
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RobotSessionConfig 机器人会话隔离策略
|
||||||
|
type RobotSessionConfig struct {
|
||||||
|
StrictUserIdentity *bool `yaml:"strict_user_identity,omitempty" json:"strict_user_identity,omitempty"` // true 时只允许真实用户标识,不允许会话/群 ID 兜底
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrictUserIdentityEnabled 返回是否启用严格用户身份模式;未配置时默认 true。
|
||||||
|
func (c RobotSessionConfig) StrictUserIdentityEnabled() bool {
|
||||||
|
if c.StrictUserIdentity == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *c.StrictUserIdentity
|
||||||
|
}
|
||||||
|
|
||||||
// RobotWecomConfig 企业微信机器人配置
|
// RobotWecomConfig 企业微信机器人配置
|
||||||
type RobotWecomConfig struct {
|
type RobotWecomConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
@@ -292,17 +306,19 @@ type RobotWecomConfig struct {
|
|||||||
|
|
||||||
// RobotDingtalkConfig 钉钉机器人配置
|
// RobotDingtalkConfig 钉钉机器人配置
|
||||||
type RobotDingtalkConfig struct {
|
type RobotDingtalkConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
|
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
|
||||||
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
|
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
|
||||||
|
AllowConversationIDFallback bool `yaml:"allow_conversation_id_fallback" json:"allow_conversation_id_fallback"` // sender_id 缺失时是否允许回退到会话 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// RobotLarkConfig 飞书机器人配置
|
// RobotLarkConfig 飞书机器人配置
|
||||||
type RobotLarkConfig struct {
|
type RobotLarkConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||||
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
|
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
|
||||||
|
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -465,7 +481,6 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Auth.SessionDurationHours <= 0 {
|
if cfg.Auth.SessionDurationHours <= 0 {
|
||||||
cfg.Auth.SessionDurationHours = 12
|
cfg.Auth.SessionDurationHours = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(cfg.Auth.Password) == "" {
|
if strings.TrimSpace(cfg.Auth.Password) == "" {
|
||||||
password, err := generateStrongPassword(24)
|
password, err := generateStrongPassword(24)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -934,6 +949,7 @@ func LoadRoleFromFile(path string) (*RoleConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
|
strictRobotIdentity := true
|
||||||
return &Config{
|
return &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
@@ -968,6 +984,11 @@ func Default() *Config {
|
|||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SessionDurationHours: 12,
|
SessionDurationHours: 12,
|
||||||
},
|
},
|
||||||
|
Robots: RobotsConfig{
|
||||||
|
Session: RobotSessionConfig{
|
||||||
|
StrictUserIdentity: &strictRobotIdentity,
|
||||||
|
},
|
||||||
|
},
|
||||||
Knowledge: KnowledgeConfig{
|
Knowledge: KnowledgeConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
BasePath: "knowledge_base",
|
BasePath: "knowledge_base",
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ func (db *DB) initTables() error {
|
|||||||
UNIQUE(conversation_id, group_id)
|
UNIQUE(conversation_id, group_id)
|
||||||
);`
|
);`
|
||||||
|
|
||||||
|
// 机器人会话绑定表(用于跨重启保持「平台+租户+用户」到 conversation 的映射)
|
||||||
|
createRobotUserSessionsTable := `
|
||||||
|
CREATE TABLE IF NOT EXISTS robot_user_sessions (
|
||||||
|
session_key TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
role_name TEXT NOT NULL DEFAULT '默认',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||||
|
);`
|
||||||
|
|
||||||
// 创建漏洞表
|
// 创建漏洞表
|
||||||
createVulnerabilitiesTable := `
|
createVulnerabilitiesTable := `
|
||||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||||
@@ -409,6 +419,7 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_created_at ON knowledge_retrieval_logs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_created_at ON knowledge_retrieval_logs(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_conversation ON conversation_group_mappings(conversation_id);
|
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_conversation ON conversation_group_mappings(conversation_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id);
|
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_robot_user_sessions_updated_at ON robot_user_sessions(updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned);
|
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned);
|
||||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id);
|
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag);
|
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag);
|
||||||
@@ -479,6 +490,9 @@ func (db *DB) initTables() error {
|
|||||||
if _, err := db.Exec(createConversationGroupMappingsTable); err != nil {
|
if _, err := db.Exec(createConversationGroupMappingsTable); err != nil {
|
||||||
return fmt.Errorf("创建conversation_group_mappings表失败: %w", err)
|
return fmt.Errorf("创建conversation_group_mappings表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if _, err := db.Exec(createRobotUserSessionsTable); err != nil {
|
||||||
|
return fmt.Errorf("创建robot_user_sessions表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RobotSessionBinding 机器人会话绑定信息。
|
||||||
|
type RobotSessionBinding struct {
|
||||||
|
SessionKey string
|
||||||
|
ConversationID string
|
||||||
|
RoleName string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRobotSessionBinding 按 session_key 获取机器人会话绑定。
|
||||||
|
func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, error) {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var b RobotSessionBinding
|
||||||
|
var updatedAt string
|
||||||
|
err := db.QueryRow(
|
||||||
|
"SELECT session_key, conversation_id, role_name, updated_at FROM robot_user_sessions WHERE session_key = ?",
|
||||||
|
sessionKey,
|
||||||
|
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("查询机器人会话绑定失败: %w", err)
|
||||||
|
}
|
||||||
|
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
|
||||||
|
b.UpdatedAt = t
|
||||||
|
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
|
||||||
|
b.UpdatedAt = t
|
||||||
|
} else {
|
||||||
|
b.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.RoleName) == "" {
|
||||||
|
b.RoleName = "默认"
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertRobotSessionBinding 写入或更新机器人会话绑定(包含角色)。
|
||||||
|
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName string) error {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
roleName = strings.TrimSpace(roleName)
|
||||||
|
if sessionKey == "" || conversationID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if roleName == "" {
|
||||||
|
roleName = "默认"
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key) DO UPDATE SET
|
||||||
|
conversation_id = excluded.conversation_id,
|
||||||
|
role_name = excluded.role_name,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, sessionKey, conversationID, roleName, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("写入机器人会话绑定失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRobotSessionBinding 删除机器人会话绑定。
|
||||||
|
func (db *DB) DeleteRobotSessionBinding(sessionKey string) error {
|
||||||
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
|
if sessionKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("DELETE FROM robot_user_sessions WHERE session_key = ?", sessionKey); err != nil {
|
||||||
|
return fmt.Errorf("删除机器人会话绑定失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+107
-25
@@ -19,6 +19,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -458,6 +459,57 @@ func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedP
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendAssistantMessageNotice 在助手消息末尾追加提示,避免覆盖已生成内容。
|
||||||
|
// 若消息为空则直接写入提示;若已包含相同提示则保持不变。
|
||||||
|
func (h *AgentHandler) appendAssistantMessageNotice(messageID, notice string) error {
|
||||||
|
trimmedNotice := strings.TrimSpace(notice)
|
||||||
|
if strings.TrimSpace(messageID) == "" || trimmedNotice == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := h.db.Exec(
|
||||||
|
`UPDATE messages
|
||||||
|
SET content = CASE
|
||||||
|
WHEN content IS NULL OR TRIM(content) = '' THEN ?
|
||||||
|
WHEN INSTR(content, ?) > 0 THEN content
|
||||||
|
ELSE content || '\n\n' || ?
|
||||||
|
END,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
trimmedNotice,
|
||||||
|
trimmedNotice,
|
||||||
|
trimmedNotice,
|
||||||
|
time.Now(),
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAssistantMessagePartialOnCancel 将取消前已生成的部分回复尽量合并进消息:
|
||||||
|
// - content 为空或仅占位(处理中...)时,直接替换为 partial;
|
||||||
|
// - 已有正文时,仅在尚未包含 partial 时追加,避免丢失与重复。
|
||||||
|
func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial string) error {
|
||||||
|
trimmedPartial := strings.TrimSpace(partial)
|
||||||
|
if strings.TrimSpace(messageID) == "" || trimmedPartial == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := h.db.Exec(
|
||||||
|
`UPDATE messages
|
||||||
|
SET content = CASE
|
||||||
|
WHEN content IS NULL OR TRIM(content) = '' OR TRIM(content) = '处理中...' THEN ?
|
||||||
|
WHEN INSTR(content, ?) > 0 THEN content
|
||||||
|
ELSE content || '\n\n' || ?
|
||||||
|
END,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
trimmedPartial,
|
||||||
|
trimmedPartial,
|
||||||
|
trimmedPartial,
|
||||||
|
time.Now(),
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
@@ -725,7 +777,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
"deep",
|
"deep",
|
||||||
)
|
)
|
||||||
if errMA != nil {
|
if errMA != nil {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||||
|
}
|
||||||
errMsg := "执行失败: " + errMA.Error()
|
errMsg := "执行失败: " + errMA.Error()
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||||
@@ -1493,6 +1547,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
defer timeoutCancel()
|
defer timeoutCancel()
|
||||||
defer cancelWithCause(nil)
|
defer cancelWithCause(nil)
|
||||||
|
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
|
|
||||||
@@ -1568,11 +1624,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
if _, updateErr := h.db.Exec(
|
if result != nil {
|
||||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
if updateErr := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); updateErr != nil {
|
||||||
cancelMsg,
|
h.logger.Warn("合并取消前的部分回复失败", zap.Error(updateErr))
|
||||||
time.Now(), assistantMessageID,
|
}
|
||||||
); updateErr != nil {
|
}
|
||||||
|
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
|
||||||
h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr))
|
h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr))
|
||||||
}
|
}
|
||||||
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||||
@@ -1717,6 +1774,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 +1783,40 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := h.tasks.CancelTask(req.ConversationID, ErrTaskCancelled)
|
if req.ContinueAfter {
|
||||||
|
if h.tasks.GetTask(req.ConversationID) == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到正在执行的任务"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
execID := h.tasks.ActiveMCPExecutionID(req.ConversationID)
|
||||||
|
if execID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "当前没有正在执行的 MCP 工具(例如模型尚在推理、尚未发起工具调用)。请等待工具开始执行后再试,或使用「彻底停止」结束整轮任务。"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note := strings.TrimSpace(req.Reason)
|
||||||
|
if !h.agent.CancelMCPToolExecutionWithNote(execID, note) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到进行中的工具执行或该调用已结束"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("对话页仅终止当前 MCP 工具",
|
||||||
|
zap.String("conversationId", req.ConversationID),
|
||||||
|
zap.String("executionId", execID),
|
||||||
|
zap.Bool("hasNote", note != ""),
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "tool_abort_requested",
|
||||||
|
"conversationId": req.ConversationID,
|
||||||
|
"executionId": execID,
|
||||||
|
"message": "已请求终止当前工具调用;工具返回后本轮推理将继续(与 MCP 监控页终止一致)。",
|
||||||
|
"continueAfter": true,
|
||||||
|
"interruptWithNote": note != "",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cause error = ErrTaskCancelled
|
||||||
|
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 +1829,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": false,
|
||||||
|
"interruptWithNote": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2517,6 +2611,8 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
|
|
||||||
// 创建进度回调函数:写 DB + 镜像到 task-events,支持刷新后继续流式展示。
|
// 创建进度回调函数:写 DB + 镜像到 task-events,支持刷新后继续流式展示。
|
||||||
progressCallback = h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
progressCallback = h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
|
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||||
|
|
||||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||||
useBatchMulti := false
|
useBatchMulti := false
|
||||||
@@ -2556,7 +2652,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if useRunResult {
|
if useRunResult && shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||||
}
|
}
|
||||||
// 检查是否是取消错误
|
// 检查是否是取消错误
|
||||||
@@ -2594,11 +2690,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
}
|
}
|
||||||
// 更新助手消息内容
|
// 更新助手消息内容
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
if _, updateErr := h.db.Exec(
|
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
|
||||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
|
||||||
cancelMsg,
|
|
||||||
time.Now(), assistantMessageID,
|
|
||||||
); updateErr != nil {
|
|
||||||
h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||||
}
|
}
|
||||||
// 保存取消详情到数据库
|
// 保存取消详情到数据库
|
||||||
@@ -2612,16 +2704,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
|
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 保存代理轨迹(如果存在)
|
|
||||||
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
|
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
|
||||||
h.logger.Warn("保存取消任务的代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
|
||||||
}
|
|
||||||
} else if useRunResult && resultMA != nil && (resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "") {
|
|
||||||
if err := h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput); err != nil {
|
|
||||||
h.logger.Warn("保存取消任务的代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
|
||||||
} else {
|
} else {
|
||||||
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
|
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{"role": "user", "content": "Hi"},
|
{"role": "user", "content": "Hi"},
|
||||||
},
|
},
|
||||||
"max_tokens": 5,
|
"max_completion_tokens": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
|
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -43,8 +44,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) {
|
||||||
|
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,36 +118,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cancelWithCause context.CancelCauseFunc
|
var cancelWithCause context.CancelCauseFunc
|
||||||
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
curFinalMessage := prep.FinalMessage
|
||||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
curHistory := prep.History
|
||||||
defer timeoutCancel()
|
roleTools := prep.RoleTools
|
||||||
defer cancelWithCause(nil)
|
|
||||||
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)
|
// 仅在成功 StartTask 后再 FinishTask。若 StartTask 因 ErrTaskAlreadyRunning 失败仍 defer FinishTask,
|
||||||
|
// 会误删其他连接上正在运行的同会话任务,导致「第一次拦截、第二次却放行」。
|
||||||
|
taskOwned := false
|
||||||
|
defer func() {
|
||||||
|
if taskOwned {
|
||||||
|
h.tasks.FinishTask(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
sendEvent("progress", "正在启动 Eino ADK 单代理(ChatModelAgent)...", map[string]interface{}{
|
sendEvent("progress", "正在启动 Eino ADK 单代理(ChatModelAgent)...", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -161,28 +148,72 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, runErr := multiagent.RunEinoSingleChatModelAgent(
|
var result *multiagent.RunResult
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
|
||||||
|
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})
|
||||||
|
timeoutCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskOwned = true
|
||||||
|
|
||||||
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
|
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||||
|
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,
|
taskCtx,
|
||||||
h.config,
|
h.config,
|
||||||
&h.config.MultiAgent,
|
&h.config.MultiAgent,
|
||||||
h.agent,
|
h.agent,
|
||||||
h.logger,
|
h.logger,
|
||||||
conversationID,
|
conversationID,
|
||||||
prep.FinalMessage,
|
curFinalMessage,
|
||||||
prep.History,
|
curHistory,
|
||||||
prep.RoleTools,
|
roleTools,
|
||||||
progressCallback,
|
progressCallback,
|
||||||
)
|
)
|
||||||
|
timeoutCancel()
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
if errors.Is(cause, ErrTaskCancelled) {
|
if errors.Is(cause, ErrTaskCancelled) {
|
||||||
taskStatus = "cancelled"
|
taskStatus = "cancelled"
|
||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", cancelMsg, time.Now(), assistantMessageID)
|
if result != nil {
|
||||||
|
if err := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); err != nil {
|
||||||
|
h.logger.Warn("合并取消前的部分回复失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
|
||||||
|
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||||
}
|
}
|
||||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||||
@@ -308,7 +339,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
progressCallback,
|
progressCallback,
|
||||||
)
|
)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,8 +268,8 @@ func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
|
|||||||
{"role": "system", "content": systemPrompt},
|
{"role": "system", "content": systemPrompt},
|
||||||
{"role": "user", "content": userPrompt},
|
{"role": "user", "content": userPrompt},
|
||||||
},
|
},
|
||||||
"temperature": 0.1,
|
"temperature": 0.1,
|
||||||
"max_tokens": 1200,
|
"max_completion_tokens": 12000,
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI 返回结构:只需要 choices[0].message.content
|
// OpenAI 返回结构:只需要 choices[0].message.content
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -60,8 +61,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) {
|
||||||
|
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,15 +134,35 @@ 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)
|
curFinalMessage := prep.FinalMessage
|
||||||
defer timeoutCancel()
|
curHistory := prep.History
|
||||||
defer cancelWithCause(nil)
|
roleTools := prep.RoleTools
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
orch := strings.TrimSpace(req.Orchestration)
|
||||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
|
||||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
taskStatus := "completed"
|
||||||
|
// 仅在成功 StartTask 后再 FinishTask;避免「任务已存在」分支 return 时误删正在运行的同会话任务。
|
||||||
|
taskOwned := false
|
||||||
|
defer func() {
|
||||||
|
if taskOwned {
|
||||||
|
h.tasks.FinishTask(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
stopKeepalive := make(chan struct{})
|
||||||
|
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
||||||
|
defer close(stopKeepalive)
|
||||||
|
|
||||||
|
var result *multiagent.RunResult
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
|
||||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||||
var errorMsg string
|
var errorMsg string
|
||||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||||
@@ -155,44 +179,52 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
|
||||||
}
|
}
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskOwned = true
|
||||||
|
|
||||||
taskStatus := "completed"
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
defer h.tasks.FinishTask(conversationID, taskStatus)
|
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||||
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{
|
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||||
"conversationId": conversationID,
|
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||||
})
|
})
|
||||||
|
|
||||||
stopKeepalive := make(chan struct{})
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
|
||||||
defer close(stopKeepalive)
|
|
||||||
|
|
||||||
result, runErr := multiagent.RunDeepAgent(
|
|
||||||
taskCtx,
|
taskCtx,
|
||||||
h.config,
|
h.config,
|
||||||
&h.config.MultiAgent,
|
&h.config.MultiAgent,
|
||||||
h.agent,
|
h.agent,
|
||||||
h.logger,
|
h.logger,
|
||||||
conversationID,
|
conversationID,
|
||||||
prep.FinalMessage,
|
curFinalMessage,
|
||||||
prep.History,
|
curHistory,
|
||||||
prep.RoleTools,
|
roleTools,
|
||||||
progressCallback,
|
progressCallback,
|
||||||
h.agentsMarkdownDir,
|
h.agentsMarkdownDir,
|
||||||
strings.TrimSpace(req.Orchestration),
|
orch,
|
||||||
)
|
)
|
||||||
|
timeoutCancel()
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
if errors.Is(cause, ErrTaskCancelled) {
|
if errors.Is(cause, ErrTaskCancelled) {
|
||||||
taskStatus = "cancelled"
|
taskStatus = "cancelled"
|
||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", cancelMsg, time.Now(), assistantMessageID)
|
if result != nil {
|
||||||
|
if err := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); err != nil {
|
||||||
|
h.logger.Warn("合并取消前的部分回复失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
|
||||||
|
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||||
}
|
}
|
||||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||||
@@ -320,7 +352,9 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
strings.TrimSpace(req.Orchestration),
|
strings.TrimSpace(req.Orchestration),
|
||||||
)
|
)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||||
errMsg := "执行失败: " + runErr.Error()
|
errMsg := "执行失败: " + runErr.Error()
|
||||||
if prep.AssistantMessageID != "" {
|
if prep.AssistantMessageID != "" {
|
||||||
|
|||||||
@@ -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": "可选。与 MCP 监控页「终止并说明」一致:非空时合并进当前工具返回给模型的文本(含 USER INTERRUPT NOTE 块)",
|
||||||
|
},
|
||||||
|
"continueAfter": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "为 true 时仅终止当前进行中的 MCP 工具调用(不取消整轮任务);须已有工具在执行,否则 400",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"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{"监控"},
|
||||||
|
|||||||
+99
-12
@@ -75,14 +75,58 @@ func (h *RobotHandler) sessionKey(platform, userID string) string {
|
|||||||
return platform + "_" + userID
|
return platform + "_" + userID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) {
|
||||||
|
if h.db == nil || strings.TrimSpace(sk) == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
binding, err := h.db.GetRobotSessionBinding(sk)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if binding == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return binding.ConversationID, binding.RoleName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) persistSessionBinding(sk, convID, role string) {
|
||||||
|
if h.db == nil || strings.TrimSpace(sk) == "" || strings.TrimSpace(convID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpsertRobotSessionBinding(sk, convID, role); err != nil {
|
||||||
|
h.logger.Warn("写入机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) deleteSessionBinding(sk string) {
|
||||||
|
if h.db == nil || strings.TrimSpace(sk) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteRobotSessionBinding(sk); err != nil {
|
||||||
|
h.logger.Warn("删除机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字)
|
// getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字)
|
||||||
func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) {
|
func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) {
|
||||||
|
sk := h.sessionKey(platform, userID)
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
convID = h.sessions[h.sessionKey(platform, userID)]
|
convID = h.sessions[sk]
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
if convID != "" {
|
if convID != "" {
|
||||||
return convID, false
|
return convID, false
|
||||||
}
|
}
|
||||||
|
if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" {
|
||||||
|
// 会话绑定持久化:服务重启后也可恢复当前对话和角色。
|
||||||
|
h.mu.Lock()
|
||||||
|
h.sessions[sk] = persistedConvID
|
||||||
|
if strings.TrimSpace(persistedRole) != "" {
|
||||||
|
h.sessionRoles[sk] = persistedRole
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return persistedConvID, false
|
||||||
|
}
|
||||||
t := strings.TrimSpace(title)
|
t := strings.TrimSpace(title)
|
||||||
if t == "" {
|
if t == "" {
|
||||||
t = "新对话 " + time.Now().Format("01-02 15:04")
|
t = "新对话 " + time.Now().Format("01-02 15:04")
|
||||||
@@ -96,34 +140,49 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
|
|||||||
}
|
}
|
||||||
convID = conv.ID
|
convID = conv.ID
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessions[h.sessionKey(platform, userID)] = convID
|
role := h.sessionRoles[sk]
|
||||||
|
h.sessions[sk] = convID
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.persistSessionBinding(sk, convID, role)
|
||||||
return convID, true
|
return convID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// setConversation 切换当前会话
|
// setConversation 切换当前会话
|
||||||
func (h *RobotHandler) setConversation(platform, userID, convID string) {
|
func (h *RobotHandler) setConversation(platform, userID, convID string) {
|
||||||
|
sk := h.sessionKey(platform, userID)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessions[h.sessionKey(platform, userID)] = convID
|
role := h.sessionRoles[sk]
|
||||||
|
h.sessions[sk] = convID
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.persistSessionBinding(sk, convID, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRole 获取当前用户使用的角色,未设置时返回"默认"
|
// getRole 获取当前用户使用的角色,未设置时返回"默认"
|
||||||
func (h *RobotHandler) getRole(platform, userID string) string {
|
func (h *RobotHandler) getRole(platform, userID string) string {
|
||||||
|
sk := h.sessionKey(platform, userID)
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
role := h.sessionRoles[h.sessionKey(platform, userID)]
|
role := h.sessionRoles[sk]
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
if role == "" {
|
if strings.TrimSpace(role) != "" {
|
||||||
return "默认"
|
return role
|
||||||
}
|
}
|
||||||
return role
|
if _, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.sessionRoles[sk] = persistedRole
|
||||||
|
h.mu.Unlock()
|
||||||
|
return persistedRole
|
||||||
|
}
|
||||||
|
return "默认"
|
||||||
}
|
}
|
||||||
|
|
||||||
// setRole 设置当前用户使用的角色
|
// setRole 设置当前用户使用的角色
|
||||||
func (h *RobotHandler) setRole(platform, userID, roleName string) {
|
func (h *RobotHandler) setRole(platform, userID, roleName string) {
|
||||||
|
sk := h.sessionKey(platform, userID)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.sessionRoles[h.sessionKey(platform, userID)] = roleName
|
h.sessionRoles[sk] = roleName
|
||||||
|
convID := h.sessions[sk]
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.persistSessionBinding(sk, convID, roleName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearConversation 清空当前会话(切换到新对话)
|
// clearConversation 清空当前会话(切换到新对话)
|
||||||
@@ -140,7 +199,16 @@ func (h *RobotHandler) clearConversation(platform, userID string) (newConvID str
|
|||||||
|
|
||||||
// HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用)
|
// HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用)
|
||||||
func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) {
|
func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) {
|
||||||
|
platform = strings.TrimSpace(platform)
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
|
if platform == "" {
|
||||||
|
platform = "unknown"
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
h.logger.Warn("机器人消息缺少用户标识,已拒绝处理", zap.String("platform", platform))
|
||||||
|
return "无法识别发送者身份,请检查机器人事件订阅权限(需返回可用的用户 ID)。"
|
||||||
|
}
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return "请输入内容或发送「帮助」/ help 查看命令。"
|
return "请输入内容或发送「帮助」/ help 查看命令。"
|
||||||
}
|
}
|
||||||
@@ -345,7 +413,9 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
|
|||||||
// 删除当前对话时,先清空会话绑定
|
// 删除当前对话时,先清空会话绑定
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.sessions, sk)
|
delete(h.sessions, sk)
|
||||||
|
delete(h.sessionRoles, sk)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
h.deleteSessionBinding(sk)
|
||||||
}
|
}
|
||||||
if err := h.db.DeleteConversation(convID); err != nil {
|
if err := h.db.DeleteConversation(convID); err != nil {
|
||||||
return "删除失败: " + err.Error()
|
return "删除失败: " + err.Error()
|
||||||
@@ -647,8 +717,25 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
|||||||
h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content))
|
h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content))
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := body.FromUserName
|
tenantKey := strings.TrimSpace(enterpriseID)
|
||||||
|
if tenantKey == "" {
|
||||||
|
tenantKey = strings.TrimSpace(h.config.Robots.Wecom.CorpID)
|
||||||
|
}
|
||||||
|
if tenantKey == "" {
|
||||||
|
tenantKey = "default"
|
||||||
|
}
|
||||||
|
rawUserID := strings.TrimSpace(body.FromUserName)
|
||||||
|
replyUserID := rawUserID
|
||||||
|
userID := ""
|
||||||
|
if rawUserID != "" {
|
||||||
|
userID = "t:" + tenantKey + "|u:" + rawUserID
|
||||||
|
}
|
||||||
text := strings.TrimSpace(body.Content)
|
text := strings.TrimSpace(body.Content)
|
||||||
|
if userID == "" {
|
||||||
|
h.logger.Warn("企业微信消息缺少可用用户标识,已忽略")
|
||||||
|
c.String(http.StatusOK, "success")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 限制回复内容长度(企业微信限制 2048 字节)
|
// 限制回复内容长度(企业微信限制 2048 字节)
|
||||||
maxReplyLen := 2000
|
maxReplyLen := 2000
|
||||||
@@ -661,14 +748,14 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
|||||||
|
|
||||||
if body.MsgType != "text" {
|
if body.MsgType != "text" {
|
||||||
h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType))
|
h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType))
|
||||||
h.sendWecomReply(c, userID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce)
|
h.sendWecomReply(c, replyUserID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。
|
// 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。
|
||||||
if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok {
|
if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok {
|
||||||
h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text))
|
h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text))
|
||||||
h.sendWecomReply(c, userID, enterpriseID, limitReply(cmdReply), timestamp, nonce)
|
h.sendWecomReply(c, replyUserID, enterpriseID, limitReply(cmdReply), timestamp, nonce)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,7 +771,7 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
|||||||
reply = limitReply(reply)
|
reply = limitReply(reply)
|
||||||
h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply))
|
h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply))
|
||||||
// 调用企业微信 API 主动发送消息
|
// 调用企业微信 API 主动发送消息
|
||||||
h.sendWecomMessageViaAPI(userID, enterpriseID, reply)
|
h.sendWecomMessageViaAPI(rawUserID, enterpriseID, reply)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -13,6 +14,13 @@ var ErrTaskCancelled = errors.New("agent task cancelled by user")
|
|||||||
// ErrTaskAlreadyRunning 会话已有任务正在执行
|
// ErrTaskAlreadyRunning 会话已有任务正在执行
|
||||||
var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation")
|
var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation")
|
||||||
|
|
||||||
|
// shouldPersistEinoAgentTraceAfterRunError:Eino 相关 Run 非成功返回时,是否仍写入 last_react_* 供下轮 loadHistoryFromAgentTrace。
|
||||||
|
// 当前策略:无论正常结束、异常结束或用户主动停止,都尽量保留最后可用轨迹,
|
||||||
|
// 以便在同一会话继续时可基于原始上下文续跑,而不是回退到仅消息文本历史。
|
||||||
|
func shouldPersistEinoAgentTraceAfterRunError(baseCtx context.Context) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// AgentTask 描述正在运行的Agent任务
|
// AgentTask 描述正在运行的Agent任务
|
||||||
type AgentTask struct {
|
type AgentTask struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
@@ -21,9 +29,56 @@ type AgentTask struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
||||||
|
|
||||||
|
// ActiveMCPExecutionID 当前正在执行的 MCP 工具 executionId(仅内存,供「中断并继续」= 仅掐当前工具)
|
||||||
|
ActiveMCPExecutionID string `json:"-"`
|
||||||
|
|
||||||
cancel func(error)
|
cancel func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterRunningTool 实现 mcp.ToolRunRegistry:工具开始时登记本会话当前 executionId。
|
||||||
|
func (m *AgentTaskManager) RegisterRunningTool(conversationID, executionID string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
if conversationID == "" || executionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
t.ActiveMCPExecutionID = executionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterRunningTool 工具结束时清除登记(仅当 id 仍匹配时清除,避免并发串单)。
|
||||||
|
func (m *AgentTaskManager) UnregisterRunningTool(conversationID, executionID string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
if conversationID == "" || executionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
if t.ActiveMCPExecutionID == executionID {
|
||||||
|
t.ActiveMCPExecutionID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
|
||||||
|
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
return strings.TrimSpace(t.ActiveMCPExecutionID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// CompletedTask 已完成的任务(用于历史记录)
|
// CompletedTask 已完成的任务(用于历史记录)
|
||||||
type CompletedTask struct {
|
type CompletedTask struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
|
|||||||
@@ -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,18 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
execCtx, runCancel := context.WithCancel(ctx)
|
||||||
|
m.registerRunningCancel(executionID, runCancel)
|
||||||
|
notifyToolRunBegin(ctx, executionID)
|
||||||
|
defer func() {
|
||||||
|
notifyToolRunEnd(ctx, executionID)
|
||||||
|
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 +476,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 +530,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 +627,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()
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolRunRegistry 在工具开始/结束时登记当前 executionId,供对话页「仅终止当前工具」与监控页共用取消逻辑。
|
||||||
|
type ToolRunRegistry interface {
|
||||||
|
RegisterRunningTool(conversationID, executionID string)
|
||||||
|
UnregisterRunningTool(conversationID, executionID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolRunRegistryCtxKey struct{}
|
||||||
|
type mcpConversationIDCtxKey struct{}
|
||||||
|
|
||||||
|
// WithToolRunRegistry 将登记器注入 ctx(Eino / 原生 Agent 任务 ctx)。
|
||||||
|
func WithToolRunRegistry(ctx context.Context, reg ToolRunRegistry) context.Context {
|
||||||
|
if ctx == nil || reg == nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, toolRunRegistryCtxKey{}, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolRunRegistryFromContext 取出登记器(无则 nil)。
|
||||||
|
func ToolRunRegistryFromContext(ctx context.Context) ToolRunRegistry {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(toolRunRegistryCtxKey{}).(ToolRunRegistry)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMCPConversationID 将对话 ID 注入 ctx,供 CallTool 内与 executionId 关联。
|
||||||
|
func WithMCPConversationID(ctx context.Context, conversationID string) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(conversationID)
|
||||||
|
if id == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, mcpConversationIDCtxKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPConversationIDFromContext 读取对话 ID。
|
||||||
|
func MCPConversationIDFromContext(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(mcpConversationIDCtxKey{}).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyToolRunBegin(ctx context.Context, executionID string) {
|
||||||
|
reg := ToolRunRegistryFromContext(ctx)
|
||||||
|
if reg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv := MCPConversationIDFromContext(ctx)
|
||||||
|
if conv == "" || strings.TrimSpace(executionID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reg.RegisterRunningTool(conv, executionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyToolRunEnd(ctx context.Context, executionID string) {
|
||||||
|
reg := ToolRunRegistryFromContext(ctx)
|
||||||
|
if reg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv := MCPConversationIDFromContext(ctx)
|
||||||
|
if conv == "" || strings.TrimSpace(executionID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reg.UnregisterRunningTool(conv, executionID)
|
||||||
|
}
|
||||||
+155
-22
@@ -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,17 @@ 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)
|
||||||
|
notifyToolRunBegin(ctx, executionID)
|
||||||
|
defer func() {
|
||||||
|
notifyToolRunEnd(ctx, executionID)
|
||||||
|
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 +821,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 +920,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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,40 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// normalizeStreamingDelta 将可能是“累计片段”的 chunk 归一化为“纯增量”。
|
||||||
|
// 一些模型/桥接层在流式过程中会重复发送已输出前缀,前端若直接 buffer+=chunk 会出现“结巴”重复。
|
||||||
|
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
|
||||||
|
if incoming == "" {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
return incoming, incoming
|
||||||
|
}
|
||||||
|
if incoming == current {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
// incoming 是累计全文(包含 current 前缀)
|
||||||
|
if strings.HasPrefix(incoming, current) {
|
||||||
|
return incoming, incoming[len(current):]
|
||||||
|
}
|
||||||
|
// incoming 完全是已输出尾部重发
|
||||||
|
if strings.HasSuffix(current, incoming) {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理边界重叠:current 后缀与 incoming 前缀重叠,只追加非重叠部分。
|
||||||
|
max := len(current)
|
||||||
|
if len(incoming) < max {
|
||||||
|
max = len(incoming)
|
||||||
|
}
|
||||||
|
for overlap := max; overlap > 0; overlap-- {
|
||||||
|
if current[len(current)-overlap:] == incoming[:overlap] {
|
||||||
|
return current + incoming[overlap:], incoming[overlap:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current + incoming, incoming
|
||||||
|
}
|
||||||
|
|
||||||
func isEinoIterationLimitError(err error) bool {
|
func isEinoIterationLimitError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
@@ -430,9 +464,10 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
streamHeaderSent := false
|
streamHeaderSent := false
|
||||||
var reasoningStreamID string
|
var reasoningStreamID string
|
||||||
var toolStreamFragments []schema.ToolCall
|
var toolStreamFragments []schema.ToolCall
|
||||||
var subAssistantBuf strings.Builder
|
var subAssistantBuf string
|
||||||
var subReplyStreamID string
|
var subReplyStreamID string
|
||||||
var mainAssistantBuf strings.Builder
|
var mainAssistantBuf string
|
||||||
|
var reasoningBuf string
|
||||||
var streamRecvErr error
|
var streamRecvErr error
|
||||||
for {
|
for {
|
||||||
chunk, rerr := mv.MessageStream.Recv()
|
chunk, rerr := mv.MessageStream.Recv()
|
||||||
@@ -453,59 +488,69 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||||
if reasoningStreamID == "" {
|
var reasoningDelta string
|
||||||
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
reasoningBuf, reasoningDelta = normalizeStreamingDelta(reasoningBuf, chunk.ReasoningContent)
|
||||||
progress("thinking_stream_start", " ", map[string]interface{}{
|
if reasoningDelta != "" {
|
||||||
"streamId": reasoningStreamID,
|
if reasoningStreamID == "" {
|
||||||
"source": "eino",
|
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
||||||
"einoAgent": ev.AgentName,
|
progress("thinking_stream_start", " ", map[string]interface{}{
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
"streamId": reasoningStreamID,
|
||||||
"orchestration": orchMode,
|
"source": "eino",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
progress("thinking_stream_delta", reasoningDelta, map[string]interface{}{
|
||||||
|
"streamId": reasoningStreamID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{
|
|
||||||
"streamId": reasoningStreamID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if chunk.Content != "" {
|
if chunk.Content != "" {
|
||||||
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
||||||
if !streamHeaderSent {
|
var contentDelta string
|
||||||
progress("response_start", "", map[string]interface{}{
|
mainAssistantBuf, contentDelta = normalizeStreamingDelta(mainAssistantBuf, chunk.Content)
|
||||||
"conversationId": conversationID,
|
if contentDelta != "" {
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
if !streamHeaderSent {
|
||||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
progress("response_start", "", map[string]interface{}{
|
||||||
"einoRole": "orchestrator",
|
"conversationId": conversationID,
|
||||||
"einoAgent": ev.AgentName,
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
"orchestration": orchMode,
|
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
streamHeaderSent = true
|
||||||
|
}
|
||||||
|
progress("response_delta", contentDelta, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"orchestration": orchMode,
|
||||||
})
|
})
|
||||||
streamHeaderSent = true
|
|
||||||
}
|
}
|
||||||
progress("response_delta", chunk.Content, map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
|
||||||
"einoRole": "orchestrator",
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"orchestration": orchMode,
|
|
||||||
})
|
|
||||||
mainAssistantBuf.WriteString(chunk.Content)
|
|
||||||
} else if !streamsMainAssistant(ev.AgentName) {
|
} else if !streamsMainAssistant(ev.AgentName) {
|
||||||
if progress != nil {
|
var subDelta string
|
||||||
if subReplyStreamID == "" {
|
subAssistantBuf, subDelta = normalizeStreamingDelta(subAssistantBuf, chunk.Content)
|
||||||
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
|
if subDelta != "" {
|
||||||
progress("eino_agent_reply_stream_start", "", map[string]interface{}{
|
if progress != nil {
|
||||||
|
if subReplyStreamID == "" {
|
||||||
|
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
|
||||||
|
progress("eino_agent_reply_stream_start", "", map[string]interface{}{
|
||||||
|
"streamId": subReplyStreamID,
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": "sub",
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
progress("eino_agent_reply_stream_delta", subDelta, map[string]interface{}{
|
||||||
"streamId": subReplyStreamID,
|
"streamId": subReplyStreamID,
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": "sub",
|
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
progress("eino_agent_reply_stream_delta", chunk.Content, map[string]interface{}{
|
|
||||||
"streamId": subReplyStreamID,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
subAssistantBuf.WriteString(chunk.Content)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(chunk.ToolCalls) > 0 {
|
if len(chunk.ToolCalls) > 0 {
|
||||||
@@ -513,7 +558,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
|
if s := strings.TrimSpace(mainAssistantBuf); s != "" {
|
||||||
lastAssistant = s
|
lastAssistant = s
|
||||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||||
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||||
@@ -521,8 +566,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subAssistantBuf.Len() > 0 && progress != nil {
|
if strings.TrimSpace(subAssistantBuf) != "" && progress != nil {
|
||||||
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
if s := strings.TrimSpace(subAssistantBuf); s != "" {
|
||||||
if subReplyStreamID != "" {
|
if subReplyStreamID != "" {
|
||||||
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
||||||
"streamId": subReplyStreamID,
|
"streamId": subReplyStreamID,
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
|
|
||||||
for {
|
for {
|
||||||
line, readErr := reader.ReadString('\n')
|
line, readErr := reader.ReadString('\n')
|
||||||
@@ -531,9 +532,14 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
|||||||
if deltaType == "text_delta" {
|
if deltaType == "text_delta" {
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
if text != "" {
|
if text != "" {
|
||||||
full.WriteString(text)
|
var textOut string
|
||||||
|
fullText, textOut = normalizeStreamingDelta(fullText, text)
|
||||||
|
if textOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full.WriteString(textOut)
|
||||||
if onDelta != nil {
|
if onDelta != nil {
|
||||||
if err := onDelta(text); err != nil {
|
if err := onDelta(textOut); err != nil {
|
||||||
return full.String(), err
|
return full.String(), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +609,7 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
finishReason := ""
|
finishReason := ""
|
||||||
|
|
||||||
// 追踪当前正在构建的 content blocks
|
// 追踪当前正在构建的 content blocks
|
||||||
@@ -665,9 +672,14 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
|||||||
if deltaType == "text_delta" {
|
if deltaType == "text_delta" {
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
if text != "" {
|
if text != "" {
|
||||||
full.WriteString(text)
|
var textOut string
|
||||||
|
fullText, textOut = normalizeStreamingDelta(fullText, text)
|
||||||
|
if textOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full.WriteString(textOut)
|
||||||
if onContentDelta != nil {
|
if onContentDelta != nil {
|
||||||
if err := onContentDelta(text); err != nil {
|
if err := onContentDelta(textOut); err != nil {
|
||||||
return full.String(), nil, finishReason, err
|
return full.String(), nil, finishReason, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,38 @@ func (e *APIError) Error() string {
|
|||||||
return fmt.Sprintf("openai api error: status=%d body=%s", e.StatusCode, e.Body)
|
return fmt.Sprintf("openai api error: status=%d body=%s", e.StatusCode, e.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeStreamingDelta 将可能是“累计片段/重发片段”的内容归一化为“纯增量”。
|
||||||
|
// 部分兼容网关会返回累计 content;若直接 append 会出现重复文本(结巴)。
|
||||||
|
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
|
||||||
|
if incoming == "" {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
return incoming, incoming
|
||||||
|
}
|
||||||
|
if incoming == current {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(incoming, current) {
|
||||||
|
return incoming, incoming[len(current):]
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(current, incoming) {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边界重叠:current 后缀与 incoming 前缀重合,仅追加非重叠部分。
|
||||||
|
max := len(current)
|
||||||
|
if len(incoming) < max {
|
||||||
|
max = len(incoming)
|
||||||
|
}
|
||||||
|
for overlap := max; overlap > 0; overlap-- {
|
||||||
|
if current[len(current)-overlap:] == incoming[:overlap] {
|
||||||
|
return current + incoming[overlap:], incoming[overlap:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current + incoming, incoming
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient 创建一个新的OpenAI客户端。
|
// NewClient 创建一个新的OpenAI客户端。
|
||||||
func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client {
|
func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client {
|
||||||
if httpClient == nil {
|
if httpClient == nil {
|
||||||
@@ -219,6 +251,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
|
|
||||||
// 典型 SSE 结构:
|
// 典型 SSE 结构:
|
||||||
// data: {...}\n\n
|
// data: {...}\n\n
|
||||||
@@ -263,9 +296,14 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
full.WriteString(delta)
|
var deltaOut string
|
||||||
|
fullText, deltaOut = normalizeStreamingDelta(fullText, delta)
|
||||||
|
if deltaOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full.WriteString(deltaOut)
|
||||||
if onDelta != nil {
|
if onDelta != nil {
|
||||||
if err := onDelta(delta); err != nil {
|
if err := onDelta(deltaOut); err != nil {
|
||||||
return full.String(), err
|
return full.String(), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +418,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
finishReason := ""
|
finishReason := ""
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -426,10 +465,14 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
content = delta.Text
|
content = delta.Text
|
||||||
}
|
}
|
||||||
if content != "" {
|
if content != "" {
|
||||||
full.WriteString(content)
|
var contentOut string
|
||||||
if onContentDelta != nil {
|
fullText, contentOut = normalizeStreamingDelta(fullText, content)
|
||||||
if err := onContentDelta(content); err != nil {
|
if contentOut != "" {
|
||||||
return full.String(), nil, finishReason, err
|
full.WriteString(contentOut)
|
||||||
|
if onContentDelta != nil {
|
||||||
|
if err := onContentDelta(contentOut); err != nil {
|
||||||
|
return full.String(), nil, finishReason, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-7
@@ -23,22 +23,23 @@ const (
|
|||||||
|
|
||||||
// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。
|
// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。
|
||||||
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
|
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
|
||||||
func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) {
|
func StartDing(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||||
|
cfg := robotsCfg.Dingtalk
|
||||||
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" {
|
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go runDingLoop(ctx, cfg, h, logger)
|
go runDingLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。
|
// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。
|
||||||
func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) {
|
func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
|
||||||
backoff := dingReconnectInitial
|
backoff := dingReconnectInitial
|
||||||
for {
|
for {
|
||||||
streamClient := client.NewStreamClient(
|
streamClient := client.NewStreamClient(
|
||||||
client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
|
client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
|
||||||
client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get",
|
client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get",
|
||||||
chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) {
|
chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||||
go handleDingMessage(ctx, msg, h, logger)
|
go handleDingMessage(ctx, msg, cfg, strictUserIdentity, h, logger)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}).OnEventReceived),
|
}).OnEventReceived),
|
||||||
)
|
)
|
||||||
@@ -66,7 +67,7 @@ func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) {
|
func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
|
||||||
if msg == nil || msg.SessionWebhook == "" {
|
if msg == nil || msg.SessionWebhook == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,9 +94,22 @@ func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content))
|
logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content))
|
||||||
userID := msg.SenderId
|
tenantKey := strings.TrimSpace(cfg.ClientID)
|
||||||
|
if tenantKey == "" {
|
||||||
|
tenantKey = "default"
|
||||||
|
}
|
||||||
|
userID := strings.TrimSpace(msg.SenderId)
|
||||||
|
if userID != "" {
|
||||||
|
userID = "t:" + tenantKey + "|u:" + userID
|
||||||
|
} else if cfg.AllowConversationIDFallback && !strictUserIdentity {
|
||||||
|
conversationID := strings.TrimSpace(msg.ConversationId)
|
||||||
|
if conversationID != "" {
|
||||||
|
userID = "t:" + tenantKey + "|c:" + conversationID
|
||||||
|
}
|
||||||
|
}
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = msg.ConversationId
|
logger.Warn("钉钉消息缺少可用用户标识,已忽略")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
reply := h.HandleMessage("dingtalk", userID, content)
|
reply := h.HandleMessage("dingtalk", userID, content)
|
||||||
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式
|
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式
|
||||||
|
|||||||
+38
-8
@@ -27,20 +27,21 @@ type larkTextContent struct {
|
|||||||
|
|
||||||
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
|
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
|
||||||
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
|
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
|
||||||
func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) {
|
func StartLark(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||||
|
cfg := robotsCfg.Lark
|
||||||
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
|
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go runLarkLoop(ctx, cfg, h, logger)
|
go runLarkLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。
|
// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。
|
||||||
func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) {
|
func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
|
||||||
backoff := larkReconnectInitial
|
backoff := larkReconnectInitial
|
||||||
for {
|
for {
|
||||||
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
|
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
|
||||||
eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
go handleLarkMessage(ctx, event, h, larkClient, logger)
|
go handleLarkMessage(ctx, event, cfg, strictUserIdentity, h, larkClient, logger)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret,
|
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret,
|
||||||
@@ -70,7 +71,7 @@ func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) {
|
func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, client *lark.Client, logger *zap.Logger) {
|
||||||
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
|
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -89,9 +90,10 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h
|
|||||||
if text == "" {
|
if text == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := ""
|
userID := resolveLarkUserID(event, cfg.AllowChatIDFallback && !strictUserIdentity)
|
||||||
if event.Event.Sender.SenderId.UserId != nil {
|
if userID == "" {
|
||||||
userID = *event.Event.Sender.SenderId.UserId
|
logger.Warn("飞书消息缺少可用用户标识,已忽略")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
messageID := larkcore.StringValue(msg.MessageId)
|
messageID := larkcore.StringValue(msg.MessageId)
|
||||||
reply := h.HandleMessage("lark", userID, text)
|
reply := h.HandleMessage("lark", userID, text)
|
||||||
@@ -109,3 +111,31 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h
|
|||||||
}
|
}
|
||||||
logger.Debug("飞书已回复", zap.String("message_id", messageID))
|
logger.Debug("飞书已回复", zap.String("message_id", messageID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLarkUserID 提取飞书会话隔离键:
|
||||||
|
// tenant_key + 稳定用户标识(user_id/open_id/union_id);按配置可选 chat_id 兜底。
|
||||||
|
func resolveLarkUserID(event *larkim.P2MessageReceiveV1, allowChatIDFallback bool) string {
|
||||||
|
if event == nil || event.Event == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tenantKey := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.TenantKey))
|
||||||
|
if tenantKey == "" {
|
||||||
|
tenantKey = "default"
|
||||||
|
}
|
||||||
|
prefix := "t:" + tenantKey + "|"
|
||||||
|
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UserId)); id != "" {
|
||||||
|
return prefix + "u:" + id
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.OpenId)); id != "" {
|
||||||
|
return prefix + "o:" + id
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UnionId)); id != "" {
|
||||||
|
return prefix + "n:" + id
|
||||||
|
}
|
||||||
|
if allowChatIDFallback && event.Event.Message != nil {
|
||||||
|
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Message.ChatId)); id != "" {
|
||||||
|
return prefix + "c:" + id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": "Same as MCP monitor \"Stop tool\": ends only the in-flight tool call; the conversation and this run continue. Optional note is merged into the tool result (bilingual USER INTERRUPT NOTE, not raw CLI). Leave empty for a plain stop. If no tool is running yet (model still thinking), wait for a tool call or use \"Stop completely\".",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -383,6 +383,16 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "任务管理",
|
"title": "任务管理",
|
||||||
"stopTask": "停止任务",
|
"stopTask": "停止任务",
|
||||||
|
"interruptModalTitle": "中断当前步骤",
|
||||||
|
"interruptReasonLabel": "中断说明",
|
||||||
|
"interruptModalHint": "与 MCP 监控页「终止工具」一致:仅结束当前这一次工具调用,整条对话与本轮推理会继续;工具返回中可附带说明(中英 USER INTERRUPT NOTE 块,与命令行原文区分)。留空则等同仅终止工具。若当前没有工具在执行(模型尚在思考),请等待工具开始或改用「彻底停止」。",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
+169
-39
@@ -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']);
|
||||||
@@ -354,6 +356,23 @@ function isChatMessagesPinnedToBottom() {
|
|||||||
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
|
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */
|
||||||
|
function findProgressIdByConversationId(conversationId) {
|
||||||
|
if (!conversationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let fallback = null;
|
||||||
|
for (const [pid, st] of progressTaskState) {
|
||||||
|
if (st && st.conversationId === conversationId) {
|
||||||
|
fallback = pid;
|
||||||
|
if (document.getElementById(pid)) {
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function registerProgressTask(progressId, conversationId = null) {
|
function registerProgressTask(progressId, conversationId = null) {
|
||||||
const state = progressTaskState.get(progressId) || {};
|
const state = progressTaskState.get(progressId) || {};
|
||||||
state.conversationId = conversationId !== undefined && conversationId !== null
|
state.conversationId = conversationId !== undefined && conversationId !== null
|
||||||
@@ -410,6 +429,140 @@ async function requestCancel(conversationId) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与 MCP 监控一致:仅终止当前进行中的工具调用,工具返回后本轮推理继续(可选 reason 合并进工具结果) */
|
||||||
|
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: progressId != null && progressId !== '' ? progressId : null,
|
||||||
|
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();
|
||||||
|
const { progressId, conversationId } = userInterruptModalPending;
|
||||||
|
closeUserInterruptModal();
|
||||||
|
const stopBtn = progressId ? document.getElementById(`${progressId}-stop-btn`) : null;
|
||||||
|
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, conversationId } = userInterruptModalPending;
|
||||||
|
closeUserInterruptModal();
|
||||||
|
if (progressId) {
|
||||||
|
await performHardCancelProgressTask(progressId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestCancel(conversationId);
|
||||||
|
loadActiveTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消任务失败:', error);
|
||||||
|
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 彻底停止任务(原「停止任务」行为) */
|
||||||
|
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 +890,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 +910,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将进度消息转换为可折叠的详情组件
|
// 将进度消息转换为可折叠的详情组件
|
||||||
@@ -2417,7 +2550,7 @@ function renderActiveTasks(tasks) {
|
|||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
cancelBtn.onclick = (evt) => {
|
cancelBtn.onclick = (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
cancelActiveTask(task.conversationId, cancelBtn);
|
cancelActiveTask(task.conversationId);
|
||||||
};
|
};
|
||||||
if (task.status === 'cancelling') {
|
if (task.status === 'cancelling') {
|
||||||
cancelBtn.disabled = true;
|
cancelBtn.disabled = true;
|
||||||
@@ -2430,21 +2563,12 @@ function renderActiveTasks(tasks) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelActiveTask(conversationId, button) {
|
function cancelActiveTask(conversationId) {
|
||||||
if (!conversationId) return;
|
if (!conversationId) {
|
||||||
const originalText = button.textContent;
|
return;
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await requestCancel(conversationId);
|
|
||||||
loadActiveTasks();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('取消任务失败:', error);
|
|
||||||
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = originalText;
|
|
||||||
}
|
}
|
||||||
|
const progressId = findProgressIdByConversationId(conversationId);
|
||||||
|
openUserInterruptModal(progressId, conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let monitorPanelFetchSeq = 0;
|
let monitorPanelFetchSeq = 0;
|
||||||
@@ -2777,7 +2901,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 +2913,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 +2930,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>
|
||||||
|
|||||||
@@ -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()">×</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()">×</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;">
|
||||||
|
|||||||
Reference in New Issue
Block a user