mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d52cdf85 | |||
| 65e48826ff | |||
| 0cff507272 | |||
| 30afd71c05 | |||
| d2b6a154de | |||
| 278d5aa25c | |||
| 215f5a4a93 | |||
| 44185d748d | |||
| fe47f1f058 | |||
| 99ce183f41 | |||
| 2ed1947f36 | |||
| 97f3e8c179 | |||
| 38b0c31b87 | |||
| cb839da4d1 | |||
| 5ed730f17c | |||
| 30b1e5f820 | |||
| 8e5c70703e | |||
| 3cc3b25a7b | |||
| 44cf63fa52 | |||
| 12057c065b | |||
| c4e0b9735c | |||
| 218e9b9880 | |||
| 82d840966e | |||
| c62ff3bde9 | |||
| df2506b651 | |||
| efe9172f85 | |||
| b788bc6dab | |||
| 9134f2bbcb | |||
| d76cf2a162 | |||
| 2f96feb98f | |||
| a374c3950c | |||
| a93e3455fa | |||
| 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 |
+8
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.3"
|
version: "v1.6.8"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -41,6 +41,13 @@ openai:
|
|||||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
api_key: sk-xxxxxxx # API 密钥(必填)
|
||||||
model: qwen3-max # 模型名称(必填)
|
model: qwen3-max # 模型名称(必填)
|
||||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||||
|
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||||
|
reasoning:
|
||||||
|
mode: off # auto | on | off;off 时不附加任何推理扩展字段
|
||||||
|
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
|
||||||
|
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||||
|
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||||
|
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||||
# ============================================
|
# ============================================
|
||||||
# 信息收集(FOFA)配置(可选)
|
# 信息收集(FOFA)配置(可选)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 832 KiB After Width: | Height: | Size: 726 KiB |
+42
-3
@@ -193,6 +193,10 @@ type ChatMessage struct {
|
|||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
// ToolName 仅 tool 角色:从 Eino/轨迹 JSON 的 name 或 tool_name 恢复,供续跑构造 ToolMessage。
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
// ReasoningContent 对应 OpenAI/DeepSeek 的 reasoning_content;思考模式 + 工具调用后续跑须回传(见 DeepSeek 文档)。
|
||||||
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON 自定义JSON序列化,将tool_calls中的arguments转换为JSON字符串
|
// MarshalJSON 自定义JSON序列化,将tool_calls中的arguments转换为JSON字符串
|
||||||
@@ -206,11 +210,17 @@ func (cm ChatMessage) MarshalJSON() ([]byte, error) {
|
|||||||
if cm.Content != "" {
|
if cm.Content != "" {
|
||||||
aux["content"] = cm.Content
|
aux["content"] = cm.Content
|
||||||
}
|
}
|
||||||
|
if cm.ReasoningContent != "" {
|
||||||
|
aux["reasoning_content"] = cm.ReasoningContent
|
||||||
|
}
|
||||||
|
|
||||||
// 添加tool_call_id(如果存在)
|
// 添加tool_call_id(如果存在)
|
||||||
if cm.ToolCallID != "" {
|
if cm.ToolCallID != "" {
|
||||||
aux["tool_call_id"] = cm.ToolCallID
|
aux["tool_call_id"] = cm.ToolCallID
|
||||||
}
|
}
|
||||||
|
if cm.ToolName != "" {
|
||||||
|
aux["tool_name"] = cm.ToolName
|
||||||
|
}
|
||||||
|
|
||||||
// 转换tool_calls,将arguments转换为JSON字符串
|
// 转换tool_calls,将arguments转换为JSON字符串
|
||||||
if len(cm.ToolCalls) > 0 {
|
if len(cm.ToolCalls) > 0 {
|
||||||
@@ -438,6 +448,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
ToolCalls: msg.ToolCalls,
|
ToolCalls: msg.ToolCalls,
|
||||||
ToolCallID: msg.ToolCallID,
|
ToolCallID: msg.ToolCallID,
|
||||||
|
ToolName: msg.ToolName,
|
||||||
})
|
})
|
||||||
addedCount++
|
addedCount++
|
||||||
contentPreview := msg.Content
|
contentPreview := msg.Content
|
||||||
@@ -657,8 +668,8 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
|
|
||||||
// 检查是否有工具调用
|
// 检查是否有工具调用
|
||||||
if len(choice.Message.ToolCalls) > 0 {
|
if len(choice.Message.ToolCalls) > 0 {
|
||||||
// 思考内容:如果本轮启用了思考流式增量(thinking_stream_*),前端会去重;
|
// ReAct 助手正文流式增量(thinking_stream_*)在 UI 上归为「思考」;若与 streamId 重复则前端会去重。
|
||||||
// 同时也需要在该“思考阶段结束”时补一条可落库的 thinking(用于刷新后持久化展示)。
|
// 该条 thinking 用于刷新后持久化展示(与流式聚合一致)。
|
||||||
if choice.Message.Content != "" {
|
if choice.Message.Content != "" {
|
||||||
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
||||||
"iteration": i + 1,
|
"iteration": i + 1,
|
||||||
@@ -1514,7 +1525,9 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
|||||||
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
||||||
if err != nil {
|
if err != nil {
|
||||||
detail := err.Error()
|
detail := err.Error()
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) {
|
||||||
|
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
||||||
|
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||||
min := 10
|
min := 10
|
||||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||||
min = a.agentConfig.ToolTimeoutMinutes
|
min = a.agentConfig.ToolTimeoutMinutes
|
||||||
@@ -1903,9 +1916,35 @@ func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationI
|
|||||||
a.currentConversationID = prev
|
a.currentConversationID = prev
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
ctx = withAgentConversationID(ctx, conversationID)
|
||||||
return a.executeToolViaMCP(ctx, toolName, args)
|
return a.executeToolViaMCP(ctx, toolName, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordLocalToolExecution 将非 CallTool 路径完成的工具调用写入 MCP 监控库(与 CallTool 落库一致),返回 executionId。
|
||||||
|
// 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。
|
||||||
|
func (a *Agent) RecordLocalToolExecution(toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
|
||||||
|
if a == nil || a.mcpServer == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.mcpServer.RecordCompletedToolInvocation(toolName, args, resultText, invokeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
|
||||||
|
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
note = strings.TrimSpace(note)
|
||||||
|
if executionID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.mcpServer != nil && a.mcpServer.CancelToolExecutionWithNote(executionID, note) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a.externalMCPMgr != nil && a.externalMCPMgr.CancelToolExecutionWithNote(executionID, note) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
|
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
|
||||||
func extractQuotedToolName(errMsg string) string {
|
func extractQuotedToolName(errMsg string) string {
|
||||||
start := strings.Index(errMsg, "\"")
|
start := strings.Index(errMsg, "\"")
|
||||||
|
|||||||
@@ -757,6 +757,7 @@ func setupRoutes(
|
|||||||
// 监控
|
// 监控
|
||||||
protected.GET("/monitor", monitorHandler.Monitor)
|
protected.GET("/monitor", monitorHandler.Monitor)
|
||||||
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
|
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
|
||||||
|
protected.POST("/monitor/execution/:id/cancel", monitorHandler.CancelExecution)
|
||||||
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
|
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
|
||||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD
|
|||||||
// 目标:以主 agent(编排器)视角输出整轮迭代
|
// 目标:以主 agent(编排器)视角输出整轮迭代
|
||||||
// - 保留:编排器工具调用/结果、对子代理的 task 调度、子代理最终回复(不含推理)
|
// - 保留:编排器工具调用/结果、对子代理的 task 调度、子代理最终回复(不含推理)
|
||||||
// - 丢弃:thinking/planning/progress 等噪声、子代理的工具细节与推理过程
|
// - 丢弃:thinking/planning/progress 等噪声、子代理的工具细节与推理过程
|
||||||
if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "planning" {
|
if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "reasoning_chain" || d.EventType == "planning" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||||
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
||||||
// HistoryInputBudgetRatio caps pre-agent history tokens as max_total_tokens * ratio (default 0.35).
|
// HistoryInputBudgetRatio 已不影响 Eino:从 last_react 轨迹转 ADK 消息时**不再**按 token 比例裁剪(完整注入)。
|
||||||
|
// 字段仍保留,便于旧版 config 不报错;新部署可省略。
|
||||||
HistoryInputBudgetRatio float64 `yaml:"history_input_budget_ratio,omitempty" json:"history_input_budget_ratio,omitempty"`
|
HistoryInputBudgetRatio float64 `yaml:"history_input_budget_ratio,omitempty" json:"history_input_budget_ratio,omitempty"`
|
||||||
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
||||||
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
||||||
@@ -345,6 +346,48 @@ type OpenAIConfig struct {
|
|||||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
Model string `yaml:"model" json:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||||
|
// Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(仅 Eino 路径生效;原生 ReAct 忽略)。
|
||||||
|
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
||||||
|
type OpenAIReasoningConfig struct {
|
||||||
|
// Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。
|
||||||
|
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
|
||||||
|
// Effort: low | medium | high | max;空表示不单独指定强度(各 profile 行为见 internal/reasoning)。
|
||||||
|
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
|
||||||
|
// AllowClientReasoning 为 false 时忽略请求体 reasoning;nil 或未设置等同于 true。
|
||||||
|
AllowClientReasoning *bool `yaml:"allow_client_reasoning,omitempty" json:"allow_client_reasoning,omitempty"`
|
||||||
|
// Profile: auto | deepseek_compat | openai_compat | output_config_effort
|
||||||
|
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
|
||||||
|
// ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。
|
||||||
|
ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeEffective returns auto when empty or default.
|
||||||
|
func (c OpenAIReasoningConfig) ModeEffective() string {
|
||||||
|
m := strings.ToLower(strings.TrimSpace(c.Mode))
|
||||||
|
if m == "" || m == "default" {
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileEffective returns auto when empty.
|
||||||
|
func (c OpenAIReasoningConfig) ProfileEffective() string {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(c.Profile))
|
||||||
|
if p == "" {
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowClientReasoningEffective true when client may send ChatRequest.reasoning.
|
||||||
|
func (c OpenAIReasoningConfig) AllowClientReasoningEffective() bool {
|
||||||
|
if c.AllowClientReasoning == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *c.AllowClientReasoning
|
||||||
}
|
}
|
||||||
|
|
||||||
type FofaConfig struct {
|
type FofaConfig struct {
|
||||||
|
|||||||
@@ -25,14 +25,15 @@ type Conversation struct {
|
|||||||
|
|
||||||
// Message 消息
|
// Message 消息
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
ReasoningContent string `json:"reasoningContent,omitempty"`
|
||||||
ProcessDetails []map[string]interface{} `json:"processDetails,omitempty"`
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
ProcessDetails []map[string]interface{} `json:"processDetails,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateConversation 创建新对话
|
// CreateConversation 创建新对话
|
||||||
@@ -498,8 +499,8 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
"INSERT INTO messages (id, conversation_id, role, content, mcp_execution_ids, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO messages (id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, conversationID, role, content, mcpIDsJSON, now, now,
|
id, conversationID, role, content, "", mcpIDsJSON, now, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("添加消息失败: %w", err)
|
return nil, fmt.Errorf("添加消息失败: %w", err)
|
||||||
@@ -523,10 +524,30 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
|
|||||||
return message, nil
|
return message, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAssistantMessageFinalize 更新助手消息终态(正文、MCP id、思考链聚合文本,供无轨迹回退时回放)。
|
||||||
|
func (db *DB) UpdateAssistantMessageFinalize(messageID, content string, mcpExecutionIDs []string, reasoningContent string) error {
|
||||||
|
var mcpIDsJSON string
|
||||||
|
if len(mcpExecutionIDs) > 0 {
|
||||||
|
jsonData, err := json.Marshal(mcpExecutionIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化MCP执行ID失败: %w", err)
|
||||||
|
}
|
||||||
|
mcpIDsJSON = string(jsonData)
|
||||||
|
}
|
||||||
|
_, err := db.Exec(
|
||||||
|
"UPDATE messages SET content = ?, mcp_execution_ids = ?, reasoning_content = ?, updated_at = ? WHERE id = ?",
|
||||||
|
content, mcpIDsJSON, strings.TrimSpace(reasoningContent), time.Now(), messageID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新助手消息失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetMessages 获取对话的所有消息
|
// GetMessages 获取对话的所有消息
|
||||||
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
"SELECT id, conversation_id, role, content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
|
"SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
|
||||||
conversationID,
|
conversationID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -537,13 +558,17 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
|||||||
var messages []Message
|
var messages []Message
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var msg Message
|
var msg Message
|
||||||
|
var reasoning sql.NullString
|
||||||
var mcpIDsJSON sql.NullString
|
var mcpIDsJSON sql.NullString
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var updatedAt sql.NullString
|
var updatedAt sql.NullString
|
||||||
|
|
||||||
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &mcpIDsJSON, &createdAt, &updatedAt); err != nil {
|
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &reasoning, &mcpIDsJSON, &createdAt, &updatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("扫描消息失败: %w", err)
|
return nil, fmt.Errorf("扫描消息失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if reasoning.Valid {
|
||||||
|
msg.ReasoningContent = reasoning.String
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err error
|
var err error
|
||||||
@@ -683,7 +708,7 @@ type ProcessDetail struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MessageID string `json:"messageId"`
|
MessageID string `json:"messageId"`
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
EventType string `json:"eventType"` // iteration, thinking, tool_calls_detected, tool_call, tool_result, progress, error
|
EventType string `json:"eventType"` // iteration, thinking, reasoning_chain, tool_calls_detected, tool_call, tool_result, progress, error
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data string `json:"data"` // JSON格式的数据
|
Data string `json:"data"` // JSON格式的数据
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
|||||||
@@ -594,6 +594,25 @@ func (db *DB) migrateMessagesTable() error {
|
|||||||
|
|
||||||
// 回填已有数据:让 updated_at 至少等于 created_at,避免前端出现空/当前时间回退。
|
// 回填已有数据:让 updated_at 至少等于 created_at,避免前端出现空/当前时间回退。
|
||||||
_, _ = db.Exec("UPDATE messages SET updated_at = created_at WHERE updated_at IS NULL OR updated_at = ''")
|
_, _ = db.Exec("UPDATE messages SET updated_at = created_at WHERE updated_at IS NULL OR updated_at = ''")
|
||||||
|
|
||||||
|
// reasoning_content:DeepSeek 思考模式 + 工具调用续跑;与 last_react_input 互补,供消息表回退路径回放
|
||||||
|
var rcColCount int
|
||||||
|
errRC := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name='reasoning_content'").Scan(&rcColCount)
|
||||||
|
if errRC != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE messages ADD COLUMN reasoning_content TEXT"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
return fmt.Errorf("添加 messages.reasoning_content 字段失败: %w", addErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if rcColCount == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE messages ADD COLUMN reasoning_content TEXT"); err != nil {
|
||||||
|
errMsg := strings.ToLower(err.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
return fmt.Errorf("添加 messages.reasoning_content 字段失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ type ExecutionRecorder func(executionID string)
|
|||||||
const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n"
|
const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n"
|
||||||
|
|
||||||
// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。
|
// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。
|
||||||
|
// invokeNotify 可选:与 runEinoADKAgentLoop 共享,在 InvokableRun 返回时触发 UI 与 pending 清理(与 ADK Tool 事件去重)。
|
||||||
|
// einoAgentName 为该套工具所属 ChatModelAgent 的 Name(主代理或子代理 id),用于 SSE 上的 einoAgent 字段。
|
||||||
func ToolsFromDefinitions(
|
func ToolsFromDefinitions(
|
||||||
ag *agent.Agent,
|
ag *agent.Agent,
|
||||||
holder *ConversationHolder,
|
holder *ConversationHolder,
|
||||||
defs []agent.Tool,
|
defs []agent.Tool,
|
||||||
rec ExecutionRecorder,
|
rec ExecutionRecorder,
|
||||||
toolOutputChunk func(toolName, toolCallID, chunk string),
|
toolOutputChunk func(toolName, toolCallID, chunk string),
|
||||||
|
invokeNotify *ToolInvokeNotifyHolder,
|
||||||
|
einoAgentName string,
|
||||||
) ([]tool.BaseTool, error) {
|
) ([]tool.BaseTool, error) {
|
||||||
out := make([]tool.BaseTool, 0, len(defs))
|
out := make([]tool.BaseTool, 0, len(defs))
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
@@ -40,12 +44,14 @@ func ToolsFromDefinitions(
|
|||||||
return nil, fmt.Errorf("tool %q: %w", d.Function.Name, err)
|
return nil, fmt.Errorf("tool %q: %w", d.Function.Name, err)
|
||||||
}
|
}
|
||||||
out = append(out, &mcpBridgeTool{
|
out = append(out, &mcpBridgeTool{
|
||||||
info: info,
|
info: info,
|
||||||
name: d.Function.Name,
|
name: d.Function.Name,
|
||||||
agent: ag,
|
agent: ag,
|
||||||
holder: holder,
|
holder: holder,
|
||||||
record: rec,
|
record: rec,
|
||||||
chunk: toolOutputChunk,
|
chunk: toolOutputChunk,
|
||||||
|
invokeNotify: invokeNotify,
|
||||||
|
einoAgentName: strings.TrimSpace(einoAgentName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -77,12 +83,14 @@ func toolInfoFromDefinition(d agent.Tool) (*schema.ToolInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mcpBridgeTool struct {
|
type mcpBridgeTool struct {
|
||||||
info *schema.ToolInfo
|
info *schema.ToolInfo
|
||||||
name string
|
name string
|
||||||
agent *agent.Agent
|
agent *agent.Agent
|
||||||
holder *ConversationHolder
|
holder *ConversationHolder
|
||||||
record ExecutionRecorder
|
record ExecutionRecorder
|
||||||
chunk func(toolName, toolCallID, chunk string)
|
chunk func(toolName, toolCallID, chunk string)
|
||||||
|
invokeNotify *ToolInvokeNotifyHolder
|
||||||
|
einoAgentName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||||
@@ -90,8 +98,27 @@ func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
|||||||
return m.info, nil
|
return m.info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (out string, err error) {
|
||||||
_ = opts
|
_ = opts
|
||||||
|
toolCallID := compose.GetToolCallID(ctx)
|
||||||
|
defer func() {
|
||||||
|
if m.invokeNotify == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := strings.TrimSpace(toolCallID)
|
||||||
|
if tid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success := err == nil && !strings.HasPrefix(out, ToolErrorPrefix)
|
||||||
|
body := out
|
||||||
|
if err != nil {
|
||||||
|
success = false
|
||||||
|
} else if strings.HasPrefix(out, ToolErrorPrefix) {
|
||||||
|
success = false
|
||||||
|
body = strings.TrimPrefix(out, ToolErrorPrefix)
|
||||||
|
}
|
||||||
|
m.invokeNotify.Fire(tid, m.name, m.einoAgentName, success, body, err)
|
||||||
|
}()
|
||||||
return runMCPToolInvocation(ctx, m.agent, m.holder, m.name, argumentsInJSON, m.record, m.chunk)
|
return runMCPToolInvocation(ctx, m.agent, m.holder, m.name, argumentsInJSON, m.record, m.chunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package einomcp
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP 桥在每次 InvokableRun 结束时 Fire,
|
||||||
|
// 用于在 ADK 未透出 schema.Tool 事件时仍推送 tool_result、清 pending,避免 UI 卡在「执行中」或迭代末 force-close。
|
||||||
|
type ToolInvokeNotifyHolder struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewToolInvokeNotifyHolder 创建可在 ToolsFromDefinitions 与 run loop 之间共享的 holder。
|
||||||
|
func NewToolInvokeNotifyHolder() *ToolInvokeNotifyHolder {
|
||||||
|
return &ToolInvokeNotifyHolder{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set 由 runEinoADKAgentLoop 在开始消费 iter 之前调用;可多次覆盖(通常仅一次)。
|
||||||
|
func (h *ToolInvokeNotifyHolder) Set(fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.fn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire 由 mcpBridgeTool 在工具调用返回时调用;若尚未 Set 或 toolCallID 为空则忽略。
|
||||||
|
func (h *ToolInvokeNotifyHolder) Fire(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
fn := h.fn
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if fn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn(toolCallID, toolName, einoAgent, success, content, invokeErr)
|
||||||
|
}
|
||||||
+217
-106
@@ -19,6 +19,8 @@ import (
|
|||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/reasoning"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -200,6 +202,14 @@ type ChatAttachment struct {
|
|||||||
ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回)
|
ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatReasoningRequest 对话页「模型推理」意图(仅 Eino 路径消费;原生 agent-loop 忽略)。
|
||||||
|
type ChatReasoningRequest struct {
|
||||||
|
// Mode: default(跟随系统)| off | on | auto
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
// Effort: low | medium | high | max;空表示不指定(由系统默认与各 profile 决定)。
|
||||||
|
Effort string `json:"effort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
@@ -208,10 +218,18 @@ type ChatRequest struct {
|
|||||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||||
|
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &reasoning.ClientIntent{Mode: r.Mode, Effort: r.Effort}
|
||||||
|
}
|
||||||
|
|
||||||
type HITLRequest struct {
|
type HITLRequest struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
@@ -458,6 +476,57 @@ func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedP
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendAssistantMessageNotice 在助手消息末尾追加提示,避免覆盖已生成内容。
|
||||||
|
// 若消息为空则直接写入提示;若已包含相同提示则保持不变。
|
||||||
|
func (h *AgentHandler) appendAssistantMessageNotice(messageID, notice string) error {
|
||||||
|
trimmedNotice := strings.TrimSpace(notice)
|
||||||
|
if strings.TrimSpace(messageID) == "" || trimmedNotice == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := h.db.Exec(
|
||||||
|
`UPDATE messages
|
||||||
|
SET content = CASE
|
||||||
|
WHEN content IS NULL OR TRIM(content) = '' THEN ?
|
||||||
|
WHEN INSTR(content, ?) > 0 THEN content
|
||||||
|
ELSE content || '\n\n' || ?
|
||||||
|
END,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
trimmedNotice,
|
||||||
|
trimmedNotice,
|
||||||
|
trimmedNotice,
|
||||||
|
time.Now(),
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAssistantMessagePartialOnCancel 将取消前已生成的部分回复尽量合并进消息:
|
||||||
|
// - content 为空或仅占位(处理中...)时,直接替换为 partial;
|
||||||
|
// - 已有正文时,仅在尚未包含 partial 时追加,避免丢失与重复。
|
||||||
|
func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial string) error {
|
||||||
|
trimmedPartial := strings.TrimSpace(partial)
|
||||||
|
if strings.TrimSpace(messageID) == "" || trimmedPartial == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := h.db.Exec(
|
||||||
|
`UPDATE messages
|
||||||
|
SET content = CASE
|
||||||
|
WHEN content IS NULL OR TRIM(content) = '' OR TRIM(content) = '处理中...' THEN ?
|
||||||
|
WHEN INSTR(content, ?) > 0 THEN content
|
||||||
|
ELSE content || '\n\n' || ?
|
||||||
|
END,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
trimmedPartial,
|
||||||
|
trimmedPartial,
|
||||||
|
trimmedPartial,
|
||||||
|
time.Now(),
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
@@ -515,14 +584,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
h.logger.Warn("获取历史消息失败", zap.Error(err))
|
h.logger.Warn("获取历史消息失败", zap.Error(err))
|
||||||
agentHistoryMessages = []agent.ChatMessage{}
|
agentHistoryMessages = []agent.ChatMessage{}
|
||||||
} else {
|
} else {
|
||||||
// 将数据库消息转换为Agent消息格式
|
agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
|
||||||
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
|
|
||||||
for _, msg := range historyMessages {
|
|
||||||
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
|
|
||||||
Role: msg.Role,
|
|
||||||
Content: msg.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
|
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -723,9 +785,12 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
progressCallback,
|
progressCallback,
|
||||||
h.agentsMarkdownDir,
|
h.agentsMarkdownDir,
|
||||||
"deep",
|
"deep",
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
if errMA != nil {
|
if errMA != nil {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||||
|
}
|
||||||
errMsg := "执行失败: " + errMA.Error()
|
errMsg := "执行失败: " + errMA.Error()
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||||
@@ -734,17 +799,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
return "", conversationID, errMA
|
return "", conversationID, errMA
|
||||||
}
|
}
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
||||||
if len(resultMA.MCPExecutionIDs) > 0 {
|
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
||||||
jsonData, _ := json.Marshal(resultMA.MCPExecutionIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
_, err = h.db.Exec(
|
|
||||||
"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))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
||||||
@@ -769,17 +825,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
|
|
||||||
// 更新助手消息内容与 MCP 执行 ID(与 stream 一致)
|
// 更新助手消息内容与 MCP 执行 ID(与 stream 一致)
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)); errU != nil {
|
||||||
if len(result.MCPExecutionIDs) > 0 {
|
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
||||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
_, err = h.db.Exec(
|
|
||||||
"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))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil {
|
if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil {
|
||||||
@@ -837,10 +884,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// thinking_stream_*:不逐条落库,按 streamId 聚合,在后续关键事件前补一条可持久化的 thinking
|
// thinking_stream_*(ReAct 等助手正文流)与 reasoning_chain_stream_*(Eino ReasoningContent):
|
||||||
|
// 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。
|
||||||
type thinkingBuf struct {
|
type thinkingBuf struct {
|
||||||
b strings.Builder
|
b strings.Builder
|
||||||
meta map[string]interface{}
|
meta map[string]interface{}
|
||||||
|
persistAs string // "thinking" | "reasoning_chain"
|
||||||
}
|
}
|
||||||
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
||||||
flushedThinking := make(map[string]bool) // streamId -> flushed
|
flushedThinking := make(map[string]bool) // streamId -> flushed
|
||||||
@@ -894,8 +943,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
data[k] = v
|
data[k] = v
|
||||||
}
|
}
|
||||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "thinking", content, data); err != nil {
|
persist := tb.persistAs
|
||||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "thinking"))
|
if persist != "reasoning_chain" {
|
||||||
|
persist = "thinking"
|
||||||
|
}
|
||||||
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, persist, content, data); err != nil {
|
||||||
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", persist))
|
||||||
}
|
}
|
||||||
flushedThinking[sid] = true
|
flushedThinking[sid] = true
|
||||||
}
|
}
|
||||||
@@ -1123,14 +1176,20 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聚合 thinking_stream_*(ReasoningContent),不逐条落库
|
// 聚合 thinking_stream_* / reasoning_chain_stream_*,不逐条落库
|
||||||
if eventType == "thinking_stream_start" {
|
if eventType == "thinking_stream_start" || eventType == "reasoning_chain_stream_start" {
|
||||||
|
persistAs := "thinking"
|
||||||
|
if eventType == "reasoning_chain_stream_start" {
|
||||||
|
persistAs = "reasoning_chain"
|
||||||
|
}
|
||||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||||
tb := thinkingStreams[sid]
|
tb := thinkingStreams[sid]
|
||||||
if tb == nil {
|
if tb == nil {
|
||||||
tb = &thinkingBuf{meta: map[string]interface{}{}}
|
tb = &thinkingBuf{meta: map[string]interface{}{}, persistAs: persistAs}
|
||||||
thinkingStreams[sid] = tb
|
thinkingStreams[sid] = tb
|
||||||
|
} else {
|
||||||
|
tb.persistAs = persistAs
|
||||||
}
|
}
|
||||||
// 记录元信息(source/einoAgent/einoRole/iteration 等)
|
// 记录元信息(source/einoAgent/einoRole/iteration 等)
|
||||||
for k, v := range dataMap {
|
for k, v := range dataMap {
|
||||||
@@ -1140,15 +1199,21 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if eventType == "thinking_stream_delta" {
|
if eventType == "thinking_stream_delta" || eventType == "reasoning_chain_stream_delta" {
|
||||||
|
persistAs := "thinking"
|
||||||
|
if eventType == "reasoning_chain_stream_delta" {
|
||||||
|
persistAs = "reasoning_chain"
|
||||||
|
}
|
||||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||||
tb := thinkingStreams[sid]
|
tb := thinkingStreams[sid]
|
||||||
if tb == nil {
|
if tb == nil {
|
||||||
tb = &thinkingBuf{meta: map[string]interface{}{}}
|
tb = &thinkingBuf{meta: map[string]interface{}{}, persistAs: persistAs}
|
||||||
thinkingStreams[sid] = tb
|
thinkingStreams[sid] = tb
|
||||||
|
} else if tb.persistAs == "" {
|
||||||
|
tb.persistAs = persistAs
|
||||||
}
|
}
|
||||||
// delta 片段直接拼接;message 本身就是 reasoning content
|
// delta 片段直接拼接
|
||||||
tb.b.WriteString(message)
|
tb.b.WriteString(message)
|
||||||
// 有时 delta 先到 start 未到,补充元信息
|
// 有时 delta 先到 start 未到,补充元信息
|
||||||
for k, v := range dataMap {
|
for k, v := range dataMap {
|
||||||
@@ -1159,10 +1224,9 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当 Agent 同时发送 thinking_stream_* 和 thinking(带同一 streamId)时,
|
// 当 Agent 同时发送 *_stream_* 与同名 streamId 的 thinking/reasoning_chain 时,
|
||||||
// thinking_stream_* 已经会在 flushThinkingStreams() 聚合落库;
|
// 流式聚合已会在 flushThinkingStreams() 落库;此处跳过逐条重复。
|
||||||
// 这里跳过同 streamId 的 thinking,避免 processDetails 双份展示。
|
if eventType == "thinking" || eventType == "reasoning_chain" {
|
||||||
if eventType == "thinking" {
|
|
||||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||||
if tb, exists := thinkingStreams[sid]; exists && tb != nil {
|
if tb, exists := thinkingStreams[sid]; exists && tb != nil {
|
||||||
@@ -1191,7 +1255,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
if eventType == "tool_result" {
|
if eventType == "tool_result" {
|
||||||
discardPlanningIfEchoesToolResult(&respPlan, data)
|
discardPlanningIfEchoesToolResult(&respPlan, data)
|
||||||
}
|
}
|
||||||
// 在关键过程事件落库前,先把「规划中」与 thinking_stream 落库
|
// 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
|
||||||
flushResponsePlan()
|
flushResponsePlan()
|
||||||
flushThinkingStreams()
|
flushThinkingStreams()
|
||||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
||||||
@@ -1373,14 +1437,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
h.logger.Warn("获取历史消息失败", zap.Error(err))
|
h.logger.Warn("获取历史消息失败", zap.Error(err))
|
||||||
agentHistoryMessages = []agent.ChatMessage{}
|
agentHistoryMessages = []agent.ChatMessage{}
|
||||||
} else {
|
} else {
|
||||||
// 将数据库消息转换为Agent消息格式
|
agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
|
||||||
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
|
|
||||||
for _, msg := range historyMessages {
|
|
||||||
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
|
|
||||||
Role: msg.Role,
|
|
||||||
Content: msg.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
|
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1493,6 +1550,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
defer timeoutCancel()
|
defer timeoutCancel()
|
||||||
defer cancelWithCause(nil)
|
defer cancelWithCause(nil)
|
||||||
|
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
|
|
||||||
@@ -1568,11 +1627,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
if _, updateErr := h.db.Exec(
|
if result != nil {
|
||||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
if updateErr := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); updateErr != nil {
|
||||||
cancelMsg,
|
h.logger.Warn("合并取消前的部分回复失败", zap.Error(updateErr))
|
||||||
time.Now(), assistantMessageID,
|
}
|
||||||
); updateErr != nil {
|
}
|
||||||
|
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
|
||||||
h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr))
|
h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr))
|
||||||
}
|
}
|
||||||
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||||
@@ -1670,20 +1730,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新助手消息内容
|
// 更新助手消息内容
|
||||||
if assistantMsg != nil {
|
if assistantMsg != nil {
|
||||||
_, err = h.db.Exec(
|
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)); errU != nil {
|
||||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
h.logger.Error("更新助手消息失败", zap.Error(errU))
|
||||||
result.Response,
|
|
||||||
func() string {
|
|
||||||
if len(result.MCPExecutionIDs) > 0 {
|
|
||||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
|
||||||
return string(jsonData)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}(),
|
|
||||||
time.Now(), assistantMessageID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("更新助手消息失败", zap.Error(err))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果之前创建失败,现在创建
|
// 如果之前创建失败,现在创建
|
||||||
@@ -1717,6 +1765,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
ConversationID string `json:"conversationId" binding:"required"`
|
ConversationID string `json:"conversationId" binding:"required"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
ContinueAfter bool `json:"continueAfter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -1724,7 +1774,64 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := h.tasks.CancelTask(req.ConversationID, ErrTaskCancelled)
|
if req.ContinueAfter {
|
||||||
|
if h.tasks.GetTask(req.ConversationID) == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到正在执行的任务"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
execID := h.tasks.ActiveMCPExecutionID(req.ConversationID)
|
||||||
|
note := strings.TrimSpace(req.Reason)
|
||||||
|
if execID != "" {
|
||||||
|
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 != "",
|
||||||
|
"continueWithoutTool": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 无进行中的 MCP 工具(模型纯推理/流式输出阶段):取消当前上下文并由 Eino 流式处理器合并用户补充后自动续跑。
|
||||||
|
h.tasks.SetInterruptContinueNote(req.ConversationID, note)
|
||||||
|
ok, err := h.tasks.CancelTask(req.ConversationID, multiagent.ErrInterruptContinue)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("中断并继续(无工具)失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到正在执行的任务"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("对话页中断并继续(无 MCP 工具,将自动续跑)",
|
||||||
|
zap.String("conversationId", req.ConversationID),
|
||||||
|
zap.Bool("hasNote", note != ""),
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "interrupt_continue_scheduled",
|
||||||
|
"conversationId": req.ConversationID,
|
||||||
|
"message": "已请求暂停当前推理;用户补充将合并到上下文并自动继续执行(无需整轮停止)。",
|
||||||
|
"continueAfter": true,
|
||||||
|
"interruptWithNote": note != "",
|
||||||
|
"continueWithoutTool": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cause error = ErrTaskCancelled
|
||||||
|
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
|
||||||
|
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("取消任务失败", zap.Error(err))
|
h.logger.Error("取消任务失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -1737,9 +1844,11 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "cancelling",
|
"status": "cancelling",
|
||||||
"conversationId": req.ConversationID,
|
"conversationId": req.ConversationID,
|
||||||
"message": "已提交取消请求,任务将在当前步骤完成后停止。",
|
"message": msg,
|
||||||
|
"continueAfter": false,
|
||||||
|
"interruptWithNote": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2517,6 +2626,8 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
|
|
||||||
// 创建进度回调函数:写 DB + 镜像到 task-events,支持刷新后继续流式展示。
|
// 创建进度回调函数:写 DB + 镜像到 task-events,支持刷新后继续流式展示。
|
||||||
progressCallback = h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
progressCallback = h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
|
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
|
||||||
|
|
||||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||||
useBatchMulti := false
|
useBatchMulti := false
|
||||||
@@ -2544,19 +2655,19 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
var runErr error
|
var runErr error
|
||||||
switch {
|
switch {
|
||||||
case useBatchMulti:
|
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)
|
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil)
|
||||||
case useEinoSingle:
|
case useEinoSingle:
|
||||||
if h.config == nil {
|
if h.config == nil {
|
||||||
runErr = fmt.Errorf("服务器配置未加载")
|
runErr = fmt.Errorf("服务器配置未加载")
|
||||||
} else {
|
} else {
|
||||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback)
|
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
|
result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if useRunResult {
|
if useRunResult && shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||||
}
|
}
|
||||||
// 检查是否是取消错误
|
// 检查是否是取消错误
|
||||||
@@ -2594,11 +2705,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
}
|
}
|
||||||
// 更新助手消息内容
|
// 更新助手消息内容
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
if _, updateErr := h.db.Exec(
|
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
|
||||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
|
||||||
cancelMsg,
|
|
||||||
time.Now(), assistantMessageID,
|
|
||||||
); updateErr != nil {
|
|
||||||
h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||||
}
|
}
|
||||||
// 保存取消详情到数据库
|
// 保存取消详情到数据库
|
||||||
@@ -2612,16 +2719,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
|
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 保存代理轨迹(如果存在)
|
|
||||||
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
|
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
|
||||||
h.logger.Warn("保存取消任务的代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
|
||||||
}
|
|
||||||
} else if useRunResult && resultMA != nil && (resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "") {
|
|
||||||
if err := h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput); err != nil {
|
|
||||||
h.logger.Warn("保存取消任务的代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
|
||||||
} else {
|
} else {
|
||||||
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
|
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
|
||||||
@@ -2662,17 +2759,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
|
|
||||||
// 更新助手消息内容
|
// 更新助手消息内容
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
||||||
if len(mcpIDs) > 0 {
|
|
||||||
jsonData, _ := json.Marshal(mcpIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
if _, updateErr := h.db.Exec(
|
|
||||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
|
||||||
resText,
|
|
||||||
mcpIDsJSON,
|
|
||||||
time.Now(), assistantMessageID,
|
|
||||||
); updateErr != nil {
|
|
||||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||||
// 如果更新失败,尝试创建新消息
|
// 如果更新失败,尝试创建新消息
|
||||||
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||||
@@ -2764,6 +2851,10 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
|
|||||||
if content, ok := msgMap["content"].(string); ok {
|
if content, ok := msgMap["content"].(string); ok {
|
||||||
msg.Content = content
|
msg.Content = content
|
||||||
}
|
}
|
||||||
|
// DeepSeek 思考模式:含工具调用的 assistant 须在后续请求中回传 reasoning_content
|
||||||
|
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
|
||||||
|
msg.ReasoningContent = rc
|
||||||
|
}
|
||||||
|
|
||||||
// 解析tool_calls(如果存在)
|
// 解析tool_calls(如果存在)
|
||||||
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
|
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
|
||||||
@@ -2819,6 +2910,11 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
|
|||||||
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
|
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
|
||||||
msg.ToolCallID = toolCallID
|
msg.ToolCallID = toolCallID
|
||||||
}
|
}
|
||||||
|
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
|
||||||
|
msg.ToolName = strings.TrimSpace(tn)
|
||||||
|
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
|
||||||
|
msg.ToolName = strings.TrimSpace(tn)
|
||||||
|
}
|
||||||
|
|
||||||
agentMessages = append(agentMessages, msg)
|
agentMessages = append(agentMessages, msg)
|
||||||
}
|
}
|
||||||
@@ -2864,3 +2960,18 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
|
|||||||
)
|
)
|
||||||
return agentMessages, nil
|
return agentMessages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbMessagesToAgentChatMessages maps DB rows to agent ChatMessage for history fallback
|
||||||
|
// (includes reasoning_content for DeepSeek thinking + tool replay).
|
||||||
|
func dbMessagesToAgentChatMessages(msgs []database.Message) []agent.ChatMessage {
|
||||||
|
out := make([]agent.ChatMessage, 0, len(msgs))
|
||||||
|
for i := range msgs {
|
||||||
|
m := msgs[i]
|
||||||
|
out = append(out, agent.ChatMessage{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: m.Content,
|
||||||
|
ReasoningContent: m.ReasoningContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -1312,6 +1312,19 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
|||||||
if cfg.MaxTotalTokens > 0 {
|
if cfg.MaxTotalTokens > 0 {
|
||||||
setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens)
|
setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens)
|
||||||
}
|
}
|
||||||
|
rn := ensureMap(openaiNode, "reasoning")
|
||||||
|
if strings.TrimSpace(cfg.Reasoning.Mode) != "" {
|
||||||
|
setStringInMap(rn, "mode", cfg.Reasoning.Mode)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Reasoning.Effort) != "" {
|
||||||
|
setStringInMap(rn, "effort", cfg.Reasoning.Effort)
|
||||||
|
}
|
||||||
|
if cfg.Reasoning.AllowClientReasoning != nil {
|
||||||
|
setBoolInMap(rn, "allow_client_reasoning", *cfg.Reasoning.AllowClientReasoning)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Reasoning.Profile) != "" {
|
||||||
|
setStringInMap(rn, "profile", cfg.Reasoning.Profile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
|
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -43,8 +44,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
var sseWriteMu sync.Mutex
|
var sseWriteMu sync.Mutex
|
||||||
var ssePublishConversationID string
|
var ssePublishConversationID string
|
||||||
sendEvent := func(eventType, message string, data interface{}) {
|
sendEvent := func(eventType, message string, data interface{}) {
|
||||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
if eventType == "error" && baseCtx != nil {
|
||||||
return
|
cause := context.Cause(baseCtx)
|
||||||
|
if errors.Is(cause, ErrTaskCancelled) || errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||||
b, errMarshal := json.Marshal(ev)
|
b, errMarshal := json.Marshal(ev)
|
||||||
@@ -114,36 +118,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cancelWithCause context.CancelCauseFunc
|
var cancelWithCause context.CancelCauseFunc
|
||||||
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
curFinalMessage := prep.FinalMessage
|
||||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
curHistory := prep.History
|
||||||
defer timeoutCancel()
|
roleTools := prep.RoleTools
|
||||||
defer cancelWithCause(nil)
|
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
|
||||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
|
||||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
|
||||||
var errorMsg string
|
|
||||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
|
||||||
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
|
|
||||||
sendEvent("error", errorMsg, map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"errorType": "task_already_running",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
errorMsg = "❌ 无法启动任务: " + err.Error()
|
|
||||||
sendEvent("error", errorMsg, nil)
|
|
||||||
}
|
|
||||||
if assistantMessageID != "" {
|
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
|
|
||||||
}
|
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
taskStatus := "completed"
|
taskStatus := "completed"
|
||||||
defer h.tasks.FinishTask(conversationID, taskStatus)
|
// 仅在成功 StartTask 后再 FinishTask。若 StartTask 因 ErrTaskAlreadyRunning 失败仍 defer FinishTask,
|
||||||
|
// 会误删其他连接上正在运行的同会话任务,导致「第一次拦截、第二次却放行」。
|
||||||
|
taskOwned := false
|
||||||
|
defer func() {
|
||||||
|
if taskOwned {
|
||||||
|
h.tasks.FinishTask(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
sendEvent("progress", "正在启动 Eino ADK 单代理(ChatModelAgent)...", map[string]interface{}{
|
sendEvent("progress", "正在启动 Eino ADK 单代理(ChatModelAgent)...", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -161,28 +148,112 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, runErr := multiagent.RunEinoSingleChatModelAgent(
|
var result *multiagent.RunResult
|
||||||
taskCtx,
|
var runErr error
|
||||||
h.config,
|
|
||||||
&h.config.MultiAgent,
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
h.agent,
|
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
h.logger,
|
|
||||||
conversationID,
|
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||||
prep.FinalMessage,
|
var errorMsg string
|
||||||
prep.History,
|
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||||
prep.RoleTools,
|
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
|
||||||
progressCallback,
|
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
|
||||||
|
|
||||||
|
var cumulativeMCPExecutionIDs []string
|
||||||
|
|
||||||
|
for {
|
||||||
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
|
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
|
||||||
|
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||||
|
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||||
|
})
|
||||||
|
|
||||||
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
|
taskCtxLoop,
|
||||||
|
h.config,
|
||||||
|
&h.config.MultiAgent,
|
||||||
|
h.agent,
|
||||||
|
h.logger,
|
||||||
|
conversationID,
|
||||||
|
curFinalMessage,
|
||||||
|
curHistory,
|
||||||
|
roleTools,
|
||||||
|
progressCallback,
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
|
)
|
||||||
|
timeoutCancel()
|
||||||
|
|
||||||
|
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||||
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
if runErr != nil {
|
|
||||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
|
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
|
note := h.tasks.TakeInterruptContinueNote(conversationID)
|
||||||
|
icSummary := interruptContinueTimelineSummary(note)
|
||||||
|
progressCallback("user_interrupt_continue", icSummary, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"rawReason": strings.TrimSpace(note),
|
||||||
|
"emptyReason": strings.TrimSpace(note) == "",
|
||||||
|
"kind": "no_active_mcp_tool",
|
||||||
|
})
|
||||||
|
inject := formatInterruptContinueUserMessage(note)
|
||||||
|
// 不写入 messages 表为 user 气泡:避免主对话流出现大段模板;说明已由 user_interrupt_continue 记入助手 process_details(迭代详情)。
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
curHistory = hist
|
||||||
|
}
|
||||||
|
curFinalMessage = inject
|
||||||
|
sendEvent("progress", "已合并用户补充与最新轨迹,正在继续推理…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "interrupt_continue",
|
||||||
|
})
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
if errors.Is(cause, ErrTaskCancelled) {
|
if errors.Is(cause, ErrTaskCancelled) {
|
||||||
taskStatus = "cancelled"
|
taskStatus = "cancelled"
|
||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", cancelMsg, time.Now(), assistantMessageID)
|
if result != nil {
|
||||||
|
if err := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); err != nil {
|
||||||
|
h.logger.Warn("合并取消前的部分回复失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
|
||||||
|
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||||
}
|
}
|
||||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||||
@@ -227,18 +298,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||||
if len(result.MCPExecutionIDs) > 0 {
|
|
||||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
_, _ = h.db.Exec(
|
|
||||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
|
||||||
result.Response,
|
|
||||||
mcpIDsJSON,
|
|
||||||
time.Now(),
|
|
||||||
assistantMessageID,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
@@ -248,7 +308,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
sendEvent("response", result.Response, map[string]interface{}{
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
@@ -306,26 +366,18 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
prep.History,
|
prep.History,
|
||||||
prep.RoleTools,
|
prep.RoleTools,
|
||||||
progressCallback,
|
progressCallback,
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
)
|
)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
if prep.AssistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||||
if len(result.MCPExecutionIDs) > 0 {
|
|
||||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
_, _ = h.db.Exec(
|
|
||||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
|
||||||
result.Response,
|
|
||||||
mcpIDsJSON,
|
|
||||||
time.Now(),
|
|
||||||
prep.AssistantMessageID,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -245,6 +248,37 @@ func (h *MonitorHandler) GetExecution(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelExecution 手动取消进行中的 MCP 工具调用(仅取消该次 tools/call 的上下文,不停止整条 Agent / 迭代任务)
|
||||||
|
// 请求体可选 JSON:{ "note": "用户说明" },将与工具已返回输出合并交给模型(含「用户终止说明」标题块,与命令行原文区分)。
|
||||||
|
func (h *MonitorHandler) CancelExecution(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
dec := json.NewDecoder(c.Request.Body)
|
||||||
|
var body struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON,例如 {\"note\":\"说明\"},可为空对象"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note = strings.TrimSpace(body.Note)
|
||||||
|
if h.mcpServer.CancelToolExecutionWithNote(id, note) {
|
||||||
|
h.logger.Info("已请求取消 MCP 工具执行", zap.String("executionId", id), zap.String("source", "internal"), zap.Bool("hasNote", note != ""))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "已发送终止信号", "executionId": id})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.externalMCPMgr != nil && h.externalMCPMgr.CancelToolExecutionWithNote(id, note) {
|
||||||
|
h.logger.Info("已请求取消 MCP 工具执行", zap.String("executionId", id), zap.String("source", "external"), zap.Bool("hasNote", note != ""))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "已发送终止信号", "executionId": id})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未找到进行中的工具执行,或该任务已结束"})
|
||||||
|
}
|
||||||
|
|
||||||
// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求)
|
// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求)
|
||||||
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -317,7 +351,7 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
|||||||
totalCalls := 1
|
totalCalls := 1
|
||||||
successCalls := 0
|
successCalls := 0
|
||||||
failedCalls := 0
|
failedCalls := 0
|
||||||
if exec.Status == "failed" {
|
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||||
failedCalls = 1
|
failedCalls = 1
|
||||||
} else if exec.Status == "completed" {
|
} else if exec.Status == "completed" {
|
||||||
successCalls = 1
|
successCalls = 1
|
||||||
@@ -381,7 +415,7 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
|
|||||||
|
|
||||||
stats := toolStats[exec.ToolName]
|
stats := toolStats[exec.ToolName]
|
||||||
stats.totalCalls++
|
stats.totalCalls++
|
||||||
if exec.Status == "failed" {
|
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||||
stats.failedCalls++
|
stats.failedCalls++
|
||||||
} else if exec.Status == "completed" {
|
} else if exec.Status == "completed" {
|
||||||
stats.successCalls++
|
stats.successCalls++
|
||||||
|
|||||||
+160
-60
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -60,8 +61,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
sendEvent := func(eventType, message string, data interface{}) {
|
sendEvent := func(eventType, message string, data interface{}) {
|
||||||
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
|
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
|
||||||
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
|
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
|
||||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
if eventType == "error" && baseCtx != nil {
|
||||||
return
|
cause := context.Cause(baseCtx)
|
||||||
|
if errors.Is(cause, ErrTaskCancelled) || errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||||
b, errMarshal := json.Marshal(ev)
|
b, errMarshal := json.Marshal(ev)
|
||||||
@@ -130,15 +134,35 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
var cancelWithCause context.CancelCauseFunc
|
||||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
curFinalMessage := prep.FinalMessage
|
||||||
defer timeoutCancel()
|
curHistory := prep.History
|
||||||
defer cancelWithCause(nil)
|
roleTools := prep.RoleTools
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
orch := strings.TrimSpace(req.Orchestration)
|
||||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
|
||||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
taskStatus := "completed"
|
||||||
|
// 仅在成功 StartTask 后再 FinishTask;避免「任务已存在」分支 return 时误删正在运行的同会话任务。
|
||||||
|
taskOwned := false
|
||||||
|
defer func() {
|
||||||
|
if taskOwned {
|
||||||
|
h.tasks.FinishTask(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
stopKeepalive := make(chan struct{})
|
||||||
|
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
||||||
|
defer close(stopKeepalive)
|
||||||
|
|
||||||
|
var result *multiagent.RunResult
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
|
||||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||||
var errorMsg string
|
var errorMsg string
|
||||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||||
@@ -155,44 +179,93 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
|
||||||
}
|
}
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
taskOwned = true
|
||||||
|
|
||||||
taskStatus := "completed"
|
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
|
||||||
defer h.tasks.FinishTask(conversationID, taskStatus)
|
var cumulativeMCPExecutionIDs []string
|
||||||
|
|
||||||
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{
|
for {
|
||||||
"conversationId": conversationID,
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
})
|
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
|
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
|
||||||
|
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||||
|
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||||
|
})
|
||||||
|
|
||||||
stopKeepalive := make(chan struct{})
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
taskCtxLoop,
|
||||||
defer close(stopKeepalive)
|
h.config,
|
||||||
|
&h.config.MultiAgent,
|
||||||
|
h.agent,
|
||||||
|
h.logger,
|
||||||
|
conversationID,
|
||||||
|
curFinalMessage,
|
||||||
|
curHistory,
|
||||||
|
roleTools,
|
||||||
|
progressCallback,
|
||||||
|
h.agentsMarkdownDir,
|
||||||
|
orch,
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
|
)
|
||||||
|
timeoutCancel()
|
||||||
|
|
||||||
result, runErr := multiagent.RunDeepAgent(
|
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||||
taskCtx,
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
h.config,
|
}
|
||||||
&h.config.MultiAgent,
|
|
||||||
h.agent,
|
if runErr == nil {
|
||||||
h.logger,
|
break
|
||||||
conversationID,
|
}
|
||||||
prep.FinalMessage,
|
|
||||||
prep.History,
|
|
||||||
prep.RoleTools,
|
|
||||||
progressCallback,
|
|
||||||
h.agentsMarkdownDir,
|
|
||||||
strings.TrimSpace(req.Orchestration),
|
|
||||||
)
|
|
||||||
|
|
||||||
if runErr != nil {
|
|
||||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
|
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
|
note := h.tasks.TakeInterruptContinueNote(conversationID)
|
||||||
|
icSummary := interruptContinueTimelineSummary(note)
|
||||||
|
progressCallback("user_interrupt_continue", icSummary, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"rawReason": strings.TrimSpace(note),
|
||||||
|
"emptyReason": strings.TrimSpace(note) == "",
|
||||||
|
"kind": "no_active_mcp_tool",
|
||||||
|
})
|
||||||
|
inject := formatInterruptContinueUserMessage(note)
|
||||||
|
// 不写入 messages 表为 user 气泡:避免主对话流出现大段模板;说明已由 user_interrupt_continue 记入助手 process_details(迭代详情)。
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
curHistory = hist
|
||||||
|
}
|
||||||
|
curFinalMessage = inject
|
||||||
|
sendEvent("progress", "已合并用户补充与最新轨迹,正在继续推理…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "interrupt_continue",
|
||||||
|
})
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
if errors.Is(cause, ErrTaskCancelled) {
|
if errors.Is(cause, ErrTaskCancelled) {
|
||||||
taskStatus = "cancelled"
|
taskStatus = "cancelled"
|
||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", cancelMsg, time.Now(), assistantMessageID)
|
if result != nil {
|
||||||
|
if err := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); err != nil {
|
||||||
|
h.logger.Warn("合并取消前的部分回复失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
|
||||||
|
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||||
}
|
}
|
||||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||||
@@ -237,18 +310,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||||
if len(result.MCPExecutionIDs) > 0 {
|
|
||||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
_, _ = h.db.Exec(
|
|
||||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
|
||||||
result.Response,
|
|
||||||
mcpIDsJSON,
|
|
||||||
time.Now(),
|
|
||||||
assistantMessageID,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
@@ -262,7 +324,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
sendEvent("response", result.Response, map[string]interface{}{
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_" + effectiveOrch,
|
"agentMode": "eino_" + effectiveOrch,
|
||||||
@@ -318,9 +380,12 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
progressCallback,
|
progressCallback,
|
||||||
h.agentsMarkdownDir,
|
h.agentsMarkdownDir,
|
||||||
strings.TrimSpace(req.Orchestration),
|
strings.TrimSpace(req.Orchestration),
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
)
|
)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||||
errMsg := "执行失败: " + runErr.Error()
|
errMsg := "执行失败: " + runErr.Error()
|
||||||
if prep.AssistantMessageID != "" {
|
if prep.AssistantMessageID != "" {
|
||||||
@@ -331,18 +396,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
if prep.AssistantMessageID != "" {
|
||||||
mcpIDsJSON := ""
|
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||||
if len(result.MCPExecutionIDs) > 0 {
|
|
||||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
|
||||||
mcpIDsJSON = string(jsonData)
|
|
||||||
}
|
|
||||||
_, _ = h.db.Exec(
|
|
||||||
"UPDATE messages SET content = ?, mcp_execution_ids = ?, updated_at = ? WHERE id = ?",
|
|
||||||
result.Response,
|
|
||||||
mcpIDsJSON,
|
|
||||||
time.Now(),
|
|
||||||
prep.AssistantMessageID,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
@@ -372,6 +426,52 @@ func (h *AgentHandler) persistEinoAgentTraceForResume(conversationID string, res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeMCPExecutionIDLists 去重合并多段 Run 的 MCP execution id(顺序:先 dst 后 more)。
|
||||||
|
func mergeMCPExecutionIDLists(dst []string, more []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(dst)+len(more))
|
||||||
|
out := make([]string, 0, len(dst)+len(more))
|
||||||
|
add := func(ids []string) {
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(dst)
|
||||||
|
add(more)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// interruptContinueTimelineSummary 时间线 / process_details 中展示的简短正文(完整模板已写入另一条用户消息)。
|
||||||
|
func interruptContinueTimelineSummary(note string) string {
|
||||||
|
note = strings.TrimSpace(note)
|
||||||
|
if note == "" {
|
||||||
|
return "用户选择「中断并继续」,未填写说明;已按默认渗透补充模板合并上下文并续跑。"
|
||||||
|
}
|
||||||
|
return "用户中断说明(原文):\n\n" + note
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatInterruptContinueUserMessage 将「中断并继续」弹窗中的说明格式化为新一轮 user 消息(渗透场景下强调路径补充与端口复扫)。
|
||||||
|
func formatInterruptContinueUserMessage(note string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("【用户补充 / 中断后继续】\n")
|
||||||
|
if s := strings.TrimSpace(note); s != "" {
|
||||||
|
b.WriteString(s)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString("【请在本轮落实】\n")
|
||||||
|
b.WriteString("- 将用户提供的接口路径、参数、业务变化纳入后续测试与推理。\n")
|
||||||
|
b.WriteString("- 若资产或目标信息有更新,请对目标重新执行端口/服务探测,再基于新结果规划下一步。\n")
|
||||||
|
b.WriteString("- 在已有轨迹基础上推进,避免无意义重复已完成的步骤。\n")
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
func multiAgentHTTPErrorStatus(err error) (int, string) {
|
func multiAgentHTTPErrorStatus(err error) (int, string) {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -55,13 +55,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
if getErr != nil {
|
if getErr != nil {
|
||||||
agentHistoryMessages = []agent.ChatMessage{}
|
agentHistoryMessages = []agent.ChatMessage{}
|
||||||
} else {
|
} else {
|
||||||
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
|
agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
|
||||||
for _, msg := range historyMessages {
|
|
||||||
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
|
|
||||||
Role: msg.Role,
|
|
||||||
Content: msg.Content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -461,6 +461,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "对话ID",
|
"description": "对话ID",
|
||||||
},
|
},
|
||||||
|
"reason": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选。与 MCP 监控页「终止并说明」一致:非空时合并进当前工具返回给模型的文本(含 USER INTERRUPT NOTE 块)",
|
||||||
|
},
|
||||||
|
"continueAfter": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "为 true 时仅终止当前进行中的 MCP 工具调用(不取消整轮任务);须已有工具在执行,否则 400",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"AgentTask": map[string]interface{}{
|
"AgentTask": map[string]interface{}{
|
||||||
@@ -3318,6 +3326,55 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/monitor/execution/{id}/cancel": map[string]interface{}{
|
||||||
|
"post": map[string]interface{}{
|
||||||
|
"tags": []string{"监控"},
|
||||||
|
"summary": "取消进行中的工具执行",
|
||||||
|
"description": "对当前进程内正在执行的 MCP 工具调用发送 context 取消信号;上层对话/多步任务可继续。若执行已结束或未在本进程内运行则返回 404。",
|
||||||
|
"operationId": "cancelExecution",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "执行ID",
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"requestBody": map[string]interface{}{
|
||||||
|
"required": false,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"note": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选。非空时与工具已返回输出合并交给大模型,并带有「用户终止说明」标题块以便与命令行原文区分",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "已发送终止信号",
|
||||||
|
},
|
||||||
|
"400": map[string]interface{}{
|
||||||
|
"description": "请求体不是合法 JSON",
|
||||||
|
},
|
||||||
|
"404": map[string]interface{}{
|
||||||
|
"description": "未找到进行中的工具执行",
|
||||||
|
},
|
||||||
|
"401": map[string]interface{}{
|
||||||
|
"description": "未授权",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/monitor/executions": map[string]interface{}{
|
"/api/monitor/executions": map[string]interface{}{
|
||||||
"delete": map[string]interface{}{
|
"delete": map[string]interface{}{
|
||||||
"tags": []string{"监控"},
|
"tags": []string{"监控"},
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrTaskCancelled 用户取消任务的错误
|
// ErrTaskCancelled 用户取消任务的错误
|
||||||
@@ -13,6 +16,13 @@ var ErrTaskCancelled = errors.New("agent task cancelled by user")
|
|||||||
// ErrTaskAlreadyRunning 会话已有任务正在执行
|
// ErrTaskAlreadyRunning 会话已有任务正在执行
|
||||||
var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation")
|
var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation")
|
||||||
|
|
||||||
|
// shouldPersistEinoAgentTraceAfterRunError:Eino 相关 Run 非成功返回时,是否仍写入 last_react_* 供下轮 loadHistoryFromAgentTrace。
|
||||||
|
// 当前策略:无论正常结束、异常结束或用户主动停止,都尽量保留最后可用轨迹,
|
||||||
|
// 以便在同一会话继续时可基于原始上下文续跑,而不是回退到仅消息文本历史。
|
||||||
|
func shouldPersistEinoAgentTraceAfterRunError(baseCtx context.Context) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// AgentTask 描述正在运行的Agent任务
|
// AgentTask 描述正在运行的Agent任务
|
||||||
type AgentTask struct {
|
type AgentTask struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
@@ -21,9 +31,103 @@ type AgentTask struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
||||||
|
|
||||||
|
// ActiveMCPExecutionID 当前正在执行的 MCP 工具 executionId(仅内存,供「中断并继续」= 仅掐当前工具)
|
||||||
|
ActiveMCPExecutionID string `json:"-"`
|
||||||
|
|
||||||
|
// InterruptContinueNote 无 MCP 时「中断并继续」由用户在弹窗中填写的补充说明(Cancel 前写入,续跑轮次读取后清空)
|
||||||
|
InterruptContinueNote string `json:"-"`
|
||||||
|
|
||||||
cancel func(error)
|
cancel func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterRunningTool 实现 mcp.ToolRunRegistry:工具开始时登记本会话当前 executionId。
|
||||||
|
func (m *AgentTaskManager) RegisterRunningTool(conversationID, executionID string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
if conversationID == "" || executionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
t.ActiveMCPExecutionID = executionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterRunningTool 工具结束时清除登记(仅当 id 仍匹配时清除,避免并发串单)。
|
||||||
|
func (m *AgentTaskManager) UnregisterRunningTool(conversationID, executionID string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
if conversationID == "" || executionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
if t.ActiveMCPExecutionID == executionID {
|
||||||
|
t.ActiveMCPExecutionID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInterruptContinueNote 在发起 ErrInterruptContinue 取消前写入用户补充说明(仅内存)。
|
||||||
|
func (m *AgentTaskManager) SetInterruptContinueNote(conversationID, note string) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
t.InterruptContinueNote = note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeInterruptContinueNote 读取并清空补充说明(续跑开始时调用一次)。
|
||||||
|
func (m *AgentTaskManager) TakeInterruptContinueNote(conversationID string) string {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
n := t.InterruptContinueNote
|
||||||
|
t.InterruptContinueNote = ""
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindTaskCancel 在同一运行任务内替换与 context 绑定的 cancel 函数(用于中断后继续时换新 baseCtx)。
|
||||||
|
func (m *AgentTaskManager) BindTaskCancel(conversationID string, cancel context.CancelCauseFunc) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" || cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
t.cancel = func(err error) {
|
||||||
|
cancel(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
|
||||||
|
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if t, ok := m.tasks[conversationID]; ok && t != nil {
|
||||||
|
return strings.TrimSpace(t.ActiveMCPExecutionID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// CompletedTask 已完成的任务(用于历史记录)
|
// CompletedTask 已完成的任务(用于历史记录)
|
||||||
type CompletedTask struct {
|
type CompletedTask struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
@@ -155,8 +259,16 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
task.Status = "cancelling"
|
// ErrInterruptContinue:仅掐断当前推理步骤,随后由处理器续跑,不进入长时间「取消中」态。
|
||||||
task.CancellingAt = time.Now()
|
if cause != nil && errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
|
task.Status = "running"
|
||||||
|
} else {
|
||||||
|
task.Status = "cancelling"
|
||||||
|
task.CancellingAt = time.Now()
|
||||||
|
}
|
||||||
|
if cause != nil && errors.Is(cause, ErrTaskCancelled) {
|
||||||
|
task.InterruptContinueNote = ""
|
||||||
|
}
|
||||||
cancel := task.cancel
|
cancel := task.cancel
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type ExternalMCPManager struct {
|
|||||||
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
||||||
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
runningCancels map[string]context.CancelFunc
|
||||||
|
abortUserNotes map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExternalMCPManager 创建外部MCP管理器
|
// NewExternalMCPManager 创建外部MCP管理器
|
||||||
@@ -42,16 +44,18 @@ func NewExternalMCPManager(logger *zap.Logger) *ExternalMCPManager {
|
|||||||
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
|
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
|
||||||
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
|
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
|
||||||
manager := &ExternalMCPManager{
|
manager := &ExternalMCPManager{
|
||||||
clients: make(map[string]ExternalMCPClient),
|
clients: make(map[string]ExternalMCPClient),
|
||||||
configs: make(map[string]config.ExternalMCPServerConfig),
|
configs: make(map[string]config.ExternalMCPServerConfig),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
executions: make(map[string]*ToolExecution),
|
executions: make(map[string]*ToolExecution),
|
||||||
stats: make(map[string]*ToolStats),
|
stats: make(map[string]*ToolStats),
|
||||||
errors: make(map[string]string),
|
errors: make(map[string]string),
|
||||||
toolCounts: make(map[string]int),
|
toolCounts: make(map[string]int),
|
||||||
toolCache: make(map[string][]Tool),
|
toolCache: make(map[string][]Tool),
|
||||||
stopRefresh: make(chan struct{}),
|
stopRefresh: make(chan struct{}),
|
||||||
|
runningCancels: make(map[string]context.CancelFunc),
|
||||||
|
abortUserNotes: make(map[string]string),
|
||||||
}
|
}
|
||||||
// 启动后台刷新工具数量的goroutine
|
// 启动后台刷新工具数量的goroutine
|
||||||
manager.startToolCountRefresh()
|
manager.startToolCountRefresh()
|
||||||
@@ -452,8 +456,18 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
execCtx, runCancel := context.WithCancel(ctx)
|
||||||
|
m.registerRunningCancel(executionID, runCancel)
|
||||||
|
notifyToolRunBegin(ctx, executionID)
|
||||||
|
defer func() {
|
||||||
|
notifyToolRunEnd(ctx, executionID)
|
||||||
|
runCancel()
|
||||||
|
m.unregisterRunningCancel(executionID)
|
||||||
|
}()
|
||||||
|
|
||||||
// 调用工具
|
// 调用工具
|
||||||
result, err := client.CallTool(ctx, actualToolName, args)
|
result, err := client.CallTool(execCtx, actualToolName, args)
|
||||||
|
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
|
||||||
|
|
||||||
// 更新执行记录
|
// 更新执行记录
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -462,16 +476,23 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
execution.Duration = now.Sub(execution.StartTime)
|
execution.Duration = now.Sub(execution.StartTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
execution.Status = "failed"
|
st, msg := executionStatusAndMessage(err)
|
||||||
execution.Error = err.Error()
|
execution.Status = st
|
||||||
|
execution.Error = msg
|
||||||
} else if result != nil && result.IsError {
|
} else if result != nil && result.IsError {
|
||||||
execution.Status = "failed"
|
if cancelledWithUserNote {
|
||||||
if len(result.Content) > 0 {
|
execution.Status = "cancelled"
|
||||||
execution.Error = result.Content[0].Text
|
execution.Error = ""
|
||||||
|
execution.Result = result
|
||||||
} else {
|
} else {
|
||||||
execution.Error = "工具执行返回错误结果"
|
execution.Status = "failed"
|
||||||
|
if len(result.Content) > 0 {
|
||||||
|
execution.Error = result.Content[0].Text
|
||||||
|
} else {
|
||||||
|
execution.Error = "工具执行返回错误结果"
|
||||||
|
}
|
||||||
|
execution.Result = result
|
||||||
}
|
}
|
||||||
execution.Result = result
|
|
||||||
} else {
|
} else {
|
||||||
execution.Status = "completed"
|
execution.Status = "completed"
|
||||||
if result == nil {
|
if result == nil {
|
||||||
@@ -509,6 +530,50 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
return result, executionID, nil
|
return result, executionID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
|
||||||
|
note := strings.TrimSpace(m.readAbortUserNote(executionID))
|
||||||
|
if note == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasErr := err != nil && *err != nil
|
||||||
|
hasRes := result != nil && *result != nil
|
||||||
|
if !hasErr && !hasRes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = m.takeAbortUserNote(executionID)
|
||||||
|
partial := ""
|
||||||
|
if hasRes {
|
||||||
|
partial = ToolResultPlainText(*result)
|
||||||
|
}
|
||||||
|
if partial == "" && hasErr {
|
||||||
|
partial = (*err).Error()
|
||||||
|
}
|
||||||
|
merged := MergePartialToolOutputAndAbortNote(partial, note)
|
||||||
|
*err = nil
|
||||||
|
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) readAbortUserNote(id string) string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.abortUserNotes == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.abortUserNotes[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) takeAbortUserNote(id string) string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.abortUserNotes == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
n := m.abortUserNotes[id]
|
||||||
|
delete(m.abortUserNotes, id)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupOldExecutions 清理旧的执行记录(保持内存中的记录数量在限制内)
|
// cleanupOldExecutions 清理旧的执行记录(保持内存中的记录数量在限制内)
|
||||||
func (m *ExternalMCPManager) cleanupOldExecutions() {
|
func (m *ExternalMCPManager) cleanupOldExecutions() {
|
||||||
const maxExecutionsInMemory = 1000
|
const maxExecutionsInMemory = 1000
|
||||||
@@ -562,6 +627,42 @@ func (m *ExternalMCPManager) GetExecution(id string) (*ToolExecution, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) registerRunningCancel(id string, cancel context.CancelFunc) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.runningCancels[id] = cancel
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) unregisterRunningCancel(id string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.runningCancels, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelToolExecutionWithNote 取消外部 MCP 工具;note 非空时与已返回输出合并后交给模型。
|
||||||
|
func (m *ExternalMCPManager) CancelToolExecutionWithNote(id string, note string) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
cancel, ok := m.runningCancels[id]
|
||||||
|
if !ok || cancel == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(note) != "" {
|
||||||
|
if m.abortUserNotes == nil {
|
||||||
|
m.abortUserNotes = make(map[string]string)
|
||||||
|
}
|
||||||
|
m.abortUserNotes[id] = strings.TrimSpace(note)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelToolExecution 取消正在执行的外部 MCP 工具(无用户说明)。
|
||||||
|
func (m *ExternalMCPManager) CancelToolExecution(id string) bool {
|
||||||
|
return m.CancelToolExecutionWithNote(id, "")
|
||||||
|
}
|
||||||
|
|
||||||
// updateStats 更新统计信息
|
// updateStats 更新统计信息
|
||||||
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
|
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolRunRegistry 在工具开始/结束时登记当前 executionId,供对话页「仅终止当前工具」与监控页共用取消逻辑。
|
||||||
|
type ToolRunRegistry interface {
|
||||||
|
RegisterRunningTool(conversationID, executionID string)
|
||||||
|
UnregisterRunningTool(conversationID, executionID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolRunRegistryCtxKey struct{}
|
||||||
|
type mcpConversationIDCtxKey struct{}
|
||||||
|
|
||||||
|
// WithToolRunRegistry 将登记器注入 ctx(Eino / 原生 Agent 任务 ctx)。
|
||||||
|
func WithToolRunRegistry(ctx context.Context, reg ToolRunRegistry) context.Context {
|
||||||
|
if ctx == nil || reg == nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, toolRunRegistryCtxKey{}, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolRunRegistryFromContext 取出登记器(无则 nil)。
|
||||||
|
func ToolRunRegistryFromContext(ctx context.Context) ToolRunRegistry {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(toolRunRegistryCtxKey{}).(ToolRunRegistry)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMCPConversationID 将对话 ID 注入 ctx,供 CallTool 内与 executionId 关联。
|
||||||
|
func WithMCPConversationID(ctx context.Context, conversationID string) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(conversationID)
|
||||||
|
if id == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, mcpConversationIDCtxKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPConversationIDFromContext 读取对话 ID。
|
||||||
|
func MCPConversationIDFromContext(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(mcpConversationIDCtxKey{}).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyToolRunBegin(ctx context.Context, executionID string) {
|
||||||
|
reg := ToolRunRegistryFromContext(ctx)
|
||||||
|
if reg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv := MCPConversationIDFromContext(ctx)
|
||||||
|
if conv == "" || strings.TrimSpace(executionID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reg.RegisterRunningTool(conv, executionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyToolRunEnd(ctx context.Context, executionID string) {
|
||||||
|
reg := ToolRunRegistryFromContext(ctx)
|
||||||
|
if reg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv := MCPConversationIDFromContext(ctx)
|
||||||
|
if conv == "" || strings.TrimSpace(executionID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reg.UnregisterRunningTool(conv, executionID)
|
||||||
|
}
|
||||||
+198
-22
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -40,6 +41,9 @@ type Server struct {
|
|||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
maxExecutionsInMemory int // 内存中最大执行记录数
|
maxExecutionsInMemory int // 内存中最大执行记录数
|
||||||
sseClients map[string]*sseClient
|
sseClients map[string]*sseClient
|
||||||
|
runningCancels map[string]context.CancelFunc
|
||||||
|
runningCancelsMu sync.Mutex
|
||||||
|
abortUserNotes map[string]string // 监控页终止时附带的用户说明,与 executionID 对应
|
||||||
}
|
}
|
||||||
|
|
||||||
type sseClient struct {
|
type sseClient struct {
|
||||||
@@ -50,6 +54,13 @@ type sseClient struct {
|
|||||||
// ToolHandler 工具处理函数
|
// ToolHandler 工具处理函数
|
||||||
type ToolHandler func(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
|
type ToolHandler func(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
|
||||||
|
|
||||||
|
func executionStatusAndMessage(err error) (status string, errMsg string) {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return "cancelled", "已手动终止(MCP 监控)"
|
||||||
|
}
|
||||||
|
return "failed", err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
// NewServer 创建新的MCP服务器
|
// NewServer 创建新的MCP服务器
|
||||||
func NewServer(logger *zap.Logger) *Server {
|
func NewServer(logger *zap.Logger) *Server {
|
||||||
return NewServerWithStorage(logger, nil)
|
return NewServerWithStorage(logger, nil)
|
||||||
@@ -68,6 +79,8 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录
|
maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录
|
||||||
sseClients: make(map[string]*sseClient),
|
sseClients: make(map[string]*sseClient),
|
||||||
|
runningCancels: make(map[string]context.CancelFunc),
|
||||||
|
abortUserNotes: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化默认提示词和资源
|
// 初始化默认提示词和资源
|
||||||
@@ -444,15 +457,22 @@ func (s *Server) handleCallTool(msg *Message) *Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
baseCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
defer cancel()
|
defer timeoutCancel()
|
||||||
|
execCtx, runCancel := context.WithCancel(baseCtx)
|
||||||
|
s.registerRunningCancel(executionID, runCancel)
|
||||||
|
defer func() {
|
||||||
|
runCancel()
|
||||||
|
s.unregisterRunningCancel(executionID)
|
||||||
|
}()
|
||||||
|
|
||||||
s.logger.Info("开始执行工具",
|
s.logger.Info("开始执行工具",
|
||||||
zap.String("toolName", req.Name),
|
zap.String("toolName", req.Name),
|
||||||
zap.Any("arguments", req.Arguments),
|
zap.Any("arguments", req.Arguments),
|
||||||
)
|
)
|
||||||
|
|
||||||
result, err := handler(ctx, req.Arguments)
|
result, err := handler(execCtx, req.Arguments)
|
||||||
|
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var failed bool
|
var failed bool
|
||||||
var finalResult *ToolResult
|
var finalResult *ToolResult
|
||||||
@@ -462,18 +482,26 @@ func (s *Server) handleCallTool(msg *Message) *Message {
|
|||||||
execution.Duration = now.Sub(execution.StartTime)
|
execution.Duration = now.Sub(execution.StartTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
execution.Status = "failed"
|
st, msg := executionStatusAndMessage(err)
|
||||||
execution.Error = err.Error()
|
execution.Status = st
|
||||||
|
execution.Error = msg
|
||||||
failed = true
|
failed = true
|
||||||
} else if result != nil && result.IsError {
|
} else if result != nil && result.IsError {
|
||||||
execution.Status = "failed"
|
if cancelledWithUserNote {
|
||||||
if len(result.Content) > 0 {
|
execution.Status = "cancelled"
|
||||||
execution.Error = result.Content[0].Text
|
execution.Error = ""
|
||||||
|
execution.Result = result
|
||||||
|
failed = true
|
||||||
} else {
|
} else {
|
||||||
execution.Error = "工具执行返回错误结果"
|
execution.Status = "failed"
|
||||||
|
if len(result.Content) > 0 {
|
||||||
|
execution.Error = result.Content[0].Text
|
||||||
|
} else {
|
||||||
|
execution.Error = "工具执行返回错误结果"
|
||||||
|
}
|
||||||
|
execution.Result = result
|
||||||
|
failed = true
|
||||||
}
|
}
|
||||||
execution.Result = result
|
|
||||||
failed = true
|
|
||||||
} else {
|
} else {
|
||||||
execution.Status = "completed"
|
execution.Status = "completed"
|
||||||
if result == nil {
|
if result == nil {
|
||||||
@@ -510,9 +538,13 @@ func (s *Server) handleCallTool(msg *Message) *Message {
|
|||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
errText := fmt.Sprintf("工具执行失败: %v", err)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
errText = "工具执行已手动终止(MCP 监控)。后续编排步骤可继续。"
|
||||||
|
}
|
||||||
errorResult, _ := json.Marshal(CallToolResponse{
|
errorResult, _ := json.Marshal(CallToolResponse{
|
||||||
Content: []Content{
|
Content: []Content{
|
||||||
{Type: "text", Text: fmt.Sprintf("工具执行失败: %v", err)},
|
{Type: "text", Text: errText},
|
||||||
},
|
},
|
||||||
IsError: true,
|
IsError: true,
|
||||||
})
|
})
|
||||||
@@ -769,7 +801,17 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := handler(ctx, args)
|
execCtx, runCancel := context.WithCancel(ctx)
|
||||||
|
s.registerRunningCancel(executionID, runCancel)
|
||||||
|
notifyToolRunBegin(ctx, executionID)
|
||||||
|
defer func() {
|
||||||
|
notifyToolRunEnd(ctx, executionID)
|
||||||
|
runCancel()
|
||||||
|
s.unregisterRunningCancel(executionID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
result, err := handler(execCtx, args)
|
||||||
|
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -779,19 +821,28 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
|||||||
var finalResult *ToolResult
|
var finalResult *ToolResult
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
execution.Status = "failed"
|
st, msg := executionStatusAndMessage(err)
|
||||||
execution.Error = err.Error()
|
execution.Status = st
|
||||||
|
execution.Error = msg
|
||||||
failed = true
|
failed = true
|
||||||
} else if result != nil && result.IsError {
|
} else if result != nil && result.IsError {
|
||||||
execution.Status = "failed"
|
if cancelledWithUserNote {
|
||||||
if len(result.Content) > 0 {
|
execution.Status = "cancelled"
|
||||||
execution.Error = result.Content[0].Text
|
execution.Error = ""
|
||||||
|
execution.Result = result
|
||||||
|
failed = true
|
||||||
|
finalResult = result
|
||||||
} else {
|
} else {
|
||||||
execution.Error = "工具执行返回错误结果"
|
execution.Status = "failed"
|
||||||
|
if len(result.Content) > 0 {
|
||||||
|
execution.Error = result.Content[0].Text
|
||||||
|
} else {
|
||||||
|
execution.Error = "工具执行返回错误结果"
|
||||||
|
}
|
||||||
|
execution.Result = result
|
||||||
|
failed = true
|
||||||
|
finalResult = result
|
||||||
}
|
}
|
||||||
execution.Result = result
|
|
||||||
failed = true
|
|
||||||
finalResult = result
|
|
||||||
} else {
|
} else {
|
||||||
execution.Status = "completed"
|
execution.Status = "completed"
|
||||||
if result == nil {
|
if result == nil {
|
||||||
@@ -832,6 +883,49 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
|||||||
return finalResult, executionID, nil
|
return finalResult, executionID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordCompletedToolInvocation 将已在其它路径完成的工具调用写入监控存储(格式与 CallTool 结束后一致),
|
||||||
|
// 用于 Eino ADK filesystem execute 等未经过 CallTool 的场景;返回 executionId 供助手消息 mcpExecutionIds 关联。
|
||||||
|
func (s *Server) RecordCompletedToolInvocation(toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
executionID := uuid.New().String()
|
||||||
|
now := time.Now()
|
||||||
|
failed := invokeErr != nil
|
||||||
|
exec := &ToolExecution{
|
||||||
|
ID: executionID,
|
||||||
|
ToolName: toolName,
|
||||||
|
Arguments: args,
|
||||||
|
StartTime: now,
|
||||||
|
EndTime: &now,
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
exec.Status = "failed"
|
||||||
|
exec.Error = invokeErr.Error()
|
||||||
|
if strings.TrimSpace(resultText) != "" {
|
||||||
|
exec.Result = &ToolResult{Content: []Content{{Type: "text", Text: resultText}}}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exec.Status = "completed"
|
||||||
|
text := resultText
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
text = "(无输出)"
|
||||||
|
}
|
||||||
|
exec.Result = &ToolResult{Content: []Content{{Type: "text", Text: text}}}
|
||||||
|
}
|
||||||
|
if s.storage != nil {
|
||||||
|
if err := s.storage.SaveToolExecution(exec); err != nil {
|
||||||
|
s.logger.Warn("RecordCompletedToolInvocation 保存失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.updateStats(toolName, failed)
|
||||||
|
return executionID
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupOldExecutions 清理旧的执行记录,防止内存无限增长
|
// cleanupOldExecutions 清理旧的执行记录,防止内存无限增长
|
||||||
func (s *Server) cleanupOldExecutions() {
|
func (s *Server) cleanupOldExecutions() {
|
||||||
if len(s.executions) <= s.maxExecutionsInMemory {
|
if len(s.executions) <= s.maxExecutionsInMemory {
|
||||||
@@ -869,6 +963,88 @@ func (s *Server) cleanupOldExecutions() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerRunningCancel(id string, cancel context.CancelFunc) {
|
||||||
|
s.runningCancelsMu.Lock()
|
||||||
|
s.runningCancels[id] = cancel
|
||||||
|
s.runningCancelsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) unregisterRunningCancel(id string) {
|
||||||
|
s.runningCancelsMu.Lock()
|
||||||
|
delete(s.runningCancels, id)
|
||||||
|
s.runningCancelsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readAbortUserNote(id string) string {
|
||||||
|
s.runningCancelsMu.Lock()
|
||||||
|
defer s.runningCancelsMu.Unlock()
|
||||||
|
if s.abortUserNotes == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.abortUserNotes[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) takeAbortUserNote(id string) string {
|
||||||
|
s.runningCancelsMu.Lock()
|
||||||
|
defer s.runningCancelsMu.Unlock()
|
||||||
|
if s.abortUserNotes == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
n := s.abortUserNotes[id]
|
||||||
|
delete(s.abortUserNotes, id)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAbortUserNoteToCancelledToolResult 监控页「终止并填写说明」时合并「工具已输出 + 用户说明」交给模型。
|
||||||
|
// exec 等工具会把失败写在 *ToolResult 里并返回 err==nil,若仅在 err!=nil 时合并会漏掉说明,甚至误 clear 掉 note。
|
||||||
|
func (s *Server) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
|
||||||
|
note := strings.TrimSpace(s.readAbortUserNote(executionID))
|
||||||
|
if note == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasErr := err != nil && *err != nil
|
||||||
|
hasRes := result != nil && *result != nil
|
||||||
|
if !hasErr && !hasRes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = s.takeAbortUserNote(executionID)
|
||||||
|
partial := ""
|
||||||
|
if hasRes {
|
||||||
|
partial = ToolResultPlainText(*result)
|
||||||
|
}
|
||||||
|
if partial == "" && hasErr {
|
||||||
|
partial = (*err).Error()
|
||||||
|
}
|
||||||
|
merged := MergePartialToolOutputAndAbortNote(partial, note)
|
||||||
|
*err = nil
|
||||||
|
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelToolExecutionWithNote 取消内部工具;note 非空时与工具已返回文本合并后交给上层模型。
|
||||||
|
func (s *Server) CancelToolExecutionWithNote(id string, note string) bool {
|
||||||
|
s.runningCancelsMu.Lock()
|
||||||
|
cancel, ok := s.runningCancels[id]
|
||||||
|
if !ok || cancel == nil {
|
||||||
|
s.runningCancelsMu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(note) != "" {
|
||||||
|
if s.abortUserNotes == nil {
|
||||||
|
s.abortUserNotes = make(map[string]string)
|
||||||
|
}
|
||||||
|
s.abortUserNotes[id] = strings.TrimSpace(note)
|
||||||
|
}
|
||||||
|
s.runningCancelsMu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelToolExecution 取消正在执行的内部工具调用(无用户说明)。
|
||||||
|
func (s *Server) CancelToolExecution(id string) bool {
|
||||||
|
return s.CancelToolExecutionWithNote(id, "")
|
||||||
|
}
|
||||||
|
|
||||||
// initDefaultPrompts 初始化默认提示词模板
|
// initDefaultPrompts 初始化默认提示词模板
|
||||||
func (s *Server) initDefaultPrompts() {
|
func (s *Server) initDefaultPrompts() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
+35
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,7 +193,7 @@ type ToolExecution struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ToolName string `json:"toolName"`
|
ToolName string `json:"toolName"`
|
||||||
Arguments map[string]interface{} `json:"arguments"`
|
Arguments map[string]interface{} `json:"arguments"`
|
||||||
Status string `json:"status"` // pending, running, completed, failed
|
Status string `json:"status"` // pending, running, completed, failed, cancelled
|
||||||
Result *ToolResult `json:"result,omitempty"`
|
Result *ToolResult `json:"result,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
StartTime time.Time `json:"startTime"`
|
StartTime time.Time `json:"startTime"`
|
||||||
@@ -293,3 +294,36 @@ type SamplingContent struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToolResultPlainText 拼接工具结果中的文本(手动终止时作为「工具原始输出」)。
|
||||||
|
func ToolResultPlainText(r *ToolResult) string {
|
||||||
|
if r == nil || len(r.Content) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range r.Content {
|
||||||
|
b.WriteString(c.Text)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbortNoteBannerForModel 标出后续文本来自「用户手动终止工具时在弹窗中填写」,避免与 stdout/stderr 混淆。
|
||||||
|
const AbortNoteBannerForModel = "---\n" +
|
||||||
|
"【用户终止说明|USER INTERRUPT NOTE】\n" +
|
||||||
|
"(以下由操作者填写,用于指示模型如何继续;不是工具原始输出。)\n" +
|
||||||
|
"(Written by the operator when stopping this tool; not raw tool output.)\n" +
|
||||||
|
"---"
|
||||||
|
|
||||||
|
// MergePartialToolOutputAndAbortNote 格式:工具原始输出 + 醒目标题 + 用户终止说明(无说明则原样返回 partial)。
|
||||||
|
func MergePartialToolOutputAndAbortNote(partial, userNote string) string {
|
||||||
|
partial = strings.TrimSpace(partial)
|
||||||
|
userNote = strings.TrimSpace(userNote)
|
||||||
|
if userNote == "" {
|
||||||
|
return partial
|
||||||
|
}
|
||||||
|
section := AbortNoteBannerForModel + "\n" + userNote
|
||||||
|
if partial == "" {
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
return partial + "\n\n" + section
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,14 +11,44 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/einomcp"
|
"cyberstrike-ai/internal/einomcp"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// normalizeStreamingDelta 将可能是“累计片段”的 chunk 归一化为“纯增量”。
|
||||||
|
// 一些模型/桥接层在流式过程中会重复发送已输出前缀,前端若直接 buffer+=chunk 会出现重复文本。
|
||||||
|
//
|
||||||
|
// 注意:与 internal/openai.normalizeStreamingDelta 保持一致。
|
||||||
|
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
|
||||||
|
if incoming == "" {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
return incoming, incoming
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(incoming, current) && len(incoming) > len(current) {
|
||||||
|
return incoming, incoming[len(current):]
|
||||||
|
}
|
||||||
|
if incoming == current && utf8.RuneCountInString(current) > 1 {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
return current + incoming, incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInterruptContinue(ctx context.Context) bool {
|
||||||
|
if ctx == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return errors.Is(context.Cause(ctx), ErrInterruptContinue)
|
||||||
|
}
|
||||||
|
|
||||||
func isEinoIterationLimitError(err error) bool {
|
func isEinoIterationLimitError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
@@ -49,10 +79,22 @@ type einoADKRunLoopArgs struct {
|
|||||||
McpIDsMu *sync.Mutex
|
McpIDsMu *sync.Mutex
|
||||||
McpIDs *[]string
|
McpIDs *[]string
|
||||||
|
|
||||||
|
// FilesystemMonitorAgent / FilesystemMonitorRecord 非 nil 时,将 Eino ADK filesystem 中间件工具(ls/read_file/write_file/edit_file/glob/grep)
|
||||||
|
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
|
||||||
|
FilesystemMonitorAgent *agent.Agent
|
||||||
|
FilesystemMonitorRecord einomcp.ExecutionRecorder
|
||||||
|
|
||||||
|
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 Set,MCP 桥 Fire 以补全 tool_result。
|
||||||
|
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||||
|
|
||||||
DA adk.Agent
|
DA adk.Agent
|
||||||
|
|
||||||
// EmptyResponseMessage 当未捕获到助手正文时的占位(多代理与单代理文案不同)。
|
// EmptyResponseMessage 当未捕获到助手正文时的占位(多代理与单代理文案不同)。
|
||||||
EmptyResponseMessage string
|
EmptyResponseMessage string
|
||||||
|
|
||||||
|
// ModelFacingTrace 可选:由各 ChatModelAgent Handlers 链末尾中间件写入「即将送入模型」的消息快照;
|
||||||
|
// 非空时优先用于 LastAgentTraceInput 序列化,使续跑与 summarization/reduction 后的上下文一致。
|
||||||
|
ModelFacingTrace *modelFacingTraceHolder
|
||||||
}
|
}
|
||||||
|
|
||||||
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
|
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
|
||||||
@@ -190,6 +232,63 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
pendingQueueByAgent = make(map[string][]string)
|
pendingQueueByAgent = make(map[string][]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 最近一次成功的 Eino filesystem execute 的标准输出(trim):用于抑制模型紧接着复述同一字符串时的重复「助手输出」时间线。
|
||||||
|
var executeStdoutDupMu sync.Mutex
|
||||||
|
var pendingExecuteStdoutDup string
|
||||||
|
recordPendingExecuteStdoutDup := func(toolName, stdout string, isErr bool) {
|
||||||
|
if isErr || !strings.EqualFold(strings.TrimSpace(toolName), "execute") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(stdout)
|
||||||
|
if t == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
executeStdoutDupMu.Lock()
|
||||||
|
pendingExecuteStdoutDup = t
|
||||||
|
executeStdoutDupMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolResultSent sync.Map // toolCallID -> struct{};与 ADK Tool 消息去重,避免 bridge 与事件流各推一次
|
||||||
|
if args.ToolInvokeNotify != nil {
|
||||||
|
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||||
|
tid := strings.TrimSpace(toolCallID)
|
||||||
|
removePendingByID(tid)
|
||||||
|
if tid == "" || progress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, loaded := toolResultSent.LoadOrStore(tid, struct{}{}); loaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isErr := !success || invokeErr != nil
|
||||||
|
body := content
|
||||||
|
if invokeErr != nil {
|
||||||
|
body = invokeErr.Error()
|
||||||
|
isErr = true
|
||||||
|
}
|
||||||
|
recordPendingExecuteStdoutDup(toolName, body, isErr)
|
||||||
|
preview := body
|
||||||
|
if len(preview) > 200 {
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
}
|
||||||
|
agentTag := strings.TrimSpace(einoAgent)
|
||||||
|
if agentTag == "" {
|
||||||
|
agentTag = orchestratorName
|
||||||
|
}
|
||||||
|
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
|
||||||
|
"toolName": toolName,
|
||||||
|
"success": !isErr,
|
||||||
|
"isError": isErr,
|
||||||
|
"result": body,
|
||||||
|
"resultPreview": preview,
|
||||||
|
"toolCallId": tid,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"einoAgent": agentTag,
|
||||||
|
"einoRole": einoRoleTag(agentTag),
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
runnerCfg := adk.RunnerConfig{
|
runnerCfg := adk.RunnerConfig{
|
||||||
Agent: da,
|
Agent: da,
|
||||||
EnableStreaming: true,
|
EnableStreaming: true,
|
||||||
@@ -318,7 +417,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
ids := snapshotMCPIDs()
|
ids := snapshotMCPIDs()
|
||||||
return buildEinoRunResultFromAccumulated(
|
return buildEinoRunResultFromAccumulated(
|
||||||
orchMode, runAccumulatedMsgs, lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, true,
|
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
|
||||||
|
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, true,
|
||||||
), runErr
|
), runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,10 +428,18 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
flushAllPendingAsFailed(ctx.Err())
|
flushAllPendingAsFailed(ctx.Err())
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("error", "Request cancelled / 请求已取消", map[string]interface{}{
|
if isInterruptContinue(ctx) {
|
||||||
"conversationId": conversationID,
|
progress("progress", "已暂停当前输出,正在合并用户补充并继续…", map[string]interface{}{
|
||||||
"source": "eino",
|
"conversationId": conversationID,
|
||||||
})
|
"source": "eino",
|
||||||
|
"kind": "interrupt_continue",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
progress("error", "Request cancelled / 请求已取消", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return takePartial(ctx.Err())
|
return takePartial(ctx.Err())
|
||||||
default:
|
default:
|
||||||
@@ -345,10 +453,18 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
flushAllPendingAsFailed(ctxErr)
|
flushAllPendingAsFailed(ctxErr)
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("error", ctxErr.Error(), map[string]interface{}{
|
if isInterruptContinue(ctx) {
|
||||||
"conversationId": conversationID,
|
progress("progress", "已暂停当前输出,正在合并用户补充并继续…", map[string]interface{}{
|
||||||
"source": "eino",
|
"conversationId": conversationID,
|
||||||
})
|
"source": "eino",
|
||||||
|
"kind": "interrupt_continue",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
progress("error", ctxErr.Error(), map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return takePartial(ctxErr)
|
return takePartial(ctxErr)
|
||||||
}
|
}
|
||||||
@@ -430,46 +546,162 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
streamHeaderSent := false
|
streamHeaderSent := false
|
||||||
var reasoningStreamID string
|
var reasoningStreamID string
|
||||||
var toolStreamFragments []schema.ToolCall
|
var toolStreamFragments []schema.ToolCall
|
||||||
var subAssistantBuf strings.Builder
|
var subAssistantBuf string
|
||||||
var subReplyStreamID string
|
var subReplyStreamID string
|
||||||
var mainAssistantBuf strings.Builder
|
var mainAssistantBuf string
|
||||||
|
var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重
|
||||||
|
var reasoningBuf string
|
||||||
|
var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示
|
||||||
var streamRecvErr error
|
var streamRecvErr error
|
||||||
|
type streamMsg struct {
|
||||||
|
chunk *schema.Message
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
recvCh := make(chan streamMsg, 8)
|
||||||
|
go func() {
|
||||||
|
defer close(recvCh)
|
||||||
|
for {
|
||||||
|
ch, rerr := mv.MessageStream.Recv()
|
||||||
|
recvCh <- streamMsg{chunk: ch, err: rerr}
|
||||||
|
if rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
streamRecvLoop:
|
||||||
for {
|
for {
|
||||||
chunk, rerr := mv.MessageStream.Recv()
|
select {
|
||||||
if rerr != nil {
|
case <-ctx.Done():
|
||||||
if errors.Is(rerr, io.EOF) {
|
streamRecvErr = ctx.Err()
|
||||||
break
|
break streamRecvLoop
|
||||||
|
case sm, ok := <-recvCh:
|
||||||
|
if !ok {
|
||||||
|
break streamRecvLoop
|
||||||
}
|
}
|
||||||
if logger != nil {
|
chunk, rerr := sm.chunk, sm.err
|
||||||
logger.Warn("eino stream recv error, flushing incomplete stream",
|
if rerr != nil {
|
||||||
zap.Error(rerr),
|
if errors.Is(rerr, io.EOF) {
|
||||||
zap.String("agent", ev.AgentName),
|
break streamRecvLoop
|
||||||
zap.Int("toolFragments", len(toolStreamFragments)))
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("eino stream recv error, flushing incomplete stream",
|
||||||
|
zap.Error(rerr),
|
||||||
|
zap.String("agent", ev.AgentName),
|
||||||
|
zap.Int("toolFragments", len(toolStreamFragments)))
|
||||||
|
}
|
||||||
|
streamRecvErr = rerr
|
||||||
|
break streamRecvLoop
|
||||||
}
|
}
|
||||||
streamRecvErr = rerr
|
if chunk == nil {
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
if chunk == nil {
|
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||||
continue
|
var reasoningDelta string
|
||||||
}
|
reasoningBuf, reasoningDelta = normalizeStreamingDelta(reasoningBuf, chunk.ReasoningContent)
|
||||||
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
if reasoningDelta != "" {
|
||||||
if reasoningStreamID == "" {
|
fullDisplay := openai.DisplayReasoningContent(reasoningBuf)
|
||||||
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
var displayDelta string
|
||||||
progress("thinking_stream_start", " ", map[string]interface{}{
|
if strings.HasPrefix(fullDisplay, prevReasoningDisplay) {
|
||||||
"streamId": reasoningStreamID,
|
displayDelta = fullDisplay[len(prevReasoningDisplay):]
|
||||||
"source": "eino",
|
} else {
|
||||||
"einoAgent": ev.AgentName,
|
displayDelta = fullDisplay
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
}
|
||||||
"orchestration": orchMode,
|
prevReasoningDisplay = fullDisplay
|
||||||
})
|
if displayDelta != "" {
|
||||||
|
if reasoningStreamID == "" {
|
||||||
|
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
||||||
|
progress("reasoning_chain_stream_start", " ", map[string]interface{}{
|
||||||
|
"streamId": reasoningStreamID,
|
||||||
|
"source": "eino",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
progress("reasoning_chain_stream_delta", displayDelta, map[string]interface{}{
|
||||||
|
"streamId": reasoningStreamID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunk.Content != "" {
|
||||||
|
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
||||||
|
var contentDelta string
|
||||||
|
mainAssistantBuf, contentDelta = normalizeStreamingDelta(mainAssistantBuf, chunk.Content)
|
||||||
|
if contentDelta != "" {
|
||||||
|
if mainAssistDupTarget == "" {
|
||||||
|
executeStdoutDupMu.Lock()
|
||||||
|
if pendingExecuteStdoutDup != "" {
|
||||||
|
mainAssistDupTarget = pendingExecuteStdoutDup
|
||||||
|
}
|
||||||
|
executeStdoutDupMu.Unlock()
|
||||||
|
}
|
||||||
|
if mainAssistDupTarget != "" {
|
||||||
|
// 已展示过 tool_result,缓冲全文;EOF 后与 execute 输出相同则不再发助手流
|
||||||
|
} else {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !streamsMainAssistant(ev.AgentName) {
|
||||||
|
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,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(chunk.ToolCalls) > 0 {
|
||||||
|
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
||||||
}
|
}
|
||||||
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{
|
|
||||||
"streamId": reasoningStreamID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if chunk.Content != "" {
|
}
|
||||||
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
if !streamHeaderSent {
|
s := strings.TrimSpace(mainAssistantBuf)
|
||||||
|
if mainAssistDupTarget != "" {
|
||||||
|
executeStdoutDupMu.Lock()
|
||||||
|
pendingExecuteStdoutDup = ""
|
||||||
|
executeStdoutDupMu.Unlock()
|
||||||
|
if s != "" && s == mainAssistDupTarget {
|
||||||
|
// 与刚展示的 execute 结果完全一致:不再发助手流式事件,仍写入轨迹与最终回复字段
|
||||||
|
lastAssistant = s
|
||||||
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||||
|
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||||
|
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(s)
|
||||||
|
}
|
||||||
|
} else if s != "" {
|
||||||
|
if progress != nil {
|
||||||
progress("response_start", "", map[string]interface{}{
|
progress("response_start", "", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
@@ -478,42 +710,21 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
})
|
})
|
||||||
streamHeaderSent = true
|
progress("response_delta", s, map[string]interface{}{
|
||||||
}
|
"conversationId": conversationID,
|
||||||
progress("response_delta", chunk.Content, map[string]interface{}{
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
"conversationId": conversationID,
|
"einoRole": "orchestrator",
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
"einoAgent": ev.AgentName,
|
||||||
"einoRole": "orchestrator",
|
"orchestration": orchMode,
|
||||||
"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{}{
|
|
||||||
"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)
|
lastAssistant = s
|
||||||
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||||
|
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||||
|
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if s != "" {
|
||||||
if len(chunk.ToolCalls) > 0 {
|
|
||||||
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
|
||||||
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
|
|
||||||
lastAssistant = s
|
lastAssistant = s
|
||||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||||
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||||
@@ -521,8 +732,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subAssistantBuf.Len() > 0 && progress != nil {
|
if strings.TrimSpace(subAssistantBuf) != "" && progress != nil {
|
||||||
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
if s := strings.TrimSpace(subAssistantBuf); s != "" {
|
||||||
if subReplyStreamID != "" {
|
if subReplyStreamID != "" {
|
||||||
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
||||||
"streamId": subReplyStreamID,
|
"streamId": subReplyStreamID,
|
||||||
@@ -543,10 +754,17 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
var lastToolChunk *schema.Message
|
var lastToolChunk *schema.Message
|
||||||
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
||||||
lastToolChunk = &schema.Message{ToolCalls: merged}
|
lastToolChunk = mergeMessageToolCalls(&schema.Message{ToolCalls: merged})
|
||||||
}
|
}
|
||||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||||
|
// 流式路径此前只把 tool_calls 推给进度 UI,未写入 runAccumulatedMsgs;落库后 loadHistory→RepairOrphan 会删掉全部 tool 结果,表现为「续跑/下轮失忆」。
|
||||||
|
if lastToolChunk != nil && len(lastToolChunk.ToolCalls) > 0 {
|
||||||
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage("", lastToolChunk.ToolCalls))
|
||||||
|
}
|
||||||
if streamRecvErr != nil {
|
if streamRecvErr != nil {
|
||||||
|
if isInterruptContinue(ctx) {
|
||||||
|
return takePartial(streamRecvErr)
|
||||||
|
}
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("eino_stream_error", streamRecvErr.Error(), map[string]interface{}{
|
progress("eino_stream_error", streamRecvErr.Error(), map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -571,7 +789,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
|
|
||||||
if mv.Role == schema.Assistant {
|
if mv.Role == schema.Assistant {
|
||||||
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||||
progress("thinking", strings.TrimSpace(msg.ReasoningContent), map[string]interface{}{
|
progress("reasoning_chain", openai.DisplayReasoningContent(strings.TrimSpace(msg.ReasoningContent)), map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
@@ -582,26 +800,42 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
body := strings.TrimSpace(msg.Content)
|
body := strings.TrimSpace(msg.Content)
|
||||||
if body != "" {
|
if body != "" {
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
if progress != nil {
|
executeStdoutDupMu.Lock()
|
||||||
progress("response_start", "", map[string]interface{}{
|
dup := pendingExecuteStdoutDup
|
||||||
"conversationId": conversationID,
|
if dup != "" && body == dup {
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
pendingExecuteStdoutDup = ""
|
||||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
executeStdoutDupMu.Unlock()
|
||||||
"einoRole": "orchestrator",
|
lastAssistant = body
|
||||||
"einoAgent": ev.AgentName,
|
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||||
"orchestration": orchMode,
|
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(body)
|
||||||
})
|
}
|
||||||
progress("response_delta", body, map[string]interface{}{
|
// 非流式:与 execute 输出相同则跳过助手通道展示(msg 已在上方写入 runAccumulatedMsgs)
|
||||||
"conversationId": conversationID,
|
} else {
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
if dup != "" {
|
||||||
"einoRole": "orchestrator",
|
pendingExecuteStdoutDup = ""
|
||||||
"einoAgent": ev.AgentName,
|
}
|
||||||
"orchestration": orchMode,
|
executeStdoutDupMu.Unlock()
|
||||||
})
|
if progress != nil {
|
||||||
}
|
progress("response_start", "", map[string]interface{}{
|
||||||
lastAssistant = body
|
"conversationId": conversationID,
|
||||||
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(body)
|
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
progress("response_delta", body, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lastAssistant = body
|
||||||
|
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||||
|
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if progress != nil {
|
} else if progress != nil {
|
||||||
progress("eino_agent_reply", body, map[string]interface{}{
|
progress("eino_agent_reply", body, map[string]interface{}{
|
||||||
@@ -657,12 +891,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
removePendingByID(toolCallID)
|
|
||||||
}
|
}
|
||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
|
removePendingByID(toolCallID)
|
||||||
|
if _, loaded := toolResultSent.LoadOrStore(toolCallID, struct{}{}); loaded {
|
||||||
|
// ToolInvokeNotify 可能已推过 tool_result(如 execute 流式包装里 Fire 仅携带截断后的 stdout),
|
||||||
|
// 此处仍应用 ADK Tool 消息中的完整内容刷新去重基准,避免模型复述全文时与截断串比对失败而重复展示「助手输出」。
|
||||||
|
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
data["toolCallId"] = toolCallID
|
data["toolCallId"] = toolCallID
|
||||||
}
|
}
|
||||||
|
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||||
|
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
||||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,11 +913,21 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
mcpIDsMu.Unlock()
|
mcpIDsMu.Unlock()
|
||||||
|
|
||||||
out := buildEinoRunResultFromAccumulated(
|
out := buildEinoRunResultFromAccumulated(
|
||||||
orchMode, runAccumulatedMsgs, lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
|
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
|
||||||
|
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
|
||||||
)
|
)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message {
|
||||||
|
if args != nil && args.ModelFacingTrace != nil {
|
||||||
|
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func einoPartialRunLastOutputHint() string {
|
func einoPartialRunLastOutputHint() string {
|
||||||
return "[执行未正常结束(用户停止、超时或异常)。续跑时请基于上文已产生的工具与结果继续,勿重复已完成步骤。]\n" +
|
return "[执行未正常结束(用户停止、超时或异常)。续跑时请基于上文已产生的工具与结果继续,勿重复已完成步骤。]\n" +
|
||||||
"[Run ended abnormally; continue from the trace above without repeating completed steps.]"
|
"[Run ended abnormally; continue from the trace above without repeating completed steps.]"
|
||||||
@@ -685,13 +936,18 @@ func einoPartialRunLastOutputHint() string {
|
|||||||
func buildEinoRunResultFromAccumulated(
|
func buildEinoRunResultFromAccumulated(
|
||||||
orchMode string,
|
orchMode string,
|
||||||
runAccumulatedMsgs []adk.Message,
|
runAccumulatedMsgs []adk.Message,
|
||||||
|
persistMsgs []adk.Message,
|
||||||
lastAssistant string,
|
lastAssistant string,
|
||||||
lastPlanExecuteExecutor string,
|
lastPlanExecuteExecutor string,
|
||||||
emptyHint string,
|
emptyHint string,
|
||||||
mcpIDs []string,
|
mcpIDs []string,
|
||||||
partial bool,
|
partial bool,
|
||||||
) *RunResult {
|
) *RunResult {
|
||||||
histJSON, _ := json.Marshal(runAccumulatedMsgs)
|
traceForJSON := persistMsgs
|
||||||
|
if len(traceForJSON) == 0 {
|
||||||
|
traceForJSON = runAccumulatedMsgs
|
||||||
|
}
|
||||||
|
histJSON, _ := json.Marshal(traceForJSON)
|
||||||
cleaned := strings.TrimSpace(lastAssistant)
|
cleaned := strings.TrimSpace(lastAssistant)
|
||||||
if orchMode == "plan_execute" {
|
if orchMode == "plan_execute" {
|
||||||
if e := strings.TrimSpace(lastPlanExecuteExecutor); e != "" {
|
if e := strings.TrimSpace(lastPlanExecuteExecutor); e != "" {
|
||||||
@@ -700,6 +956,11 @@ func buildEinoRunResultFromAccumulated(
|
|||||||
cleaned = UnwrapPlanExecuteUserText(cleaned)
|
cleaned = UnwrapPlanExecuteUserText(cleaned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if cleaned == "" {
|
||||||
|
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
|
||||||
|
cleaned = fb
|
||||||
|
}
|
||||||
|
}
|
||||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||||
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
||||||
// 防止超长响应导致 JSON 序列化慢或 OOM(多代理拼接大量工具输出时可能触发)。
|
// 防止超长响应导致 JSON 序列化慢或 OOM(多代理拼接大量工具输出时可能触发)。
|
||||||
@@ -726,6 +987,79 @@ func buildEinoRunResultFromAccumulated(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。
|
||||||
|
// 典型场景:监督者仅调用 exit(final_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。
|
||||||
|
//
|
||||||
|
// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。
|
||||||
|
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
|
||||||
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
|
m := msgs[i]
|
||||||
|
if m == nil || m.Role != schema.Tool {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(m.Content)
|
||||||
|
if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
|
m := msgs[i]
|
||||||
|
if m == nil || m.Role != schema.Assistant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func einoExtractExitFinalFromAssistantToolCalls(msg *schema.Message) string {
|
||||||
|
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := len(msg.ToolCalls) - 1; i >= 0; i-- {
|
||||||
|
tc := msg.ToolCalls[i]
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(tc.Function.Name), adk.ToolInfoExit.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := einoParseExitFinalResultArguments(tc.Function.Arguments); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func einoParseExitFinalResultArguments(arguments string) string {
|
||||||
|
arguments = strings.TrimSpace(arguments)
|
||||||
|
if arguments == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var wrap struct {
|
||||||
|
FinalResult json.RawMessage `json:"final_result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(arguments), &wrap); err != nil || len(wrap.FinalResult) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(wrap.FinalResult, &s); err == nil {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
var anyVal interface{}
|
||||||
|
if err := json.Unmarshal(wrap.FinalResult, &anyVal); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(anyVal)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
func buildEinoCheckpointID(orchMode string) string {
|
func buildEinoCheckpointID(orchMode string) string {
|
||||||
mode := sanitizeEinoPathSegment(strings.TrimSpace(orchMode))
|
mode := sanitizeEinoPathSegment(strings.TrimSpace(orchMode))
|
||||||
if mode == "" {
|
if mode == "" {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/einomcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEinoExecuteMonitorCallback 在 Eino filesystem execute 结束时写入 MCP 监控库并 recorder(executionId),
|
||||||
|
// 与 CallTool 路径一致,供助手消息展示「渗透测试详情」芯片。
|
||||||
|
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(command, stdout string, success bool, invokeErr error) {
|
||||||
|
return func(command, stdout string, success bool, invokeErr error) {
|
||||||
|
if ag == nil || recorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if !success {
|
||||||
|
if invokeErr != nil {
|
||||||
|
err = invokeErr
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("execute failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := map[string]interface{}{"command": command}
|
||||||
|
id := ag.RecordLocalToolExecution("execute", args, stdout, err)
|
||||||
|
if id != "" {
|
||||||
|
recorder(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,16 @@ package multiagent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/einomcp"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk/filesystem"
|
"github.com/cloudwego/eino/adk/filesystem"
|
||||||
|
"github.com/cloudwego/eino/compose"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,8 +19,15 @@ import (
|
|||||||
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
|
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
|
||||||
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
|
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
|
||||||
// 对「完全后台」命令自动开启 RunInBackendGround,与 local.runCmdInBackground 行为对齐。
|
// 对「完全后台」命令自动开启 RunInBackendGround,与 local.runCmdInBackground 行为对齐。
|
||||||
|
//
|
||||||
|
// 使用 Pipe 将内层流转发给调用方:在 inner EOF 后、关闭 Pipe 前同步调用 ToolInvokeNotify.Fire,
|
||||||
|
// 保证 run loop 在模型开始下一轮输出前已记录 execute 结果(用于 UI 与「重复助手复述」去重)。
|
||||||
type einoStreamingShellWrap struct {
|
type einoStreamingShellWrap struct {
|
||||||
inner filesystem.StreamingShell
|
inner filesystem.StreamingShell
|
||||||
|
invokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||||
|
einoAgentName string
|
||||||
|
// recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。
|
||||||
|
recordMonitor func(command, stdout string, success bool, invokeErr error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||||
@@ -26,8 +38,73 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
|||||||
return w.inner.ExecuteStreaming(ctx, nil)
|
return w.inner.ExecuteStreaming(ctx, nil)
|
||||||
}
|
}
|
||||||
req := *input
|
req := *input
|
||||||
|
cmd := strings.TrimSpace(req.Command)
|
||||||
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
|
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
|
||||||
req.RunInBackendGround = true
|
req.RunInBackendGround = true
|
||||||
}
|
}
|
||||||
return w.inner.ExecuteStreaming(ctx, &req)
|
sr, err := w.inner.ExecuteStreaming(ctx, &req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tid := strings.TrimSpace(compose.GetToolCallID(ctx))
|
||||||
|
if sr == nil || w.invokeNotify == nil || tid == "" {
|
||||||
|
return sr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32)
|
||||||
|
agentTag := strings.TrimSpace(w.einoAgentName)
|
||||||
|
|
||||||
|
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string) {
|
||||||
|
defer inner.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
const maxCapture = 16 * 1024
|
||||||
|
success := true
|
||||||
|
var invokeErr error
|
||||||
|
exitCode := 0
|
||||||
|
hasExitCode := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
resp, rerr := inner.Recv()
|
||||||
|
if errors.Is(rerr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
success = false
|
||||||
|
invokeErr = rerr
|
||||||
|
_ = outW.Send(nil, rerr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
if resp.ExitCode != nil {
|
||||||
|
hasExitCode = true
|
||||||
|
exitCode = *resp.ExitCode
|
||||||
|
}
|
||||||
|
if remain := maxCapture - sb.Len(); remain > 0 {
|
||||||
|
out := resp.Output
|
||||||
|
if len(out) > remain {
|
||||||
|
out = out[:remain]
|
||||||
|
}
|
||||||
|
sb.WriteString(out)
|
||||||
|
}
|
||||||
|
if outW.Send(resp, nil) {
|
||||||
|
success = false
|
||||||
|
invokeErr = fmt.Errorf("execute stream closed by consumer")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if success && hasExitCode && exitCode != 0 {
|
||||||
|
success = false
|
||||||
|
invokeErr = fmt.Errorf("execute exited with code %d", exitCode)
|
||||||
|
}
|
||||||
|
if w.recordMonitor != nil {
|
||||||
|
w.recordMonitor(command, sb.String(), success, invokeErr)
|
||||||
|
}
|
||||||
|
w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr)
|
||||||
|
outW.Close()
|
||||||
|
}(sr, cmd)
|
||||||
|
|
||||||
|
return outR, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_exitToolMessage(t *testing.T) {
|
||||||
|
u := schema.UserMessage("hi")
|
||||||
|
tm := schema.ToolMessage("answer for user", "call-exit-1")
|
||||||
|
tm.ToolName = "exit"
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{u, tm}); got != "answer for user" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_lastExitWins(t *testing.T) {
|
||||||
|
msgs := []*schema.Message{
|
||||||
|
schema.UserMessage("hi"),
|
||||||
|
toolExitMsg("first", "c1"),
|
||||||
|
toolExitMsg("second", "c2"),
|
||||||
|
}
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "second" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_fromAssistantToolCalls(t *testing.T) {
|
||||||
|
m := schema.AssistantMessage("", []schema.ToolCall{{
|
||||||
|
ID: "x",
|
||||||
|
Type: "function",
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: "exit",
|
||||||
|
Arguments: `{"final_result":"from args"}`,
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{m}); got != "from args" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t *testing.T) {
|
||||||
|
asst := schema.AssistantMessage("", []schema.ToolCall{{
|
||||||
|
ID: "x",
|
||||||
|
Type: "function",
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: "exit",
|
||||||
|
Arguments: `{"final_result":"from args"}`,
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
tool := toolExitMsg("from tool", "c1")
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{asst, tool}); got != "from tool" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolExitMsg(content, callID string) *schema.Message {
|
||||||
|
m := schema.ToolMessage(content, callID)
|
||||||
|
m.ToolName = "exit"
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/einomcp"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// einoADKFilesystemToolNames 与 cloudwego/eino/adk/middlewares/filesystem 默认 ToolName* 一致。
|
||||||
|
// execute 已由 eino_execute_monitor 落库,此处不包含。
|
||||||
|
var einoADKFilesystemToolNames = map[string]struct{}{
|
||||||
|
"ls": {},
|
||||||
|
"read_file": {},
|
||||||
|
"write_file": {},
|
||||||
|
"edit_file": {},
|
||||||
|
"glob": {},
|
||||||
|
"grep": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBuiltinEinoADKFilesystemToolName(name string) bool {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
_, ok := einoADKFilesystemToolNames[n]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolCallArgsFromAccumulated(msgs []adk.Message, toolCallID, expectToolName string) map[string]interface{} {
|
||||||
|
tid := strings.TrimSpace(toolCallID)
|
||||||
|
expect := strings.TrimSpace(expectToolName)
|
||||||
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
|
m := msgs[i]
|
||||||
|
if m == nil || m.Role != schema.Assistant || len(m.ToolCalls) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := len(m.ToolCalls) - 1; j >= 0; j-- {
|
||||||
|
tc := m.ToolCalls[j]
|
||||||
|
if tid != "" && strings.TrimSpace(tc.ID) != tid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn := strings.TrimSpace(tc.Function.Name)
|
||||||
|
if expect != "" && !strings.EqualFold(fn, expect) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(tc.Function.Arguments)
|
||||||
|
if raw == "" {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
var args map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||||
|
return map[string]interface{}{"arguments_raw": raw}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordEinoADKFilesystemToolMonitor 将 Eino ADK filesystem 中间件工具结果写入 MCP 监控(与 execute / MCP 桥芯片一致)。
|
||||||
|
func recordEinoADKFilesystemToolMonitor(
|
||||||
|
ag *agent.Agent,
|
||||||
|
rec einomcp.ExecutionRecorder,
|
||||||
|
toolName string,
|
||||||
|
toolCallID string,
|
||||||
|
msgs []adk.Message,
|
||||||
|
resultText string,
|
||||||
|
isErr bool,
|
||||||
|
) {
|
||||||
|
if ag == nil || rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(toolName)
|
||||||
|
if name == "" || strings.EqualFold(name, "execute") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isBuiltinEinoADKFilesystemToolName(name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := toolCallArgsFromAccumulated(msgs, toolCallID, name)
|
||||||
|
storedName := "eino_fs::" + strings.ToLower(name)
|
||||||
|
var invErr error
|
||||||
|
if isErr {
|
||||||
|
t := strings.TrimSpace(resultText)
|
||||||
|
if t == "" {
|
||||||
|
invErr = errors.New("tool error")
|
||||||
|
} else {
|
||||||
|
invErr = errors.New(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := ag.RecordLocalToolExecution(storedName, args, resultText, invErr)
|
||||||
|
if id != "" {
|
||||||
|
rec(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelFacingTraceHolder 保存「即将送入 ChatModel」的消息快照(已走 summarization / reduction / orphan 修剪等),
|
||||||
|
// 用于 last_react_input 落库,使续跑与「上下文压缩后」的模型视角一致,而非仅依赖事件流 append 的 runAccumulatedMsgs。
|
||||||
|
type modelFacingTraceHolder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
// msgs 为深拷贝后的切片,避免框架后续原地修改污染快照
|
||||||
|
msgs []adk.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModelFacingTraceHolder() *modelFacingTraceHolder {
|
||||||
|
return &modelFacingTraceHolder{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot 返回当前快照的再一次深拷贝(供序列化落库,避免与 holder 互斥长期持锁)。
|
||||||
|
func (h *modelFacingTraceHolder) Snapshot() []adk.Message {
|
||||||
|
if h == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return cloneADKMessagesForTrace(h.msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *modelFacingTraceHolder) storeFromState(state *adk.ChatModelAgentState) {
|
||||||
|
if h == nil || state == nil || len(state.Messages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cloned := cloneADKMessagesForTrace(state.Messages)
|
||||||
|
if len(cloned) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.msgs = cloned
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneADKMessagesForTrace(msgs []adk.Message) []adk.Message {
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(msgs)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []adk.Message
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelFacingTraceMiddleware 必须在 Handlers 链中处于 **BeforeModel 最后**(telemetry 之后),
|
||||||
|
// 此时 state.Messages 即为本次 LLM 调用的最终入参。
|
||||||
|
type modelFacingTraceMiddleware struct {
|
||||||
|
adk.BaseChatModelAgentMiddleware
|
||||||
|
holder *modelFacingTraceHolder
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModelFacingTraceMiddleware(holder *modelFacingTraceHolder) adk.ChatModelAgentMiddleware {
|
||||||
|
if holder == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &modelFacingTraceMiddleware{holder: holder}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelFacingTraceMiddleware) BeforeModelRewriteState(
|
||||||
|
ctx context.Context,
|
||||||
|
state *adk.ChatModelAgentState,
|
||||||
|
mc *adk.ModelContext,
|
||||||
|
) (context.Context, *adk.ChatModelAgentState, error) {
|
||||||
|
if m.holder != nil && state != nil {
|
||||||
|
m.holder.storeFromState(state)
|
||||||
|
}
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ type PlanExecuteRootArgs struct {
|
|||||||
FilesystemMiddleware adk.ChatModelAgentMiddleware
|
FilesystemMiddleware adk.ChatModelAgentMiddleware
|
||||||
// PlannerReplannerRewriteHandlers applies BeforeModelRewriteState pipeline for planner/replanner input.
|
// PlannerReplannerRewriteHandlers applies BeforeModelRewriteState pipeline for planner/replanner input.
|
||||||
PlannerReplannerRewriteHandlers []adk.ChatModelAgentMiddleware
|
PlannerReplannerRewriteHandlers []adk.ChatModelAgentMiddleware
|
||||||
|
// ModelFacingTrace 可选:由 Executor Handlers 链末尾写入,供 last_react 与 summarization 后上下文对齐。
|
||||||
|
ModelFacingTrace *modelFacingTraceHolder
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
|
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
|
||||||
@@ -101,6 +103,11 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
|||||||
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
|
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
|
||||||
execHandlers = append(execHandlers, teleMw)
|
execHandlers = append(execHandlers, teleMw)
|
||||||
}
|
}
|
||||||
|
if a.ModelFacingTrace != nil {
|
||||||
|
if capMw := newModelFacingTraceMiddleware(a.ModelFacingTrace); capMw != nil {
|
||||||
|
execHandlers = append(execHandlers, capMw)
|
||||||
|
}
|
||||||
|
}
|
||||||
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
|
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
|
||||||
Model: a.ExecModel,
|
Model: a.ExecModel,
|
||||||
ToolsConfig: a.ToolsCfg,
|
ToolsConfig: a.ToolsCfg,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/einomcp"
|
"cyberstrike-ai/internal/einomcp"
|
||||||
"cyberstrike-ai/internal/openai"
|
"cyberstrike-ai/internal/openai"
|
||||||
|
"cyberstrike-ai/internal/reasoning"
|
||||||
|
|
||||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
@@ -37,6 +38,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
history []agent.ChatMessage,
|
history []agent.ChatMessage,
|
||||||
roleTools []string,
|
roleTools []string,
|
||||||
progress func(eventType, message string, data interface{}),
|
progress func(eventType, message string, data interface{}),
|
||||||
|
reasoningClient *reasoning.ClientIntent,
|
||||||
) (*RunResult, error) {
|
) (*RunResult, error) {
|
||||||
if appCfg == nil || ag == nil {
|
if appCfg == nil || ag == nil {
|
||||||
return nil, fmt.Errorf("eino single: 配置或 Agent 为空")
|
return nil, fmt.Errorf("eino single: 配置或 Agent 为空")
|
||||||
@@ -86,8 +88,10 @@ func RunEinoSingleChatModelAgent(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||||
|
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
|
||||||
mainDefs := ag.ToolsForRole(roleTools)
|
mainDefs := ag.ToolsForRole(roleTools)
|
||||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk)
|
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, einoSingleAgentName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
Model: appCfg.OpenAI.Model,
|
Model: appCfg.OpenAI.Model,
|
||||||
HTTPClient: httpClient,
|
HTTPClient: httpClient,
|
||||||
}
|
}
|
||||||
|
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
||||||
|
|
||||||
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,13 +135,15 @@ func RunEinoSingleChatModelAgent(
|
|||||||
return nil, fmt.Errorf("eino single summarization: %w", err)
|
return nil, fmt.Errorf("eino single summarization: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handlers := make([]adk.ChatModelAgentMiddleware, 0, 4)
|
modelFacingTrace := newModelFacingTraceHolder()
|
||||||
|
|
||||||
|
handlers := make([]adk.ChatModelAgentMiddleware, 0, 8)
|
||||||
if len(mainOrchestratorPre) > 0 {
|
if len(mainOrchestratorPre) > 0 {
|
||||||
handlers = append(handlers, mainOrchestratorPre...)
|
handlers = append(handlers, mainOrchestratorPre...)
|
||||||
}
|
}
|
||||||
if einoSkillMW != nil {
|
if einoSkillMW != nil {
|
||||||
if einoFSTools && einoLoc != nil {
|
if einoFSTools && einoLoc != nil {
|
||||||
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc)
|
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor)
|
||||||
if fsErr != nil {
|
if fsErr != nil {
|
||||||
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
||||||
}
|
}
|
||||||
@@ -148,6 +155,9 @@ func RunEinoSingleChatModelAgent(
|
|||||||
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "eino_single"); teleMw != nil {
|
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "eino_single"); teleMw != nil {
|
||||||
handlers = append(handlers, teleMw)
|
handlers = append(handlers, teleMw)
|
||||||
}
|
}
|
||||||
|
if capMw := newModelFacingTraceMiddleware(modelFacingTrace); capMw != nil {
|
||||||
|
handlers = append(handlers, capMw)
|
||||||
|
}
|
||||||
|
|
||||||
maxIter := ma.MaxIteration
|
maxIter := ma.MaxIteration
|
||||||
if maxIter <= 0 {
|
if maxIter <= 0 {
|
||||||
@@ -162,7 +172,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
Tools: mainToolsForCfg,
|
Tools: mainToolsForCfg,
|
||||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||||
{Invokable: hitlToolCallMiddleware()},
|
hitlToolCallMiddleware(),
|
||||||
{Invokable: softRecoveryToolCallMiddleware()},
|
{Invokable: softRecoveryToolCallMiddleware()},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -221,18 +231,22 @@ func RunEinoSingleChatModelAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||||
OrchMode: "eino_single",
|
OrchMode: "eino_single",
|
||||||
OrchestratorName: einoSingleAgentName,
|
OrchestratorName: einoSingleAgentName,
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
SnapshotMCPIDs: snapshotMCPIDs,
|
SnapshotMCPIDs: snapshotMCPIDs,
|
||||||
StreamsMainAssistant: streamsMainAssistant,
|
StreamsMainAssistant: streamsMainAssistant,
|
||||||
EinoRoleTag: einoRoleTag,
|
EinoRoleTag: einoRoleTag,
|
||||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
DA: chatAgent,
|
FilesystemMonitorAgent: ag,
|
||||||
|
FilesystemMonitorRecord: recorder,
|
||||||
|
ToolInvokeNotify: toolInvokeNotify,
|
||||||
|
DA: chatAgent,
|
||||||
|
ModelFacingTrace: modelFacingTrace,
|
||||||
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
}, baseMsgs)
|
}, baseMsgs)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/einomcp"
|
||||||
|
|
||||||
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
|
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
@@ -75,12 +76,23 @@ func prepareEinoSkills(
|
|||||||
// subAgentFilesystemMiddleware returns filesystem middleware for a sub-agent when Deep itself
|
// subAgentFilesystemMiddleware returns filesystem middleware for a sub-agent when Deep itself
|
||||||
// does not set Backend (fsTools false on orchestrator) but we still want tools on subs — not used;
|
// does not set Backend (fsTools false on orchestrator) but we still want tools on subs — not used;
|
||||||
// when orchestrator has Backend, builtin FS is only on outer agent; subs need explicit FS for parity.
|
// when orchestrator has Backend, builtin FS is only on outer agent; subs need explicit FS for parity.
|
||||||
func subAgentFilesystemMiddleware(ctx context.Context, loc *localbk.Local) (adk.ChatModelAgentMiddleware, error) {
|
func subAgentFilesystemMiddleware(
|
||||||
|
ctx context.Context,
|
||||||
|
loc *localbk.Local,
|
||||||
|
invokeNotify *einomcp.ToolInvokeNotifyHolder,
|
||||||
|
einoAgentName string,
|
||||||
|
recordMonitor func(command, stdout string, success bool, invokeErr error),
|
||||||
|
) (adk.ChatModelAgentMiddleware, error) {
|
||||||
if loc == nil {
|
if loc == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return filesystem.New(ctx, &filesystem.MiddlewareConfig{
|
return filesystem.New(ctx, &filesystem.MiddlewareConfig{
|
||||||
Backend: loc,
|
Backend: loc,
|
||||||
StreamingShell: &einoStreamingShellWrap{inner: loc},
|
StreamingShell: &einoStreamingShellWrap{
|
||||||
|
inner: loc,
|
||||||
|
invokeNotify: invokeNotify,
|
||||||
|
einoAgentName: strings.TrimSpace(einoAgentName),
|
||||||
|
recordMonitor: recordMonitor,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ func summarizeFinalizeWithRecentAssistantToolTrail(
|
|||||||
selectedCount++
|
selectedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 还原时间顺序
|
// 还原时间顺序。round 内为原始 *schema.Message 指针,保留 ReasoningContent(DeepSeek 工具续跑所必需)。
|
||||||
selectedMsgs := make([]adk.Message, 0, 8)
|
selectedMsgs := make([]adk.Message, 0, 8)
|
||||||
for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
|
for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
|
||||||
selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
|
selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
"github.com/cloudwego/eino/compose"
|
"github.com/cloudwego/eino/compose"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
type hitlInterceptorKey struct{}
|
type hitlInterceptorKey struct{}
|
||||||
@@ -41,7 +42,31 @@ func WithHITLToolInterceptor(ctx context.Context, fn HITLToolInterceptor) contex
|
|||||||
return context.WithValue(ctx, hitlInterceptorKey{}, fn)
|
return context.WithValue(ctx, hitlInterceptorKey{}, fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hitlToolCallMiddleware() compose.InvokableToolMiddleware {
|
// hitlToolCallMiddleware 同时注册 Invokable 与 Streamable。
|
||||||
|
// Eino filesystem 的 execute 为流式工具(StreamableTool),仅挂 Invokable 时人机协同不会拦截,会直接执行。
|
||||||
|
func hitlToolCallMiddleware() compose.ToolMiddleware {
|
||||||
|
return compose.ToolMiddleware{
|
||||||
|
Invokable: hitlInvokableToolCallMiddleware(),
|
||||||
|
Streamable: hitlStreamableToolCallMiddleware(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hitlClearReturnDirectlyIfTransfer(ctx context.Context, toolName string) {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(toolName), adk.TransferToAgentToolName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = compose.ProcessState[*adk.State](ctx, func(_ context.Context, st *adk.State) error {
|
||||||
|
if st == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st.ReturnDirectlyToolCallID = ""
|
||||||
|
st.HasReturnDirectly = false
|
||||||
|
st.ReturnDirectlyEvent = nil
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||||
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
||||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||||
if input != nil {
|
if input != nil {
|
||||||
@@ -55,17 +80,7 @@ func hitlToolCallMiddleware() compose.InvokableToolMiddleware {
|
|||||||
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
|
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
|
||||||
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
|
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
|
||||||
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
|
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
|
||||||
if strings.EqualFold(strings.TrimSpace(input.Name), adk.TransferToAgentToolName) {
|
hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
|
||||||
_ = compose.ProcessState[*adk.State](ctx, func(_ context.Context, st *adk.State) error {
|
|
||||||
if st == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
st.ReturnDirectlyToolCallID = ""
|
|
||||||
st.HasReturnDirectly = false
|
|
||||||
st.ReturnDirectlyEvent = nil
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &compose.ToolOutput{Result: msg}, nil
|
return &compose.ToolOutput{Result: msg}, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -79,3 +94,30 @@ func hitlToolCallMiddleware() compose.InvokableToolMiddleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
||||||
|
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
|
||||||
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
|
||||||
|
if input != nil {
|
||||||
|
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
||||||
|
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
if IsHumanRejectError(err) {
|
||||||
|
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
||||||
|
input.Name, strings.TrimSpace(err.Error()))
|
||||||
|
hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
|
||||||
|
return &compose.StreamToolOutput{
|
||||||
|
Result: schema.StreamReaderFromArray([]string{msg}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if edited != "" {
|
||||||
|
input.Arguments = edited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next(ctx, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrInterruptContinue 作为 context.CancelCause 使用:用户选择「中断并继续」且当前无进行中的 MCP 工具时,
|
||||||
|
// 取消当前推理/流式输出,并在同一会话任务内携带用户补充说明自动续跑下一轮(类似 Hermes 式人机回合)。
|
||||||
|
var ErrInterruptContinue = errors.New("agent interrupt: continue with user-supplied context")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AggregatedReasoningFromTraceJSON concatenates non-empty assistant `reasoning_content`
|
||||||
|
// fields from last_react-style JSON (slice of message objects) in document order.
|
||||||
|
// Used to persist on the single assistant bubble row for audit and for GetMessages fallback
|
||||||
|
// when the full trace JSON is unavailable. For strict per-message replay, prefer last_react_input.
|
||||||
|
func AggregatedReasoningFromTraceJSON(traceJSON string) string {
|
||||||
|
traceJSON = strings.TrimSpace(traceJSON)
|
||||||
|
if traceJSON == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var arr []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(traceJSON), &arr); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range arr {
|
||||||
|
role, _ := m["role"].(string)
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(role), "assistant") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc := reasoningContentFromMessageMap(m)
|
||||||
|
if rc == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString(rc)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasoningContentFromMessageMap(m map[string]interface{}) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v := m["reasoning_content"].(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAggregatedReasoningFromTraceJSON(t *testing.T) {
|
||||||
|
const j = `[
|
||||||
|
{"role":"user","content":"hi"},
|
||||||
|
{"role":"assistant","content":"c1","reasoning_content":"r1","tool_calls":[{"id":"1","type":"function","function":{"name":"f","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","tool_call_id":"1","content":"out"},
|
||||||
|
{"role":"assistant","content":"c2","reasoning_content":"r2"}
|
||||||
|
]`
|
||||||
|
got := AggregatedReasoningFromTraceJSON(j)
|
||||||
|
want := "r1\nr2"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if AggregatedReasoningFromTraceJSON("") != "" || AggregatedReasoningFromTraceJSON("[]") != "" {
|
||||||
|
t.Fatal("empty expected")
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-84
@@ -17,6 +17,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/einomcp"
|
"cyberstrike-ai/internal/einomcp"
|
||||||
"cyberstrike-ai/internal/openai"
|
"cyberstrike-ai/internal/openai"
|
||||||
|
"cyberstrike-ai/internal/reasoning"
|
||||||
|
|
||||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
@@ -48,6 +49,7 @@ type toolCallPendingInfo struct {
|
|||||||
|
|
||||||
// RunDeepAgent 使用 Eino 多代理预置编排执行一轮对话(deep / plan_execute / supervisor;流式事件通过 progress 回调输出)。
|
// RunDeepAgent 使用 Eino 多代理预置编排执行一轮对话(deep / plan_execute / supervisor;流式事件通过 progress 回调输出)。
|
||||||
// orchestrationOverride 非空时优先(如聊天/WebShell 请求体);否则用 multi_agent.orchestration(遗留 yaml);皆空则按 deep。
|
// orchestrationOverride 非空时优先(如聊天/WebShell 请求体);否则用 multi_agent.orchestration(遗留 yaml);皆空则按 deep。
|
||||||
|
// reasoningClient 来自 ChatRequest.reasoning;可为 nil(机器人/批量等走全局 openai.reasoning)。
|
||||||
func RunDeepAgent(
|
func RunDeepAgent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
appCfg *config.Config,
|
appCfg *config.Config,
|
||||||
@@ -61,6 +63,7 @@ func RunDeepAgent(
|
|||||||
progress func(eventType, message string, data interface{}),
|
progress func(eventType, message string, data interface{}),
|
||||||
agentsMarkdownDir string,
|
agentsMarkdownDir string,
|
||||||
orchestrationOverride string,
|
orchestrationOverride string,
|
||||||
|
reasoningClient *reasoning.ClientIntent,
|
||||||
) (*RunResult, error) {
|
) (*RunResult, error) {
|
||||||
if appCfg == nil || ma == nil || ag == nil {
|
if appCfg == nil || ma == nil || ag == nil {
|
||||||
return nil, fmt.Errorf("multiagent: 配置或 Agent 为空")
|
return nil, fmt.Errorf("multiagent: 配置或 Agent 为空")
|
||||||
@@ -110,6 +113,7 @@ func RunDeepAgent(
|
|||||||
mcpIDs = append(mcpIDs, id)
|
mcpIDs = append(mcpIDs, id)
|
||||||
mcpIDsMu.Unlock()
|
mcpIDsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
|
||||||
|
|
||||||
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
|
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
|
||||||
snapshotMCPIDs := func() []string {
|
snapshotMCPIDs := func() []string {
|
||||||
@@ -120,6 +124,7 @@ func RunDeepAgent(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||||
mainDefs := ag.ToolsForRole(roleTools)
|
mainDefs := ag.ToolsForRole(roleTools)
|
||||||
toolOutputChunk := func(toolName, toolCallID, chunk string) {
|
toolOutputChunk := func(toolName, toolCallID, chunk string) {
|
||||||
// When toolCallId is missing, frontend ignores tool_result_delta.
|
// When toolCallId is missing, frontend ignores tool_result_delta.
|
||||||
@@ -137,16 +142,6 @@ func RunDeepAgent(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mainToolsForCfg, mainOrchestratorPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Timeout: 30 * time.Minute,
|
Timeout: 30 * time.Minute,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
@@ -171,6 +166,7 @@ func RunDeepAgent(
|
|||||||
Model: appCfg.OpenAI.Model,
|
Model: appCfg.OpenAI.Model,
|
||||||
HTTPClient: httpClient,
|
HTTPClient: httpClient,
|
||||||
}
|
}
|
||||||
|
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
||||||
|
|
||||||
deepMaxIter := ma.MaxIteration
|
deepMaxIter := ma.MaxIteration
|
||||||
if deepMaxIter <= 0 {
|
if deepMaxIter <= 0 {
|
||||||
@@ -222,7 +218,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
subDefs := ag.ToolsForRole(roleTools)
|
subDefs := ag.ToolsForRole(roleTools)
|
||||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk)
|
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk, toolInvokeNotify, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
|
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -248,7 +244,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
if einoSkillMW != nil {
|
if einoSkillMW != nil {
|
||||||
if einoFSTools && einoLoc != nil {
|
if einoFSTools && einoLoc != nil {
|
||||||
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc)
|
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor)
|
||||||
if fsErr != nil {
|
if fsErr != nil {
|
||||||
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
||||||
}
|
}
|
||||||
@@ -293,7 +289,7 @@ func RunDeepAgent(
|
|||||||
Tools: subToolsForCfg,
|
Tools: subToolsForCfg,
|
||||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||||
{Invokable: hitlToolCallMiddleware()},
|
hitlToolCallMiddleware(),
|
||||||
{Invokable: softRecoveryToolCallMiddleware()},
|
{Invokable: softRecoveryToolCallMiddleware()},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -319,6 +315,8 @@ func RunDeepAgent(
|
|||||||
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
|
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modelFacingTrace := newModelFacingTraceHolder()
|
||||||
|
|
||||||
// 与 deep.Config.Name / supervisor 主代理 Name 一致。
|
// 与 deep.Config.Name / supervisor 主代理 Name 一致。
|
||||||
orchestratorName := "cyberstrike-deep"
|
orchestratorName := "cyberstrike-deep"
|
||||||
orchDescription := "Coordinates specialist agents and MCP tools for authorized security testing."
|
orchDescription := "Coordinates specialist agents and MCP tools for authorized security testing."
|
||||||
@@ -338,6 +336,16 @@ func RunDeepAgent(
|
|||||||
orchDescription = d
|
orchDescription = d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, orchestratorName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mainToolsForCfg, mainOrchestratorPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools)
|
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools)
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
mainNames := collectToolNames(ctx, mainTools)
|
mainNames := collectToolNames(ctx, mainTools)
|
||||||
@@ -381,7 +389,12 @@ func RunDeepAgent(
|
|||||||
var deepShell filesystem.StreamingShell
|
var deepShell filesystem.StreamingShell
|
||||||
if einoLoc != nil && einoFSTools {
|
if einoLoc != nil && einoFSTools {
|
||||||
deepBackend = einoLoc
|
deepBackend = einoLoc
|
||||||
deepShell = einoLoc
|
deepShell = &einoStreamingShellWrap{
|
||||||
|
inner: einoLoc,
|
||||||
|
invokeNotify: toolInvokeNotify,
|
||||||
|
einoAgentName: orchestratorName,
|
||||||
|
recordMonitor: einoExecMonitor,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
|
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
|
||||||
@@ -400,6 +413,9 @@ func RunDeepAgent(
|
|||||||
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
|
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
|
||||||
deepHandlers = append(deepHandlers, teleMw)
|
deepHandlers = append(deepHandlers, teleMw)
|
||||||
}
|
}
|
||||||
|
if capMw := newModelFacingTraceMiddleware(modelFacingTrace); capMw != nil {
|
||||||
|
deepHandlers = append(deepHandlers, capMw)
|
||||||
|
}
|
||||||
|
|
||||||
supHandlers := []adk.ChatModelAgentMiddleware{}
|
supHandlers := []adk.ChatModelAgentMiddleware{}
|
||||||
if len(mainOrchestratorPre) > 0 {
|
if len(mainOrchestratorPre) > 0 {
|
||||||
@@ -413,13 +429,16 @@ func RunDeepAgent(
|
|||||||
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
|
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
|
||||||
supHandlers = append(supHandlers, teleMw)
|
supHandlers = append(supHandlers, teleMw)
|
||||||
}
|
}
|
||||||
|
if capMw := newModelFacingTraceMiddleware(modelFacingTrace); capMw != nil {
|
||||||
|
supHandlers = append(supHandlers, capMw)
|
||||||
|
}
|
||||||
|
|
||||||
mainToolsCfg := adk.ToolsConfig{
|
mainToolsCfg := adk.ToolsConfig{
|
||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
Tools: mainToolsForCfg,
|
Tools: mainToolsForCfg,
|
||||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||||
{Invokable: hitlToolCallMiddleware()},
|
hitlToolCallMiddleware(),
|
||||||
{Invokable: softRecoveryToolCallMiddleware()},
|
{Invokable: softRecoveryToolCallMiddleware()},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -438,7 +457,7 @@ func RunDeepAgent(
|
|||||||
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||||
var peFsMw adk.ChatModelAgentMiddleware
|
var peFsMw adk.ChatModelAgentMiddleware
|
||||||
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
||||||
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc)
|
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
||||||
}
|
}
|
||||||
@@ -458,6 +477,7 @@ func RunDeepAgent(
|
|||||||
ExecPreMiddlewares: mainOrchestratorPre,
|
ExecPreMiddlewares: mainOrchestratorPre,
|
||||||
SkillMiddleware: einoSkillMW,
|
SkillMiddleware: einoSkillMW,
|
||||||
FilesystemMiddleware: peFsMw,
|
FilesystemMiddleware: peFsMw,
|
||||||
|
ModelFacingTrace: modelFacingTrace,
|
||||||
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
|
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
|
||||||
mainSumMw,
|
mainSumMw,
|
||||||
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
|
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
|
||||||
@@ -549,95 +569,99 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||||
OrchMode: orchMode,
|
OrchMode: orchMode,
|
||||||
OrchestratorName: orchestratorName,
|
OrchestratorName: orchestratorName,
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
SnapshotMCPIDs: snapshotMCPIDs,
|
SnapshotMCPIDs: snapshotMCPIDs,
|
||||||
StreamsMainAssistant: streamsMainAssistant,
|
StreamsMainAssistant: streamsMainAssistant,
|
||||||
EinoRoleTag: einoRoleTag,
|
EinoRoleTag: einoRoleTag,
|
||||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
DA: da,
|
FilesystemMonitorAgent: ag,
|
||||||
|
FilesystemMonitorRecord: recorder,
|
||||||
|
ToolInvokeNotify: toolInvokeNotify,
|
||||||
|
DA: da,
|
||||||
|
ModelFacingTrace: modelFacingTrace,
|
||||||
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
|
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
}, baseMsgs)
|
}, baseMsgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatToolCallsToSchema(tcs []agent.ToolCall) []schema.ToolCall {
|
||||||
|
if len(tcs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]schema.ToolCall, 0, len(tcs))
|
||||||
|
for _, tc := range tcs {
|
||||||
|
if strings.TrimSpace(tc.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
argsStr := ""
|
||||||
|
if tc.Function.Arguments != nil {
|
||||||
|
b, err := json.Marshal(tc.Function.Arguments)
|
||||||
|
if err == nil {
|
||||||
|
argsStr = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typ := tc.Type
|
||||||
|
if typ == "" {
|
||||||
|
typ = "function"
|
||||||
|
}
|
||||||
|
out = append(out, schema.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Type: typ,
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: argsStr,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyToMessages 将轨迹恢复的 ChatMessage 转为 Eino ADK 消息:**不裁剪条数、不按 token 预算截断**,
|
||||||
|
// 并保留 user / assistant(含仅 tool_calls)/ tool,与库中 last_react 轨迹一致。
|
||||||
func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
|
func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
|
||||||
|
_ = appCfg
|
||||||
|
_ = mwCfg
|
||||||
if len(history) == 0 {
|
if len(history) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Keep a bounded tail first; then enforce a token budget.
|
raw := make([]adk.Message, 0, len(history))
|
||||||
const maxHistoryMessages = 200
|
for _, h := range history {
|
||||||
start := 0
|
role := strings.ToLower(strings.TrimSpace(h.Role))
|
||||||
if len(history) > maxHistoryMessages {
|
switch role {
|
||||||
start = len(history) - maxHistoryMessages
|
|
||||||
}
|
|
||||||
raw := make([]adk.Message, 0, len(history[start:]))
|
|
||||||
for _, h := range history[start:] {
|
|
||||||
switch h.Role {
|
|
||||||
case "user":
|
case "user":
|
||||||
if strings.TrimSpace(h.Content) != "" {
|
if strings.TrimSpace(h.Content) != "" {
|
||||||
raw = append(raw, schema.UserMessage(h.Content))
|
raw = append(raw, schema.UserMessage(h.Content))
|
||||||
}
|
}
|
||||||
case "assistant":
|
case "assistant":
|
||||||
if strings.TrimSpace(h.Content) == "" && len(h.ToolCalls) > 0 {
|
toolSchema := chatToolCallsToSchema(h.ToolCalls)
|
||||||
|
hasRC := strings.TrimSpace(h.ReasoningContent) != ""
|
||||||
|
if len(toolSchema) > 0 || strings.TrimSpace(h.Content) != "" || hasRC {
|
||||||
|
am := schema.AssistantMessage(h.Content, toolSchema)
|
||||||
|
if hasRC {
|
||||||
|
am.ReasoningContent = strings.TrimSpace(h.ReasoningContent)
|
||||||
|
}
|
||||||
|
raw = append(raw, am)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
if strings.TrimSpace(h.ToolCallID) == "" && strings.TrimSpace(h.Content) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(h.Content) != "" {
|
var opts []schema.ToolMessageOption
|
||||||
raw = append(raw, schema.AssistantMessage(h.Content, nil))
|
if tn := strings.TrimSpace(h.ToolName); tn != "" {
|
||||||
|
opts = append(opts, schema.WithToolName(tn))
|
||||||
}
|
}
|
||||||
|
raw = append(raw, schema.ToolMessage(h.Content, h.ToolCallID, opts...))
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(raw) == 0 {
|
return raw
|
||||||
return raw
|
|
||||||
}
|
|
||||||
maxTotal := 120000
|
|
||||||
modelName := "gpt-4o"
|
|
||||||
if appCfg != nil {
|
|
||||||
if appCfg.OpenAI.MaxTotalTokens > 0 {
|
|
||||||
maxTotal = appCfg.OpenAI.MaxTotalTokens
|
|
||||||
}
|
|
||||||
if m := strings.TrimSpace(appCfg.OpenAI.Model); m != "" {
|
|
||||||
modelName = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ratio := 0.35
|
|
||||||
if mwCfg != nil {
|
|
||||||
ratio = mwCfg.HistoryInputBudgetRatioEffective()
|
|
||||||
}
|
|
||||||
budget := int(float64(maxTotal) * ratio)
|
|
||||||
if budget < 4096 {
|
|
||||||
budget = 4096
|
|
||||||
}
|
|
||||||
tc := agent.NewTikTokenCounter()
|
|
||||||
outRev := make([]adk.Message, 0, len(raw))
|
|
||||||
used := 0
|
|
||||||
for i := len(raw) - 1; i >= 0; i-- {
|
|
||||||
msg := raw[i]
|
|
||||||
n, err := tc.Count(modelName, string(msg.Role)+"\n"+msg.Content)
|
|
||||||
if err != nil {
|
|
||||||
n = (len(msg.Content) + 3) / 4
|
|
||||||
}
|
|
||||||
if n <= 0 {
|
|
||||||
n = 1
|
|
||||||
}
|
|
||||||
if used+n > budget {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
used += n
|
|
||||||
outRev = append(outRev, msg)
|
|
||||||
}
|
|
||||||
out := make([]adk.Message, 0, len(outRev))
|
|
||||||
for i := len(outRev) - 1; i >= 0; i-- {
|
|
||||||
out = append(out, outRev[i])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeStreamingToolCallFragments 将流式多帧的 ToolCall 按 index 合并 arguments(与 schema.concatToolCalls 行为一致)。
|
// mergeStreamingToolCallFragments 将流式多帧的 ToolCall 按 index 合并 arguments(与 schema.concatToolCalls 行为一致)。
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHistoryToMessagesPreservesReasoningContent(t *testing.T) {
|
||||||
|
h := []agent.ChatMessage{
|
||||||
|
{Role: "user", Content: "u"},
|
||||||
|
{Role: "assistant", Content: "c", ReasoningContent: "r1", ToolCalls: []agent.ToolCall{{ID: "t1", Type: "function", Function: agent.FunctionCall{Name: "f", Arguments: map[string]interface{}{}}}}},
|
||||||
|
}
|
||||||
|
msgs := historyToMessages(h, nil, nil)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("len=%d", len(msgs))
|
||||||
|
}
|
||||||
|
am := msgs[1]
|
||||||
|
if am.ReasoningContent != "r1" || am.Content != "c" {
|
||||||
|
t.Fatalf("got reasoning=%q content=%q", am.ReasoningContent, am.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ package openai
|
|||||||
// Stream: Claude SSE (event: content_block_delta / message_delta) → OpenAI SSE 格式
|
// Stream: Claude SSE (event: content_block_delta / message_delta) → OpenAI SSE 格式
|
||||||
// Auth: Bearer → x-api-key
|
// Auth: Bearer → x-api-key
|
||||||
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
|
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
|
||||||
|
//
|
||||||
|
// Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
|
||||||
|
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -38,6 +41,7 @@ type claudeRequest struct {
|
|||||||
Messages []claudeMessage `json:"messages"`
|
Messages []claudeMessage `json:"messages"`
|
||||||
Tools []claudeTool `json:"tools,omitempty"`
|
Tools []claudeTool `json:"tools,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type claudeMessage struct {
|
type claudeMessage struct {
|
||||||
@@ -76,6 +80,10 @@ type claudeContentBlock struct {
|
|||||||
// text block
|
// text block
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
// thinking block (extended thinking)
|
||||||
|
Thinking string `json:"thinking,omitempty"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
|
||||||
// tool_use block (assistant 返回)
|
// tool_use block (assistant 返回)
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
@@ -176,7 +184,13 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
|
|||||||
|
|
||||||
// tool_calls (assistant 消息中包含工具调用)
|
// tool_calls (assistant 消息中包含工具调用)
|
||||||
if role == "assistant" {
|
if role == "assistant" {
|
||||||
|
rc, _ := mm["reasoning_content"].(string)
|
||||||
|
_, thinkingReplay := parseClaudeReasoningAssistantBlocks(rc)
|
||||||
|
|
||||||
var blocks []claudeContentBlock
|
var blocks []claudeContentBlock
|
||||||
|
for _, tb := range thinkingReplay {
|
||||||
|
blocks = append(blocks, tb)
|
||||||
|
}
|
||||||
if content != "" {
|
if content != "" {
|
||||||
blocks = append(blocks, claudeContentBlock{Type: "text", Text: content})
|
blocks = append(blocks, claudeContentBlock{Type: "text", Text: content})
|
||||||
}
|
}
|
||||||
@@ -290,6 +304,13 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras.
|
||||||
|
if th, ok := oai["thinking"]; ok && th != nil {
|
||||||
|
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
|
||||||
|
req.Thinking = json.RawMessage(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +339,12 @@ func claudeToOpenAIResponseJSON(claudeBody []byte) ([]byte, error) {
|
|||||||
|
|
||||||
var textContent string
|
var textContent string
|
||||||
var toolCalls []interface{}
|
var toolCalls []interface{}
|
||||||
|
var thinkingBlocks []claudeContentBlock
|
||||||
|
|
||||||
for _, block := range cr.Content {
|
for _, block := range cr.Content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
|
case "thinking":
|
||||||
|
thinkingBlocks = append(thinkingBlocks, block)
|
||||||
case "text":
|
case "text":
|
||||||
textContent += block.Text
|
textContent += block.Text
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
@@ -344,6 +368,18 @@ func claudeToOpenAIResponseJSON(claudeBody []byte) ([]byte, error) {
|
|||||||
if len(toolCalls) > 0 {
|
if len(toolCalls) > 0 {
|
||||||
message["tool_calls"] = toolCalls
|
message["tool_calls"] = toolCalls
|
||||||
}
|
}
|
||||||
|
if len(thinkingBlocks) > 0 {
|
||||||
|
var parts []string
|
||||||
|
for _, tb := range thinkingBlocks {
|
||||||
|
if strings.TrimSpace(tb.Thinking) != "" {
|
||||||
|
parts = append(parts, tb.Thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rc := appendClaudeReasoningRoundTrip(strings.Join(parts, "\n\n"), thinkingBlocks)
|
||||||
|
if rc != "" {
|
||||||
|
message["reasoning_content"] = rc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
choice := map[string]interface{}{
|
choice := map[string]interface{}{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
@@ -499,6 +535,7 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
|
|
||||||
for {
|
for {
|
||||||
line, readErr := reader.ReadString('\n')
|
line, readErr := reader.ReadString('\n')
|
||||||
@@ -531,9 +568,14 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
|||||||
if deltaType == "text_delta" {
|
if deltaType == "text_delta" {
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
if text != "" {
|
if text != "" {
|
||||||
full.WriteString(text)
|
var textOut string
|
||||||
|
fullText, textOut = normalizeStreamingDelta(fullText, text)
|
||||||
|
if textOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full.WriteString(textOut)
|
||||||
if onDelta != nil {
|
if onDelta != nil {
|
||||||
if err := onDelta(text); err != nil {
|
if err := onDelta(textOut); err != nil {
|
||||||
return full.String(), err
|
return full.String(), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +645,7 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
finishReason := ""
|
finishReason := ""
|
||||||
|
|
||||||
// 追踪当前正在构建的 content blocks
|
// 追踪当前正在构建的 content blocks
|
||||||
@@ -665,9 +708,14 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
|||||||
if deltaType == "text_delta" {
|
if deltaType == "text_delta" {
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
if text != "" {
|
if text != "" {
|
||||||
full.WriteString(text)
|
var textOut string
|
||||||
|
fullText, textOut = normalizeStreamingDelta(fullText, text)
|
||||||
|
if textOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full.WriteString(textOut)
|
||||||
if onContentDelta != nil {
|
if onContentDelta != nil {
|
||||||
if err := onContentDelta(text); err != nil {
|
if err := onContentDelta(textOut); err != nil {
|
||||||
return full.String(), nil, finishReason, err
|
return full.String(), nil, finishReason, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -889,8 +937,16 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
blockToToolIndex := make(map[int]int)
|
blockToToolIndex := make(map[int]int)
|
||||||
|
blockIndexToType := make(map[int]string)
|
||||||
nextToolIndex := 0
|
nextToolIndex := 0
|
||||||
|
|
||||||
|
type thinkingAcc struct {
|
||||||
|
text strings.Builder
|
||||||
|
sig strings.Builder
|
||||||
|
}
|
||||||
|
thinkingByIndex := make(map[int]*thinkingAcc)
|
||||||
|
var finishedThinking []claudeContentBlock
|
||||||
|
|
||||||
for {
|
for {
|
||||||
line, readErr := reader.ReadString('\n')
|
line, readErr := reader.ReadString('\n')
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
@@ -935,6 +991,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
blockIdx := int(blockIdxFlt)
|
blockIdx := int(blockIdxFlt)
|
||||||
cb, _ := event["content_block"].(map[string]interface{})
|
cb, _ := event["content_block"].(map[string]interface{})
|
||||||
bt, _ := cb["type"].(string)
|
bt, _ := cb["type"].(string)
|
||||||
|
blockIndexToType[blockIdx] = bt
|
||||||
|
|
||||||
|
if bt == "thinking" {
|
||||||
|
thinkingByIndex[blockIdx] = &thinkingAcc{}
|
||||||
|
}
|
||||||
|
|
||||||
if bt == "tool_use" {
|
if bt == "tool_use" {
|
||||||
id, _ := cb["id"].(string)
|
id, _ := cb["id"].(string)
|
||||||
@@ -974,7 +1035,35 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
delta, _ := event["delta"].(map[string]interface{})
|
delta, _ := event["delta"].(map[string]interface{})
|
||||||
dt, _ := delta["type"].(string)
|
dt, _ := delta["type"].(string)
|
||||||
|
|
||||||
if dt == "text_delta" {
|
if dt == "thinking_delta" {
|
||||||
|
tPart, _ := delta["thinking"].(string)
|
||||||
|
if tPart != "" {
|
||||||
|
if acc := thinkingByIndex[blockIdx]; acc != nil {
|
||||||
|
acc.text.WriteString(tPart)
|
||||||
|
}
|
||||||
|
oaiChunk := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"delta": map[string]interface{}{
|
||||||
|
"reasoning_content": tPart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(oaiChunk)
|
||||||
|
if !writeLine("data: " + string(b) + "\n\n") {
|
||||||
|
pw.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if dt == "signature_delta" {
|
||||||
|
sigPart, _ := delta["signature"].(string)
|
||||||
|
if sigPart != "" {
|
||||||
|
if acc := thinkingByIndex[blockIdx]; acc != nil {
|
||||||
|
acc.sig.WriteString(sigPart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if dt == "text_delta" {
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
oaiChunk := map[string]interface{}{
|
oaiChunk := map[string]interface{}{
|
||||||
"choices": []map[string]interface{}{
|
"choices": []map[string]interface{}{
|
||||||
@@ -1019,6 +1108,21 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "content_block_stop":
|
||||||
|
blockIdxFlt, _ := event["index"].(float64)
|
||||||
|
blockIdx := int(blockIdxFlt)
|
||||||
|
bt := blockIndexToType[blockIdx]
|
||||||
|
if bt == "thinking" {
|
||||||
|
if acc := thinkingByIndex[blockIdx]; acc != nil {
|
||||||
|
finishedThinking = append(finishedThinking, claudeContentBlock{
|
||||||
|
Type: "thinking",
|
||||||
|
Thinking: acc.text.String(),
|
||||||
|
Signature: acc.sig.String(),
|
||||||
|
})
|
||||||
|
delete(thinkingByIndex, blockIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "message_delta":
|
case "message_delta":
|
||||||
d, _ := event["delta"].(map[string]interface{})
|
d, _ := event["delta"].(map[string]interface{})
|
||||||
if sr, ok := d["stop_reason"].(string); ok {
|
if sr, ok := d["stop_reason"].(string); ok {
|
||||||
@@ -1039,6 +1143,25 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "message_stop":
|
case "message_stop":
|
||||||
|
if len(finishedThinking) > 0 {
|
||||||
|
suffix := appendClaudeReasoningRoundTrip("", finishedThinking)
|
||||||
|
if strings.TrimSpace(suffix) != "" {
|
||||||
|
oaiChunk := map[string]interface{}{
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"delta": map[string]interface{}{
|
||||||
|
"reasoning_content": suffix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(oaiChunk)
|
||||||
|
if !writeLine("data: " + string(b) + "\n\n") {
|
||||||
|
pw.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
writeLine("data: [DONE]\n\n")
|
writeLine("data: [DONE]\n\n")
|
||||||
pw.Close()
|
pw.Close()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// claudeReasoningRoundTripSep separates human-readable reasoning from a JSON payload of
|
||||||
|
// Anthropic thinking blocks (with signatures) for multi-turn extended thinking + tools.
|
||||||
|
// Not shown in UI (see DisplayReasoningContent).
|
||||||
|
const claudeReasoningRoundTripSep = "\n---CSAI_CLAUDE_THINKING_BLOCKS---\n"
|
||||||
|
|
||||||
|
// DisplayReasoningContent returns reasoning text suitable for the UI (strips internal
|
||||||
|
// Claude round-trip JSON suffix). Safe for DeepSeek/plain reasoning strings (no-op).
|
||||||
|
func DisplayReasoningContent(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(s, claudeReasoningRoundTripSep)
|
||||||
|
if i < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendClaudeReasoningRoundTrip(display string, blocks []claudeContentBlock) string {
|
||||||
|
var payload []map[string]string
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Type != "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload = append(payload, map[string]string{
|
||||||
|
"type": b.Type,
|
||||||
|
"thinking": b.Thinking,
|
||||||
|
"signature": b.Signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return strings.TrimSpace(display)
|
||||||
|
}
|
||||||
|
js, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(display)
|
||||||
|
}
|
||||||
|
d := strings.TrimSpace(display)
|
||||||
|
if d == "" {
|
||||||
|
return claudeReasoningRoundTripSep + string(js)
|
||||||
|
}
|
||||||
|
return d + claudeReasoningRoundTripSep + string(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseClaudeReasoningAssistantBlocks extracts Anthropic thinking blocks from an OpenAI-style
|
||||||
|
// reasoning_content string. When no suffix is present, blocks is nil (caller must not invent signatures).
|
||||||
|
func parseClaudeReasoningAssistantBlocks(reasoningContent string) (display string, blocks []claudeContentBlock) {
|
||||||
|
reasoningContent = strings.TrimSpace(reasoningContent)
|
||||||
|
if reasoningContent == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(reasoningContent, claudeReasoningRoundTripSep)
|
||||||
|
if idx < 0 {
|
||||||
|
return reasoningContent, nil
|
||||||
|
}
|
||||||
|
display = strings.TrimSpace(reasoningContent[:idx])
|
||||||
|
jsonPart := strings.TrimSpace(reasoningContent[idx+len(claudeReasoningRoundTripSep):])
|
||||||
|
var arr []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(jsonPart), &arr); err != nil {
|
||||||
|
return reasoningContent, nil
|
||||||
|
}
|
||||||
|
for _, x := range arr {
|
||||||
|
if x.Type != "thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blocks = append(blocks, claudeContentBlock{Type: "thinking", Thinking: x.Thinking, Signature: x.Signature})
|
||||||
|
}
|
||||||
|
return display, blocks
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisplayReasoningContent(t *testing.T) {
|
||||||
|
raw := "hello" + claudeReasoningRoundTripSep + `[{"type":"thinking","thinking":"x","signature":"sig"}]`
|
||||||
|
if d := DisplayReasoningContent(raw); d != "hello" {
|
||||||
|
t.Fatalf("got %q", d)
|
||||||
|
}
|
||||||
|
if DisplayReasoningContent("plain") != "plain" {
|
||||||
|
t.Fatal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendParseClaudeReasoningRoundTrip(t *testing.T) {
|
||||||
|
blocks := []claudeContentBlock{
|
||||||
|
{Type: "thinking", Thinking: "a", Signature: "s1"},
|
||||||
|
{Type: "thinking", Thinking: "b", Signature: "s2"},
|
||||||
|
}
|
||||||
|
s := appendClaudeReasoningRoundTrip("sum", blocks)
|
||||||
|
if !strings.Contains(s, claudeReasoningRoundTripSep) {
|
||||||
|
t.Fatal("missing sep")
|
||||||
|
}
|
||||||
|
display, back := parseClaudeReasoningAssistantBlocks(s)
|
||||||
|
if display != "sum" || len(back) != 2 {
|
||||||
|
t.Fatalf("display=%q len=%d", display, len(back))
|
||||||
|
}
|
||||||
|
if back[0].Signature != "s1" || back[1].Thinking != "b" {
|
||||||
|
t.Fatalf("%+v", back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
|
||||||
|
rc := appendClaudeReasoningRoundTrip("vis", []claudeContentBlock{
|
||||||
|
{Type: "thinking", Thinking: "t1", Signature: "sig1"},
|
||||||
|
})
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": "claude-3-5-sonnet-latest",
|
||||||
|
"messages": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "out",
|
||||||
|
"reasoning_content": rc,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := convertOpenAIToClaude(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(req.Messages) != 1 {
|
||||||
|
t.Fatalf("messages=%d", len(req.Messages))
|
||||||
|
}
|
||||||
|
blocks := req.Messages[0].Content.Blocks
|
||||||
|
if len(blocks) < 2 {
|
||||||
|
t.Fatalf("blocks=%d", len(blocks))
|
||||||
|
}
|
||||||
|
if blocks[0].Type != "thinking" || blocks[0].Signature != "sig1" {
|
||||||
|
t.Fatalf("first block %+v", blocks[0])
|
||||||
|
}
|
||||||
|
foundText := false
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Type == "text" && b.Text == "out" {
|
||||||
|
foundText = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundText {
|
||||||
|
t.Fatalf("blocks=%+v", blocks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
|
||||||
|
claudeBody := []byte(`{
|
||||||
|
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
||||||
|
"content":[
|
||||||
|
{"type":"thinking","thinking":"step","signature":"sigx"},
|
||||||
|
{"type":"text","text":"hi"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
oai, err := claudeToOpenAIResponseJSON(claudeBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wrap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(oai, &wrap); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
choices := wrap["choices"].([]interface{})
|
||||||
|
ch0 := choices[0].(map[string]interface{})
|
||||||
|
msg := ch0["message"].(map[string]interface{})
|
||||||
|
rc, _ := msg["reasoning_content"].(string)
|
||||||
|
if !strings.Contains(rc, "step") || !strings.Contains(rc, claudeReasoningRoundTripSep) {
|
||||||
|
t.Fatalf("reasoning_content=%q", rc)
|
||||||
|
}
|
||||||
|
if msg["content"] != "hi" {
|
||||||
|
t.Fatal()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeStreamingDelta_RepeatedCharBoundary(t *testing.T) {
|
||||||
|
// 流式在重复数字边界分片:不得把 "43" 的首字符与 "194" 尾字符误合并。
|
||||||
|
cur, d := normalizeStreamingDelta("https://x:194", "43")
|
||||||
|
if want := "https://x:19443"; cur != want {
|
||||||
|
t.Fatalf("next: want %q got %q", want, cur)
|
||||||
|
}
|
||||||
|
if d != "43" {
|
||||||
|
t.Fatalf("delta: want %q got %q", "43", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeStreamingDelta_CumulativePrefix(t *testing.T) {
|
||||||
|
cur, d := normalizeStreamingDelta("今天", "今天天气")
|
||||||
|
if cur != "今天天气" || d != "天气" {
|
||||||
|
t.Fatalf("got cur=%q d=%q", cur, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeStreamingDelta_FullRetransmit(t *testing.T) {
|
||||||
|
cur, d := normalizeStreamingDelta("今天", "今天")
|
||||||
|
if d != "" || cur != "今天" {
|
||||||
|
t.Fatalf("got cur=%q d=%q", cur, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeStreamingDelta_SingleRuneRepeated(t *testing.T) {
|
||||||
|
cur, d := normalizeStreamingDelta("呀", "呀")
|
||||||
|
if want := "呀呀"; cur != want {
|
||||||
|
t.Fatalf("next: want %q got %q", want, cur)
|
||||||
|
}
|
||||||
|
if d != "呀" {
|
||||||
|
t.Fatalf("delta: want %q got %q", "呀", d)
|
||||||
|
}
|
||||||
|
cur, d = normalizeStreamingDelta("4", "4")
|
||||||
|
if want := "44"; cur != want {
|
||||||
|
t.Fatalf("next: want %q got %q", want, cur)
|
||||||
|
}
|
||||||
|
if d != "4" {
|
||||||
|
t.Fatalf("delta: want %q got %q", "4", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeStreamingDelta_CumulativeExtendsNumber(t *testing.T) {
|
||||||
|
// 已缓冲 "194" 后收到累计串 "19443"(注意 "1943" 并非 "19443" 的前缀,不能靠误写的中间态测 HasPrefix)。
|
||||||
|
cur, d := normalizeStreamingDelta("194", "19443")
|
||||||
|
if want := "19443"; cur != want {
|
||||||
|
t.Fatalf("next: want %q got %q", want, cur)
|
||||||
|
}
|
||||||
|
if d != "43" {
|
||||||
|
t.Fatalf("delta: want %q got %q", "43", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
@@ -33,6 +34,32 @@ func (e *APIError) Error() string {
|
|||||||
return fmt.Sprintf("openai api error: status=%d body=%s", e.StatusCode, e.Body)
|
return fmt.Sprintf("openai api error: status=%d body=%s", e.StatusCode, e.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeStreamingDelta 将可能是“累计片段/重发片段”的内容归一化为“纯增量”。
|
||||||
|
// 部分兼容网关会返回累计 content;若直接 append 会出现重复文本。
|
||||||
|
//
|
||||||
|
// 注意:
|
||||||
|
// - 不做「任意后缀与前缀重叠」合并;流式可能在重复字符边界分片("194"+"43"→"19443")。
|
||||||
|
// - HasPrefix 仅在 incoming 严格长于 current 时视为累计全文,否则会把分片产生的第二个相同
|
||||||
|
// 单字/单码点(叠字、44、22 等)误判为「整段重复」而吞字。
|
||||||
|
// - incoming==current 仅当 current 长度 >1 个码点时才视为整包重发;单码点重复必须走拼接。
|
||||||
|
// - 不再使用「current 以 incoming 结尾则丢弃」:否则 "1943"+"43" 会误吞增量(19443 显示成 1943)。
|
||||||
|
// 若网关重复发送尾部片段,应重复送完整累计串,由 HasPrefix 分支去重。
|
||||||
|
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
|
||||||
|
if incoming == "" {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
return incoming, incoming
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(incoming, current) && len(incoming) > len(current) {
|
||||||
|
return incoming, incoming[len(current):]
|
||||||
|
}
|
||||||
|
if incoming == current && utf8.RuneCountInString(current) > 1 {
|
||||||
|
return current, ""
|
||||||
|
}
|
||||||
|
return current + incoming, incoming
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient 创建一个新的OpenAI客户端。
|
// NewClient 创建一个新的OpenAI客户端。
|
||||||
func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client {
|
func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client {
|
||||||
if httpClient == nil {
|
if httpClient == nil {
|
||||||
@@ -219,6 +246,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
|
|
||||||
// 典型 SSE 结构:
|
// 典型 SSE 结构:
|
||||||
// data: {...}\n\n
|
// data: {...}\n\n
|
||||||
@@ -263,9 +291,14 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
full.WriteString(delta)
|
var deltaOut string
|
||||||
|
fullText, deltaOut = normalizeStreamingDelta(fullText, delta)
|
||||||
|
if deltaOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full.WriteString(deltaOut)
|
||||||
if onDelta != nil {
|
if onDelta != nil {
|
||||||
if err := onDelta(delta); err != nil {
|
if err := onDelta(deltaOut); err != nil {
|
||||||
return full.String(), err
|
return full.String(), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +413,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
fullText := ""
|
||||||
finishReason := ""
|
finishReason := ""
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -426,10 +460,14 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
content = delta.Text
|
content = delta.Text
|
||||||
}
|
}
|
||||||
if content != "" {
|
if content != "" {
|
||||||
full.WriteString(content)
|
var contentOut string
|
||||||
if onContentDelta != nil {
|
fullText, contentOut = normalizeStreamingDelta(fullText, content)
|
||||||
if err := onContentDelta(content); err != nil {
|
if contentOut != "" {
|
||||||
return full.String(), nil, finishReason, err
|
full.WriteString(contentOut)
|
||||||
|
if onContentDelta != nil {
|
||||||
|
if err := onContentDelta(contentOut); err != nil {
|
||||||
|
return full.String(), nil, finishReason, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Package reasoning maps user/config intent to CloudWeGo Eino OpenAI ChatModel fields
|
||||||
|
// (ReasoningEffort, ExtraFields such as thinking / reasoning_effort / output_config).
|
||||||
|
package reasoning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientIntent is optional per-request override from ChatRequest.reasoning.
|
||||||
|
type ClientIntent struct {
|
||||||
|
Mode string
|
||||||
|
Effort string
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireProfile int
|
||||||
|
|
||||||
|
const (
|
||||||
|
wireNone wireProfile = iota
|
||||||
|
wireClaude
|
||||||
|
wireDeepseek
|
||||||
|
wireOpenAI
|
||||||
|
wireOutputConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
|
||||||
|
// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set.
|
||||||
|
func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) {
|
||||||
|
if cfg == nil || oa == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sr := &oa.Reasoning
|
||||||
|
allowClient := sr.AllowClientReasoningEffective()
|
||||||
|
mode := effectiveMode(sr, client, allowClient)
|
||||||
|
|
||||||
|
// Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking`
|
||||||
|
// (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent.
|
||||||
|
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") ||
|
||||||
|
strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") {
|
||||||
|
if len(sr.ExtraRequestFields) > 0 {
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
for k, v := range sr.ExtraRequestFields {
|
||||||
|
cfg.ExtraFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "off" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "off" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
effort := effectiveEffort(sr, client, allowClient)
|
||||||
|
prof := resolveWireProfile(oa, sr)
|
||||||
|
|
||||||
|
// Admin-defined extra root fields (merged first; automatic keys may follow).
|
||||||
|
if len(sr.ExtraRequestFields) > 0 {
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
for k, v := range sr.ExtraRequestFields {
|
||||||
|
cfg.ExtraFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prof {
|
||||||
|
case wireClaude, wireNone:
|
||||||
|
return
|
||||||
|
case wireDeepseek:
|
||||||
|
applyDeepseek(cfg, mode, effort)
|
||||||
|
case wireOutputConfig:
|
||||||
|
applyOutputConfigEffort(cfg, mode, effort)
|
||||||
|
default: // wireOpenAI
|
||||||
|
applyOpenAICompat(cfg, mode, effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields.
|
||||||
|
// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget.
|
||||||
|
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
|
||||||
|
if cfg == nil || mode == "off" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
if _, exists := cfg.ExtraFields["thinking"]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := strings.ToLower(strings.TrimSpace(model))
|
||||||
|
thinking := map[string]any{
|
||||||
|
"type": "adaptive",
|
||||||
|
"display": "summarized",
|
||||||
|
}
|
||||||
|
// Sonnet 3.7: manual extended thinking is the documented path.
|
||||||
|
if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") {
|
||||||
|
thinking = map[string]any{
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": 10000,
|
||||||
|
"display": "summarized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Opus 4.7+: manual enabled+budget rejected — keep adaptive only.
|
||||||
|
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
|
||||||
|
thinking = map[string]any{
|
||||||
|
"type": "adaptive",
|
||||||
|
"display": "summarized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place
|
||||||
|
cfg.ExtraFields["thinking"] = thinking
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
|
||||||
|
server := strings.ToLower(strings.TrimSpace(sr.ModeEffective()))
|
||||||
|
if server == "" || server == "default" {
|
||||||
|
server = "auto"
|
||||||
|
}
|
||||||
|
if !allowClient || client == nil {
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
cm := strings.ToLower(strings.TrimSpace(client.Mode))
|
||||||
|
if cm == "" || cm == "default" {
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
return cm
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveEffort(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
|
||||||
|
se := normalizeEffort(sr.Effort)
|
||||||
|
if !allowClient || client == nil {
|
||||||
|
return se
|
||||||
|
}
|
||||||
|
ce := normalizeEffort(client.Effort)
|
||||||
|
if ce != "" {
|
||||||
|
return ce
|
||||||
|
}
|
||||||
|
return se
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEffort(s string) string {
|
||||||
|
e := strings.ToLower(strings.TrimSpace(s))
|
||||||
|
switch e {
|
||||||
|
case "low", "medium", "high", "max":
|
||||||
|
return e
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") {
|
||||||
|
return wireClaude
|
||||||
|
}
|
||||||
|
p := strings.ToLower(strings.TrimSpace(sr.ProfileEffective()))
|
||||||
|
switch p {
|
||||||
|
case "output_config", "output_config_effort":
|
||||||
|
return wireOutputConfig
|
||||||
|
case "openai", "openai_compat":
|
||||||
|
return wireOpenAI
|
||||||
|
case "deepseek", "deepseek_compat":
|
||||||
|
return wireDeepseek
|
||||||
|
case "auto", "":
|
||||||
|
bu := strings.ToLower(oa.BaseURL)
|
||||||
|
mo := strings.ToLower(oa.Model)
|
||||||
|
if strings.Contains(bu, "deepseek") || strings.Contains(mo, "deepseek") {
|
||||||
|
return wireDeepseek
|
||||||
|
}
|
||||||
|
return wireOpenAI
|
||||||
|
default:
|
||||||
|
return wireOpenAI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) {
|
||||||
|
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking.
|
||||||
|
if mode == "off" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "auto" || mode == "on" {
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
cfg.ExtraFields["thinking"] = map[string]any{"type": "enabled"}
|
||||||
|
}
|
||||||
|
if effort != "" {
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
cfg.ExtraFields["reasoning_effort"] = effortStringForAPI(effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOpenAICompat(cfg *einoopenai.ChatModelConfig, mode, effort string) {
|
||||||
|
if mode == "auto" && effort == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := effort
|
||||||
|
if mode == "on" && e == "" {
|
||||||
|
e = "medium"
|
||||||
|
}
|
||||||
|
if e == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e == "max" {
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
cfg.ExtraFields["reasoning_effort"] = "max"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch e {
|
||||||
|
case "low":
|
||||||
|
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelLow
|
||||||
|
case "medium":
|
||||||
|
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelMedium
|
||||||
|
case "high":
|
||||||
|
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOutputConfigEffort(cfg *einoopenai.ChatModelConfig, mode, effort string) {
|
||||||
|
if mode == "auto" && effort == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := effort
|
||||||
|
if mode == "on" && e == "" {
|
||||||
|
e = "high"
|
||||||
|
}
|
||||||
|
if e == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
cfg.ExtraFields = make(map[string]any)
|
||||||
|
}
|
||||||
|
cfg.ExtraFields["output_config"] = map[string]any{"effort": effortStringForAPI(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effortStringForAPI(e string) string {
|
||||||
|
// Gateways expect lowercase strings; "max" kept as max.
|
||||||
|
return strings.ToLower(strings.TrimSpace(e))
|
||||||
|
}
|
||||||
+165
-2
@@ -2391,7 +2391,118 @@ header {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container > .chat-input-with-files {
|
.chat-input-primary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-leading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eino:模型推理收进浮层,保持主输入行简洁 */
|
||||||
|
.chat-reasoning-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-btn {
|
||||||
|
max-width: 10.5rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-btn .chat-reasoning-btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-btn.active .chat-reasoning-btn-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-btn .chat-reasoning-btn-summary {
|
||||||
|
max-width: 7.6rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-btn.active {
|
||||||
|
border-color: rgba(49, 130, 206, 0.45);
|
||||||
|
background: rgba(49, 130, 206, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
width: 288px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-panel-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-panel-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #718096);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #718096);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reasoning-select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: 1px solid var(--border-color, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
color: var(--text-color, #2d3748);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container .chat-input-with-files,
|
||||||
|
.chat-input-primary-row .chat-input-with-files {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2399,7 +2510,8 @@ header {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container > .chat-input-field {
|
.chat-input-container > .chat-input-field,
|
||||||
|
.chat-input-primary-row .chat-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3196,6 +3308,12 @@ header {
|
|||||||
border-color: rgba(220, 53, 69, 0.3);
|
border-color: rgba(220, 53, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-chip.status-cancelled {
|
||||||
|
background: rgba(108, 117, 125, 0.12);
|
||||||
|
color: var(--text-secondary, #6c757d);
|
||||||
|
border-color: rgba(108, 117, 125, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.status-chip.status-pending,
|
.status-chip.status-pending,
|
||||||
.status-chip.status-unknown {
|
.status-chip.status-unknown {
|
||||||
background: rgba(255, 193, 7, 0.12);
|
background: rgba(255, 193, 7, 0.12);
|
||||||
@@ -3203,6 +3321,18 @@ header {
|
|||||||
border-color: rgba(255, 193, 7, 0.3);
|
border-color: rgba(255, 193, 7, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-abort-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.88;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-abort-section .btn-monitor-abort {
|
||||||
|
border-color: rgba(253, 126, 20, 0.55);
|
||||||
|
color: #fd7e14;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-code-card {
|
.detail-code-card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px dashed rgba(0, 0, 0, 0.06);
|
border: 1px dashed rgba(0, 0, 0, 0.06);
|
||||||
@@ -3550,6 +3680,11 @@ header {
|
|||||||
background: rgba(156, 39, 176, 0.05);
|
background: rgba(156, 39, 176, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-item-reasoning_chain {
|
||||||
|
border-left-color: #5c6bc0;
|
||||||
|
background: rgba(92, 107, 192, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-item-tool_call {
|
.timeline-item-tool_call {
|
||||||
border-left-color: #ff9800;
|
border-left-color: #ff9800;
|
||||||
background: rgba(255, 152, 0, 0.05);
|
background: rgba(255, 152, 0, 0.05);
|
||||||
@@ -3575,6 +3710,11 @@ header {
|
|||||||
background: rgba(255, 112, 67, 0.12);
|
background: rgba(255, 112, 67, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-item-user_interrupt_continue {
|
||||||
|
border-left-color: #d97706;
|
||||||
|
background: rgba(217, 119, 6, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-item-header {
|
.timeline-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3605,6 +3745,12 @@ header {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 流式增量阶段纯文本展示(避免半段 Markdown 反复解析) */
|
||||||
|
.timeline-item-content.timeline-stream-plain {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-details {
|
.tool-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -5517,6 +5663,16 @@ header {
|
|||||||
color: var(--error-color);
|
color: var(--error-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.monitor-status-chip.cancelled {
|
||||||
|
background: rgba(108, 117, 125, 0.15);
|
||||||
|
color: var(--text-muted, #6c757d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-execution-actions .btn-monitor-abort {
|
||||||
|
border-color: rgba(253, 126, 20, 0.55);
|
||||||
|
color: #fd7e14;
|
||||||
|
}
|
||||||
|
|
||||||
.monitor-execution-actions {
|
.monitor-execution-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -12255,6 +12411,9 @@ header {
|
|||||||
.webshell-ai-process-block .webshell-ai-timeline-thinking {
|
.webshell-ai-process-block .webshell-ai-timeline-thinking {
|
||||||
border-left-color: #9c27b0;
|
border-left-color: #9c27b0;
|
||||||
}
|
}
|
||||||
|
.webshell-ai-process-block .webshell-ai-timeline-reasoning_chain {
|
||||||
|
border-left-color: #5c6bc0;
|
||||||
|
}
|
||||||
.webshell-ai-process-block .webshell-ai-timeline-tool_call,
|
.webshell-ai-process-block .webshell-ai-timeline-tool_call,
|
||||||
.webshell-ai-process-block .webshell-ai-timeline-tool_calls_detected {
|
.webshell-ai-process-block .webshell-ai-timeline-tool_calls_detected {
|
||||||
border-left-color: #ff9800;
|
border-left-color: #ff9800;
|
||||||
@@ -18272,6 +18431,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-files-toast.chat-toast--error {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
/* 对话附件读取 / 文件管理上传 进度条 */
|
/* 对话附件读取 / 文件管理上传 进度条 */
|
||||||
/* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */
|
/* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */
|
||||||
.chat-upload-progress-row[hidden] {
|
.chat-upload-progress-row[hidden] {
|
||||||
|
|||||||
@@ -277,6 +277,7 @@
|
|||||||
"planExecuteStreamPhase": "Phase output",
|
"planExecuteStreamPhase": "Phase output",
|
||||||
"einoSubAgentStep": "Sub-agent {{agent}} · step {{n}}",
|
"einoSubAgentStep": "Sub-agent {{agent}} · step {{n}}",
|
||||||
"aiThinking": "AI thinking",
|
"aiThinking": "AI thinking",
|
||||||
|
"reasoningChain": "Reasoning process",
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
"assistantStreamPhase": "Assistant output",
|
"assistantStreamPhase": "Assistant output",
|
||||||
"toolCallsDetected": "Detected {{count}} tool call(s)",
|
"toolCallsDetected": "Detected {{count}} tool call(s)",
|
||||||
@@ -288,6 +289,7 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.",
|
"streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.",
|
||||||
"taskCancelled": "Task cancelled",
|
"taskCancelled": "Task cancelled",
|
||||||
|
"userInterruptContinueTitle": "⏸️ User interrupt & continue",
|
||||||
"unknownTool": "Unknown tool",
|
"unknownTool": "Unknown tool",
|
||||||
"einoAgentReplyTitle": "Sub-agent reply",
|
"einoAgentReplyTitle": "Sub-agent reply",
|
||||||
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
|
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
|
||||||
@@ -328,6 +330,19 @@
|
|||||||
"agentModeMulti": "Multi-agent",
|
"agentModeMulti": "Multi-agent",
|
||||||
"agentModeSingleHint": "Single-model ReAct loop for chat and tool use",
|
"agentModeSingleHint": "Single-model ReAct loop for chat and tool use",
|
||||||
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
||||||
|
"reasoningModeLabel": "Model reasoning",
|
||||||
|
"reasoningEffortLabel": "Reasoning effort",
|
||||||
|
"reasoningModeDefault": "Use system default",
|
||||||
|
"reasoningModeOff": "Off",
|
||||||
|
"reasoningModeOn": "On",
|
||||||
|
"reasoningModeAuto": "Auto",
|
||||||
|
"reasoningEffortUnset": "Unspecified",
|
||||||
|
"reasoningCompactLabel": "Reasoning",
|
||||||
|
"reasoningCompactAria": "Open model reasoning options",
|
||||||
|
"reasoningPanelTitle": "Model reasoning",
|
||||||
|
"reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.",
|
||||||
|
"reasoningSummaryFollow": "System",
|
||||||
|
"reasoningSummaryDash": "—",
|
||||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||||
"agentModeOrchSupervisor": "Supervisor",
|
"agentModeOrchSupervisor": "Supervisor",
|
||||||
"hitlTitle": "Human-in-the-loop",
|
"hitlTitle": "Human-in-the-loop",
|
||||||
@@ -394,6 +409,16 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Task Management",
|
"title": "Task Management",
|
||||||
"stopTask": "Stop task",
|
"stopTask": "Stop task",
|
||||||
|
"interruptModalTitle": "Interrupt current step",
|
||||||
|
"interruptReasonLabel": "Interrupt note",
|
||||||
|
"interruptModalHint": "When a tool is running: same as MCP monitor \"Stop tool\" — only that call is stopped and the run continues; your note can be merged into the tool result (USER INTERRUPT NOTE). When no tool is running (model thinking/streaming only): \"Interrupt & continue\" still works — current output pauses, your note is merged into context and the run resumes automatically; the progress timeline shows a \"User interrupt & continue\" entry. Use this instead of a full stop when you only want to steer; use \"Stop completely\" to end the whole task.",
|
||||||
|
"interruptReasonPlaceholder": "e.g. Tool is too slow—skip and summarize…",
|
||||||
|
"interruptReasonRequired": "Please enter a short note so the model can continue accordingly.",
|
||||||
|
"interruptSubmitting": "Submitting...",
|
||||||
|
"interruptConfirmContinue": "Interrupt & continue",
|
||||||
|
"interruptHardStop": "Stop completely",
|
||||||
|
"interruptModalClose": "Close",
|
||||||
|
"userInterruptTimelineTitle": "User interrupt note (continuing)",
|
||||||
"collapseDetail": "Collapse details",
|
"collapseDetail": "Collapse details",
|
||||||
"newTask": "New task",
|
"newTask": "New task",
|
||||||
"autoRefresh": "Auto refresh",
|
"autoRefresh": "Auto refresh",
|
||||||
@@ -1260,6 +1285,8 @@
|
|||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
"statusRunning": "Running",
|
"statusRunning": "Running",
|
||||||
"statusFailed": "Failed",
|
"statusFailed": "Failed",
|
||||||
|
"statusCancelled": "Cancelled",
|
||||||
|
"terminateExecution": "Stop",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noStatsData": "No statistical data",
|
"noStatsData": "No statistical data",
|
||||||
"noExecutions": "No execution records",
|
"noExecutions": "No execution records",
|
||||||
@@ -1579,6 +1606,10 @@
|
|||||||
"maxTotalTokens": "Max Context Tokens",
|
"maxTotalTokens": "Max Context Tokens",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||||
|
"openaiReasoningTitle": "Model reasoning (Eino)",
|
||||||
|
"openaiReasoningHint": "Applies to Eino single-agent and multi-agent only; works with chat-page reasoning controls.",
|
||||||
|
"openaiReasoningProfile": "Wire profile",
|
||||||
|
"openaiReasoningAllowClient": "Allow chat page to override reasoning options",
|
||||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
|
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
|
||||||
"fofaBaseUrlHint": "Leave empty for default.",
|
"fofaBaseUrlHint": "Leave empty for default.",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1727,8 +1758,22 @@
|
|||||||
"statusRunning": "Running",
|
"statusRunning": "Running",
|
||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Completed",
|
||||||
"statusFailed": "Failed",
|
"statusFailed": "Failed",
|
||||||
|
"statusCancelled": "Cancelled",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"getDetailFailed": "Failed to get details",
|
"getDetailFailed": "Failed to get details",
|
||||||
|
"runningNoResponseYet": "No output yet; the tool may still be running. If it hangs, use \"Stop tool\" below to end this call only.",
|
||||||
|
"abortTitle": "Execution control",
|
||||||
|
"abortHint": "Stops only this tool call. The conversation / multi-step task continues (unlike stopping the whole task).",
|
||||||
|
"abortBtn": "Stop tool",
|
||||||
|
"abortConfirm": "Stop this tool call? The overall conversation or iterative task will not be cancelled.",
|
||||||
|
"abortSuccess": "Cancellation requested; status will update when the tool returns.",
|
||||||
|
"abortFailed": "Failed to stop tool",
|
||||||
|
"abortNoteModalTitle": "Stop tool with a note",
|
||||||
|
"abortNoteModalHint": "Optional: why you stopped or how the model should continue. The model sees any tool output first, then a labeled block (USER INTERRUPT NOTE — not raw tool output), then your text. Leave empty for a plain stop.",
|
||||||
|
"abortNoteLabel": "Note (optional)",
|
||||||
|
"abortNotePlaceholder": "e.g. Output is enough—skip waiting and continue…",
|
||||||
|
"abortNoteSubmit": "Stop tool",
|
||||||
|
"abortNoteClose": "Cancel",
|
||||||
"execSuccessNoContent": "Execution succeeded with no displayable content.",
|
"execSuccessNoContent": "Execution succeeded with no displayable content.",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
"executionId": "Execution ID",
|
"executionId": "Execution ID",
|
||||||
|
|||||||
@@ -266,6 +266,7 @@
|
|||||||
"planExecuteStreamPhase": "阶段输出",
|
"planExecuteStreamPhase": "阶段输出",
|
||||||
"einoSubAgentStep": "子代理 {{agent}} · 第 {{n}} 步",
|
"einoSubAgentStep": "子代理 {{agent}} · 第 {{n}} 步",
|
||||||
"aiThinking": "AI思考",
|
"aiThinking": "AI思考",
|
||||||
|
"reasoningChain": "推理过程",
|
||||||
"planning": "规划中",
|
"planning": "规划中",
|
||||||
"assistantStreamPhase": "助手输出",
|
"assistantStreamPhase": "助手输出",
|
||||||
"toolCallsDetected": "检测到 {{count}} 个工具调用",
|
"toolCallsDetected": "检测到 {{count}} 个工具调用",
|
||||||
@@ -277,6 +278,7 @@
|
|||||||
"error": "错误",
|
"error": "错误",
|
||||||
"streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。",
|
"streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。",
|
||||||
"taskCancelled": "任务已取消",
|
"taskCancelled": "任务已取消",
|
||||||
|
"userInterruptContinueTitle": "⏸️ 用户中断并继续",
|
||||||
"unknownTool": "未知工具",
|
"unknownTool": "未知工具",
|
||||||
"einoAgentReplyTitle": "子代理回复",
|
"einoAgentReplyTitle": "子代理回复",
|
||||||
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
||||||
@@ -317,6 +319,19 @@
|
|||||||
"agentModeMulti": "多代理",
|
"agentModeMulti": "多代理",
|
||||||
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
|
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
|
||||||
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
||||||
|
"reasoningModeLabel": "模型推理",
|
||||||
|
"reasoningEffortLabel": "推理强度",
|
||||||
|
"reasoningModeDefault": "跟随系统",
|
||||||
|
"reasoningModeOff": "关闭",
|
||||||
|
"reasoningModeOn": "开启",
|
||||||
|
"reasoningModeAuto": "自动",
|
||||||
|
"reasoningEffortUnset": "不指定",
|
||||||
|
"reasoningCompactLabel": "推理",
|
||||||
|
"reasoningCompactAria": "打开模型推理选项",
|
||||||
|
"reasoningPanelTitle": "模型推理",
|
||||||
|
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
|
||||||
|
"reasoningSummaryFollow": "系统",
|
||||||
|
"reasoningSummaryDash": "—",
|
||||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||||
"agentModeOrchSupervisor": "Supervisor",
|
"agentModeOrchSupervisor": "Supervisor",
|
||||||
"hitlTitle": "人机协同",
|
"hitlTitle": "人机协同",
|
||||||
@@ -383,6 +398,16 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "任务管理",
|
"title": "任务管理",
|
||||||
"stopTask": "停止任务",
|
"stopTask": "停止任务",
|
||||||
|
"interruptModalTitle": "中断当前步骤",
|
||||||
|
"interruptReasonLabel": "中断说明",
|
||||||
|
"interruptModalHint": "有工具在执行时:与 MCP 监控页「终止工具」一致,仅结束当前这一次工具调用,本轮推理会继续;说明可写入工具返回(USER INTERRUPT NOTE)。无工具在执行时(模型纯思考/流式输出):仍可「中断并继续」——会暂停当前输出,把你的说明合并进上下文并自动续跑;进度详情时间线会出现「用户中断并继续」条目。不需要整轮停止时请优先用本按钮;要结束整条任务请用「彻底停止」。",
|
||||||
|
"interruptReasonPlaceholder": "例如:工具耗时过长,请先跳过并总结当前结果…",
|
||||||
|
"interruptReasonRequired": "请填写中断说明,以便模型根据你的意图继续。",
|
||||||
|
"interruptSubmitting": "提交中...",
|
||||||
|
"interruptConfirmContinue": "中断并继续",
|
||||||
|
"interruptHardStop": "彻底停止",
|
||||||
|
"interruptModalClose": "关闭",
|
||||||
|
"userInterruptTimelineTitle": "用户中断说明(继续迭代)",
|
||||||
"collapseDetail": "收起详情",
|
"collapseDetail": "收起详情",
|
||||||
"newTask": "新建任务",
|
"newTask": "新建任务",
|
||||||
"autoRefresh": "自动刷新",
|
"autoRefresh": "自动刷新",
|
||||||
@@ -1249,6 +1274,8 @@
|
|||||||
"statusCompleted": "已完成",
|
"statusCompleted": "已完成",
|
||||||
"statusRunning": "执行中",
|
"statusRunning": "执行中",
|
||||||
"statusFailed": "失败",
|
"statusFailed": "失败",
|
||||||
|
"statusCancelled": "已终止",
|
||||||
|
"terminateExecution": "终止",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noStatsData": "暂无统计数据",
|
"noStatsData": "暂无统计数据",
|
||||||
"noExecutions": "暂无执行记录",
|
"noExecutions": "暂无执行记录",
|
||||||
@@ -1568,6 +1595,10 @@
|
|||||||
"maxTotalTokens": "最大上下文 Token 数",
|
"maxTotalTokens": "最大上下文 Token 数",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||||
|
"openaiReasoningTitle": "模型推理(Eino)",
|
||||||
|
"openaiReasoningHint": "仅 Eino 单代理与多代理请求生效;与对话页「模型推理」下拉配合使用。",
|
||||||
|
"openaiReasoningProfile": "线路 profile",
|
||||||
|
"openaiReasoningAllowClient": "允许对话页覆盖推理选项",
|
||||||
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
|
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
|
||||||
"fofaBaseUrlHint": "留空则使用默认地址。",
|
"fofaBaseUrlHint": "留空则使用默认地址。",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -1716,8 +1747,22 @@
|
|||||||
"statusRunning": "执行中",
|
"statusRunning": "执行中",
|
||||||
"statusCompleted": "已完成",
|
"statusCompleted": "已完成",
|
||||||
"statusFailed": "失败",
|
"statusFailed": "失败",
|
||||||
|
"statusCancelled": "已终止",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"getDetailFailed": "获取详情失败",
|
"getDetailFailed": "获取详情失败",
|
||||||
|
"runningNoResponseYet": "尚无返回,工具可能仍在执行。若长时间无响应,可使用下方「终止工具」结束本次调用。",
|
||||||
|
"abortTitle": "运行控制",
|
||||||
|
"abortHint": "仅中断当前这一次工具调用;对话与多步迭代任务会继续,不会等同于「停止任务」。",
|
||||||
|
"abortBtn": "终止工具",
|
||||||
|
"abortConfirm": "确定终止此次工具调用?整条对话或迭代任务不会因此停止。",
|
||||||
|
"abortSuccess": "已发送终止请求,工具返回后状态将更新。",
|
||||||
|
"abortFailed": "终止失败",
|
||||||
|
"abortNoteModalTitle": "终止工具并补充说明",
|
||||||
|
"abortNoteModalHint": "可选:说明为何终止或希望模型如何继续。提交后模型会先看到工具已输出内容(若有),再看到带「用户终止说明」标题的独立区块(中英标注,与命令行原文区分),最后是您的文字。留空则与原先仅终止一致。",
|
||||||
|
"abortNoteLabel": "终止说明(可选)",
|
||||||
|
"abortNotePlaceholder": "例如:输出已够判断,请停止等待并继续下一步…",
|
||||||
|
"abortNoteSubmit": "提交终止",
|
||||||
|
"abortNoteClose": "取消",
|
||||||
"execSuccessNoContent": "执行成功,未返回可展示的文本内容。",
|
"execSuccessNoContent": "执行成功,未返回可展示的文本内容。",
|
||||||
"time": "时间",
|
"time": "时间",
|
||||||
"executionId": "执行 ID",
|
"executionId": "执行 ID",
|
||||||
|
|||||||
@@ -306,12 +306,13 @@ async function bootstrapApp() {
|
|||||||
|
|
||||||
// 通用工具函数
|
// 通用工具函数
|
||||||
function getStatusText(status) {
|
function getStatusText(status) {
|
||||||
|
const s = (status && String(status).toLowerCase()) || '';
|
||||||
if (typeof window.t !== 'function') {
|
if (typeof window.t !== 'function') {
|
||||||
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' };
|
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败', cancelled: '已终止' };
|
||||||
return fallback[status] || status;
|
return fallback[s] || status;
|
||||||
}
|
}
|
||||||
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' };
|
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled' };
|
||||||
const key = keyMap[status];
|
const key = keyMap[s];
|
||||||
return key ? window.t(key) : status;
|
return key ? window.t(key) : status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+303
-4
@@ -26,6 +26,11 @@ const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
|
|||||||
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
|
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
|
||||||
const MAX_CHAT_FILES = 10;
|
const MAX_CHAT_FILES = 10;
|
||||||
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
|
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
|
||||||
|
/** 与 handler.formatInterruptContinueUserMessage 首段一致;主对话不展示,仅迭代详情(user_interrupt_continue) */
|
||||||
|
const CHAT_INTERRUPT_CONTINUE_USER_PREFIX = '【用户补充 / 中断后继续】';
|
||||||
|
function isInterruptContinueInjectChatMessage(content) {
|
||||||
|
return typeof content === 'string' && content.trimStart().startsWith(CHAT_INTERRUPT_CONTINUE_USER_PREFIX);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 对话附件:选文件后异步 POST /api/chat-uploads,发送时只传 serverPath(绝对路径),请求体不再内联大文件内容。
|
* 对话附件:选文件后异步 POST /api/chat-uploads,发送时只传 serverPath(绝对路径),请求体不再内联大文件内容。
|
||||||
* @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise<void>|null, uploadError: string|null }[]}
|
* @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise<void>|null, uploadError: string|null }[]}
|
||||||
@@ -35,6 +40,8 @@ let chatAttachmentSeq = 0;
|
|||||||
|
|
||||||
// 对话模式:react = 原生 ReAct(/agent-loop);eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
// 对话模式:react = 原生 ReAct(/agent-loop);eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||||
|
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||||
|
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||||
const CHAT_AGENT_MODE_REACT = 'react';
|
const CHAT_AGENT_MODE_REACT = 'react';
|
||||||
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
||||||
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||||
@@ -51,6 +58,28 @@ const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
|||||||
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
||||||
let hitlApplyFeedbackTimer = null;
|
let hitlApplyFeedbackTimer = null;
|
||||||
|
|
||||||
|
/** 非阻塞提示(与 chat-files-toast 样式共用) */
|
||||||
|
function showChatToast(message, type) {
|
||||||
|
const text = message == null ? '' : String(message);
|
||||||
|
if (!text) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'chat-files-toast' + (type === 'error' ? ' chat-toast--error' : '');
|
||||||
|
el.setAttribute('role', 'status');
|
||||||
|
el.textContent = text;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
el.classList.add('chat-files-toast-visible');
|
||||||
|
});
|
||||||
|
const hideMs = type === 'error' ? 4500 : 2600;
|
||||||
|
setTimeout(function () {
|
||||||
|
el.classList.remove('chat-files-toast-visible');
|
||||||
|
setTimeout(function () { el.remove(); }, 300);
|
||||||
|
}, hideMs);
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.showChatToast = showChatToast;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeOrchestrationClient(s) {
|
function normalizeOrchestrationClient(s) {
|
||||||
const v = String(s || '').trim().toLowerCase().replace(/-/g, '_');
|
const v = String(s || '').trim().toLowerCase().replace(/-/g, '_');
|
||||||
if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute';
|
if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute';
|
||||||
@@ -293,7 +322,7 @@ function showHitlApplyFeedback(text, isError, partial) {
|
|||||||
}
|
}
|
||||||
if (!el) {
|
if (!el) {
|
||||||
if (text && isError) {
|
if (text && isError) {
|
||||||
alert(text);
|
showChatToast(text, 'error');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -465,6 +494,132 @@ function syncAgentModeFromValue(value) {
|
|||||||
const v = el.getAttribute('data-value');
|
const v = el.getAttribute('data-value');
|
||||||
el.classList.toggle('selected', v === value);
|
el.classList.toggle('selected', v === value);
|
||||||
});
|
});
|
||||||
|
syncReasoningRowVisibility(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReasoningRowVisibility(modeVal) {
|
||||||
|
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||||
|
if (!wrap) return;
|
||||||
|
const show = modeVal === CHAT_AGENT_MODE_EINO_SINGLE || (multiAgentAPIEnabled && chatAgentModeIsEino(modeVal));
|
||||||
|
wrap.style.display = show ? '' : 'none';
|
||||||
|
if (!show) {
|
||||||
|
closeChatReasoningPanel();
|
||||||
|
} else {
|
||||||
|
updateChatReasoningSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningSummaryModeLabel(mode) {
|
||||||
|
const m = (mode || 'default').trim();
|
||||||
|
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||||
|
switch (m) {
|
||||||
|
case 'off': return t('chat.reasoningModeOff');
|
||||||
|
case 'on': return t('chat.reasoningModeOn');
|
||||||
|
case 'auto': return t('chat.reasoningModeAuto');
|
||||||
|
default: return t('chat.reasoningSummaryFollow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChatReasoningSummary() {
|
||||||
|
const el = document.getElementById('chat-reasoning-summary');
|
||||||
|
const modeEl = document.getElementById('chat-reasoning-mode');
|
||||||
|
const effEl = document.getElementById('chat-reasoning-effort');
|
||||||
|
if (!el || !modeEl) return;
|
||||||
|
const mode = (modeEl.value || 'default').trim();
|
||||||
|
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
|
||||||
|
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
|
||||||
|
const modePart = reasoningSummaryModeLabel(mode);
|
||||||
|
const effPart = effort || t('chat.reasoningSummaryDash');
|
||||||
|
el.textContent = modePart + ' / ' + effPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChatReasoningPanel() {
|
||||||
|
const panel = document.getElementById('chat-reasoning-panel');
|
||||||
|
const btn = document.getElementById('chat-reasoning-btn');
|
||||||
|
if (panel) panel.style.display = 'none';
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChatReasoningPanel() {
|
||||||
|
const panel = document.getElementById('chat-reasoning-panel');
|
||||||
|
const btn = document.getElementById('chat-reasoning-btn');
|
||||||
|
if (!panel || !btn) return;
|
||||||
|
const isOpen = panel.style.display === 'flex';
|
||||||
|
if (isOpen) {
|
||||||
|
closeChatReasoningPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof closeAgentModePanel === 'function') {
|
||||||
|
closeAgentModePanel();
|
||||||
|
}
|
||||||
|
if (typeof closeRoleSelectionPanel === 'function') {
|
||||||
|
closeRoleSelectionPanel();
|
||||||
|
}
|
||||||
|
updateChatReasoningSummary();
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreChatReasoningControlsFromStorage() {
|
||||||
|
try {
|
||||||
|
const m = document.getElementById('chat-reasoning-mode');
|
||||||
|
const e = document.getElementById('chat-reasoning-effort');
|
||||||
|
if (m) {
|
||||||
|
const v = localStorage.getItem(REASONING_MODE_LS);
|
||||||
|
if (v && ['default', 'off', 'on', 'auto'].indexOf(v) !== -1) {
|
||||||
|
m.value = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e) {
|
||||||
|
const v = localStorage.getItem(REASONING_EFFORT_LS);
|
||||||
|
if (v !== null && ['', 'low', 'medium', 'high', 'max'].indexOf(v) !== -1) {
|
||||||
|
e.value = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateChatReasoningSummary();
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistChatReasoningPrefs() {
|
||||||
|
try {
|
||||||
|
const m = document.getElementById('chat-reasoning-mode');
|
||||||
|
const elEff = document.getElementById('chat-reasoning-effort');
|
||||||
|
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
|
||||||
|
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
|
||||||
|
updateChatReasoningSummary();
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 WebShell 等复用:在 Eino 路径下返回 reasoning 请求片段或 undefined */
|
||||||
|
function buildReasoningRequestPayload() {
|
||||||
|
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||||
|
if (!wrap || wrap.style.display === 'none') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const modeEl = document.getElementById('chat-reasoning-mode');
|
||||||
|
const effEl = document.getElementById('chat-reasoning-effort');
|
||||||
|
if (!modeEl) return undefined;
|
||||||
|
const mode = (modeEl.value || 'default').trim();
|
||||||
|
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
|
||||||
|
if (mode === 'default' && !effort) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const o = {};
|
||||||
|
if (mode !== 'default') o.mode = mode;
|
||||||
|
if (effort) o.effort = effort;
|
||||||
|
return Object.keys(o).length ? o : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
|
||||||
|
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
|
||||||
|
window.closeChatReasoningPanel = closeChatReasoningPanel;
|
||||||
|
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
|
||||||
|
window.updateChatReasoningSummary = updateChatReasoningSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAgentModePanel() {
|
function closeAgentModePanel() {
|
||||||
@@ -486,6 +641,9 @@ function toggleAgentModePanel() {
|
|||||||
closeAgentModePanel();
|
closeAgentModePanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (typeof closeChatReasoningPanel === 'function') {
|
||||||
|
closeChatReasoningPanel();
|
||||||
|
}
|
||||||
if (typeof closeRoleSelectionPanel === 'function') {
|
if (typeof closeRoleSelectionPanel === 'function') {
|
||||||
closeRoleSelectionPanel();
|
closeRoleSelectionPanel();
|
||||||
}
|
}
|
||||||
@@ -536,6 +694,8 @@ async function initChatAgentModeFromConfig() {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
sel.value = stored;
|
sel.value = stored;
|
||||||
syncAgentModeFromValue(stored);
|
syncAgentModeFromValue(stored);
|
||||||
|
restoreChatReasoningControlsFromStorage();
|
||||||
|
syncReasoningRowVisibility(stored);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('initChatAgentModeFromConfig', e);
|
console.warn('initChatAgentModeFromConfig', e);
|
||||||
}
|
}
|
||||||
@@ -548,6 +708,9 @@ document.addEventListener('languagechange', function () {
|
|||||||
if (v === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
|
if (v === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
|
||||||
syncAgentModeFromValue(v);
|
syncAgentModeFromValue(v);
|
||||||
}
|
}
|
||||||
|
if (typeof updateChatReasoningSummary === 'function') {
|
||||||
|
updateChatReasoningSummary();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 保存输入框草稿到localStorage(防抖版本)
|
// 保存输入框草稿到localStorage(防抖版本)
|
||||||
@@ -733,6 +896,10 @@ async function sendMessage() {
|
|||||||
serverPath: a.serverPath
|
serverPath: a.serverPath
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
const reasoningPayload = buildReasoningRequestPayload();
|
||||||
|
if (reasoningPayload) {
|
||||||
|
body.reasoning = reasoningPayload;
|
||||||
|
}
|
||||||
// 发送后清空附件列表
|
// 发送后清空附件列表
|
||||||
chatAttachments = [];
|
chatAttachments = [];
|
||||||
renderChatFileChips();
|
renderChatFileChips();
|
||||||
@@ -2201,6 +2368,8 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
}
|
}
|
||||||
} else if (eventType === 'thinking') {
|
} else if (eventType === 'thinking') {
|
||||||
itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
|
itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
|
||||||
|
} else if (eventType === 'reasoning_chain') {
|
||||||
|
itemTitle = agPx + '🔗 ' + (typeof window.t === 'function' ? window.t('chat.reasoningChain') : '推理过程');
|
||||||
} else if (eventType === 'planning') {
|
} else if (eventType === 'planning') {
|
||||||
if (typeof window.einoMainStreamPlanningTitle === 'function') {
|
if (typeof window.einoMainStreamPlanningTitle === 'function') {
|
||||||
itemTitle = window.einoMainStreamPlanningTitle(data);
|
itemTitle = window.einoMainStreamPlanningTitle(data);
|
||||||
@@ -2237,6 +2406,10 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
itemTitle = agPx + '🧑⚖️ HITL · ' + hitlMsg;
|
itemTitle = agPx + '🧑⚖️ HITL · ' + hitlMsg;
|
||||||
} else if (eventType === 'progress') {
|
} else if (eventType === 'progress') {
|
||||||
itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
|
itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
|
||||||
|
} else if (eventType === 'user_interrupt_continue') {
|
||||||
|
itemTitle = typeof window.t === 'function'
|
||||||
|
? window.t('chat.userInterruptContinueTitle')
|
||||||
|
: '⏸️ 用户中断并继续';
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimelineItem(timeline, eventType, {
|
addTimelineItem(timeline, eventType, {
|
||||||
@@ -2446,7 +2619,24 @@ async function showMCPDetail(executionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据';
|
if (normalizedStatus === 'running') {
|
||||||
|
responseElement.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.runningNoResponseYet') : '尚无返回,工具可能仍在执行。若长时间无响应,可在下方终止本次调用。';
|
||||||
|
} else {
|
||||||
|
responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortSection = document.getElementById('detail-abort-section');
|
||||||
|
const abortBtn = document.getElementById('detail-abort-btn');
|
||||||
|
if (abortSection && abortBtn) {
|
||||||
|
if (normalizedStatus === 'running') {
|
||||||
|
abortSection.style.display = 'block';
|
||||||
|
abortBtn.dataset.execId = exec.id || '';
|
||||||
|
abortBtn.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.abortBtn') : '终止工具';
|
||||||
|
} else {
|
||||||
|
abortSection.style.display = 'none';
|
||||||
|
delete abortBtn.dataset.execId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示模态框
|
// 显示模态框
|
||||||
@@ -2464,6 +2654,101 @@ function closeMCPDetail() {
|
|||||||
document.getElementById('mcp-detail-modal').style.display = 'none';
|
document.getElementById('mcp-detail-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
|
||||||
|
async function abortMCPToolExecutionFromDetail() {
|
||||||
|
const btn = document.getElementById('detail-abort-btn');
|
||||||
|
const id = btn && btn.dataset.execId;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await cancelMCPToolExecution(id, { refreshDetail: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 MCP 工具终止弹窗(说明会经服务端加上「用户终止说明」标题块后与工具输出合并给模型)
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {{ refreshDetail?: boolean }} [options]
|
||||||
|
*/
|
||||||
|
function openMcpToolAbortModal(executionId, options = {}) {
|
||||||
|
window.__mcpToolAbortContext = { executionId: executionId, options: options || {} };
|
||||||
|
const ta = document.getElementById('mcp-tool-abort-note');
|
||||||
|
if (ta) {
|
||||||
|
ta.value = '';
|
||||||
|
}
|
||||||
|
const m = document.getElementById('mcp-tool-abort-modal');
|
||||||
|
if (m) {
|
||||||
|
m.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMcpToolAbortModal() {
|
||||||
|
window.__mcpToolAbortContext = null;
|
||||||
|
const m = document.getElementById('mcp-tool-abort-modal');
|
||||||
|
if (m) {
|
||||||
|
m.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMcpToolAbortModal() {
|
||||||
|
const ctx = window.__mcpToolAbortContext;
|
||||||
|
if (!ctx || !ctx.executionId) {
|
||||||
|
closeMcpToolAbortModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const note = (document.getElementById('mcp-tool-abort-note') && document.getElementById('mcp-tool-abort-note').value || '').trim();
|
||||||
|
const executionId = ctx.executionId;
|
||||||
|
const options = ctx.options || {};
|
||||||
|
closeMcpToolAbortModal();
|
||||||
|
await cancelMCPToolExecutionSubmit(executionId, note, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交终止请求(body: { note })
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {string} userNote
|
||||||
|
* @param {{ refreshDetail?: boolean }} [options]
|
||||||
|
*/
|
||||||
|
async function cancelMCPToolExecutionSubmit(executionId, userNote, options = {}) {
|
||||||
|
if (!executionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/monitor/execution/${encodeURIComponent(executionId)}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note: userNote || '' }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.error || body.message || res.statusText);
|
||||||
|
}
|
||||||
|
const okMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortSuccess') : '已发送终止请求';
|
||||||
|
alert(okMsg);
|
||||||
|
if (options.refreshDetail && typeof showMCPDetail === 'function') {
|
||||||
|
await showMCPDetail(executionId);
|
||||||
|
}
|
||||||
|
if (typeof refreshMonitorPanel === 'function') {
|
||||||
|
const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page) ? monitorState.pagination.page : 1;
|
||||||
|
await refreshMonitorPanel(page);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const failMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortFailed') : '终止失败';
|
||||||
|
alert(failMsg + ': ' + (e && e.message ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消单次 MCP 工具执行(监控页「终止」)。弹出说明框后提交;仅取消该次 tools/call,不停止整条对话/迭代任务。
|
||||||
|
* @param {string} executionId
|
||||||
|
* @param {{ refreshDetail?: boolean }} [options]
|
||||||
|
*/
|
||||||
|
async function cancelMCPToolExecution(executionId, options = {}) {
|
||||||
|
if (!executionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMcpToolAbortModal(executionId, options);
|
||||||
|
}
|
||||||
|
|
||||||
// 复制详情面板中的内容
|
// 复制详情面板中的内容
|
||||||
function copyDetailBlock(elementId, triggerBtn = null) {
|
function copyDetailBlock(elementId, triggerBtn = null) {
|
||||||
const target = document.getElementById(elementId);
|
const target = document.getElementById(elementId);
|
||||||
@@ -2741,7 +3026,7 @@ async function loadConversation(conversationId) {
|
|||||||
const conversation = await response.json();
|
const conversation = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
alert('加载对话失败: ' + (conversation.error || '未知错误'));
|
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (seq !== loadConversationRequestSeq) {
|
if (seq !== loadConversationRequestSeq) {
|
||||||
@@ -2841,6 +3126,9 @@ async function loadConversation(conversationId) {
|
|||||||
|
|
||||||
// 渲染单条消息的辅助函数
|
// 渲染单条消息的辅助函数
|
||||||
const renderOneMessage = (msg) => {
|
const renderOneMessage = (msg) => {
|
||||||
|
if (msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let displayContent = msg.content;
|
let displayContent = msg.content;
|
||||||
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
|
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
|
||||||
for (let i = msg.processDetails.length - 1; i >= 0; i--) {
|
for (let i = msg.processDetails.length - 1; i >= 0; i--) {
|
||||||
@@ -2949,7 +3237,7 @@ async function loadConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载对话失败:', error);
|
console.error('加载对话失败:', error);
|
||||||
alert('加载对话失败: ' + error.message);
|
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6505,6 +6793,9 @@ function formatConversationAsMarkdown(conversation, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messages.forEach((msg, index) => {
|
messages.forEach((msg, index) => {
|
||||||
|
if (msg && msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const role = getConversationRoleLabel(msg && msg.role);
|
const role = getConversationRoleLabel(msg && msg.role);
|
||||||
const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt);
|
const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt);
|
||||||
const content = msg && typeof msg.content === 'string' ? msg.content : '';
|
const content = msg && typeof msg.content === 'string' ? msg.content : '';
|
||||||
@@ -7084,6 +7375,14 @@ document.addEventListener('click', function(event) {
|
|||||||
closeAgentModePanel();
|
closeAgentModePanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reasoningWrap = document.getElementById('chat-reasoning-wrapper');
|
||||||
|
const reasoningPanel = document.getElementById('chat-reasoning-panel');
|
||||||
|
if (reasoningWrap && reasoningPanel && reasoningPanel.style.display === 'flex') {
|
||||||
|
if (!reasoningWrap.contains(event.target)) {
|
||||||
|
closeChatReasoningPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建分组
|
// 创建分组
|
||||||
|
|||||||
+337
-84
@@ -1,4 +1,6 @@
|
|||||||
const progressTaskState = new Map();
|
const progressTaskState = new Map();
|
||||||
|
/** @type {{ progressId: string, conversationId: string } | null} */
|
||||||
|
let userInterruptModalPending = null;
|
||||||
let activeTaskInterval = null;
|
let activeTaskInterval = null;
|
||||||
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
||||||
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
||||||
@@ -271,6 +273,47 @@ function escapeHtmlLocal(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 internal/openai.normalizeStreamingDelta 一致:兼容网关/模型返回「累计全文」或整包重发,
|
||||||
|
* 避免前端 buffer += chunk 与后端已归一化的增量叠加导致逐段重复(如「响应中显示了响应中显示了」)。
|
||||||
|
* @returns {[string, string]} [nextBuffer, effectiveDelta]
|
||||||
|
*/
|
||||||
|
function normalizeStreamingDeltaJs(current, incoming) {
|
||||||
|
const cur = current == null ? '' : String(current);
|
||||||
|
const inc = incoming == null ? '' : String(incoming);
|
||||||
|
if (inc === '') {
|
||||||
|
return [cur, ''];
|
||||||
|
}
|
||||||
|
if (cur === '') {
|
||||||
|
return [inc, inc];
|
||||||
|
}
|
||||||
|
if (inc.startsWith(cur) && inc.length > cur.length) {
|
||||||
|
return [inc, inc.slice(cur.length)];
|
||||||
|
}
|
||||||
|
const runeCount = Array.from(cur).length;
|
||||||
|
if (inc === cur && runeCount > 1) {
|
||||||
|
return [cur, ''];
|
||||||
|
}
|
||||||
|
return [cur + inc, inc];
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.normalizeStreamingDeltaJs = normalizeStreamingDeltaJs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */
|
||||||
|
function setTimelineItemContentStreamPlain(contentEl, text) {
|
||||||
|
if (!contentEl) return;
|
||||||
|
contentEl.classList.add('timeline-stream-plain');
|
||||||
|
contentEl.textContent = text == null ? '' : String(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */
|
||||||
|
function setTimelineItemContentStreamRich(contentEl, html) {
|
||||||
|
if (!contentEl) return;
|
||||||
|
contentEl.classList.remove('timeline-stream-plain');
|
||||||
|
contentEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function formatAssistantMarkdownContent(text) {
|
function formatAssistantMarkdownContent(text) {
|
||||||
const raw = text == null ? '' : String(text);
|
const raw = text == null ? '' : String(text);
|
||||||
if (typeof marked !== 'undefined') {
|
if (typeof marked !== 'undefined') {
|
||||||
@@ -354,6 +397,23 @@ function isChatMessagesPinnedToBottom() {
|
|||||||
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
|
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */
|
||||||
|
function findProgressIdByConversationId(conversationId) {
|
||||||
|
if (!conversationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let fallback = null;
|
||||||
|
for (const [pid, st] of progressTaskState) {
|
||||||
|
if (st && st.conversationId === conversationId) {
|
||||||
|
fallback = pid;
|
||||||
|
if (document.getElementById(pid)) {
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function registerProgressTask(progressId, conversationId = null) {
|
function registerProgressTask(progressId, conversationId = null) {
|
||||||
const state = progressTaskState.get(progressId) || {};
|
const state = progressTaskState.get(progressId) || {};
|
||||||
state.conversationId = conversationId !== undefined && conversationId !== null
|
state.conversationId = conversationId !== undefined && conversationId !== null
|
||||||
@@ -410,6 +470,140 @@ async function requestCancel(conversationId) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与 MCP 监控一致:仅终止当前进行中的工具调用,工具返回后本轮推理继续(可选 reason 合并进工具结果) */
|
||||||
|
async function requestCancelWithContinue(conversationId, reason) {
|
||||||
|
const response = await apiFetch('/api/agent-loop/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversationId,
|
||||||
|
reason: reason || '',
|
||||||
|
continueAfter: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败'));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserInterruptModal(progressId, conversationId) {
|
||||||
|
userInterruptModalPending = {
|
||||||
|
progressId: progressId != null && progressId !== '' ? progressId : null,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const ta = document.getElementById('user-interrupt-reason');
|
||||||
|
if (ta) {
|
||||||
|
ta.value = '';
|
||||||
|
}
|
||||||
|
const m = document.getElementById('user-interrupt-modal');
|
||||||
|
if (m) {
|
||||||
|
m.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserInterruptModal() {
|
||||||
|
userInterruptModalPending = null;
|
||||||
|
const m = document.getElementById('user-interrupt-modal');
|
||||||
|
if (m) {
|
||||||
|
m.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUserInterruptContinue() {
|
||||||
|
if (!userInterruptModalPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = (document.getElementById('user-interrupt-reason') && document.getElementById('user-interrupt-reason').value || '').trim();
|
||||||
|
const { progressId, conversationId } = userInterruptModalPending;
|
||||||
|
closeUserInterruptModal();
|
||||||
|
const stopBtn = progressId ? document.getElementById(`${progressId}-stop-btn`) : null;
|
||||||
|
try {
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...';
|
||||||
|
}
|
||||||
|
await requestCancelWithContinue(conversationId, reason);
|
||||||
|
loadActiveTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('中断并继续失败:', error);
|
||||||
|
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '操作失败') + ': ' + error.message);
|
||||||
|
} finally {
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUserInterruptHardCancel() {
|
||||||
|
if (!userInterruptModalPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { progressId, conversationId } = userInterruptModalPending;
|
||||||
|
closeUserInterruptModal();
|
||||||
|
if (progressId) {
|
||||||
|
await performHardCancelProgressTask(progressId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestCancel(conversationId);
|
||||||
|
loadActiveTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消任务失败:', error);
|
||||||
|
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 彻底停止任务(原「停止任务」行为) */
|
||||||
|
async function performHardCancelProgressTask(progressId) {
|
||||||
|
const state = progressTaskState.get(progressId);
|
||||||
|
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
|
||||||
|
|
||||||
|
if (!state || !state.conversationId) {
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cancelling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
markProgressCancelling(progressId);
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestCancel(state.conversationId);
|
||||||
|
loadActiveTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消任务失败:', error);
|
||||||
|
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
|
||||||
|
}
|
||||||
|
const currentState = progressTaskState.get(progressId);
|
||||||
|
if (currentState) {
|
||||||
|
currentState.cancelling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addProgressMessage() {
|
function addProgressMessage() {
|
||||||
const messagesDiv = document.getElementById('chat-messages');
|
const messagesDiv = document.getElementById('chat-messages');
|
||||||
const messageDiv = document.createElement('div');
|
const messageDiv = document.createElement('div');
|
||||||
@@ -590,19 +784,33 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
|||||||
mcpSection.appendChild(buttonsContainer);
|
mcpSection.appendChild(buttonsContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasExecBtns = buttonsContainer.querySelector('.mcp-detail-btn:not(.process-detail-btn)');
|
let maxExecIndex = 0;
|
||||||
if (mcpIds.length > 0 && !hasExecBtns) {
|
const existingExecBtns = buttonsContainer.querySelectorAll('.mcp-detail-btn:not(.process-detail-btn)');
|
||||||
mcpIds.forEach((execId, index) => {
|
existingExecBtns.forEach(function (btn) {
|
||||||
|
const n = parseInt(btn.dataset.execIndex, 10);
|
||||||
|
if (!isNaN(n) && n > maxExecIndex) maxExecIndex = n;
|
||||||
|
});
|
||||||
|
const seenExec = new Set();
|
||||||
|
existingExecBtns.forEach(function (btn) {
|
||||||
|
if (btn.dataset.execId) seenExec.add(String(btn.dataset.execId).trim());
|
||||||
|
});
|
||||||
|
let appendedAny = false;
|
||||||
|
if (mcpIds.length > 0) {
|
||||||
|
mcpIds.forEach(function (execId) {
|
||||||
|
const id = execId != null ? String(execId).trim() : '';
|
||||||
|
if (!id || seenExec.has(id)) return;
|
||||||
|
seenExec.add(id);
|
||||||
|
maxExecIndex += 1;
|
||||||
|
appendedAny = true;
|
||||||
const detailBtn = document.createElement('button');
|
const detailBtn = document.createElement('button');
|
||||||
detailBtn.className = 'mcp-detail-btn';
|
detailBtn.className = 'mcp-detail-btn';
|
||||||
detailBtn.dataset.execId = execId;
|
detailBtn.dataset.execId = id;
|
||||||
detailBtn.dataset.execIndex = String(index + 1);
|
detailBtn.dataset.execIndex = String(maxExecIndex);
|
||||||
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: maxExecIndex }) : '调用 #' + maxExecIndex) + '</span>';
|
||||||
detailBtn.onclick = () => showMCPDetail(execId);
|
detailBtn.onclick = function () { showMCPDetail(id); };
|
||||||
buttonsContainer.appendChild(detailBtn);
|
buttonsContainer.appendChild(detailBtn);
|
||||||
});
|
});
|
||||||
// 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
|
if (appendedAny && typeof batchUpdateButtonToolNames === 'function') {
|
||||||
if (typeof batchUpdateButtonToolNames === 'function') {
|
|
||||||
batchUpdateButtonToolNames(buttonsContainer, mcpIds);
|
batchUpdateButtonToolNames(buttonsContainer, mcpIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,7 +945,7 @@ function toggleProcessDetails(progressId, assistantMessageId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止当前进度对应的任务
|
// 停止当前进度:弹出「中断并说明 / 彻底停止」
|
||||||
async function cancelProgressTask(progressId) {
|
async function cancelProgressTask(progressId) {
|
||||||
const state = progressTaskState.get(progressId);
|
const state = progressTaskState.get(progressId);
|
||||||
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
|
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
|
||||||
@@ -757,27 +965,7 @@ async function cancelProgressTask(progressId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
markProgressCancelling(progressId);
|
openUserInterruptModal(progressId, state.conversationId);
|
||||||
if (stopBtn) {
|
|
||||||
stopBtn.disabled = true;
|
|
||||||
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await requestCancel(state.conversationId);
|
|
||||||
loadActiveTasks();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('取消任务失败:', error);
|
|
||||||
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
|
||||||
if (stopBtn) {
|
|
||||||
stopBtn.disabled = false;
|
|
||||||
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
|
|
||||||
}
|
|
||||||
const currentState = progressTaskState.get(progressId);
|
|
||||||
if (currentState) {
|
|
||||||
currentState.cancelling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将进度消息转换为可折叠的详情组件
|
// 将进度消息转换为可折叠的详情组件
|
||||||
@@ -905,6 +1093,24 @@ function resolveStreamTimeline(progressId) {
|
|||||||
return timeline;
|
return timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 去重合并 MCP execution id(顺序:先 prev 后 next),用于多段 Run / 多次 SSE 同一任务。 */
|
||||||
|
function mergeMcpExecutionIDLists(prev, next) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
const add = function (arr) {
|
||||||
|
if (!Array.isArray(arr)) return;
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
const s = arr[i] != null ? String(arr[i]).trim() : '';
|
||||||
|
if (!s || seen.has(s)) continue;
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add(prev);
|
||||||
|
add(next);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// 处理流式事件
|
// 处理流式事件
|
||||||
function handleStreamEvent(event, progressElement, progressId,
|
function handleStreamEvent(event, progressElement, progressId,
|
||||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||||
@@ -1017,20 +1223,38 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'thinking_stream_start': {
|
case 'thinking_stream_start':
|
||||||
|
case 'reasoning_chain_stream_start': {
|
||||||
const d = event.data || {};
|
const d = event.data || {};
|
||||||
const streamId = d.streamId || null;
|
const streamId = d.streamId || null;
|
||||||
if (!streamId) break;
|
if (!streamId) break;
|
||||||
|
|
||||||
|
const timelineType = event.type === 'reasoning_chain_stream_start' ? 'reasoning_chain' : 'thinking';
|
||||||
|
|
||||||
let state = thinkingStreamStateByProgressId.get(progressId);
|
let state = thinkingStreamStateByProgressId.get(progressId);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = new Map();
|
state = new Map();
|
||||||
thinkingStreamStateByProgressId.set(progressId, state);
|
thinkingStreamStateByProgressId.set(progressId, state);
|
||||||
}
|
}
|
||||||
// 若已存在,重置 buffer
|
// 同一 streamId 重复 start:复用已有条目,避免孤儿卡片 + 新条目重复收 delta
|
||||||
const thinkBase = typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考';
|
if (state.has(streamId)) {
|
||||||
const title = timelineAgentBracketPrefix(d) + '🤔 ' + thinkBase;
|
const ex = state.get(streamId);
|
||||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
ex.buffer = '';
|
||||||
|
const existingItem = document.getElementById(ex.itemId);
|
||||||
|
if (existingItem) {
|
||||||
|
const contentEl = existingItem.querySelector('.timeline-item-content');
|
||||||
|
if (contentEl) {
|
||||||
|
setTimelineItemContentStreamPlain(contentEl, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const labelBase = typeof window.t === 'function'
|
||||||
|
? window.t(timelineType === 'reasoning_chain' ? 'chat.reasoningChain' : 'chat.aiThinking')
|
||||||
|
: (timelineType === 'reasoning_chain' ? '推理过程' : 'AI思考');
|
||||||
|
const emoji = timelineType === 'reasoning_chain' ? '🔗' : '🤔';
|
||||||
|
const title = timelineAgentBracketPrefix(d) + emoji + ' ' + labelBase;
|
||||||
|
const itemId = addTimelineItem(timeline, timelineType, {
|
||||||
title: title,
|
title: title,
|
||||||
message: ' ',
|
message: ' ',
|
||||||
data: d
|
data: d
|
||||||
@@ -1039,7 +1263,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'thinking_stream_delta': {
|
case 'thinking_stream_delta':
|
||||||
|
case 'reasoning_chain_stream_delta': {
|
||||||
const d = event.data || {};
|
const d = event.data || {};
|
||||||
const streamId = d.streamId || null;
|
const streamId = d.streamId || null;
|
||||||
if (!streamId) break;
|
if (!streamId) break;
|
||||||
@@ -1049,24 +1274,23 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const s = state.get(streamId);
|
const s = state.get(streamId);
|
||||||
|
|
||||||
const delta = event.message || '';
|
const delta = event.message || '';
|
||||||
s.buffer += delta;
|
const merged = normalizeStreamingDeltaJs(s.buffer, delta);
|
||||||
|
s.buffer = merged[0];
|
||||||
|
|
||||||
const item = document.getElementById(s.itemId);
|
const item = document.getElementById(s.itemId);
|
||||||
if (item) {
|
if (item) {
|
||||||
const contentEl = item.querySelector('.timeline-item-content');
|
const contentEl = item.querySelector('.timeline-item-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
if (typeof formatMarkdown === 'function') {
|
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
||||||
contentEl.innerHTML = formatMarkdown(s.buffer);
|
|
||||||
} else {
|
|
||||||
contentEl.textContent = s.buffer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
// 如果本 thinking 是由 thinking_stream_* 聚合出来的(带 streamId),避免重复创建 timeline item
|
case 'reasoning_chain': {
|
||||||
|
const timelineType = event.type === 'reasoning_chain' ? 'reasoning_chain' : 'thinking';
|
||||||
|
// 若已由 *_stream_* 聚合(带 streamId),避免重复创建 timeline item
|
||||||
if (event.data && event.data.streamId) {
|
if (event.data && event.data.streamId) {
|
||||||
const streamId = event.data.streamId;
|
const streamId = event.data.streamId;
|
||||||
const state = thinkingStreamStateByProgressId.get(progressId);
|
const state = thinkingStreamStateByProgressId.get(progressId);
|
||||||
@@ -1077,11 +1301,10 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
if (item) {
|
if (item) {
|
||||||
const contentEl = item.querySelector('.timeline-item-content');
|
const contentEl = item.querySelector('.timeline-item-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
// contentEl.innerHTML 用于兼容 Markdown 展示
|
|
||||||
if (typeof formatMarkdown === 'function') {
|
if (typeof formatMarkdown === 'function') {
|
||||||
contentEl.innerHTML = formatMarkdown(s.buffer);
|
setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer));
|
||||||
} else {
|
} else {
|
||||||
contentEl.textContent = s.buffer;
|
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1089,12 +1312,17 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimelineItem(timeline, 'thinking', {
|
const labelBase = typeof window.t === 'function'
|
||||||
title: timelineAgentBracketPrefix(event.data) + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
|
? window.t(timelineType === 'reasoning_chain' ? 'chat.reasoningChain' : 'chat.aiThinking')
|
||||||
|
: (timelineType === 'reasoning_chain' ? '推理过程' : 'AI思考');
|
||||||
|
const emoji = timelineType === 'reasoning_chain' ? '🔗' : '🤔';
|
||||||
|
addTimelineItem(timeline, timelineType, {
|
||||||
|
title: timelineAgentBracketPrefix(event.data) + emoji + ' ' + labelBase,
|
||||||
message: event.message,
|
message: event.message,
|
||||||
data: event.data
|
data: event.data
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool_calls_detected':
|
case 'tool_calls_detected':
|
||||||
addTimelineItem(timeline, 'tool_calls_detected', {
|
addTimelineItem(timeline, 'tool_calls_detected', {
|
||||||
@@ -1138,6 +1366,19 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'user_interrupt_continue': {
|
||||||
|
const d = event.data || {};
|
||||||
|
const titleBase = typeof window.t === 'function'
|
||||||
|
? window.t('chat.userInterruptContinueTitle')
|
||||||
|
: '⏸️ 用户中断并继续';
|
||||||
|
addTimelineItem(timeline, 'user_interrupt_continue', {
|
||||||
|
title: titleBase,
|
||||||
|
message: event.message || '',
|
||||||
|
data: d
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'eino_stream_error': {
|
case 'eino_stream_error': {
|
||||||
const d = event.data || {};
|
const d = event.data || {};
|
||||||
const agent = d.einoAgent ? String(d.einoAgent) : '';
|
const agent = d.einoAgent ? String(d.einoAgent) : '';
|
||||||
@@ -1323,6 +1564,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
stateMap = new Map();
|
stateMap = new Map();
|
||||||
einoAgentReplyStreamStateByProgressId.set(progressId, stateMap);
|
einoAgentReplyStreamStateByProgressId.set(progressId, stateMap);
|
||||||
}
|
}
|
||||||
|
if (stateMap.has(streamId)) {
|
||||||
|
const ex = stateMap.get(streamId);
|
||||||
|
ex.buffer = '';
|
||||||
|
const existingItem = document.getElementById(ex.itemId);
|
||||||
|
if (existingItem) {
|
||||||
|
let contentEl = existingItem.querySelector('.timeline-item-content');
|
||||||
|
if (contentEl) {
|
||||||
|
setTimelineItemContentStreamPlain(contentEl, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
|
const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
|
||||||
const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
|
const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
|
||||||
const itemId = addTimelineItem(timeline, 'eino_agent_reply', {
|
const itemId = addTimelineItem(timeline, 'eino_agent_reply', {
|
||||||
@@ -1344,7 +1597,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId);
|
const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId);
|
||||||
if (!stateMap || !stateMap.has(streamId)) break;
|
if (!stateMap || !stateMap.has(streamId)) break;
|
||||||
const s = stateMap.get(streamId);
|
const s = stateMap.get(streamId);
|
||||||
s.buffer += delta;
|
const merged = normalizeStreamingDeltaJs(s.buffer, delta);
|
||||||
|
s.buffer = merged[0];
|
||||||
const item = document.getElementById(s.itemId);
|
const item = document.getElementById(s.itemId);
|
||||||
if (item) {
|
if (item) {
|
||||||
let contentEl = item.querySelector('.timeline-item-content');
|
let contentEl = item.querySelector('.timeline-item-content');
|
||||||
@@ -1357,11 +1611,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
if (typeof formatMarkdown === 'function') {
|
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
||||||
contentEl.innerHTML = formatMarkdown(s.buffer);
|
|
||||||
} else {
|
|
||||||
contentEl.textContent = s.buffer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1389,9 +1639,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
item.appendChild(contentEl);
|
item.appendChild(contentEl);
|
||||||
}
|
}
|
||||||
if (typeof formatMarkdown === 'function') {
|
if (typeof formatMarkdown === 'function') {
|
||||||
contentEl.innerHTML = formatMarkdown(full);
|
setTimelineItemContentStreamRich(contentEl, formatMarkdown(full));
|
||||||
} else {
|
} else {
|
||||||
contentEl.textContent = full;
|
setTimelineItemContentStreamPlain(contentEl, full);
|
||||||
}
|
}
|
||||||
if (d.einoAgent != null && String(d.einoAgent).trim() !== '') {
|
if (d.einoAgent != null && String(d.einoAgent).trim() !== '') {
|
||||||
item.dataset.einoAgent = String(d.einoAgent).trim();
|
item.dataset.einoAgent = String(d.einoAgent).trim();
|
||||||
@@ -1481,7 +1731,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
|
|
||||||
const responseData = event.data || {};
|
const responseData = event.data || {};
|
||||||
const mcpIds = responseData.mcpExecutionIds || [];
|
const mcpIds = responseData.mcpExecutionIds || [];
|
||||||
setMcpIds(mcpIds);
|
setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds));
|
||||||
|
|
||||||
if (responseData.conversationId) {
|
if (responseData.conversationId) {
|
||||||
// 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略
|
// 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略
|
||||||
@@ -1532,7 +1782,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deltaContent = event.message || '';
|
const deltaContent = event.message || '';
|
||||||
state.buffer += deltaContent;
|
const mergedResp = normalizeStreamingDeltaJs(state.buffer, deltaContent);
|
||||||
|
state.buffer = mergedResp[0];
|
||||||
|
|
||||||
// 更新时间线条目内容
|
// 更新时间线条目内容
|
||||||
if (state.itemId) {
|
if (state.itemId) {
|
||||||
@@ -1542,11 +1793,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
const meta = state.streamMeta || responseData;
|
const meta = state.streamMeta || responseData;
|
||||||
const body = formatTimelineStreamBody(state.buffer, meta);
|
const body = formatTimelineStreamBody(state.buffer, meta);
|
||||||
if (typeof formatMarkdown === 'function') {
|
setTimelineItemContentStreamPlain(contentEl, body);
|
||||||
contentEl.innerHTML = formatMarkdown(body);
|
|
||||||
} else {
|
|
||||||
contentEl.textContent = body;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1560,7 +1807,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
|
|
||||||
// 先更新 mcp ids
|
// 先更新 mcp ids
|
||||||
const responseData = event.data || {};
|
const responseData = event.data || {};
|
||||||
const mcpIds = responseData.mcpExecutionIds || [];
|
const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []);
|
||||||
setMcpIds(mcpIds);
|
setMcpIds(mcpIds);
|
||||||
|
|
||||||
// 更新对话ID
|
// 更新对话ID
|
||||||
@@ -2084,7 +2331,7 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
if (line.indexOf('data: ') === 0) {
|
if (line.indexOf('data: ') === 0) {
|
||||||
try {
|
try {
|
||||||
const eventData = JSON.parse(line.slice(6));
|
const eventData = JSON.parse(line.slice(6));
|
||||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; });
|
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('task-events parse', e);
|
console.error('task-events parse', e);
|
||||||
}
|
}
|
||||||
@@ -2242,7 +2489,7 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// 根据类型添加详细内容
|
// 根据类型添加详细内容
|
||||||
if ((type === 'thinking' || type === 'planning') && options.message) {
|
if ((type === 'thinking' || type === 'reasoning_chain' || type === 'planning') && options.message) {
|
||||||
const streamBody = typeof formatTimelineStreamBody === 'function'
|
const streamBody = typeof formatTimelineStreamBody === 'function'
|
||||||
? formatTimelineStreamBody(options.message, options.data)
|
? formatTimelineStreamBody(options.message, options.data)
|
||||||
: options.message;
|
: options.message;
|
||||||
@@ -2297,6 +2544,11 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
${escapeHtml(options.message || taskCancelledLabel)}
|
${escapeHtml(options.message || taskCancelledLabel)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else if (type === 'user_interrupt_continue' && options.message) {
|
||||||
|
const streamBody = typeof formatTimelineStreamBody === 'function'
|
||||||
|
? formatTimelineStreamBody(options.message, options.data)
|
||||||
|
: options.message;
|
||||||
|
content += `<div class="timeline-item-content">${formatMarkdown(streamBody)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.innerHTML = content;
|
item.innerHTML = content;
|
||||||
@@ -2417,7 +2669,7 @@ function renderActiveTasks(tasks) {
|
|||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
cancelBtn.onclick = (evt) => {
|
cancelBtn.onclick = (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
cancelActiveTask(task.conversationId, cancelBtn);
|
cancelActiveTask(task.conversationId);
|
||||||
};
|
};
|
||||||
if (task.status === 'cancelling') {
|
if (task.status === 'cancelling') {
|
||||||
cancelBtn.disabled = true;
|
cancelBtn.disabled = true;
|
||||||
@@ -2430,21 +2682,12 @@ function renderActiveTasks(tasks) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelActiveTask(conversationId, button) {
|
function cancelActiveTask(conversationId) {
|
||||||
if (!conversationId) return;
|
if (!conversationId) {
|
||||||
const originalText = button.textContent;
|
return;
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await requestCancel(conversationId);
|
|
||||||
loadActiveTasks();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('取消任务失败:', error);
|
|
||||||
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = originalText;
|
|
||||||
}
|
}
|
||||||
|
const progressId = findProgressIdByConversationId(conversationId);
|
||||||
|
openUserInterruptModal(progressId, conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let monitorPanelFetchSeq = 0;
|
let monitorPanelFetchSeq = 0;
|
||||||
@@ -2777,7 +3020,8 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
|||||||
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
|
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
|
||||||
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
|
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
|
||||||
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
|
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
|
||||||
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' };
|
const terminateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.terminateExecution') : '终止';
|
||||||
|
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed', cancelled: 'statusCancelled' };
|
||||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
||||||
const rows = executions
|
const rows = executions
|
||||||
.map(exec => {
|
.map(exec => {
|
||||||
@@ -2788,7 +3032,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
|||||||
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
|
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
|
||||||
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
||||||
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
|
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
|
||||||
const executionId = escapeHtml(exec.id || '');
|
const rawExecId = exec.id || '';
|
||||||
|
const executionId = escapeHtml(rawExecId);
|
||||||
|
const terminateBtn = status === 'running'
|
||||||
|
? `<button type="button" class="btn-secondary btn-monitor-abort" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -2801,6 +3049,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
|||||||
<td>
|
<td>
|
||||||
<div class="monitor-execution-actions">
|
<div class="monitor-execution-actions">
|
||||||
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
|
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
|
||||||
|
${terminateBtn}
|
||||||
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
|
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -3175,6 +3424,8 @@ function refreshProgressAndTimelineI18n() {
|
|||||||
} else {
|
} else {
|
||||||
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
|
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
|
||||||
}
|
}
|
||||||
|
} else if (type === 'reasoning_chain') {
|
||||||
|
titleSpan.textContent = ap + '\uD83D\uDD17 ' + _t('chat.reasoningChain');
|
||||||
} else if (type === 'planning') {
|
} else if (type === 'planning') {
|
||||||
if (item.dataset.orchestration && typeof einoMainStreamPlanningTitle === 'function') {
|
if (item.dataset.orchestration && typeof einoMainStreamPlanningTitle === 'function') {
|
||||||
titleSpan.textContent = einoMainStreamPlanningTitle({
|
titleSpan.textContent = einoMainStreamPlanningTitle({
|
||||||
@@ -3201,6 +3452,8 @@ function refreshProgressAndTimelineI18n() {
|
|||||||
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
|
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
|
||||||
} else if (type === 'cancelled') {
|
} else if (type === 'cancelled') {
|
||||||
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
|
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
|
||||||
|
} else if (type === 'user_interrupt_continue') {
|
||||||
|
titleSpan.textContent = _t('chat.userInterruptContinueTitle');
|
||||||
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
|
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
|
||||||
titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage;
|
titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ function toggleRoleSelectionPanel() {
|
|||||||
if (typeof closeAgentModePanel === 'function') {
|
if (typeof closeAgentModePanel === 'function') {
|
||||||
closeAgentModePanel();
|
closeAgentModePanel();
|
||||||
}
|
}
|
||||||
|
if (typeof closeChatReasoningPanel === 'function') {
|
||||||
|
closeChatReasoningPanel();
|
||||||
|
}
|
||||||
panel.style.display = 'flex'; // 使用flex布局
|
panel.style.display = 'flex'; // 使用flex布局
|
||||||
// 添加打开状态的视觉反馈
|
// 添加打开状态的视觉反馈
|
||||||
if (roleSelectorBtn) {
|
if (roleSelectorBtn) {
|
||||||
|
|||||||
@@ -159,6 +159,27 @@ async function loadConfig(loadTools = true) {
|
|||||||
if (maxTokensEl) {
|
if (maxTokensEl) {
|
||||||
maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000;
|
maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000;
|
||||||
}
|
}
|
||||||
|
const orm = currentConfig.openai && currentConfig.openai.reasoning ? currentConfig.openai.reasoning : {};
|
||||||
|
const orModeEl = document.getElementById('openai-reasoning-mode');
|
||||||
|
if (orModeEl) {
|
||||||
|
const mv = (orm.mode || 'auto').toString().trim().toLowerCase();
|
||||||
|
orModeEl.value = ['auto', 'on', 'off'].includes(mv) ? mv : 'auto';
|
||||||
|
}
|
||||||
|
const orEffEl = document.getElementById('openai-reasoning-effort');
|
||||||
|
if (orEffEl) {
|
||||||
|
const ev = (orm.effort || '').toString().trim().toLowerCase();
|
||||||
|
orEffEl.value = ['', 'low', 'medium', 'high', 'max'].includes(ev) ? ev : '';
|
||||||
|
}
|
||||||
|
const orProfEl = document.getElementById('openai-reasoning-profile');
|
||||||
|
if (orProfEl) {
|
||||||
|
const pv = (orm.profile || 'auto').toString().trim().toLowerCase();
|
||||||
|
const ok = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'];
|
||||||
|
orProfEl.value = ok.includes(pv) ? pv : 'auto';
|
||||||
|
}
|
||||||
|
const orAllowEl = document.getElementById('openai-reasoning-allow-client');
|
||||||
|
if (orAllowEl) {
|
||||||
|
orAllowEl.checked = orm.allow_client_reasoning !== false;
|
||||||
|
}
|
||||||
|
|
||||||
// 填充FOFA配置
|
// 填充FOFA配置
|
||||||
const fofa = currentConfig.fofa || {};
|
const fofa = currentConfig.fofa || {};
|
||||||
@@ -1065,13 +1086,22 @@ async function applySettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||||
|
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
|
||||||
const config = {
|
const config = {
|
||||||
openai: {
|
openai: {
|
||||||
|
...prevOpenai,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
model: model,
|
model: model,
|
||||||
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000
|
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000,
|
||||||
|
reasoning: {
|
||||||
|
...(prevOpenai.reasoning || {}),
|
||||||
|
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
|
||||||
|
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
|
||||||
|
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
|
||||||
|
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
fofa: {
|
fofa: {
|
||||||
email: document.getElementById('fofa-email')?.value.trim() || '',
|
email: document.getElementById('fofa-email')?.value.trim() || '',
|
||||||
|
|||||||
+46
-14
@@ -1658,6 +1658,8 @@ function buildWebshellTimelineItemFromDetail(detail) {
|
|||||||
title = ap + ((typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代'));
|
title = ap + ((typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代'));
|
||||||
} else if (eventType === 'thinking') {
|
} else if (eventType === 'thinking') {
|
||||||
title = ap + '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考');
|
title = ap + '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考');
|
||||||
|
} else if (eventType === 'reasoning_chain') {
|
||||||
|
title = ap + '🔗 ' + ((typeof window.t === 'function') ? window.t('chat.reasoningChain') : '推理过程');
|
||||||
} else if (eventType === 'tool_calls_detected') {
|
} else if (eventType === 'tool_calls_detected') {
|
||||||
title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用'));
|
title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用'));
|
||||||
} else if (eventType === 'tool_call') {
|
} else if (eventType === 'tool_call') {
|
||||||
@@ -2847,6 +2849,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
if (info && info.orchestration) {
|
if (info && info.orchestration) {
|
||||||
body.orchestration = info.orchestration;
|
body.orchestration = info.orchestration;
|
||||||
}
|
}
|
||||||
|
if (typeof window.buildReasoningRequestPayload === 'function') {
|
||||||
|
var rp = window.buildReasoningRequestPayload();
|
||||||
|
if (rp) {
|
||||||
|
body.reasoning = rp;
|
||||||
|
}
|
||||||
|
}
|
||||||
return apiFetch(info.path, {
|
return apiFetch(info.path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -2898,7 +2906,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
} else if (_et === 'response_delta') {
|
} else if (_et === 'response_delta') {
|
||||||
var deltaText = (_em != null && _em !== '') ? String(_em) : '';
|
var deltaText = (_em != null && _em !== '') ? String(_em) : '';
|
||||||
if (deltaText) {
|
if (deltaText) {
|
||||||
streamingTarget += deltaText;
|
var normR = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||||
|
? window.normalizeStreamingDeltaJs(streamingTarget, deltaText)
|
||||||
|
: [streamingTarget + deltaText, deltaText];
|
||||||
|
streamingTarget = normR[0];
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||||
@@ -2950,23 +2961,33 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, _ed);
|
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, _ed);
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
|
|
||||||
// ─── Thinking (non-stream + stream) ───
|
// ─── Thinking / reasoning_chain(推理过程,reasoning_content) ───
|
||||||
} else if (_et === 'thinking_stream_start' && _ed.streamId) {
|
} else if ((_et === 'thinking_stream_start' || _et === 'reasoning_chain_stream_start') && _ed.streamId) {
|
||||||
var thinkSLabel = wsTOr('chat.aiThinking', 'AI 思考');
|
var isRcStart = _et === 'reasoning_chain_stream_start';
|
||||||
|
if (wsThinkingStreams.has(_ed.streamId)) {
|
||||||
|
var tsExist = wsThinkingStreams.get(_ed.streamId);
|
||||||
|
tsExist.buf = '';
|
||||||
|
if (tsExist.body) tsExist.body.textContent = '';
|
||||||
|
} else {
|
||||||
|
var thinkSLabel = wsTOr(isRcStart ? 'chat.reasoningChain' : 'chat.aiThinking', isRcStart ? '推理过程' : 'AI 思考');
|
||||||
|
var thinkEmoji = isRcStart ? '🔗' : '🤔';
|
||||||
var thinkSItem = document.createElement('div');
|
var thinkSItem = document.createElement('div');
|
||||||
thinkSItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-thinking';
|
thinkSItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + (isRcStart ? 'reasoning_chain' : 'thinking');
|
||||||
thinkSItem.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(_ed) + '🤔 ' + thinkSLabel) + '</span>';
|
thinkSItem.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(_ed) + thinkEmoji + ' ' + thinkSLabel) + '</span>';
|
||||||
var thinkSPre = document.createElement('div');
|
var thinkSPre = document.createElement('div');
|
||||||
thinkSPre.className = 'webshell-ai-timeline-msg webshell-thinking-stream-body';
|
thinkSPre.className = 'webshell-ai-timeline-msg webshell-thinking-stream-body';
|
||||||
thinkSItem.appendChild(thinkSPre);
|
thinkSItem.appendChild(thinkSPre);
|
||||||
timelineContainer.appendChild(thinkSItem);
|
timelineContainer.appendChild(thinkSItem);
|
||||||
timelineContainer.classList.add('has-items');
|
timelineContainer.classList.add('has-items');
|
||||||
wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' });
|
wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' });
|
||||||
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (_et === 'thinking_stream_delta' && _ed.streamId) {
|
} else if ((_et === 'thinking_stream_delta' || _et === 'reasoning_chain_stream_delta') && _ed.streamId) {
|
||||||
var tsD = wsThinkingStreams.get(_ed.streamId);
|
var tsD = wsThinkingStreams.get(_ed.streamId);
|
||||||
if (tsD) {
|
if (tsD) {
|
||||||
tsD.buf += (_em || '');
|
var normT = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||||
|
? window.normalizeStreamingDeltaJs(tsD.buf, _em || '') : [tsD.buf + (_em || ''), _em || ''];
|
||||||
|
tsD.buf = normT[0];
|
||||||
if (typeof formatMarkdown === 'function') {
|
if (typeof formatMarkdown === 'function') {
|
||||||
tsD.body.innerHTML = formatMarkdown(tsD.buf);
|
tsD.body.innerHTML = formatMarkdown(tsD.buf);
|
||||||
} else {
|
} else {
|
||||||
@@ -2974,7 +2995,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (_et === 'thinking_stream_end' && _ed.streamId) {
|
} else if ((_et === 'thinking_stream_end' || _et === 'reasoning_chain_stream_end') && _ed.streamId) {
|
||||||
var tsE = wsThinkingStreams.get(_ed.streamId);
|
var tsE = wsThinkingStreams.get(_ed.streamId);
|
||||||
if (tsE) {
|
if (tsE) {
|
||||||
var fullThink = (_em != null && _em !== '') ? String(_em) : tsE.buf;
|
var fullThink = (_em != null && _em !== '') ? String(_em) : tsE.buf;
|
||||||
@@ -2985,13 +3006,15 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
wsThinkingStreams.delete(_ed.streamId);
|
wsThinkingStreams.delete(_ed.streamId);
|
||||||
}
|
}
|
||||||
} else if (_et === 'thinking' && _em) {
|
} else if ((_et === 'thinking' || _et === 'reasoning_chain') && _em) {
|
||||||
// 如果有 streamId 且已存在流式条目,跳过避免重复
|
// 如果有 streamId 且已存在流式条目,跳过避免重复
|
||||||
if (_ed.streamId && wsThinkingStreams.has(_ed.streamId)) {
|
if (_ed.streamId && wsThinkingStreams.has(_ed.streamId)) {
|
||||||
// 已由 thinking_stream_* 处理
|
// 已由 *_stream_* 处理
|
||||||
} else {
|
} else {
|
||||||
var thinkLabel = wsTOr('chat.aiThinking', 'AI 思考');
|
var isRc = _et === 'reasoning_chain';
|
||||||
appendTimelineItem('thinking', webshellAgentPx(_ed) + '🤔 ' + thinkLabel, _em, _ed);
|
var thinkLabel = wsTOr(isRc ? 'chat.reasoningChain' : 'chat.aiThinking', isRc ? '推理过程' : 'AI 思考');
|
||||||
|
var thinkEm = isRc ? '🔗' : '🤔';
|
||||||
|
appendTimelineItem(isRc ? 'reasoning_chain' : 'thinking', webshellAgentPx(_ed) + thinkEm + ' ' + thinkLabel, _em, _ed);
|
||||||
}
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
|
|
||||||
@@ -3076,6 +3099,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
|
|
||||||
// ─── Eino sub-agent reply streaming ───
|
// ─── Eino sub-agent reply streaming ───
|
||||||
} else if (_et === 'eino_agent_reply_stream_start' && _ed.streamId) {
|
} else if (_et === 'eino_agent_reply_stream_start' && _ed.streamId) {
|
||||||
|
if (einoSubReplyStreams.has(_ed.streamId)) {
|
||||||
|
var stExist = einoSubReplyStreams.get(_ed.streamId);
|
||||||
|
stExist.buf = '';
|
||||||
|
var preExist = stExist.el && stExist.el.querySelector('.webshell-eino-reply-stream-body');
|
||||||
|
if (preExist) preExist.textContent = '';
|
||||||
|
} else {
|
||||||
var repTS = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
|
var repTS = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
|
||||||
var runTS = wsTOr('timeline.running', '执行中...');
|
var runTS = wsTOr('timeline.running', '执行中...');
|
||||||
var itemS = document.createElement('div');
|
var itemS = document.createElement('div');
|
||||||
@@ -3084,11 +3113,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
timelineContainer.appendChild(itemS);
|
timelineContainer.appendChild(itemS);
|
||||||
timelineContainer.classList.add('has-items');
|
timelineContainer.classList.add('has-items');
|
||||||
einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' });
|
einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' });
|
||||||
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
|
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
|
||||||
var stD = einoSubReplyStreams.get(_ed.streamId);
|
var stD = einoSubReplyStreams.get(_ed.streamId);
|
||||||
if (stD) {
|
if (stD) {
|
||||||
stD.buf += (_em || '');
|
var normS = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||||
|
? window.normalizeStreamingDeltaJs(stD.buf, _em || '') : [stD.buf + (_em || ''), _em || ''];
|
||||||
|
stD.buf = normS[0];
|
||||||
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
|
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
|
||||||
if (!preD) {
|
if (!preD) {
|
||||||
preD = document.createElement('pre');
|
preD = document.createElement('pre');
|
||||||
|
|||||||
@@ -894,6 +894,8 @@
|
|||||||
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
||||||
<div id="chat-messages" class="chat-messages"></div>
|
<div id="chat-messages" class="chat-messages"></div>
|
||||||
<div id="chat-input-container" class="chat-input-container">
|
<div id="chat-input-container" class="chat-input-container">
|
||||||
|
<div class="chat-input-primary-row">
|
||||||
|
<div class="chat-input-leading">
|
||||||
<div class="role-selector-wrapper">
|
<div class="role-selector-wrapper">
|
||||||
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" data-i18n="chat.selectRole" data-i18n-attr="title" title="选择角色">
|
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" data-i18n="chat.selectRole" data-i18n-attr="title" title="选择角色">
|
||||||
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
|
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
|
||||||
@@ -979,6 +981,50 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="agent-mode-select" value="react" autocomplete="off">
|
<input type="hidden" id="agent-mode-select" value="react" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper" style="display: none;">
|
||||||
|
<div class="chat-reasoning-inner">
|
||||||
|
<button type="button" id="chat-reasoning-btn" class="role-selector-btn chat-reasoning-btn" onclick="toggleChatReasoningPanel()" aria-expanded="false" aria-haspopup="dialog" aria-controls="chat-reasoning-panel" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
|
||||||
|
<span class="chat-reasoning-btn-icon" aria-hidden="true">🔎</span>
|
||||||
|
<span id="chat-reasoning-summary" class="role-selector-text chat-reasoning-btn-summary"></span>
|
||||||
|
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="chat-reasoning-panel" class="chat-reasoning-panel" style="display: none;" role="dialog" aria-labelledby="chat-reasoning-panel-title">
|
||||||
|
<div class="role-selection-panel-header chat-reasoning-panel-header">
|
||||||
|
<h3 id="chat-reasoning-panel-title" class="role-selection-panel-title" data-i18n="chat.reasoningPanelTitle">模型推理</h3>
|
||||||
|
<button type="button" class="role-selection-panel-close" onclick="closeChatReasoningPanel()" data-i18n="common.close" data-i18n-attr="title" title="关闭">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
|
||||||
|
<div class="chat-reasoning-fields">
|
||||||
|
<div class="chat-reasoning-field">
|
||||||
|
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
|
||||||
|
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||||
|
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
|
||||||
|
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||||
|
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||||
|
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="chat-reasoning-field">
|
||||||
|
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
|
||||||
|
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
|
||||||
|
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||||
|
<option value="low">low</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="max">max</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="chat-input-with-files">
|
<div class="chat-input-with-files">
|
||||||
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
|
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
|
||||||
<div id="chat-attachment-progress" class="chat-upload-progress-row" hidden role="status" aria-live="polite">
|
<div id="chat-attachment-progress" class="chat-upload-progress-row" hidden role="status" aria-live="polite">
|
||||||
@@ -1002,6 +1048,7 @@
|
|||||||
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1053,6 +1100,7 @@
|
|||||||
<option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option>
|
<option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option>
|
||||||
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
|
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
|
||||||
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
|
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
|
||||||
|
<option value="cancelled" data-i18n="mcpMonitor.statusCancelled">已终止</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1988,6 +2036,38 @@
|
|||||||
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
||||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settingsBasic.openaiReasoningTitle">模型推理(Eino)</label>
|
||||||
|
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">仅影响 Eino 单代理与多代理;对话页可覆盖(见下方「允许对话覆盖」)。</small>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
|
||||||
|
<label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||||
|
<select id="openai-reasoning-mode" style="min-width: 120px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||||
|
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||||
|
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||||
|
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||||
|
</select>
|
||||||
|
<label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label>
|
||||||
|
<select id="openai-reasoning-effort" style="min-width: 100px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||||
|
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||||
|
<option value="low">low</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="max">max</option>
|
||||||
|
</select>
|
||||||
|
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
||||||
|
<select id="openai-reasoning-profile" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||||
|
<option value="auto">auto</option>
|
||||||
|
<option value="deepseek_compat">deepseek_compat</option>
|
||||||
|
<option value="openai_compat">openai_compat</option>
|
||||||
|
<option value="output_config_effort">output_config_effort</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-label" style="margin-top: 8px;">
|
||||||
|
<input type="checkbox" id="openai-reasoning-allow-client" class="modern-checkbox" checked />
|
||||||
|
<span class="checkbox-custom"></span>
|
||||||
|
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
||||||
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
||||||
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||||
@@ -2449,6 +2529,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-section detail-abort-section" id="detail-abort-section" style="display: none;">
|
||||||
|
<div class="detail-section-header">
|
||||||
|
<h3 data-i18n="mcpDetailModal.abortTitle">运行控制</h3>
|
||||||
|
</div>
|
||||||
|
<p class="detail-abort-hint" data-i18n="mcpDetailModal.abortHint">仅中断当前工具调用;对话与多步任务会继续。</p>
|
||||||
|
<button type="button" class="btn-secondary btn-monitor-abort" id="detail-abort-btn" onclick="abortMCPToolExecutionFromDetail()">终止工具</button>
|
||||||
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="detail-section-header">
|
<div class="detail-section-header">
|
||||||
<h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3>
|
<h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3>
|
||||||
@@ -2489,6 +2576,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户中断并说明(继续迭代) -->
|
||||||
|
<div id="user-interrupt-modal" class="modal">
|
||||||
|
<div class="modal-content" style="max-width: 520px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 data-i18n="tasks.interruptModalTitle">中断当前步骤</h2>
|
||||||
|
<span class="modal-close" onclick="closeUserInterruptModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="detail-abort-hint" data-i18n="tasks.interruptModalHint">填写说明后将写入对话并由智能体继续迭代。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-interrupt-reason"><span data-i18n="tasks.interruptReasonLabel">中断说明</span></label>
|
||||||
|
<textarea id="user-interrupt-reason" class="form-control" rows="4" data-i18n="tasks.interruptReasonPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeUserInterruptModal()" data-i18n="tasks.interruptModalClose">关闭</button>
|
||||||
|
<button type="button" class="btn-secondary btn-delete" onclick="submitUserInterruptHardCancel()" data-i18n="tasks.interruptHardStop">彻底停止</button>
|
||||||
|
<button type="button" class="btn-primary" onclick="submitUserInterruptContinue()" data-i18n="tasks.interruptConfirmContinue">中断并继续</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MCP 工具终止:可填写给模型的说明 -->
|
||||||
|
<div id="mcp-tool-abort-modal" class="modal">
|
||||||
|
<div class="modal-content" style="max-width: 520px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 data-i18n="mcpDetailModal.abortNoteModalTitle">终止工具并补充说明</h2>
|
||||||
|
<span class="modal-close" onclick="closeMcpToolAbortModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="detail-abort-hint" data-i18n="mcpDetailModal.abortNoteModalHint">可选说明。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mcp-tool-abort-note"><span data-i18n="mcpDetailModal.abortNoteLabel">终止说明(可选)</span></label>
|
||||||
|
<textarea id="mcp-tool-abort-note" class="form-control" rows="4" data-i18n="mcpDetailModal.abortNotePlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeMcpToolAbortModal()" data-i18n="mcpDetailModal.abortNoteClose">取消</button>
|
||||||
|
<button type="button" class="btn-primary" onclick="submitMcpToolAbortModal()" data-i18n="mcpDetailModal.abortNoteSubmit">提交终止</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 外部MCP配置模态框 -->
|
<!-- 外部MCP配置模态框 -->
|
||||||
<div id="external-mcp-modal" class="modal">
|
<div id="external-mcp-modal" class="modal">
|
||||||
<div class="modal-content" style="max-width: 900px;">
|
<div class="modal-content" style="max-width: 900px;">
|
||||||
|
|||||||
Reference in New Issue
Block a user