mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 13:43:31 +02:00
Compare commits
35 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 | |||
| b28f9c25f8 | |||
| 6f5d0b0174 | |||
| 231a48db8e | |||
| d82ea60827 | |||
| 24a0c813e2 |
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.1"
|
||||
version: "v1.6.5"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -9,13 +9,13 @@ toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/cloudwego/eino v0.8.8
|
||||
github.com/cloudwego/eino v0.8.13
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260416081055-0ebab92e14f2
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260416081055-0ebab92e14f2
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260416081055-0ebab92e14f2
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260416081055-0ebab92e14f2
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.12
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/eino-contrib/jsonschema v1.0.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
@@ -40,7 +40,7 @@ require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
|
||||
@@ -20,22 +20,22 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.8.8 h1:64NuheQBmxOXe/28Tm85rkBkxXMB5ZhjSu/j0RDFyZU=
|
||||
github.com/cloudwego/eino v0.8.8/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
|
||||
github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0=
|
||||
github.com/cloudwego/eino v0.8.13/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 h1:v2w9TyLAmNsMWo8NwntCc76uvNf6isTFkHB+oZZ8NqI=
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48=
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260416081055-0ebab92e14f2 h1:H5Ohr3OWSjiTOe7y9pOPyVCKCNjAVj9YMaWmvZNTYPg=
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260416081055-0ebab92e14f2 h1:PRli0CmPfgUhwMGWGEAwg8nxde8hInC2OWv0vcIuwMk=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:KVOVct4e2BQ7epDONW2QE1qU5+ccoh91FzJTs9vIJj0=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260416081055-0ebab92e14f2 h1:8sOFcDf9MtMVDQyozZtuhrmt+mLQRHEaf6dYC20Vxhs=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo=
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260416081055-0ebab92e14f2 h1:OzKPBfGCJhjbtO+WfIMNSSnXxsj6/hUiyYOTaG2LUf4=
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.12 h1:vcwNXeT7bpaXMNwUhtcHZwMYY8II2jAihuooyivmEZ0=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.12/go.mod h1:ve/+/hLZMvxD5AieQ355xHIFhAZVlsG4rdwTnE16aQU=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0=
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc=
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b h1:3owjV4nv+XRplavTeqFlCeAV4v7EHR2tIXDqLEmPc38=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b/go.mod h1:KVOVct4e2BQ7epDONW2QE1qU5+ccoh91FzJTs9vIJj0=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b h1:j8sj/5QiooV3LWphFDsJvyD/csWwupz+UKXeG+nqiNg=
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo=
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b h1:pOqupZQyc46rw2Z0HeybtTmSMTwqfTrbRuGDuDsNf2A=
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
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 {
|
||||
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
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
min = a.agentConfig.ToolTimeoutMinutes
|
||||
@@ -1903,9 +1905,26 @@ func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationI
|
||||
a.currentConversationID = prev
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
ctx = withAgentConversationID(ctx, conversationID)
|
||||
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 尝试从错误信息中提取被引用的工具名称
|
||||
func extractQuotedToolName(errMsg string) string {
|
||||
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 != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
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 != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
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/execution/:id", monitorHandler.GetExecution)
|
||||
protected.POST("/monitor/execution/:id/cancel", monitorHandler.CancelExecution)
|
||||
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
|
||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||
|
||||
@@ -811,8 +811,8 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) (
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 8000,
|
||||
"temperature": 0.3,
|
||||
"max_completion_tokens": 80000,
|
||||
}
|
||||
|
||||
var apiResponse struct {
|
||||
|
||||
@@ -275,11 +275,25 @@ type MultiAgentAPIUpdate struct {
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||
type RobotsConfig struct {
|
||||
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,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 企业微信机器人配置
|
||||
type RobotWecomConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
@@ -292,17 +306,19 @@ type RobotWecomConfig struct {
|
||||
|
||||
// RobotDingtalkConfig 钉钉机器人配置
|
||||
type RobotDingtalkConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
|
||||
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
|
||||
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 飞书机器人配置
|
||||
type RobotLarkConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||
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 {
|
||||
@@ -465,7 +481,6 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Auth.SessionDurationHours <= 0 {
|
||||
cfg.Auth.SessionDurationHours = 12
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.Auth.Password) == "" {
|
||||
password, err := generateStrongPassword(24)
|
||||
if err != nil {
|
||||
@@ -934,6 +949,7 @@ func LoadRoleFromFile(path string) (*RoleConfig, error) {
|
||||
}
|
||||
|
||||
func Default() *Config {
|
||||
strictRobotIdentity := true
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
@@ -968,6 +984,11 @@ func Default() *Config {
|
||||
Auth: AuthConfig{
|
||||
SessionDurationHours: 12,
|
||||
},
|
||||
Robots: RobotsConfig{
|
||||
Session: RobotSessionConfig{
|
||||
StrictUserIdentity: &strictRobotIdentity,
|
||||
},
|
||||
},
|
||||
Knowledge: KnowledgeConfig{
|
||||
Enabled: true,
|
||||
BasePath: "knowledge_base",
|
||||
|
||||
@@ -32,6 +32,7 @@ type Message struct {
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
||||
ProcessDetails []map[string]interface{} `json:"processDetails,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// CreateConversation 创建新对话
|
||||
@@ -484,6 +485,7 @@ func (db *DB) ConversationHasToolProcessDetails(conversationID string) (bool, er
|
||||
// AddMessage 添加消息
|
||||
func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs []string) (*Message, error) {
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
var mcpIDsJSON string
|
||||
if len(mcpExecutionIDs) > 0 {
|
||||
@@ -496,8 +498,8 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
|
||||
}
|
||||
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, mcp_execution_ids, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, conversationID, role, content, mcpIDsJSON, time.Now(),
|
||||
"INSERT INTO messages (id, conversation_id, role, content, mcp_execution_ids, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, conversationID, role, content, mcpIDsJSON, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("添加消息失败: %w", err)
|
||||
@@ -514,7 +516,8 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
|
||||
Role: role,
|
||||
Content: content,
|
||||
MCPExecutionIDs: mcpExecutionIDs,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return message, nil
|
||||
@@ -523,7 +526,7 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
|
||||
// GetMessages 获取对话的所有消息
|
||||
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, conversation_id, role, content, mcp_execution_ids, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
|
||||
"SELECT id, conversation_id, role, content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
|
||||
conversationID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -536,8 +539,9 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||
var msg Message
|
||||
var mcpIDsJSON sql.NullString
|
||||
var createdAt string
|
||||
var updatedAt sql.NullString
|
||||
|
||||
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &mcpIDsJSON, &createdAt); err != nil {
|
||||
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &mcpIDsJSON, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描消息失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -551,6 +555,20 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||
msg.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
// updated_at 兼容老库:字段不存在/为空时回退为 created_at
|
||||
if updatedAt.Valid && strings.TrimSpace(updatedAt.String) != "" {
|
||||
msg.UpdatedAt, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt.String)
|
||||
if err != nil {
|
||||
msg.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAt.String)
|
||||
}
|
||||
if err != nil {
|
||||
msg.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
}
|
||||
if msg.UpdatedAt.IsZero() {
|
||||
msg.UpdatedAt = msg.CreatedAt
|
||||
}
|
||||
|
||||
// 解析MCP执行ID
|
||||
if mcpIDsJSON.Valid && mcpIDsJSON.String != "" {
|
||||
if err := json.Unmarshal([]byte(mcpIDsJSON.String), &msg.MCPExecutionIDs); err != nil {
|
||||
|
||||
@@ -82,6 +82,7 @@ func (db *DB) initTables() error {
|
||||
content TEXT NOT NULL,
|
||||
mcp_execution_ids TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
@@ -202,6 +203,16 @@ func (db *DB) initTables() error {
|
||||
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 := `
|
||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||
@@ -408,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_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_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_vulnerabilities_conversation_id ON vulnerabilities(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag);
|
||||
@@ -478,6 +490,9 @@ func (db *DB) initTables() error {
|
||||
if _, err := db.Exec(createConversationGroupMappingsTable); err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||
@@ -518,6 +533,11 @@ func (db *DB) initTables() error {
|
||||
// 不返回错误,允许继续运行
|
||||
}
|
||||
|
||||
if err := db.migrateMessagesTable(); err != nil {
|
||||
db.logger.Warn("迁移messages表失败", zap.Error(err))
|
||||
// 不返回错误,允许继续运行
|
||||
}
|
||||
|
||||
if err := db.migrateConversationGroupsTable(); err != nil {
|
||||
db.logger.Warn("迁移conversation_groups表失败", zap.Error(err))
|
||||
// 不返回错误,允许继续运行
|
||||
@@ -550,6 +570,33 @@ func (db *DB) initTables() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateMessagesTable 迁移 messages 表,补充 updated_at 字段。
|
||||
// 语义:updated_at 表示该条消息最后一次被写入/更新的时间(例如助手占位消息在任务结束时更新正文)。
|
||||
func (db *DB) migrateMessagesTable() error {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name='updated_at'").Scan(&count)
|
||||
if err != nil {
|
||||
// 如果查询失败,尝试添加字段
|
||||
if _, addErr := db.Exec("ALTER TABLE messages ADD COLUMN updated_at DATETIME"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
return fmt.Errorf("添加 messages.updated_at 字段失败: %w", addErr)
|
||||
}
|
||||
}
|
||||
} else if count == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE messages ADD COLUMN updated_at DATETIME"); err != nil {
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
return fmt.Errorf("添加 messages.updated_at 字段失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回填已有数据:让 updated_at 至少等于 created_at,避免前端出现空/当前时间回退。
|
||||
_, _ = db.Exec("UPDATE messages SET updated_at = created_at WHERE updated_at IS NULL OR updated_at = ''")
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateConversationsTable 迁移conversations表,添加新字段
|
||||
func (db *DB) migrateConversationsTable() error {
|
||||
// 检查last_react_input字段是否存在
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+254
-103
@@ -19,6 +19,7 @@ import (
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
@@ -458,6 +459,57 @@ func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedP
|
||||
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 聊天响应
|
||||
type ChatResponse struct {
|
||||
Response string `json:"response"`
|
||||
@@ -725,10 +777,12 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
||||
"deep",
|
||||
)
|
||||
if errMA != nil {
|
||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
|
||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||
}
|
||||
errMsg := "执行失败: " + errMA.Error()
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
return "", conversationID, errMA
|
||||
@@ -740,8 +794,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, err = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
resultMA.Response, mcpIDsJSON, assistantMessageID,
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
resultMA.Response, mcpIDsJSON, time.Now(), assistantMessageID,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
|
||||
@@ -761,7 +815,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
||||
if err != nil {
|
||||
errMsg := "执行失败: " + err.Error()
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
return "", conversationID, err
|
||||
@@ -775,8 +829,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, err = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
result.Response, mcpIDsJSON, assistantMessageID,
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
result.Response, mcpIDsJSON, time.Now(), assistantMessageID,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
|
||||
@@ -1493,6 +1547,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
defer cancelWithCause(nil)
|
||||
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
|
||||
@@ -1515,9 +1571,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
// 更新助手消息内容并保存错误详情到数据库
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
||||
errorMsg,
|
||||
assistantMessageID,
|
||||
time.Now(), assistantMessageID,
|
||||
); updateErr != nil {
|
||||
h.logger.Warn("更新错误后的助手消息失败", zap.Error(updateErr))
|
||||
}
|
||||
@@ -1568,11 +1624,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
cancelMsg,
|
||||
assistantMessageID,
|
||||
); updateErr != nil {
|
||||
if result != nil {
|
||||
if updateErr := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); updateErr != nil {
|
||||
h.logger.Warn("合并取消前的部分回复失败", zap.Error(updateErr))
|
||||
}
|
||||
}
|
||||
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
|
||||
h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr))
|
||||
}
|
||||
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||
@@ -1604,9 +1661,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
||||
timeoutMsg,
|
||||
assistantMessageID,
|
||||
time.Now(), assistantMessageID,
|
||||
); updateErr != nil {
|
||||
h.logger.Warn("更新超时后的助手消息失败", zap.Error(updateErr))
|
||||
}
|
||||
@@ -1639,9 +1696,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
||||
errorMsg,
|
||||
assistantMessageID,
|
||||
time.Now(), assistantMessageID,
|
||||
); updateErr != nil {
|
||||
h.logger.Warn("更新失败后的助手消息失败", zap.Error(updateErr))
|
||||
}
|
||||
@@ -1671,7 +1728,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
// 更新助手消息内容
|
||||
if assistantMsg != nil {
|
||||
_, err = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
result.Response,
|
||||
func() string {
|
||||
if len(result.MCPExecutionIDs) > 0 {
|
||||
@@ -1680,7 +1737,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
assistantMessageID,
|
||||
time.Now(), assistantMessageID,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("更新助手消息失败", zap.Error(err))
|
||||
@@ -1717,6 +1774,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
var req struct {
|
||||
ConversationID string `json:"conversationId" binding:"required"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ContinueAfter bool `json:"continueAfter,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -1724,7 +1783,40 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
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 {
|
||||
h.logger.Error("取消任务失败", zap.Error(err))
|
||||
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{
|
||||
"status": "cancelling",
|
||||
"status": "cancelling",
|
||||
"conversationId": req.ConversationID,
|
||||
"message": "已提交取消请求,任务将在当前步骤完成后停止。",
|
||||
"message": msg,
|
||||
"continueAfter": false,
|
||||
"interruptWithNote": false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2448,76 +2542,146 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
if assistantMsg != nil {
|
||||
assistantMessageID = assistantMsg.ID
|
||||
}
|
||||
progressCallback := h.createProgressCallback(context.Background(), nil, conversationID, assistantMessageID, nil)
|
||||
// 注意:批量任务没有前端直连的 POST /stream,因此若要支持「刷新后补流」,
|
||||
// 需要把进度事件镜像到 TaskEventBus(GET /api/agent-loop/task-events 会订阅这里)。
|
||||
// progressCallback 将在子任务的 IIFE 内创建,以便拿到 taskCtx/cancelWithCause 与 sendEvent。
|
||||
var progressCallback func(eventType, message string, data interface{})
|
||||
|
||||
// 执行任务(使用包含角色提示词的finalMessage和角色工具列表)
|
||||
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
|
||||
|
||||
// 单个子任务超时时间:从30分钟调整为6小时,适配长时间渗透/扫描任务
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
|
||||
// 存储取消函数,以便在取消队列时能够取消当前任务
|
||||
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||
useBatchMulti := false
|
||||
useEinoSingle := false
|
||||
batchOrch := "deep"
|
||||
am := strings.TrimSpace(strings.ToLower(queue.AgentMode))
|
||||
if am == "multi" {
|
||||
am = "deep"
|
||||
}
|
||||
if am == "eino_single" {
|
||||
useEinoSingle = true
|
||||
} else if batchQueueWantsEino(queue.AgentMode) && h.config != nil && h.config.MultiAgent.Enabled {
|
||||
useBatchMulti = true
|
||||
batchOrch = config.NormalizeMultiAgentOrchestration(am)
|
||||
} else if queue.AgentMode == "" {
|
||||
// 兼容历史数据:未配置队列代理模式时,沿用旧的系统级开关
|
||||
if h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.BatchUseMultiAgent {
|
||||
func() {
|
||||
// 与对话流式接口一致:同 conversationId 仅允许一个运行中任务,并支持 /api/agent-loop/cancel 与会话锁对齐。
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
// 单个子任务超时:6 小时(与原先 WithTimeout(Background) 一致)
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 6*time.Hour)
|
||||
|
||||
registered := false
|
||||
finishStatus := "completed"
|
||||
|
||||
defer func() {
|
||||
h.batchTaskManager.SetTaskCancel(queueID, nil)
|
||||
timeoutCancel()
|
||||
if registered {
|
||||
// 与流式接口保持一致:结束前补一个 done,便于前端 task-events 侧及时收口 UI。
|
||||
if h.taskEventBus != nil {
|
||||
ev := StreamEvent{Type: "done", Message: "", Data: map[string]interface{}{"conversationId": conversationID}}
|
||||
if b, err := json.Marshal(ev); err == nil {
|
||||
h.taskEventBus.Publish(conversationID, append(append([]byte("data: "), b...), '\n', '\n'))
|
||||
}
|
||||
}
|
||||
h.tasks.FinishTask(conversationID, finishStatus)
|
||||
}
|
||||
cancelWithCause(nil)
|
||||
}()
|
||||
|
||||
// 事件镜像:只发布到 TaskEventBus,不直接写 HTTP Response(用于刷新后的补流)。
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
if h.taskEventBus == nil {
|
||||
return
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
b = []byte(`{"type":"error","message":"marshal failed"}`)
|
||||
}
|
||||
line := make([]byte, 0, len(b)+8)
|
||||
line = append(line, []byte("data: ")...)
|
||||
line = append(line, b...)
|
||||
line = append(line, '\n', '\n')
|
||||
h.taskEventBus.Publish(conversationID, line)
|
||||
}
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, task.Message, cancelWithCause); err != nil {
|
||||
h.logger.Warn("批量队列子任务注册会话运行状态失败",
|
||||
zap.String("queueId", queueID),
|
||||
zap.String("taskId", task.ID),
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.Error(err))
|
||||
failMsg := err.Error()
|
||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||
failMsg = "会话已有任务正在执行,无法在该会话上并行启动批量子任务"
|
||||
}
|
||||
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", failMsg)
|
||||
return
|
||||
}
|
||||
registered = true
|
||||
// 存储取消函数:暂停队列时取消子任务 context(与原先语义一致)
|
||||
h.batchTaskManager.SetTaskCancel(queueID, timeoutCancel)
|
||||
|
||||
// 创建进度回调函数:写 DB + 镜像到 task-events,支持刷新后继续流式展示。
|
||||
progressCallback = h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||
|
||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||
useBatchMulti := false
|
||||
useEinoSingle := false
|
||||
batchOrch := "deep"
|
||||
am := strings.TrimSpace(strings.ToLower(queue.AgentMode))
|
||||
if am == "multi" {
|
||||
am = "deep"
|
||||
}
|
||||
if am == "eino_single" {
|
||||
useEinoSingle = true
|
||||
} else if batchQueueWantsEino(queue.AgentMode) && h.config != nil && h.config.MultiAgent.Enabled {
|
||||
useBatchMulti = true
|
||||
batchOrch = "deep"
|
||||
batchOrch = config.NormalizeMultiAgentOrchestration(am)
|
||||
} else if queue.AgentMode == "" {
|
||||
// 兼容历史数据:未配置队列代理模式时,沿用旧的系统级开关
|
||||
if h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.BatchUseMultiAgent {
|
||||
useBatchMulti = true
|
||||
batchOrch = "deep"
|
||||
}
|
||||
}
|
||||
}
|
||||
useRunResult := useBatchMulti || useEinoSingle
|
||||
var result *agent.AgentLoopResult
|
||||
var resultMA *multiagent.RunResult
|
||||
var runErr error
|
||||
switch {
|
||||
case useBatchMulti:
|
||||
resultMA, runErr = multiagent.RunDeepAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch)
|
||||
case useEinoSingle:
|
||||
if h.config == nil {
|
||||
runErr = fmt.Errorf("服务器配置未加载")
|
||||
} else {
|
||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback)
|
||||
useRunResult := useBatchMulti || useEinoSingle
|
||||
var result *agent.AgentLoopResult
|
||||
var resultMA *multiagent.RunResult
|
||||
var runErr error
|
||||
switch {
|
||||
case useBatchMulti:
|
||||
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch)
|
||||
case useEinoSingle:
|
||||
if h.config == nil {
|
||||
runErr = fmt.Errorf("服务器配置未加载")
|
||||
} else {
|
||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback)
|
||||
}
|
||||
default:
|
||||
result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
|
||||
}
|
||||
default:
|
||||
result, runErr = h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
|
||||
}
|
||||
// 任务执行完成,清理取消函数
|
||||
h.batchTaskManager.SetTaskCancel(queueID, nil)
|
||||
cancel()
|
||||
|
||||
if runErr != nil {
|
||||
if useRunResult {
|
||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||
}
|
||||
// 检查是否是取消错误
|
||||
// 1. 直接检查是否是 context.Canceled(包括包装后的错误)
|
||||
// 2. 检查错误消息中是否包含"context canceled"或"cancelled"关键字
|
||||
// 3. 检查 result.Response 中是否包含取消相关的消息
|
||||
errStr := runErr.Error()
|
||||
partialResp := ""
|
||||
if useRunResult && resultMA != nil {
|
||||
partialResp = resultMA.Response
|
||||
} else if result != nil {
|
||||
partialResp = result.Response
|
||||
}
|
||||
isCancelled := errors.Is(runErr, context.Canceled) ||
|
||||
strings.Contains(strings.ToLower(errStr), "context canceled") ||
|
||||
strings.Contains(strings.ToLower(errStr), "context cancelled") ||
|
||||
(partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")))
|
||||
if runErr != nil {
|
||||
if useRunResult && shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||
}
|
||||
// 检查是否是取消错误
|
||||
// 1. 直接检查是否是 context.Canceled(包括包装后的错误)
|
||||
// 2. 检查错误消息中是否包含"context canceled"或"cancelled"关键字
|
||||
// 3. 检查 result.Response 中是否包含取消相关的消息
|
||||
errStr := runErr.Error()
|
||||
partialResp := ""
|
||||
if useRunResult && resultMA != nil {
|
||||
partialResp = resultMA.Response
|
||||
} else if result != nil {
|
||||
partialResp = result.Response
|
||||
}
|
||||
isCancelled := errors.Is(context.Cause(baseCtx), ErrTaskCancelled) ||
|
||||
errors.Is(runErr, context.Canceled) ||
|
||||
strings.Contains(strings.ToLower(errStr), "context canceled") ||
|
||||
strings.Contains(strings.ToLower(errStr), "context cancelled") ||
|
||||
(partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")))
|
||||
isTimeout := errors.Is(runErr, context.DeadlineExceeded) || errors.Is(context.Cause(taskCtx), context.DeadlineExceeded)
|
||||
|
||||
if isCancelled {
|
||||
if isTimeout {
|
||||
finishStatus = "timeout"
|
||||
} else if isCancelled {
|
||||
finishStatus = "cancelled"
|
||||
} else {
|
||||
finishStatus = "failed"
|
||||
}
|
||||
|
||||
if isCancelled {
|
||||
h.logger.Info("批量任务被取消", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
// 如果执行结果中有更具体的取消消息,使用它
|
||||
@@ -2526,11 +2690,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
}
|
||||
// 更新助手消息内容
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
cancelMsg,
|
||||
assistantMessageID,
|
||||
); updateErr != nil {
|
||||
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
|
||||
h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||
}
|
||||
// 保存取消详情到数据库
|
||||
@@ -2544,16 +2704,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
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)
|
||||
} else {
|
||||
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
|
||||
@@ -2561,9 +2711,9 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
// 更新助手消息内容
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
||||
errorMsg,
|
||||
assistantMessageID,
|
||||
time.Now(), assistantMessageID,
|
||||
); updateErr != nil {
|
||||
h.logger.Warn("更新失败后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||
}
|
||||
@@ -2600,10 +2750,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
resText,
|
||||
mcpIDsJSON,
|
||||
assistantMessageID,
|
||||
time.Now(), assistantMessageID,
|
||||
); updateErr != nil {
|
||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||
// 如果更新失败,尝试创建新消息
|
||||
@@ -2632,6 +2782,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
// 保存结果
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", resText, "", conversationID)
|
||||
}
|
||||
}()
|
||||
|
||||
// 移动到下一个任务
|
||||
h.batchTaskManager.MoveToNextTask(queueID)
|
||||
|
||||
@@ -886,7 +886,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "Hi"},
|
||||
},
|
||||
"max_tokens": 5,
|
||||
"max_completion_tokens": 5,
|
||||
}
|
||||
|
||||
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -43,8 +44,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
var sseWriteMu sync.Mutex
|
||||
var ssePublishConversationID string
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
||||
return
|
||||
if eventType == "error" && baseCtx != nil {
|
||||
cause := context.Cause(baseCtx)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, errMarshal := json.Marshal(ev)
|
||||
@@ -114,36 +118,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
|
||||
var cancelWithCause context.CancelCauseFunc
|
||||
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
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 = ? WHERE id = ?", errorMsg, assistantMessageID)
|
||||
}
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
curFinalMessage := prep.FinalMessage
|
||||
curHistory := prep.History
|
||||
roleTools := prep.RoleTools
|
||||
|
||||
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{}{
|
||||
"conversationId": conversationID,
|
||||
@@ -161,28 +148,72 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
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,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
conversationID,
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
curFinalMessage,
|
||||
curHistory,
|
||||
roleTools,
|
||||
progressCallback,
|
||||
)
|
||||
timeoutCancel()
|
||||
|
||||
if runErr != nil {
|
||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||
cause := context.Cause(baseCtx)
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||
}
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
taskStatus = "cancelled"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", cancelMsg, 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)
|
||||
}
|
||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||
@@ -198,7 +229,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
timeoutMsg := "任务执行超时,已自动终止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", timeoutMsg, assistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", timeoutMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
|
||||
}
|
||||
sendEvent("error", timeoutMsg, map[string]interface{}{
|
||||
@@ -215,7 +246,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
sendEvent("error", errMsg, map[string]interface{}{
|
||||
@@ -233,9 +264,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, _ = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
result.Response,
|
||||
mcpIDsJSON,
|
||||
time.Now(),
|
||||
assistantMessageID,
|
||||
)
|
||||
}
|
||||
@@ -307,7 +339,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
progressCallback,
|
||||
)
|
||||
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()})
|
||||
return
|
||||
}
|
||||
@@ -319,9 +353,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, _ = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
result.Response,
|
||||
mcpIDsJSON,
|
||||
time.Now(),
|
||||
prep.AssistantMessageID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -268,8 +268,8 @@ func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 1200,
|
||||
"temperature": 0.1,
|
||||
"max_completion_tokens": 12000,
|
||||
}
|
||||
|
||||
// OpenAI 返回结构:只需要 choices[0].message.content
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -245,6 +248,37 @@ func (h *MonitorHandler) GetExecution(c *gin.Context) {
|
||||
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 请求)
|
||||
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -317,7 +351,7 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
||||
totalCalls := 1
|
||||
successCalls := 0
|
||||
failedCalls := 0
|
||||
if exec.Status == "failed" {
|
||||
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||
failedCalls = 1
|
||||
} else if exec.Status == "completed" {
|
||||
successCalls = 1
|
||||
@@ -381,7 +415,7 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
|
||||
|
||||
stats := toolStats[exec.ToolName]
|
||||
stats.totalCalls++
|
||||
if exec.Status == "failed" {
|
||||
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||
stats.failedCalls++
|
||||
} else if exec.Status == "completed" {
|
||||
stats.successCalls++
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,8 +61,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
|
||||
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
|
||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
||||
return
|
||||
if eventType == "error" && baseCtx != nil {
|
||||
cause := context.Cause(baseCtx)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, errMarshal := json.Marshal(ev)
|
||||
@@ -130,15 +134,35 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
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)
|
||||
var cancelWithCause context.CancelCauseFunc
|
||||
curFinalMessage := prep.FinalMessage
|
||||
curHistory := prep.History
|
||||
roleTools := prep.RoleTools
|
||||
orch := strings.TrimSpace(req.Orchestration)
|
||||
|
||||
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 {
|
||||
var errorMsg string
|
||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||
@@ -152,47 +176,55 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
sendEvent("error", errorMsg, nil)
|
||||
}
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errorMsg, 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
|
||||
|
||||
taskStatus := "completed"
|
||||
defer h.tasks.FinishTask(conversationID, taskStatus)
|
||||
|
||||
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
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)
|
||||
})
|
||||
|
||||
stopKeepalive := make(chan struct{})
|
||||
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
||||
defer close(stopKeepalive)
|
||||
|
||||
result, runErr := multiagent.RunDeepAgent(
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
conversationID,
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
curFinalMessage,
|
||||
curHistory,
|
||||
roleTools,
|
||||
progressCallback,
|
||||
h.agentsMarkdownDir,
|
||||
strings.TrimSpace(req.Orchestration),
|
||||
orch,
|
||||
)
|
||||
timeoutCancel()
|
||||
|
||||
if runErr != nil {
|
||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||
cause := context.Cause(baseCtx)
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||
}
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
taskStatus = "cancelled"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", cancelMsg, 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)
|
||||
}
|
||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||
@@ -208,7 +240,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
timeoutMsg := "任务执行超时,已自动终止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", timeoutMsg, assistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", timeoutMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
|
||||
}
|
||||
sendEvent("error", timeoutMsg, map[string]interface{}{
|
||||
@@ -225,7 +257,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
sendEvent("error", errMsg, map[string]interface{}{
|
||||
@@ -243,9 +275,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, _ = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
result.Response,
|
||||
mcpIDsJSON,
|
||||
time.Now(),
|
||||
assistantMessageID,
|
||||
)
|
||||
}
|
||||
@@ -319,11 +352,13 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
strings.TrimSpace(req.Orchestration),
|
||||
)
|
||||
if runErr != nil {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
}
|
||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if prep.AssistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, prep.AssistantMessageID)
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||
return
|
||||
@@ -336,9 +371,10 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, _ = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
||||
result.Response,
|
||||
mcpIDsJSON,
|
||||
time.Now(),
|
||||
prep.AssistantMessageID,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -461,6 +461,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"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{}{
|
||||
@@ -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{}{
|
||||
"delete": map[string]interface{}{
|
||||
"tags": []string{"监控"},
|
||||
|
||||
+99
-12
@@ -75,14 +75,58 @@ func (h *RobotHandler) sessionKey(platform, userID string) string {
|
||||
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字)
|
||||
func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) {
|
||||
sk := h.sessionKey(platform, userID)
|
||||
h.mu.RLock()
|
||||
convID = h.sessions[h.sessionKey(platform, userID)]
|
||||
convID = h.sessions[sk]
|
||||
h.mu.RUnlock()
|
||||
if convID != "" {
|
||||
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)
|
||||
if t == "" {
|
||||
t = "新对话 " + time.Now().Format("01-02 15:04")
|
||||
@@ -96,34 +140,49 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
|
||||
}
|
||||
convID = conv.ID
|
||||
h.mu.Lock()
|
||||
h.sessions[h.sessionKey(platform, userID)] = convID
|
||||
role := h.sessionRoles[sk]
|
||||
h.sessions[sk] = convID
|
||||
h.mu.Unlock()
|
||||
h.persistSessionBinding(sk, convID, role)
|
||||
return convID, true
|
||||
}
|
||||
|
||||
// setConversation 切换当前会话
|
||||
func (h *RobotHandler) setConversation(platform, userID, convID string) {
|
||||
sk := h.sessionKey(platform, userID)
|
||||
h.mu.Lock()
|
||||
h.sessions[h.sessionKey(platform, userID)] = convID
|
||||
role := h.sessionRoles[sk]
|
||||
h.sessions[sk] = convID
|
||||
h.mu.Unlock()
|
||||
h.persistSessionBinding(sk, convID, role)
|
||||
}
|
||||
|
||||
// getRole 获取当前用户使用的角色,未设置时返回"默认"
|
||||
func (h *RobotHandler) getRole(platform, userID string) string {
|
||||
sk := h.sessionKey(platform, userID)
|
||||
h.mu.RLock()
|
||||
role := h.sessionRoles[h.sessionKey(platform, userID)]
|
||||
role := h.sessionRoles[sk]
|
||||
h.mu.RUnlock()
|
||||
if role == "" {
|
||||
return "默认"
|
||||
if strings.TrimSpace(role) != "" {
|
||||
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 设置当前用户使用的角色
|
||||
func (h *RobotHandler) setRole(platform, userID, roleName string) {
|
||||
sk := h.sessionKey(platform, userID)
|
||||
h.mu.Lock()
|
||||
h.sessionRoles[h.sessionKey(platform, userID)] = roleName
|
||||
h.sessionRoles[sk] = roleName
|
||||
convID := h.sessions[sk]
|
||||
h.mu.Unlock()
|
||||
h.persistSessionBinding(sk, convID, roleName)
|
||||
}
|
||||
|
||||
// clearConversation 清空当前会话(切换到新对话)
|
||||
@@ -140,7 +199,16 @@ func (h *RobotHandler) clearConversation(platform, userID string) (newConvID str
|
||||
|
||||
// HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用)
|
||||
func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) {
|
||||
platform = strings.TrimSpace(platform)
|
||||
userID = strings.TrimSpace(userID)
|
||||
text = strings.TrimSpace(text)
|
||||
if platform == "" {
|
||||
platform = "unknown"
|
||||
}
|
||||
if userID == "" {
|
||||
h.logger.Warn("机器人消息缺少用户标识,已拒绝处理", zap.String("platform", platform))
|
||||
return "无法识别发送者身份,请检查机器人事件订阅权限(需返回可用的用户 ID)。"
|
||||
}
|
||||
if text == "" {
|
||||
return "请输入内容或发送「帮助」/ help 查看命令。"
|
||||
}
|
||||
@@ -345,7 +413,9 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
|
||||
// 删除当前对话时,先清空会话绑定
|
||||
h.mu.Lock()
|
||||
delete(h.sessions, sk)
|
||||
delete(h.sessionRoles, sk)
|
||||
h.mu.Unlock()
|
||||
h.deleteSessionBinding(sk)
|
||||
}
|
||||
if err := h.db.DeleteConversation(convID); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
if userID == "" {
|
||||
h.logger.Warn("企业微信消息缺少可用用户标识,已忽略")
|
||||
c.String(http.StatusOK, "success")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制回复内容长度(企业微信限制 2048 字节)
|
||||
maxReplyLen := 2000
|
||||
@@ -661,14 +748,14 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
||||
|
||||
if body.MsgType != "text" {
|
||||
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
|
||||
}
|
||||
|
||||
// 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。
|
||||
if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -684,7 +771,7 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
||||
reply = limitReply(reply)
|
||||
h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply))
|
||||
// 调用企业微信 API 主动发送消息
|
||||
h.sendWecomMessageViaAPI(userID, enterpriseID, reply)
|
||||
h.sendWecomMessageViaAPI(rawUserID, enterpriseID, reply)
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -13,6 +14,13 @@ var ErrTaskCancelled = errors.New("agent task cancelled by user")
|
||||
// ErrTaskAlreadyRunning 会话已有任务正在执行
|
||||
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任务
|
||||
type AgentTask struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
@@ -21,9 +29,56 @@ type AgentTask struct {
|
||||
Status string `json:"status"`
|
||||
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
||||
|
||||
// ActiveMCPExecutionID 当前正在执行的 MCP 工具 executionId(仅内存,供「中断并继续」= 仅掐当前工具)
|
||||
ActiveMCPExecutionID string `json:"-"`
|
||||
|
||||
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 已完成的任务(用于历史记录)
|
||||
type CompletedTask struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
|
||||
@@ -32,6 +32,8 @@ type ExternalMCPManager struct {
|
||||
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
||||
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
||||
mu sync.RWMutex
|
||||
runningCancels map[string]context.CancelFunc
|
||||
abortUserNotes map[string]string
|
||||
}
|
||||
|
||||
// NewExternalMCPManager 创建外部MCP管理器
|
||||
@@ -42,16 +44,18 @@ func NewExternalMCPManager(logger *zap.Logger) *ExternalMCPManager {
|
||||
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
|
||||
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
|
||||
manager := &ExternalMCPManager{
|
||||
clients: make(map[string]ExternalMCPClient),
|
||||
configs: make(map[string]config.ExternalMCPServerConfig),
|
||||
logger: logger,
|
||||
storage: storage,
|
||||
executions: make(map[string]*ToolExecution),
|
||||
stats: make(map[string]*ToolStats),
|
||||
errors: make(map[string]string),
|
||||
toolCounts: make(map[string]int),
|
||||
toolCache: make(map[string][]Tool),
|
||||
stopRefresh: make(chan struct{}),
|
||||
clients: make(map[string]ExternalMCPClient),
|
||||
configs: make(map[string]config.ExternalMCPServerConfig),
|
||||
logger: logger,
|
||||
storage: storage,
|
||||
executions: make(map[string]*ToolExecution),
|
||||
stats: make(map[string]*ToolStats),
|
||||
errors: make(map[string]string),
|
||||
toolCounts: make(map[string]int),
|
||||
toolCache: make(map[string][]Tool),
|
||||
stopRefresh: make(chan struct{}),
|
||||
runningCancels: make(map[string]context.CancelFunc),
|
||||
abortUserNotes: make(map[string]string),
|
||||
}
|
||||
// 启动后台刷新工具数量的goroutine
|
||||
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()
|
||||
@@ -462,16 +476,23 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
||||
execution.Duration = now.Sub(execution.StartTime)
|
||||
|
||||
if err != nil {
|
||||
execution.Status = "failed"
|
||||
execution.Error = err.Error()
|
||||
st, msg := executionStatusAndMessage(err)
|
||||
execution.Status = st
|
||||
execution.Error = msg
|
||||
} else if result != nil && result.IsError {
|
||||
execution.Status = "failed"
|
||||
if len(result.Content) > 0 {
|
||||
execution.Error = result.Content[0].Text
|
||||
if cancelledWithUserNote {
|
||||
execution.Status = "cancelled"
|
||||
execution.Error = ""
|
||||
execution.Result = result
|
||||
} 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 {
|
||||
execution.Status = "completed"
|
||||
if result == nil {
|
||||
@@ -509,6 +530,50 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
||||
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 清理旧的执行记录(保持内存中的记录数量在限制内)
|
||||
func (m *ExternalMCPManager) cleanupOldExecutions() {
|
||||
const maxExecutionsInMemory = 1000
|
||||
@@ -562,6 +627,42 @@ func (m *ExternalMCPManager) GetExecution(id string) (*ToolExecution, bool) {
|
||||
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 更新统计信息
|
||||
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
|
||||
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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -40,6 +41,9 @@ type Server struct {
|
||||
logger *zap.Logger
|
||||
maxExecutionsInMemory int // 内存中最大执行记录数
|
||||
sseClients map[string]*sseClient
|
||||
runningCancels map[string]context.CancelFunc
|
||||
runningCancelsMu sync.Mutex
|
||||
abortUserNotes map[string]string // 监控页终止时附带的用户说明,与 executionID 对应
|
||||
}
|
||||
|
||||
type sseClient struct {
|
||||
@@ -50,6 +54,13 @@ type sseClient struct {
|
||||
// ToolHandler 工具处理函数
|
||||
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服务器
|
||||
func NewServer(logger *zap.Logger) *Server {
|
||||
return NewServerWithStorage(logger, nil)
|
||||
@@ -68,6 +79,8 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
|
||||
logger: logger,
|
||||
maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录
|
||||
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)
|
||||
defer cancel()
|
||||
baseCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer timeoutCancel()
|
||||
execCtx, runCancel := context.WithCancel(baseCtx)
|
||||
s.registerRunningCancel(executionID, runCancel)
|
||||
defer func() {
|
||||
runCancel()
|
||||
s.unregisterRunningCancel(executionID)
|
||||
}()
|
||||
|
||||
s.logger.Info("开始执行工具",
|
||||
zap.String("toolName", req.Name),
|
||||
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()
|
||||
var failed bool
|
||||
var finalResult *ToolResult
|
||||
@@ -462,18 +482,26 @@ func (s *Server) handleCallTool(msg *Message) *Message {
|
||||
execution.Duration = now.Sub(execution.StartTime)
|
||||
|
||||
if err != nil {
|
||||
execution.Status = "failed"
|
||||
execution.Error = err.Error()
|
||||
st, msg := executionStatusAndMessage(err)
|
||||
execution.Status = st
|
||||
execution.Error = msg
|
||||
failed = true
|
||||
} else if result != nil && result.IsError {
|
||||
execution.Status = "failed"
|
||||
if len(result.Content) > 0 {
|
||||
execution.Error = result.Content[0].Text
|
||||
if cancelledWithUserNote {
|
||||
execution.Status = "cancelled"
|
||||
execution.Error = ""
|
||||
execution.Result = result
|
||||
failed = true
|
||||
} 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 {
|
||||
execution.Status = "completed"
|
||||
if result == nil {
|
||||
@@ -510,9 +538,13 @@ func (s *Server) handleCallTool(msg *Message) *Message {
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
errText := fmt.Sprintf("工具执行失败: %v", err)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
errText = "工具执行已手动终止(MCP 监控)。后续编排步骤可继续。"
|
||||
}
|
||||
errorResult, _ := json.Marshal(CallToolResponse{
|
||||
Content: []Content{
|
||||
{Type: "text", Text: fmt.Sprintf("工具执行失败: %v", err)},
|
||||
{Type: "text", Text: errText},
|
||||
},
|
||||
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()
|
||||
now := time.Now()
|
||||
@@ -779,19 +821,28 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
||||
var finalResult *ToolResult
|
||||
|
||||
if err != nil {
|
||||
execution.Status = "failed"
|
||||
execution.Error = err.Error()
|
||||
st, msg := executionStatusAndMessage(err)
|
||||
execution.Status = st
|
||||
execution.Error = msg
|
||||
failed = true
|
||||
} else if result != nil && result.IsError {
|
||||
execution.Status = "failed"
|
||||
if len(result.Content) > 0 {
|
||||
execution.Error = result.Content[0].Text
|
||||
if cancelledWithUserNote {
|
||||
execution.Status = "cancelled"
|
||||
execution.Error = ""
|
||||
execution.Result = result
|
||||
failed = true
|
||||
finalResult = result
|
||||
} 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 {
|
||||
execution.Status = "completed"
|
||||
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 初始化默认提示词模板
|
||||
func (s *Server) initDefaultPrompts() {
|
||||
s.mu.Lock()
|
||||
|
||||
+35
-1
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -192,7 +193,7 @@ type ToolExecution struct {
|
||||
ID string `json:"id"`
|
||||
ToolName string `json:"toolName"`
|
||||
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"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
@@ -293,3 +294,36 @@ type SamplingContent struct {
|
||||
Type string `json:"type"`
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -430,9 +464,10 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
streamHeaderSent := false
|
||||
var reasoningStreamID string
|
||||
var toolStreamFragments []schema.ToolCall
|
||||
var subAssistantBuf strings.Builder
|
||||
var subAssistantBuf string
|
||||
var subReplyStreamID string
|
||||
var mainAssistantBuf strings.Builder
|
||||
var mainAssistantBuf string
|
||||
var reasoningBuf string
|
||||
var streamRecvErr error
|
||||
for {
|
||||
chunk, rerr := mv.MessageStream.Recv()
|
||||
@@ -453,59 +488,69 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
continue
|
||||
}
|
||||
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if reasoningStreamID == "" {
|
||||
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
||||
progress("thinking_stream_start", " ", map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
"source": "eino",
|
||||
"einoAgent": ev.AgentName,
|
||||
"einoRole": einoRoleTag(ev.AgentName),
|
||||
"orchestration": orchMode,
|
||||
var reasoningDelta string
|
||||
reasoningBuf, reasoningDelta = normalizeStreamingDelta(reasoningBuf, chunk.ReasoningContent)
|
||||
if reasoningDelta != "" {
|
||||
if reasoningStreamID == "" {
|
||||
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
||||
progress("thinking_stream_start", " ", map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
"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 progress != nil && streamsMainAssistant(ev.AgentName) {
|
||||
if !streamHeaderSent {
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
var contentDelta string
|
||||
mainAssistantBuf, contentDelta = normalizeStreamingDelta(mainAssistantBuf, chunk.Content)
|
||||
if contentDelta != "" {
|
||||
if !streamHeaderSent {
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"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) {
|
||||
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{}{
|
||||
var subDelta string
|
||||
subAssistantBuf, subDelta = normalizeStreamingDelta(subAssistantBuf, chunk.Content)
|
||||
if subDelta != "" {
|
||||
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,
|
||||
"einoAgent": ev.AgentName,
|
||||
"einoRole": "sub",
|
||||
"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 {
|
||||
@@ -513,7 +558,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
}
|
||||
if streamsMainAssistant(ev.AgentName) {
|
||||
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
|
||||
if s := strings.TrimSpace(mainAssistantBuf); s != "" {
|
||||
lastAssistant = s
|
||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||
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 s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
||||
if strings.TrimSpace(subAssistantBuf) != "" && progress != nil {
|
||||
if s := strings.TrimSpace(subAssistantBuf); s != "" {
|
||||
if subReplyStreamID != "" {
|
||||
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
||||
"streamId": subReplyStreamID,
|
||||
|
||||
@@ -499,6 +499,7 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var full strings.Builder
|
||||
fullText := ""
|
||||
|
||||
for {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
@@ -531,9 +532,14 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
||||
if deltaType == "text_delta" {
|
||||
text, _ := delta["text"].(string)
|
||||
if text != "" {
|
||||
full.WriteString(text)
|
||||
var textOut string
|
||||
fullText, textOut = normalizeStreamingDelta(fullText, text)
|
||||
if textOut == "" {
|
||||
continue
|
||||
}
|
||||
full.WriteString(textOut)
|
||||
if onDelta != nil {
|
||||
if err := onDelta(text); err != nil {
|
||||
if err := onDelta(textOut); err != nil {
|
||||
return full.String(), err
|
||||
}
|
||||
}
|
||||
@@ -603,6 +609,7 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var full strings.Builder
|
||||
fullText := ""
|
||||
finishReason := ""
|
||||
|
||||
// 追踪当前正在构建的 content blocks
|
||||
@@ -665,9 +672,14 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
||||
if deltaType == "text_delta" {
|
||||
text, _ := delta["text"].(string)
|
||||
if text != "" {
|
||||
full.WriteString(text)
|
||||
var textOut string
|
||||
fullText, textOut = normalizeStreamingDelta(fullText, text)
|
||||
if textOut == "" {
|
||||
continue
|
||||
}
|
||||
full.WriteString(textOut)
|
||||
if onContentDelta != nil {
|
||||
if err := onContentDelta(text); err != nil {
|
||||
if err := onContentDelta(textOut); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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客户端。
|
||||
func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client {
|
||||
if httpClient == nil {
|
||||
@@ -219,6 +251,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var full strings.Builder
|
||||
fullText := ""
|
||||
|
||||
// 典型 SSE 结构:
|
||||
// data: {...}\n\n
|
||||
@@ -263,9 +296,14 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
||||
continue
|
||||
}
|
||||
|
||||
full.WriteString(delta)
|
||||
var deltaOut string
|
||||
fullText, deltaOut = normalizeStreamingDelta(fullText, delta)
|
||||
if deltaOut == "" {
|
||||
continue
|
||||
}
|
||||
full.WriteString(deltaOut)
|
||||
if onDelta != nil {
|
||||
if err := onDelta(delta); err != nil {
|
||||
if err := onDelta(deltaOut); err != nil {
|
||||
return full.String(), err
|
||||
}
|
||||
}
|
||||
@@ -380,6 +418,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var full strings.Builder
|
||||
fullText := ""
|
||||
finishReason := ""
|
||||
|
||||
for {
|
||||
@@ -426,10 +465,14 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
content = delta.Text
|
||||
}
|
||||
if content != "" {
|
||||
full.WriteString(content)
|
||||
if onContentDelta != nil {
|
||||
if err := onContentDelta(content); err != nil {
|
||||
return full.String(), nil, finishReason, err
|
||||
var contentOut string
|
||||
fullText, contentOut = normalizeStreamingDelta(fullText, content)
|
||||
if contentOut != "" {
|
||||
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 回复。
|
||||
// 断线(如笔记本睡眠、网络中断)后会自动重连;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 == "" {
|
||||
return
|
||||
}
|
||||
go runDingLoop(ctx, cfg, h, logger)
|
||||
go runDingLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
|
||||
}
|
||||
|
||||
// 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
|
||||
for {
|
||||
streamClient := client.NewStreamClient(
|
||||
client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
|
||||
client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get",
|
||||
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
|
||||
}).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 == "" {
|
||||
return
|
||||
}
|
||||
@@ -93,9 +94,22 @@ func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
userID = msg.ConversationId
|
||||
logger.Warn("钉钉消息缺少可用用户标识,已忽略")
|
||||
return
|
||||
}
|
||||
reply := h.HandleMessage("dingtalk", userID, content)
|
||||
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式
|
||||
|
||||
+38
-8
@@ -27,20 +27,21 @@ type larkTextContent struct {
|
||||
|
||||
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
|
||||
// 断线(如笔记本睡眠、网络中断)后会自动重连;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 == "" {
|
||||
return
|
||||
}
|
||||
go runLarkLoop(ctx, cfg, h, logger)
|
||||
go runLarkLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
|
||||
}
|
||||
|
||||
// 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
|
||||
for {
|
||||
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
|
||||
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
|
||||
})
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -89,9 +90,10 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
userID := ""
|
||||
if event.Event.Sender.SenderId.UserId != nil {
|
||||
userID = *event.Event.Sender.SenderId.UserId
|
||||
userID := resolveLarkUserID(event, cfg.AllowChatIDFallback && !strictUserIdentity)
|
||||
if userID == "" {
|
||||
logger.Warn("飞书消息缺少可用用户标识,已忽略")
|
||||
return
|
||||
}
|
||||
messageID := larkcore.StringValue(msg.MessageId)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
.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-unknown {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
@@ -3203,6 +3209,18 @@ header {
|
||||
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 {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed rgba(0, 0, 0, 0.06);
|
||||
@@ -5517,6 +5535,16 @@ header {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -394,6 +394,16 @@
|
||||
"tasks": {
|
||||
"title": "Task Management",
|
||||
"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",
|
||||
"newTask": "New task",
|
||||
"autoRefresh": "Auto refresh",
|
||||
@@ -1260,6 +1270,8 @@
|
||||
"statusCompleted": "Completed",
|
||||
"statusRunning": "Running",
|
||||
"statusFailed": "Failed",
|
||||
"statusCancelled": "Cancelled",
|
||||
"terminateExecution": "Stop",
|
||||
"loading": "Loading...",
|
||||
"noStatsData": "No statistical data",
|
||||
"noExecutions": "No execution records",
|
||||
@@ -1727,8 +1739,22 @@
|
||||
"statusRunning": "Running",
|
||||
"statusCompleted": "Completed",
|
||||
"statusFailed": "Failed",
|
||||
"statusCancelled": "Cancelled",
|
||||
"unknown": "Unknown",
|
||||
"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.",
|
||||
"time": "Time",
|
||||
"executionId": "Execution ID",
|
||||
|
||||
@@ -383,6 +383,16 @@
|
||||
"tasks": {
|
||||
"title": "任务管理",
|
||||
"stopTask": "停止任务",
|
||||
"interruptModalTitle": "中断当前步骤",
|
||||
"interruptReasonLabel": "中断说明",
|
||||
"interruptModalHint": "与 MCP 监控页「终止工具」一致:仅结束当前这一次工具调用,整条对话与本轮推理会继续;工具返回中可附带说明(中英 USER INTERRUPT NOTE 块,与命令行原文区分)。留空则等同仅终止工具。若当前没有工具在执行(模型尚在思考),请等待工具开始或改用「彻底停止」。",
|
||||
"interruptReasonPlaceholder": "例如:工具耗时过长,请先跳过并总结当前结果…",
|
||||
"interruptReasonRequired": "请填写中断说明,以便模型根据你的意图继续。",
|
||||
"interruptSubmitting": "提交中...",
|
||||
"interruptConfirmContinue": "中断并继续",
|
||||
"interruptHardStop": "彻底停止",
|
||||
"interruptModalClose": "关闭",
|
||||
"userInterruptTimelineTitle": "用户中断说明(继续迭代)",
|
||||
"collapseDetail": "收起详情",
|
||||
"newTask": "新建任务",
|
||||
"autoRefresh": "自动刷新",
|
||||
@@ -1249,6 +1259,8 @@
|
||||
"statusCompleted": "已完成",
|
||||
"statusRunning": "执行中",
|
||||
"statusFailed": "失败",
|
||||
"statusCancelled": "已终止",
|
||||
"terminateExecution": "终止",
|
||||
"loading": "加载中...",
|
||||
"noStatsData": "暂无统计数据",
|
||||
"noExecutions": "暂无执行记录",
|
||||
@@ -1716,8 +1728,22 @@
|
||||
"statusRunning": "执行中",
|
||||
"statusCompleted": "已完成",
|
||||
"statusFailed": "失败",
|
||||
"statusCancelled": "已终止",
|
||||
"unknown": "未知",
|
||||
"getDetailFailed": "获取详情失败",
|
||||
"runningNoResponseYet": "尚无返回,工具可能仍在执行。若长时间无响应,可使用下方「终止工具」结束本次调用。",
|
||||
"abortTitle": "运行控制",
|
||||
"abortHint": "仅中断当前这一次工具调用;对话与多步迭代任务会继续,不会等同于「停止任务」。",
|
||||
"abortBtn": "终止工具",
|
||||
"abortConfirm": "确定终止此次工具调用?整条对话或迭代任务不会因此停止。",
|
||||
"abortSuccess": "已发送终止请求,工具返回后状态将更新。",
|
||||
"abortFailed": "终止失败",
|
||||
"abortNoteModalTitle": "终止工具并补充说明",
|
||||
"abortNoteModalHint": "可选:说明为何终止或希望模型如何继续。提交后模型会先看到工具已输出内容(若有),再看到带「用户终止说明」标题的独立区块(中英标注,与命令行原文区分),最后是您的文字。留空则与原先仅终止一致。",
|
||||
"abortNoteLabel": "终止说明(可选)",
|
||||
"abortNotePlaceholder": "例如:输出已够判断,请停止等待并继续下一步…",
|
||||
"abortNoteSubmit": "提交终止",
|
||||
"abortNoteClose": "取消",
|
||||
"execSuccessNoContent": "执行成功,未返回可展示的文本内容。",
|
||||
"time": "时间",
|
||||
"executionId": "执行 ID",
|
||||
|
||||
@@ -306,12 +306,13 @@ async function bootstrapApp() {
|
||||
|
||||
// 通用工具函数
|
||||
function getStatusText(status) {
|
||||
const s = (status && String(status).toLowerCase()) || '';
|
||||
if (typeof window.t !== 'function') {
|
||||
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' };
|
||||
return fallback[status] || status;
|
||||
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败', cancelled: '已终止' };
|
||||
return fallback[s] || status;
|
||||
}
|
||||
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' };
|
||||
const key = keyMap[status];
|
||||
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled' };
|
||||
const key = keyMap[s];
|
||||
return key ? window.t(key) : status;
|
||||
}
|
||||
|
||||
|
||||
+118
-2
@@ -2446,7 +2446,24 @@ async function showMCPDetail(executionId) {
|
||||
}
|
||||
}
|
||||
} 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';
|
||||
}
|
||||
|
||||
/** 从详情模态框触发:取消当前进行中的 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) {
|
||||
const target = document.getElementById(elementId);
|
||||
@@ -2852,7 +2964,11 @@ async function loadConversation(conversationId) {
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
|
||||
// 消息时间口径:
|
||||
// - user: createdAt 即可(发送后不会再更新)
|
||||
// - assistant: 如果后端提供 updatedAt(任务完成时写回),优先用它,避免占位消息“任务开始时间”误导
|
||||
const msgTime = (msg && msg.role === 'assistant' && msg.updatedAt) ? msg.updatedAt : (msg ? msg.createdAt : null);
|
||||
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msgTime);
|
||||
const messageEl = document.getElementById(messageId);
|
||||
if (messageEl && msg && msg.id) {
|
||||
messageEl.dataset.backendMessageId = String(msg.id);
|
||||
|
||||
+169
-39
@@ -1,4 +1,6 @@
|
||||
const progressTaskState = new Map();
|
||||
/** @type {{ progressId: string, conversationId: string } | null} */
|
||||
let userInterruptModalPending = null;
|
||||
let activeTaskInterval = null;
|
||||
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
||||
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;
|
||||
}
|
||||
|
||||
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 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) {
|
||||
const state = progressTaskState.get(progressId) || {};
|
||||
state.conversationId = conversationId !== undefined && conversationId !== null
|
||||
@@ -410,6 +429,140 @@ async function requestCancel(conversationId) {
|
||||
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() {
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
const messageDiv = document.createElement('div');
|
||||
@@ -737,7 +890,7 @@ function toggleProcessDetails(progressId, assistantMessageId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 停止当前进度对应的任务
|
||||
// 停止当前进度:弹出「中断并说明 / 彻底停止」
|
||||
async function cancelProgressTask(progressId) {
|
||||
const state = progressTaskState.get(progressId);
|
||||
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
|
||||
@@ -757,27 +910,7 @@ async function cancelProgressTask(progressId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
openUserInterruptModal(progressId, state.conversationId);
|
||||
}
|
||||
|
||||
// 将进度消息转换为可折叠的详情组件
|
||||
@@ -2417,7 +2550,7 @@ function renderActiveTasks(tasks) {
|
||||
if (cancelBtn) {
|
||||
cancelBtn.onclick = (evt) => {
|
||||
evt.stopPropagation();
|
||||
cancelActiveTask(task.conversationId, cancelBtn);
|
||||
cancelActiveTask(task.conversationId);
|
||||
};
|
||||
if (task.status === 'cancelling') {
|
||||
cancelBtn.disabled = true;
|
||||
@@ -2430,21 +2563,12 @@ function renderActiveTasks(tasks) {
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelActiveTask(conversationId, button) {
|
||||
if (!conversationId) return;
|
||||
const originalText = button.textContent;
|
||||
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;
|
||||
function cancelActiveTask(conversationId) {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
const progressId = findProgressIdByConversationId(conversationId);
|
||||
openUserInterruptModal(progressId, conversationId);
|
||||
}
|
||||
|
||||
let monitorPanelFetchSeq = 0;
|
||||
@@ -2777,7 +2901,8 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
|
||||
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
|
||||
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 rows = executions
|
||||
.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 duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
||||
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 `
|
||||
<tr>
|
||||
<td>
|
||||
@@ -2801,6 +2930,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
<td>
|
||||
<div class="monitor-execution-actions">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
+61
-10
@@ -170,6 +170,14 @@
|
||||
<span data-i18n="nav.vulnerabilities">漏洞管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="chat-files">
|
||||
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span data-i18n="nav.chatFiles">文件管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="webshell">
|
||||
<div class="nav-item-content" data-title="WebShell管理" onclick="switchPage('webshell')" data-i18n="nav.webshell" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -201,14 +209,6 @@
|
||||
<div class="nav-submenu-item" data-page="c2-profiles" onclick="switchPage('c2-profiles')" data-i18n="nav.c2Profiles">流量伪装</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="chat-files">
|
||||
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span data-i18n="nav.chatFiles">文件管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item nav-item-has-submenu" data-page="mcp">
|
||||
<div class="nav-item-content" data-title="MCP" onclick="window.toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -1053,6 +1053,7 @@
|
||||
<option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option>
|
||||
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
|
||||
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
|
||||
<option value="cancelled" data-i18n="mcpMonitor.statusCancelled">已终止</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2449,6 +2450,13 @@
|
||||
</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-header">
|
||||
<h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3>
|
||||
@@ -2489,6 +2497,49 @@
|
||||
</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配置模态框 -->
|
||||
<div id="external-mcp-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 900px;">
|
||||
@@ -2548,7 +2599,7 @@
|
||||
<h2 data-i18n="attackChainModal.title">攻击链可视化</h2>
|
||||
<div class="modal-header-actions">
|
||||
<button class="btn-primary attack-chain-action-btn" onclick="regenerateAttackChain()" data-i18n="attackChainModal.regenerateTitle" data-i18n-attr="title" data-i18n-skip-text="true" title="重新生成攻击链(包含最新对话内容)">
|
||||
🔄 <span data-i18n="attackChainModal.regenerate">重新生成</span>
|
||||
<span data-i18n="attackChainModal.regenerate">重新生成</span>
|
||||
</button>
|
||||
<button class="btn-secondary attack-chain-action-btn" onclick="exportAttackChain('png')" data-i18n="attackChainModal.exportPng" data-i18n-attr="title" title="导出为PNG">
|
||||
📥 PNG
|
||||
@@ -2557,7 +2608,7 @@
|
||||
📥 SVG
|
||||
</button>
|
||||
<button class="btn-secondary attack-chain-action-btn" onclick="refreshAttackChain()" data-i18n="attackChainModal.refreshTitle" data-i18n-attr="title" title="刷新当前攻击链(不重新生成)">
|
||||
↻ <span data-i18n="common.refresh">刷新</span>
|
||||
<span data-i18n="common.refresh">刷新</span>
|
||||
</button>
|
||||
<span class="modal-close" onclick="closeAttackChainModal()">×</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user