Compare commits

...

43 Commits

Author SHA1 Message Date
公明 bcfb7b8da1 Update config.yaml 2026-04-29 04:11:31 +08:00
公明 f420ae0265 Add files via upload 2026-04-29 03:28:32 +08:00
公明 e3f59b29ab Add files via upload 2026-04-29 03:26:27 +08:00
公明 87cba37203 Add files via upload 2026-04-29 03:24:48 +08:00
公明 4773b9e963 Update config.yaml 2026-04-29 03:01:21 +08:00
公明 eda5f9bba1 Add files via upload 2026-04-29 02:59:34 +08:00
公明 1318607813 Add files via upload 2026-04-29 02:57:22 +08:00
公明 5100924abe Add files via upload 2026-04-29 02:54:43 +08:00
公明 44079674dd Add files via upload 2026-04-28 14:07:01 +08:00
公明 d959390e27 Update config.yaml 2026-04-28 11:45:27 +08:00
公明 62a0d8cb71 Add files via upload 2026-04-28 11:40:09 +08:00
公明 b53cae3a02 Add files via upload 2026-04-28 11:37:52 +08:00
公明 3b3d094dc4 Add files via upload 2026-04-28 10:26:09 +08:00
公明 47922c2083 Add files via upload 2026-04-28 10:23:24 +08:00
公明 dfaf0bc77f Update config.yaml 2026-04-28 01:23:57 +08:00
公明 3eb7edb1b8 Add files via upload 2026-04-28 01:23:33 +08:00
公明 f82f6b861e Add files via upload 2026-04-28 01:22:21 +08:00
公明 2acf43c454 Add files via upload 2026-04-28 01:19:01 +08:00
公明 fad6b3c808 Add files via upload 2026-04-28 01:05:58 +08:00
公明 0597838217 Add files via upload 2026-04-28 01:04:58 +08:00
公明 1532426b4f Add files via upload 2026-04-28 01:02:30 +08:00
公明 3aeb8c3474 Add files via upload 2026-04-28 00:37:46 +08:00
公明 b2b166972a Add files via upload 2026-04-28 00:33:29 +08:00
公明 36b669771c Delete internal/multiagent directory 2026-04-28 00:30:34 +08:00
公明 96564d4d89 Update default_single_system_prompt.go 2026-04-27 14:58:49 +08:00
公明 d85afa2d39 Add files via upload 2026-04-27 11:29:16 +08:00
公明 55b6bceb21 Update config.yaml 2026-04-26 15:11:48 +08:00
公明 65d73b3d66 Add files via upload 2026-04-26 15:08:48 +08:00
公明 913115d1fb Add files via upload 2026-04-26 04:26:29 +08:00
公明 e1b967d781 Add files via upload 2026-04-26 04:18:38 +08:00
公明 9d9efa886f Add files via upload 2026-04-26 04:17:27 +08:00
公明 cae45e9dc5 Add files via upload 2026-04-26 04:16:25 +08:00
公明 c788b59f25 Update config.yaml 2026-04-24 20:01:42 +08:00
公明 5edf3a70f9 Add files via upload 2026-04-24 20:00:50 +08:00
公明 3dfb3b4e82 Add files via upload 2026-04-24 19:59:15 +08:00
公明 a517fe0931 Add files via upload 2026-04-24 19:56:09 +08:00
公明 0ab5e31a64 Add files via upload 2026-04-24 18:24:52 +08:00
公明 ea6e027b25 Add files via upload 2026-04-24 17:30:22 +08:00
公明 ba9d2f0afd Update config.yaml 2026-04-24 15:43:00 +08:00
公明 6ce835703e Add files via upload 2026-04-24 11:24:10 +08:00
公明 666980ad8f Add files via upload 2026-04-24 11:08:47 +08:00
公明 bc8e81307e Add files via upload 2026-04-24 11:07:03 +08:00
公明 053534feaa Add files via upload 2026-04-24 11:04:55 +08:00
90 changed files with 3596 additions and 1665 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="web/static/logo.png" alt="CyberStrikeAI Logo" width="200">
<img src="images/logo.png" alt="CyberStrikeAI Logo" width="200">
</div>
# CyberStrikeAI
+1 -1
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="web/static/logo.png" alt="CyberStrikeAI Logo" width="200">
<img src="images/logo.png" alt="CyberStrikeAI Logo" width="200">
</div>
# CyberStrikeAI
+15 -5
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.5.5"
version: "v1.5.13"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -70,7 +70,7 @@ multi_agent:
robot_use_multi_agent: true # true 时企业微信/钉钉/飞书机器人也走 Eino 多代理(成本更高)
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。Executor 未暴露 Handlerspatch/reduction/plantask 不作用于 PE,但 tool_search 工具列表拆分仍通过共享 ToolsConfig 作用于执行器
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件
plan_execute_loop_max_iterations: 0
sub_agent_max_iterations: 120
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
@@ -87,15 +87,25 @@ multi_agent:
# Eino ADK 中间件与 Deep/Supervisor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig
eino_middleware:
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
tool_search_enable: false # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
tool_search_always_visible_tools: [read_file, glob, grep, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
plantask_enable: false # true:主代理(Deep / Supervisor 主)挂载 TaskCreate/Get/Update/List;需 eino_skills 可用且 skills_dir 存在,否则仅打日志并跳过
plantask_rel_dir: .eino/plantask # 结构化任务文件相对 skills_dir 的子目录,其下再按会话 ID 分子目录存放
reduction_enable: false # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理)
reduction_max_tokens_for_clear: 160000 # 历史工具结果清理阈值(tokens),超阈值时在模型调用前清理旧结果
reduction_root_dir: "" # 非空:截断/清理内容落盘根路径;空:使用系统临时目录下按会话隔离的默认路径
reduction_clear_exclude: [] # 不参与「清理阶段」的工具名额外列表(会与 task/transfer/exit 等内置排除项合并);需要时用 YAML 列表填写
reduction_sub_agents: false # true:子代理也挂 reductionfalse:仅编排主代理使用 reduction
reduction_sub_agents: true # true:子代理也挂 reductionfalse:仅编排主代理使用 reduction
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
summarization_emit_internal_events: true # true:发出 summarization 内部事件(便于诊断)
history_input_budget_ratio: 0.35 # 历史入队预算比例(max_total_tokens * ratio
plan_execute_user_input_budget_ratio: 0.35 # plan_execute 中 userInput 预算比例(planner/replanner/executor 共用)
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
checkpoint_dir: "" # 非空:为 adk.NewRunner 启用按会话子目录的文件型 CheckPointStore,便于中断恢复持久化;Resume 的 HTTP/前端流程需另行对接
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入
deep_model_retry_max_retries: 0 # >0ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+51 -32
View File
@@ -39,6 +39,7 @@ type Agent struct {
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
currentConversationID string // 当前对话ID(用于自动传递给工具)
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录)
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
}
// ResultStorage 结果存储接口(直接使用 storage 包的类型)
@@ -162,6 +163,7 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
resultStorage: resultStorage,
largeResultThreshold: largeResultThreshold,
toolNameMapping: make(map[string]string), // 初始化工具名称映射
toolDescriptionMode: "short",
}
}
@@ -336,10 +338,10 @@ func (fc *FunctionCall) UnmarshalJSON(data []byte) error {
// AgentLoopResult Agent Loop执行结果
type AgentLoopResult struct {
Response string
MCPExecutionIDs []string
LastReActInput string // 最后一轮ReAct的输入(压缩后的messagesJSON格式
LastReActOutput string // 最终大模型的输出
Response string
MCPExecutionIDs []string
LastAgentTraceInput string // 最后一轮代理消息轨迹(压缩后的 messagesJSON;与 multiagent.RunResult 字段对齐
LastAgentTraceOutput string // 最终助手输出文本
}
// ProgressCallback 进度回调函数类型
@@ -471,7 +473,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
}
// 用于保存当前的messages,以便在异常情况下也能保存ReAct输入
var currentReActInput string
var currentAgentTraceInput string
maxIterations := a.maxIterations
thinkingStreamSeq := 0
@@ -490,9 +492,9 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
if err != nil {
a.logger.Warn("序列化ReAct输入失败", zap.Error(err))
} else {
currentReActInput = string(messagesJSON)
currentAgentTraceInput = string(messagesJSON)
// 更新result中的值,确保始终保存最新的ReAct输入(压缩后的)
result.LastReActInput = currentReActInput
result.LastAgentTraceInput = currentAgentTraceInput
}
// 检查上下文是否已取消
@@ -500,13 +502,13 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
case <-ctx.Done():
// 上下文被取消(可能是用户主动暂停或其他原因)
a.logger.Info("检测到上下文取消,保存当前ReAct数据", zap.Error(ctx.Err()))
result.LastReActInput = currentReActInput
result.LastAgentTraceInput = currentAgentTraceInput
if ctx.Err() == context.Canceled {
result.Response = "任务已被取消。"
} else {
result.Response = fmt.Sprintf("任务执行中断: %v", ctx.Err())
}
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
return result, ctx.Err()
default:
}
@@ -600,10 +602,10 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
if err != nil {
// API调用失败,保存当前的ReAct输入和错误信息作为输出
result.LastReActInput = currentReActInput
result.LastAgentTraceInput = currentAgentTraceInput
errorMsg := fmt.Sprintf("调用OpenAI失败: %v", err)
result.Response = errorMsg
result.LastReActOutput = errorMsg
result.LastAgentTraceOutput = errorMsg
a.logger.Warn("OpenAI调用失败,已保存ReAct数据", zap.Error(err))
return result, fmt.Errorf("调用OpenAI失败: %w", err)
}
@@ -629,19 +631,19 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
continue
}
// OpenAI返回错误,保存当前的ReAct输入和错误信息作为输出
result.LastReActInput = currentReActInput
result.LastAgentTraceInput = currentAgentTraceInput
errorMsg := fmt.Sprintf("OpenAI错误: %s", response.Error.Message)
result.Response = errorMsg
result.LastReActOutput = errorMsg
result.LastAgentTraceOutput = errorMsg
return result, fmt.Errorf("OpenAI错误: %s", response.Error.Message)
}
if len(response.Choices) == 0 {
// 没有收到响应,保存当前的ReAct输入和错误信息作为输出
result.LastReActInput = currentReActInput
result.LastAgentTraceInput = currentAgentTraceInput
errorMsg := "没有收到响应"
result.Response = errorMsg
result.LastReActOutput = errorMsg
result.LastAgentTraceOutput = errorMsg
return result, fmt.Errorf("没有收到响应")
}
@@ -816,7 +818,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
@@ -863,14 +865,14 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果获取总结失败,使用当前回复作为结果
if choice.Message.Content != "" {
result.Response = choice.Message.Content
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
return result, nil
}
// 如果都没有内容,跳出循环,让后续逻辑处理
@@ -881,7 +883,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
if choice.FinishReason == "stop" {
sendProgress("progress", "正在生成最终回复...", nil)
result.Response = choice.Message.Content
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
return result, nil
}
}
@@ -910,19 +912,19 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果无法生成总结,返回友好的提示
result.Response = fmt.Sprintf("已达到最大迭代次数(%d轮)。系统已执行了多轮测试,但由于达到迭代上限,无法继续自动执行。建议您查看已执行的工具结果,或提出新的测试请求以继续测试。", a.maxIterations)
result.LastReActOutput = result.Response
result.LastAgentTraceOutput = result.Response
return result, nil
}
// getAvailableTools 获取可用工具
// 从MCP服务器动态获取工具列表,使用简短描述以减少token消耗
// 从MCP服务器动态获取工具列表,描述模式由 tool_description_mode 控制
// roleTools: 角色配置的工具列表(toolKey格式),如果为空或nil,则使用所有工具(默认角色)
func (a *Agent) getAvailableTools(roleTools []string) []Tool {
// 构建角色工具集合(用于快速查找)
@@ -946,11 +948,7 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
continue // 不在角色工具列表中,跳过
}
}
// 使用简短描述(如果存在),否则使用详细描述
description := mcpTool.ShortDescription
if description == "" {
description = mcpTool.Description
}
description := a.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description)
// 转换schema中的类型为OpenAI标准类型
convertedSchema := a.convertSchemaTypes(mcpTool.InputSchema)
@@ -1024,11 +1022,7 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
continue
}
// 使用简短描述(如果存在),否则使用详细描述
description := externalTool.ShortDescription
if description == "" {
description = externalTool.Description
}
description := a.pickToolDescription(externalTool.ShortDescription, externalTool.Description)
// 转换schema中的类型为OpenAI标准类型
convertedSchema := a.convertSchemaTypes(externalTool.InputSchema)
@@ -1063,6 +1057,19 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
return tools
}
func (a *Agent) pickToolDescription(shortDesc, fullDesc string) string {
a.mu.RLock()
mode := strings.TrimSpace(strings.ToLower(a.toolDescriptionMode))
a.mu.RUnlock()
if mode == "full" {
return fullDesc
}
if shortDesc != "" {
return shortDesc
}
return fullDesc
}
// convertSchemaTypes 递归转换schema中的类型为OpenAI标准类型
func (a *Agent) convertSchemaTypes(schema map[string]interface{}) map[string]interface{} {
if schema == nil {
@@ -1665,6 +1672,18 @@ func (a *Agent) UpdateMaxIterations(maxIterations int) {
}
}
// UpdateToolDescriptionMode 更新工具描述模式(short/full)
func (a *Agent) UpdateToolDescriptionMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
mode = strings.TrimSpace(strings.ToLower(mode))
if mode != "full" {
mode = "short"
}
a.toolDescriptionMode = mode
a.logger.Info("Agent工具描述模式已更新", zap.String("tool_description_mode", mode))
}
// formatToolError 格式化工具错误信息,提供更友好的错误描述
func (a *Agent) formatToolError(toolName string, args map[string]interface{}, err error) string {
errorMsg := fmt.Sprintf(`工具执行失败
+48 -49
View File
@@ -18,62 +18,62 @@ import (
func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
logger := zap.NewNop()
mcpServer := mcp.NewServer(logger)
openAICfg := &config.OpenAIConfig{
APIKey: "test-key",
BaseURL: "https://api.test.com/v1",
Model: "test-model",
}
agentCfg := &config.AgentConfig{
MaxIterations: 10,
LargeResultThreshold: 100, // 设置较小的阈值便于测试
ResultStorageDir: "",
}
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 10)
// 创建测试存储
tmpDir := filepath.Join(os.TempDir(), "test_agent_storage_"+time.Now().Format("20060102_150405"))
testStorage, err := storage.NewFileResultStorage(tmpDir, logger)
if err != nil {
t.Fatalf("创建测试存储失败: %v", err)
}
agent.SetResultStorage(testStorage)
return agent, testStorage
}
func TestAgent_FormatMinimalNotification(t *testing.T) {
agent, testStorage := setupTestAgent(t)
_ = testStorage // 避免未使用变量警告
executionID := "test_exec_001"
toolName := "nmap_scan"
size := 50000
lineCount := 1000
filePath := "tmp/test_exec_001.txt"
notification := agent.formatMinimalNotification(executionID, toolName, size, lineCount, filePath)
// 验证通知包含必要信息
if !strings.Contains(notification, executionID) {
t.Errorf("通知中应该包含执行ID: %s", executionID)
}
if !strings.Contains(notification, toolName) {
t.Errorf("通知中应该包含工具名称: %s", toolName)
}
if !strings.Contains(notification, "50000") {
t.Errorf("通知中应该包含大小信息")
}
if !strings.Contains(notification, "1000") {
t.Errorf("通知中应该包含行数信息")
}
if !strings.Contains(notification, "query_execution_result") {
t.Errorf("通知中应该包含查询工具的使用说明")
}
@@ -81,7 +81,7 @@ func TestAgent_FormatMinimalNotification(t *testing.T) {
func TestAgent_ExecuteToolViaMCP_LargeResult(t *testing.T) {
agent, _ := setupTestAgent(t)
// 创建模拟的MCP工具结果(大结果)
largeResult := &mcp.ToolResult{
Content: []mcp.Content{
@@ -92,59 +92,59 @@ func TestAgent_ExecuteToolViaMCP_LargeResult(t *testing.T) {
},
IsError: false,
}
// 模拟MCP服务器返回大结果
// 由于我们需要模拟CallTool的行为,这里需要创建一个mock或者使用实际的MCP服务器
// 为了简化测试,我们直接测试结果处理逻辑
// 设置阈值
agent.mu.Lock()
agent.largeResultThreshold = 1000 // 设置较小的阈值
agent.mu.Unlock()
// 创建执行ID
executionID := "test_exec_large_001"
toolName := "test_tool"
// 格式化结果
var resultText strings.Builder
for _, content := range largeResult.Content {
resultText.WriteString(content.Text)
resultText.WriteString("\n")
}
resultStr := resultText.String()
resultSize := len(resultStr)
// 检测大结果并保存
agent.mu.RLock()
threshold := agent.largeResultThreshold
storage := agent.resultStorage
agent.mu.RUnlock()
if resultSize > threshold && storage != nil {
// 保存大结果
err := storage.SaveResult(executionID, toolName, resultStr)
if err != nil {
t.Fatalf("保存大结果失败: %v", err)
}
// 生成通知
lines := strings.Split(resultStr, "\n")
filePath := storage.GetResultPath(executionID)
notification := agent.formatMinimalNotification(executionID, toolName, resultSize, len(lines), filePath)
// 验证通知格式
if !strings.Contains(notification, executionID) {
t.Errorf("通知中应该包含执行ID")
}
// 验证结果已保存
savedResult, err := storage.GetResult(executionID)
if err != nil {
t.Fatalf("获取保存的结果失败: %v", err)
}
if savedResult != resultStr {
t.Errorf("保存的结果与原始结果不匹配")
}
@@ -155,7 +155,7 @@ func TestAgent_ExecuteToolViaMCP_LargeResult(t *testing.T) {
func TestAgent_ExecuteToolViaMCP_SmallResult(t *testing.T) {
agent, _ := setupTestAgent(t)
// 创建小结果
smallResult := &mcp.ToolResult{
Content: []mcp.Content{
@@ -166,32 +166,32 @@ func TestAgent_ExecuteToolViaMCP_SmallResult(t *testing.T) {
},
IsError: false,
}
// 设置较大的阈值
agent.mu.Lock()
agent.largeResultThreshold = 100000 // 100KB
agent.mu.Unlock()
// 格式化结果
var resultText strings.Builder
for _, content := range smallResult.Content {
resultText.WriteString(content.Text)
resultText.WriteString("\n")
}
resultStr := resultText.String()
resultSize := len(resultStr)
// 检测大结果
agent.mu.RLock()
threshold := agent.largeResultThreshold
storage := agent.resultStorage
agent.mu.RUnlock()
if resultSize > threshold && storage != nil {
t.Fatal("小结果不应该被保存")
}
// 小结果应该直接返回
if resultSize <= threshold {
// 这是预期的行为
@@ -203,26 +203,26 @@ func TestAgent_ExecuteToolViaMCP_SmallResult(t *testing.T) {
func TestAgent_SetResultStorage(t *testing.T) {
agent, _ := setupTestAgent(t)
// 创建新的存储
tmpDir := filepath.Join(os.TempDir(), "test_new_storage_"+time.Now().Format("20060102_150405"))
newStorage, err := storage.NewFileResultStorage(tmpDir, zap.NewNop())
if err != nil {
t.Fatalf("创建新存储失败: %v", err)
}
// 设置新存储
agent.SetResultStorage(newStorage)
// 验证存储已更新
agent.mu.RLock()
currentStorage := agent.resultStorage
agent.mu.RUnlock()
if currentStorage != newStorage {
t.Fatal("存储未正确更新")
}
// 清理
os.RemoveAll(tmpDir)
}
@@ -230,24 +230,24 @@ func TestAgent_SetResultStorage(t *testing.T) {
func TestAgent_NewAgent_DefaultValues(t *testing.T) {
logger := zap.NewNop()
mcpServer := mcp.NewServer(logger)
openAICfg := &config.OpenAIConfig{
APIKey: "test-key",
BaseURL: "https://api.test.com/v1",
Model: "test-model",
}
// 测试默认配置
agent := NewAgent(openAICfg, nil, mcpServer, nil, logger, 0)
if agent.maxIterations != 30 {
t.Errorf("默认迭代次数不匹配。期望: 30, 实际: %d", agent.maxIterations)
}
agent.mu.RLock()
threshold := agent.largeResultThreshold
agent.mu.RUnlock()
if threshold != 50*1024 {
t.Errorf("默认阈值不匹配。期望: %d, 实际: %d", 50*1024, threshold)
}
@@ -256,31 +256,30 @@ func TestAgent_NewAgent_DefaultValues(t *testing.T) {
func TestAgent_NewAgent_CustomConfig(t *testing.T) {
logger := zap.NewNop()
mcpServer := mcp.NewServer(logger)
openAICfg := &config.OpenAIConfig{
APIKey: "test-key",
BaseURL: "https://api.test.com/v1",
Model: "test-model",
}
agentCfg := &config.AgentConfig{
MaxIterations: 20,
LargeResultThreshold: 100 * 1024, // 100KB
ResultStorageDir: "custom_tmp",
}
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 15)
if agent.maxIterations != 15 {
t.Errorf("迭代次数不匹配。期望: 15, 实际: %d", agent.maxIterations)
}
agent.mu.RLock()
threshold := agent.largeResultThreshold
agent.mu.RUnlock()
if threshold != 100*1024 {
t.Errorf("阈值不匹配。期望: %d, 实际: %d", 100*1024, threshold)
}
}
@@ -91,6 +91,20 @@ func DefaultSingleAgentSystemPrompt() string {
当工具返回错误时,错误信息会包含在工具响应中,请仔细阅读并做出合理的决策。
## 结束条件与停止约束
- 在「未完成用户目标」前,不得输出纯计划/纯建议式结论并结束本轮;必须继续给出可执行下一步,并优先通过工具验证。
- 若你准备结束回答,先执行一次自检:
1) 是否已有可验证证据支撑“任务完成/无法继续”的结论;
2) 是否至少尝试过当前路径的合理替代(参数、路径、方法、入口);
3) 是否仍存在可执行且低成本的下一步验证动作。
- 仅当满足以下任一条件时,才允许输出最终收尾:
1) 已达到用户目标并给出证据;
2) 达到明确边界(超时、权限、目标不可达、工具不可用且无替代),并清楚说明阻断点与已尝试项;
3) 用户明确要求停止。
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
## 漏洞记录
发现有效漏洞时,必须使用 ` + builtin.ToolRecordVulnerability + ` 记录:标题、描述、严重程度、类型、目标、证明(POC)、影响、修复建议。
+5 -5
View File
@@ -256,11 +256,11 @@ func orchestratorConfigFromOrchestrator(o *OrchestratorMarkdown) config.MultiAge
return config.MultiAgentSubConfig{}
}
return config.MultiAgentSubConfig{
ID: o.EinoName,
Name: o.DisplayName,
Description: o.Description,
Instruction: o.Instruction,
Kind: "orchestrator",
ID: o.EinoName,
Name: o.DisplayName,
Description: o.Description,
Instruction: o.Instruction,
Kind: "orchestrator",
}
}
+4
View File
@@ -133,6 +133,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
maxIterations = 30 // 默认值
}
agent := agent.NewAgent(&cfg.OpenAI, &cfg.Agent, mcpServer, externalMCPMgr, log.Logger, maxIterations)
agent.UpdateToolDescriptionMode(cfg.Security.ToolDescriptionMode)
// 设置结果存储到Agent
agent.SetResultStorage(resultStorage)
@@ -657,6 +658,7 @@ func setupRoutes(
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
protected.GET("/hitl/pending", agentHandler.ListHITLPending)
protected.POST("/hitl/decision", agentHandler.DecideHITLInterrupt)
protected.POST("/hitl/dismiss", agentHandler.DismissHITLInterrupt)
protected.GET("/hitl/config/:conversationId", agentHandler.GetHITLConversationConfig)
protected.PUT("/hitl/config", agentHandler.UpsertHITLConversationConfig)
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
@@ -900,6 +902,8 @@ func setupRoutes(
// 漏洞管理
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
protected.POST("/vulnerabilities", vulnerabilityHandler.CreateVulnerability)
+10 -10
View File
@@ -145,7 +145,7 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
}
// 1. 优先尝试从数据库获取保存的最后一轮ReAct输入和输出
reactInputJSON, modelOutput, err := b.db.GetReActData(conversationID)
reactInputJSON, modelOutput, err := b.db.GetAgentTrace(conversationID)
if err != nil {
b.logger.Warn("获取保存的ReAct数据失败,将使用消息历史构建", zap.Error(err))
// 继续使用原来的逻辑
@@ -170,7 +170,7 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
messageCount = len(tempMessages)
}
dataSource = "database_last_react_input"
dataSource = "database_last_agent_trace"
b.logger.Info("使用保存的ReAct数据构建攻击链",
zap.String("conversationId", conversationID),
zap.String("dataSource", dataSource),
@@ -183,7 +183,7 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
// userInput = b.extractUserInputFromReActInput(reactInputJSON)
// 将JSON格式的messages转换为可读格式
reactInputFinal = b.formatReActInputFromJSON(reactInputJSON)
reactInputFinal = b.formatAgentTraceInputFromJSON(reactInputJSON)
} else {
// 2. 如果没有保存的ReAct数据,从对话消息构建
dataSource = "messages_table"
@@ -201,7 +201,7 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
}
// 提取最后一轮ReAct的输入(历史消息+当前用户输入)
reactInputFinal = b.buildReActInput(messages)
reactInputFinal = b.buildAgentTraceInput(messages)
// 提取大模型最后的输出(最后一条assistant消息)
for i := len(messages) - 1; i >= 0; i-- {
@@ -212,7 +212,7 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
}
}
// 多代理:保存的 last_react_input 可能仅为首轮用户消息,不含工具轨迹;补充最后一轮助手的过程详情(与单代理「最后一轮 ReAct」对齐)
// 多代理:保存的轨迹列可能仅为首轮用户消息,不含工具轨迹;补充最后一轮助手的过程详情(与单代理完整轨迹对齐)
hasMCPOnAssistant := false
var lastAssistantID string
for i := len(messages) - 1; i >= 0; i-- {
@@ -320,7 +320,7 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD
}
// 1) 编排器的工具调用/结果:保留(这是“主 agent 调了什么工具”)
if (d.EventType == "tool_call" || d.EventType == "tool_result" || d.EventType == "tool_calls_detected" || d.EventType == "iteration" || d.EventType == "eino_recovery") && einoRole == "orchestrator" {
if (d.EventType == "tool_call" || d.EventType == "tool_result" || d.EventType == "tool_calls_detected" || d.EventType == "iteration") && einoRole == "orchestrator" {
sb.WriteString("[")
sb.WriteString(d.EventType)
sb.WriteString("] ")
@@ -366,8 +366,8 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD
return strings.TrimSpace(sb.String())
}
// buildReActInput 构建最后一轮ReAct的输入(历史消息+当前用户输入)
func (b *Builder) buildReActInput(messages []database.Message) string {
// buildAgentTraceInput 构建最后一轮ReAct的输入(历史消息+当前用户输入)
func (b *Builder) buildAgentTraceInput(messages []database.Message) string {
var builder strings.Builder
for _, msg := range messages {
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", msg.Role, msg.Content))
@@ -396,8 +396,8 @@ func (b *Builder) buildReActInput(messages []database.Message) string {
// return ""
// }
// formatReActInputFromJSON 将JSON格式的messages数组转换为可读的字符串格式
func (b *Builder) formatReActInputFromJSON(reactInputJSON string) string {
// formatAgentTraceInputFromJSON 将JSON格式的messages数组转换为可读的字符串格式
func (b *Builder) formatAgentTraceInputFromJSON(reactInputJSON string) string {
var messages []map[string]interface{}
if err := json.Unmarshal([]byte(reactInputJSON), &messages); err != nil {
b.logger.Warn("解析ReAct输入JSON失败", zap.Error(err))
+112
View File
@@ -72,6 +72,8 @@ type MultiAgentEinoMiddlewareConfig struct {
ToolSearchEnable bool `yaml:"tool_search_enable,omitempty" json:"tool_search_enable,omitempty"`
ToolSearchMinTools int `yaml:"tool_search_min_tools,omitempty" json:"tool_search_min_tools,omitempty"` // default 20; applies when len(tools) >= this
ToolSearchAlwaysVisible int `yaml:"tool_search_always_visible,omitempty" json:"tool_search_always_visible,omitempty"` // default 12; first N tools stay always visible
// ToolSearchAlwaysVisibleTools keeps specified tool names always visible (never hidden by tool_search).
ToolSearchAlwaysVisibleTools []string `yaml:"tool_search_always_visible_tools,omitempty" json:"tool_search_always_visible_tools,omitempty"`
// Plantask adds TaskCreate/Get/Update/List (file-backed under skills dir); requires eino_skills + local backend.
PlantaskEnable bool `yaml:"plantask_enable,omitempty" json:"plantask_enable,omitempty"`
// PlantaskRelDir relative to skills_dir for per-conversation task boards (default .eino/plantask).
@@ -79,8 +81,24 @@ type MultiAgentEinoMiddlewareConfig struct {
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
// SummarizationTriggerRatio controls summarization trigger threshold as max_total_tokens * ratio (default 0.8).
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
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 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 float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
// PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2).
PlanExecuteExecutedStepsBudgetRatio float64 `yaml:"plan_execute_executed_steps_budget_ratio,omitempty" json:"plan_execute_executed_steps_budget_ratio,omitempty"`
// PlanExecuteMaxStepResultRunes caps each executed step result length for prompt view (default 4000).
PlanExecuteMaxStepResultRunes int `yaml:"plan_execute_max_step_result_runes,omitempty" json:"plan_execute_max_step_result_runes,omitempty"`
// PlanExecuteKeepLastSteps keeps only the tail steps in prompt view (default 8).
PlanExecuteKeepLastSteps int `yaml:"plan_execute_keep_last_steps,omitempty" json:"plan_execute_keep_last_steps,omitempty"`
// CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence.
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
@@ -91,6 +109,97 @@ type MultiAgentEinoMiddlewareConfig struct {
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
}
func (c MultiAgentEinoMiddlewareConfig) SummarizationTriggerRatioEffective() float64 {
v := c.SummarizationTriggerRatio
if v <= 0 {
return 0.8
}
if v < 0.5 {
return 0.5
}
if v > 0.95 {
return 0.95
}
return v
}
func (c MultiAgentEinoMiddlewareConfig) SummarizationEmitInternalEventsEffective() bool {
if c.SummarizationEmitInternalEvents != nil {
return *c.SummarizationEmitInternalEvents
}
return true
}
func (c MultiAgentEinoMiddlewareConfig) HistoryInputBudgetRatioEffective() float64 {
v := c.HistoryInputBudgetRatio
if v <= 0 {
return 0.35
}
if v < 0.15 {
return 0.15
}
if v > 0.6 {
return 0.6
}
return v
}
func (c MultiAgentEinoMiddlewareConfig) PlanExecuteUserInputBudgetRatioEffective() float64 {
v := c.PlanExecuteUserInputBudgetRatio
if v <= 0 {
return 0.35
}
if v < 0.1 {
return 0.1
}
if v > 0.6 {
return 0.6
}
return v
}
func (c MultiAgentEinoMiddlewareConfig) PlanExecuteExecutedStepsBudgetRatioEffective() float64 {
v := c.PlanExecuteExecutedStepsBudgetRatio
if v <= 0 {
return 0.2
}
if v < 0.08 {
return 0.08
}
if v > 0.5 {
return 0.5
}
return v
}
func (c MultiAgentEinoMiddlewareConfig) PlanExecuteMaxStepResultRunesEffective() int {
if c.PlanExecuteMaxStepResultRunes > 0 {
return c.PlanExecuteMaxStepResultRunes
}
return 4000
}
func (c MultiAgentEinoMiddlewareConfig) PlanExecuteKeepLastStepsEffective() int {
if c.PlanExecuteKeepLastSteps > 0 {
return c.PlanExecuteKeepLastSteps
}
return 8
}
func (c MultiAgentEinoMiddlewareConfig) ReductionMaxLengthForTruncEffective() int {
if c.ReductionMaxLengthForTrunc > 0 {
return c.ReductionMaxLengthForTrunc
}
return 12000
}
func (c MultiAgentEinoMiddlewareConfig) ReductionMaxTokensForClearEffective() int {
if c.ReductionMaxTokensForClear > 0 {
return c.ReductionMaxTokensForClear
}
return 50000
}
// MultiAgentEinoSkillsConfig toggles Eino official skill progressive disclosure and host filesystem tools.
type MultiAgentEinoSkillsConfig struct {
// Disable skips skill middleware (and does not attach local FS tools for Deep).
@@ -137,6 +246,8 @@ type MultiAgentPublic struct {
SubAgentCount int `json:"sub_agent_count"`
Orchestration string `json:"orchestration,omitempty"`
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
}
// NormalizeMultiAgentOrchestration 返回 deep、plan_execute 或 supervisor。
@@ -158,6 +269,7 @@ type MultiAgentAPIUpdate struct {
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
}
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
-1
View File
@@ -165,4 +165,3 @@ func (db *DB) DeleteAttackChain(conversationID string) error {
return nil
}
+23 -12
View File
@@ -4,6 +4,8 @@ import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -308,7 +310,7 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
var rows *sql.Rows
var err error
if search != "" {
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
searchPattern := "%" + search + "%"
@@ -327,7 +329,7 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
limit, offset,
)
}
if err != nil {
return nil, fmt.Errorf("查询对话列表失败: %w", err)
}
@@ -416,25 +418,34 @@ func (db *DB) DeleteConversation(id string) error {
if err != nil {
return fmt.Errorf("删除对话失败: %w", err)
}
// Best-effort cleanup for conversation-scoped filesystem artifacts
// (e.g., summarization transcript, reduction/checkpoint files under conversation_artifacts/<id>).
if base := strings.TrimSpace(db.conversationArtifactsDir); base != "" {
artDir := filepath.Join(base, id)
if rmErr := os.RemoveAll(artDir); rmErr != nil {
db.logger.Warn("删除会话 artifacts 目录失败", zap.String("conversationId", id), zap.String("dir", artDir), zap.Error(rmErr))
}
}
db.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id))
return nil
}
// SaveReActData 保存最后一轮ReAct的输入和输出
func (db *DB) SaveReActData(conversationID, reactInput, reactOutput string) error {
// SaveAgentTrace 保存最后一轮代理消息轨迹与助手输出摘要。
// SQLite 列名仍为 last_react_input / last_react_output,与历史库表兼容;语义上为「全模式代理轨迹」,非仅 ReAct。
func (db *DB) SaveAgentTrace(conversationID, traceInputJSON, assistantOutput string) error {
_, err := db.Exec(
"UPDATE conversations SET last_react_input = ?, last_react_output = ?, updated_at = ? WHERE id = ?",
reactInput, reactOutput, time.Now(), conversationID,
traceInputJSON, assistantOutput, time.Now(), conversationID,
)
if err != nil {
return fmt.Errorf("保存ReAct数据失败: %w", err)
return fmt.Errorf("保存代理轨迹失败: %w", err)
}
return nil
}
// GetReActData 获取最后一轮ReAct的输入和输出
func (db *DB) GetReActData(conversationID string) (reactInput, reactOutput string, err error) {
// GetAgentTrace 读取 conversations 中保存的代理轨迹(列名 last_react_*)。
func (db *DB) GetAgentTrace(conversationID string) (traceInputJSON, assistantOutput string, err error) {
var input, output sql.NullString
err = db.QueryRow(
"SELECT last_react_input, last_react_output FROM conversations WHERE id = ?",
@@ -444,17 +455,17 @@ func (db *DB) GetReActData(conversationID string) (reactInput, reactOutput strin
if err == sql.ErrNoRows {
return "", "", fmt.Errorf("对话不存在")
}
return "", "", fmt.Errorf("获取ReAct数据失败: %w", err)
return "", "", fmt.Errorf("获取代理轨迹失败: %w", err)
}
if input.Valid {
reactInput = input.String
traceInputJSON = input.String
}
if output.Valid {
reactOutput = output.String
assistantOutput = output.String
}
return reactInput, reactOutput, nil
return traceInputJSON, assistantOutput, nil
}
// ConversationHasToolProcessDetails 对话是否存在已落库的工具调用/结果(用于多代理等场景下 MCP execution id 未汇总时的攻击链判定)。
+51 -2
View File
@@ -3,6 +3,8 @@ package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -21,7 +23,8 @@ func configureDBPool(db *sql.DB) {
// DB 数据库连接
type DB struct {
*sql.DB
logger *zap.Logger
logger *zap.Logger
conversationArtifactsDir string
}
// NewDB 创建数据库连接
@@ -41,6 +44,13 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
DB: db,
logger: logger,
}
// Keep conversation-scoped artifacts near database files, so cleanup can follow conversation lifecycle.
baseDir := filepath.Join(filepath.Dir(dbPath), "conversation_artifacts")
if mkErr := os.MkdirAll(baseDir, 0o755); mkErr == nil {
database.conversationArtifactsDir = baseDir
} else if logger != nil {
logger.Warn("创建 conversation artifacts 目录失败", zap.String("dir", baseDir), zap.Error(mkErr))
}
// 初始化表
if err := database.initTables(); err != nil {
@@ -52,7 +62,7 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
// initTables 初始化数据库表
func (db *DB) initTables() error {
// 创建对话表
// 创建对话表last_react_input / last_react_output 存「代理消息轨迹」JSON 与助手摘要,列名保留以兼容已有库)
createConversationsTable := `
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
@@ -197,6 +207,8 @@ func (db *DB) initTables() error {
CREATE TABLE IF NOT EXISTS vulnerabilities (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
conversation_tag TEXT,
task_tag TEXT,
title TEXT NOT NULL,
description TEXT,
severity TEXT NOT NULL,
@@ -289,6 +301,8 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id);
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_task_tag ON vulnerabilities(task_tag);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at);
@@ -383,6 +397,10 @@ func (db *DB) initTables() error {
db.logger.Warn("迁移batch_task_queues表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if err := db.migrateVulnerabilitiesTable(); err != nil {
db.logger.Warn("迁移vulnerabilities表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
@@ -683,6 +701,37 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
return nil
}
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
func (db *DB) migrateVulnerabilitiesTable() error {
columns := []struct {
name string
stmt string
}{
{name: "conversation_tag", stmt: "ALTER TABLE vulnerabilities ADD COLUMN conversation_tag TEXT"},
{name: "task_tag", stmt: "ALTER TABLE vulnerabilities ADD COLUMN task_tag TEXT"},
}
for _, col := range columns {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('vulnerabilities') WHERE name=?", col.name).Scan(&count)
if err != nil {
if _, addErr := db.Exec(col.stmt); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加vulnerabilities字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
continue
}
if count == 0 {
if _, addErr := db.Exec(col.stmt); addErr != nil {
db.logger.Warn("添加vulnerabilities字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
}
return nil
}
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
+118 -30
View File
@@ -12,7 +12,11 @@ import (
// Vulnerability 漏洞
type Vulnerability struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
ConversationID string `json:"conversation_id"`
ConversationTag string `json:"conversation_tag,omitempty"`
TaskTag string `json:"task_tag,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskQueueID string `json:"task_queue_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"` // critical, high, medium, low, info
@@ -42,15 +46,15 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
query := `
INSERT INTO vulnerabilities (
id, conversation_id, title, description, severity, status,
id, conversation_id, conversation_tag, task_tag, title, description, severity, status,
vulnerability_type, target, proof, impact, recommendation,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(
query,
vuln.ID, vuln.ConversationID, vuln.Title, vuln.Description,
vuln.ID, vuln.ConversationID, vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
vuln.Proof, vuln.Impact, vuln.Recommendation,
vuln.CreatedAt, vuln.UpdatedAt,
@@ -67,7 +71,9 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
var vuln Vulnerability
query := `
SELECT id, conversation_id, title, description, severity, status,
vulnerability_type, target, proof, impact, recommendation,
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
created_at, updated_at
FROM vulnerabilities
WHERE id = ?
@@ -75,8 +81,9 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
err := db.QueryRow(query, id).Scan(
&vuln.ID, &vuln.ConversationID, &vuln.Title, &vuln.Description,
&vuln.Severity, &vuln.Status, &vuln.Type, &vuln.Target,
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
&vuln.Proof, &vuln.Impact, &vuln.Recommendation,
&vuln.TaskID, &vuln.TaskQueueID,
&vuln.CreatedAt, &vuln.UpdatedAt,
)
if err != nil {
@@ -90,10 +97,12 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
}
// ListVulnerabilities 列出漏洞
func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severity, status string) ([]*Vulnerability, error) {
func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severity, status, taskID, conversationTag, taskTag string) ([]*Vulnerability, error) {
query := `
SELECT id, conversation_id, title, description, severity, status,
SELECT id, conversation_id, title, description, severity, status, conversation_tag, task_tag,
vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
created_at, updated_at
FROM vulnerabilities
WHERE 1=1
@@ -108,6 +117,18 @@ func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severit
query += " AND conversation_id = ?"
args = append(args, conversationID)
}
if taskID != "" {
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
args = append(args, taskID, taskID)
}
if conversationTag != "" {
query += " AND conversation_tag = ?"
args = append(args, conversationTag)
}
if taskTag != "" {
query += " AND task_tag = ?"
args = append(args, taskTag)
}
if severity != "" {
query += " AND severity = ?"
args = append(args, severity)
@@ -131,8 +152,9 @@ func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severit
var vuln Vulnerability
err := rows.Scan(
&vuln.ID, &vuln.ConversationID, &vuln.Title, &vuln.Description,
&vuln.Severity, &vuln.Status, &vuln.Type, &vuln.Target,
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
&vuln.Proof, &vuln.Impact, &vuln.Recommendation,
&vuln.TaskID, &vuln.TaskQueueID,
&vuln.CreatedAt, &vuln.UpdatedAt,
)
if err != nil {
@@ -146,7 +168,7 @@ func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severit
}
// CountVulnerabilities 统计漏洞总数(支持筛选条件)
func (db *DB) CountVulnerabilities(id, conversationID, severity, status string) (int, error) {
func (db *DB) CountVulnerabilities(id, conversationID, severity, status, taskID, conversationTag, taskTag string) (int, error) {
query := "SELECT COUNT(*) FROM vulnerabilities WHERE 1=1"
args := []interface{}{}
@@ -158,6 +180,18 @@ func (db *DB) CountVulnerabilities(id, conversationID, severity, status string)
query += " AND conversation_id = ?"
args = append(args, conversationID)
}
if taskID != "" {
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
args = append(args, taskID, taskID)
}
if conversationTag != "" {
query += " AND conversation_tag = ?"
args = append(args, conversationTag)
}
if taskTag != "" {
query += " AND task_tag = ?"
args = append(args, taskTag)
}
if severity != "" {
query += " AND severity = ?"
args = append(args, severity)
@@ -182,7 +216,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
query := `
UPDATE vulnerabilities
SET title = ?, description = ?, severity = ?, status = ?,
SET conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?,
vulnerability_type = ?, target = ?, proof = ?, impact = ?,
recommendation = ?, updated_at = ?
WHERE id = ?
@@ -190,7 +224,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
_, err := db.Exec(
query,
vuln.Title, vuln.Description, vuln.Severity, vuln.Status,
vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status,
vuln.Type, vuln.Target, vuln.Proof, vuln.Impact,
vuln.Recommendation, vuln.UpdatedAt, id,
)
@@ -210,18 +244,24 @@ func (db *DB) DeleteVulnerability(id string) error {
return nil
}
// GetVulnerabilityStats 获取漏洞统计
func (db *DB) GetVulnerabilityStats(conversationID string) (map[string]interface{}, error) {
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
func (db *DB) GetVulnerabilityStats(conversationID, taskID string) (map[string]interface{}, error) {
stats := make(map[string]interface{})
where := "WHERE 1=1"
args := []interface{}{}
if conversationID != "" {
where += " AND conversation_id = ?"
args = append(args, conversationID)
}
if taskID != "" {
where += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
args = append(args, taskID, taskID)
}
// 总漏洞数
var totalCount int
query := "SELECT COUNT(*) FROM vulnerabilities"
args := []interface{}{}
if conversationID != "" {
query += " WHERE conversation_id = ?"
args = append(args, conversationID)
}
query := "SELECT COUNT(*) FROM vulnerabilities " + where
err := db.QueryRow(query, args...).Scan(&totalCount)
if err != nil {
return nil, fmt.Errorf("获取总漏洞数失败: %w", err)
@@ -229,11 +269,7 @@ func (db *DB) GetVulnerabilityStats(conversationID string) (map[string]interface
stats["total"] = totalCount
// 按严重程度统计
severityQuery := "SELECT severity, COUNT(*) FROM vulnerabilities"
if conversationID != "" {
severityQuery += " WHERE conversation_id = ?"
}
severityQuery += " GROUP BY severity"
severityQuery := "SELECT severity, COUNT(*) FROM vulnerabilities " + where + " GROUP BY severity"
rows, err := db.Query(severityQuery, args...)
if err != nil {
@@ -253,11 +289,7 @@ func (db *DB) GetVulnerabilityStats(conversationID string) (map[string]interface
stats["by_severity"] = severityStats
// 按状态统计
statusQuery := "SELECT status, COUNT(*) FROM vulnerabilities"
if conversationID != "" {
statusQuery += " WHERE conversation_id = ?"
}
statusQuery += " GROUP BY status"
statusQuery := "SELECT status, COUNT(*) FROM vulnerabilities " + where + " GROUP BY status"
rows, err = db.Query(statusQuery, args...)
if err != nil {
@@ -279,3 +311,59 @@ func (db *DB) GetVulnerabilityStats(conversationID string) (map[string]interface
return stats, nil
}
// GetVulnerabilityFilterOptions 获取漏洞筛选建议项
func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
collect := func(query string, args ...interface{}) ([]string, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]string, 0)
for rows.Next() {
var val string
if err := rows.Scan(&val); err != nil {
continue
}
if val == "" {
continue
}
items = append(items, val)
}
return items, nil
}
vulnIDs, err := collect(`SELECT DISTINCT id FROM vulnerabilities ORDER BY created_at DESC LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err)
}
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id <> '' ORDER BY created_at DESC LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
}
taskIDs, err := collect(`SELECT DISTINCT id FROM batch_tasks WHERE id <> '' ORDER BY rowid DESC LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询任务ID建议失败: %w", err)
}
queueIDs, err := collect(`SELECT DISTINCT queue_id FROM batch_tasks WHERE queue_id <> '' ORDER BY rowid DESC LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询队列ID建议失败: %w", err)
}
conversationTags, err := collect(`SELECT DISTINCT conversation_tag FROM vulnerabilities WHERE conversation_tag IS NOT NULL AND conversation_tag <> '' ORDER BY conversation_tag LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询对话标签建议失败: %w", err)
}
taskTags, err := collect(`SELECT DISTINCT task_tag FROM vulnerabilities WHERE task_tag IS NOT NULL AND task_tag <> '' ORDER BY task_tag LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询任务标签建议失败: %w", err)
}
return map[string][]string{
"vulnerability_ids": vulnIDs,
"conversation_ids": conversationIDs,
"task_ids": taskIDs,
"queue_ids": queueIDs,
"conversation_tags": conversationTags,
"task_tags": taskTags,
}, nil
}
+5 -5
View File
@@ -160,17 +160,17 @@ func runMCPToolInvocation(
}
// UnknownToolReminderHandler 供 compose.ToolsNodeConfig.UnknownToolsHandler 使用:
// 模型请求了未注册的工具名时,返回一个「可恢复」的错误,让上层 runner 触发重试与纠错提示
// 同时避免 UI 永远停留在“执行中”(runner 会在 recoverable 分支 flush 掉 pending 的 tool_call
// 模型请求了未注册的工具名时,返回一个「软错误」工具结果(nil error
// 让模型在同一轮继续自我修正,避免触发 run-loop 级别的 full rerun
// 不进行名称猜测或映射,避免误执行。
func UnknownToolReminderHandler() func(ctx context.Context, name, input string) (string, error) {
return func(ctx context.Context, name, input string) (string, error) {
_ = ctx
_ = input
requested := strings.TrimSpace(name)
// Return a recoverable error that still carries a friendly, bilingual hint.
// This will be caught by multiagent runner as "tool not found" and trigger a retry.
return "", fmt.Errorf("tool %q not found: %s", requested, unknownToolReminderText(requested))
// Return a soft tool-result error so the graph keeps running and the LLM
// can correct tool name/arguments within the same run.
return ToolErrorPrefix + unknownToolReminderText(requested), nil
}
}
+92 -95
View File
@@ -497,10 +497,10 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
defer h.hitlManager.DeactivateConversation(conversationID)
}
// 优先尝试从保存的ReAct数据恢复历史上下文
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
// 优先尝试从保存的代理轨迹恢复历史上下文
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil {
h.logger.Warn("从ReAct数据加载历史消息失败,使用消息表", zap.Error(err))
h.logger.Warn("从代理轨迹加载历史消息失败,使用消息表", zap.Error(err))
// 回退到使用数据库消息表
historyMessages, err := h.db.GetMessages(conversationID)
if err != nil {
@@ -518,7 +518,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
}
} else {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
h.logger.Info("从代理轨迹恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
}
// 校验附件数量(非流式)
@@ -603,20 +603,22 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
progressCallback := h.createProgressCallback(baseCtx, cancelWithCause, conversationID, "", nil)
baseCtx = h.injectReactHITLInterceptor(baseCtx, cancelWithCause, conversationID, "", nil)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
defer timeoutCancel()
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, "", nil)
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, "", nil)
// 执行Agent Loop,传入历史消息和对话ID(使用包含角色提示词的finalMessage和角色工具列表)
result, err := h.agent.AgentLoopWithProgress(baseCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
if err != nil {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
// 即使执行失败,也尝试保存ReAct数据(如果result中有)
if result != nil && (result.LastReActInput != "" || result.LastReActOutput != "") {
if saveErr := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); saveErr != nil {
h.logger.Warn("保存失败任务的ReAct数据失败", zap.Error(saveErr))
// 即使执行失败,也尝试保存代理轨迹(如果 result 中有)
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
if saveErr := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); saveErr != nil {
h.logger.Warn("保存失败任务的代理轨迹失败", zap.Error(saveErr))
} else {
h.logger.Info("已保存失败任务的ReAct数据", zap.String("conversationId", conversationID))
h.logger.Info("已保存失败任务的代理轨迹", zap.String("conversationId", conversationID))
}
}
@@ -632,12 +634,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
// 因为AI已经生成了回复,用户应该能看到
}
// 保存最后一轮ReAct的输入和输出
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存ReAct数据失败", zap.Error(err))
// 保存最后一轮代理轨迹与助手输出
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存代理轨迹失败", zap.Error(err))
} else {
h.logger.Info("已保存ReAct数据", zap.String("conversationId", conversationID))
h.logger.Info("已保存代理轨迹", zap.String("conversationId", conversationID))
}
}
@@ -664,7 +666,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
}
}
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil {
historyMessages, getErr := h.db.GetMessages(conversationID)
if getErr != nil {
@@ -720,6 +722,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
"deep",
)
if errMA != nil {
h.persistEinoAgentTraceForResume(conversationID, resultMA)
errMsg := "执行失败: " + errMA.Error()
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
@@ -745,8 +748,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
}
}
if resultMA.LastReActInput != "" || resultMA.LastReActOutput != "" {
_ = h.db.SaveReActData(conversationID, resultMA.LastReActInput, resultMA.LastReActOutput)
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
}
return resultMA.Response, conversationID, nil
}
@@ -780,8 +783,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
}
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
_ = h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
}
return result.Response, conversationID, nil
}
@@ -1209,6 +1212,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
eventJSON, _ := json.Marshal(event)
fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON)
done := StreamEvent{Type: "done", Message: ""}
doneJSON, _ := json.Marshal(done)
fmt.Fprintf(c.Writer, "data: %s\n\n", doneJSON)
c.Writer.Flush()
return
}
@@ -1354,10 +1360,10 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
ssePublishConversationID = conversationID
// 优先尝试从保存的ReAct数据恢复历史上下文
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
// 优先尝试从保存的代理轨迹恢复历史上下文
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil {
h.logger.Warn("从ReAct数据加载历史消息失败,使用消息表", zap.Error(err))
h.logger.Warn("从代理轨迹加载历史消息失败,使用消息表", zap.Error(err))
// 回退到使用数据库消息表
historyMessages, err := h.db.GetMessages(conversationID)
if err != nil {
@@ -1375,7 +1381,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
}
} else {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
h.logger.Info("从代理轨迹恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
}
// 校验附件数量
@@ -1574,12 +1580,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
}
// 即使任务被取消,也尝试保存ReAct数据(如果result中有)
if result != nil && (result.LastReActInput != "" || result.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存取消任务的ReAct数据失败", zap.Error(err))
// 即使任务被取消,也尝试保存代理轨迹(如果 result 中有)
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存取消任务的代理轨迹失败", zap.Error(err))
} else {
h.logger.Info("已保存取消任务的ReAct数据", zap.String("conversationId", conversationID))
h.logger.Info("已保存取消任务的代理轨迹", zap.String("conversationId", conversationID))
}
}
@@ -1609,12 +1615,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
}
// 即使任务超时,也尝试保存ReAct数据(如果result中有)
if result != nil && (result.LastReActInput != "" || result.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存超时任务的ReAct数据失败", zap.Error(err))
// 即使任务超时,也尝试保存代理轨迹(如果 result 中有)
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存超时任务的代理轨迹失败", zap.Error(err))
} else {
h.logger.Info("已保存超时任务的ReAct数据", zap.String("conversationId", conversationID))
h.logger.Info("已保存超时任务的代理轨迹", zap.String("conversationId", conversationID))
}
}
@@ -1644,12 +1650,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errorMsg, nil)
}
// 即使任务失败,也尝试保存ReAct数据(如果result中有)
if result != nil && (result.LastReActInput != "" || result.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存失败任务的ReAct数据失败", zap.Error(err))
// 即使任务失败,也尝试保存代理轨迹(如果 result 中有)
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存失败任务的代理轨迹失败", zap.Error(err))
} else {
h.logger.Info("已保存失败任务的ReAct数据", zap.String("conversationId", conversationID))
h.logger.Info("已保存失败任务的代理轨迹", zap.String("conversationId", conversationID))
}
}
@@ -1689,12 +1695,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
}
// 保存最后一轮ReAct的输入和输出
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存ReAct数据失败", zap.Error(err))
// 保存最后一轮代理轨迹与助手输出
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存代理轨迹失败", zap.Error(err))
} else {
h.logger.Info("已保存ReAct数据", zap.String("conversationId", conversationID))
h.logger.Info("已保存代理轨迹", zap.String("conversationId", conversationID))
}
}
@@ -2494,6 +2500,9 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
cancel()
if runErr != nil {
if useRunResult {
h.persistEinoAgentTraceForResume(conversationID, resultMA)
}
// 检查是否是取消错误
// 1. 直接检查是否是 context.Canceled(包括包装后的错误)
// 2. 检查错误消息中是否包含"context canceled"或"cancelled"关键字
@@ -2537,14 +2546,14 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
}
}
// 保存ReAct数据(如果存在)
if result != nil && (result.LastReActInput != "" || result.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存取消任务的ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
// 保存代理轨迹(如果存在)
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.LastReActInput != "" || resultMA.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, resultMA.LastReActInput, resultMA.LastReActOutput); err != nil {
h.logger.Warn("保存取消任务的ReAct数据失败", 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)
@@ -2576,13 +2585,13 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
if useRunResult {
resText = resultMA.Response
mcpIDs = resultMA.MCPExecutionIDs
lastIn = resultMA.LastReActInput
lastOut = resultMA.LastReActOutput
lastIn = resultMA.LastAgentTraceInput
lastOut = resultMA.LastAgentTraceOutput
} else {
resText = result.Response
mcpIDs = result.MCPExecutionIDs
lastIn = result.LastReActInput
lastOut = result.LastReActOutput
lastIn = result.LastAgentTraceInput
lastOut = result.LastAgentTraceOutput
}
// 更新助手消息内容
@@ -2613,12 +2622,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
}
}
// 保存ReAct数据
// 保存代理轨迹
if lastIn != "" || lastOut != "" {
if err := h.db.SaveReActData(conversationID, lastIn, lastOut); err != nil {
h.logger.Warn("保存ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
if err := h.db.SaveAgentTrace(conversationID, lastIn, lastOut); err != nil {
h.logger.Warn("保存代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
} else {
h.logger.Info("已保存ReAct数据", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
h.logger.Info("已保存代理轨迹", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
}
}
@@ -2637,36 +2646,33 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
}
}
// loadHistoryFromReActData 从保存的ReAct数据恢复历史消息上下文
// 采用与攻击链生成类似的拼接逻辑:优先使用保存的last_react_input和last_react_output,若不存在则回退消息表
func (h *AgentHandler) loadHistoryFromReActData(conversationID string) ([]agent.ChatMessage, error) {
// 获取保存的ReAct输入和输出
reactInputJSON, reactOutput, err := h.db.GetReActData(conversationID)
// loadHistoryFromAgentTrace 从库中保存的代理消息轨迹恢复历史(列 last_react_*;含单代理与 Eino)。
// 逻辑与攻击链一致:优先用保存的 JSON 消息带 + 最后一轮助手摘要,否则回退消息表
func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent.ChatMessage, error) {
traceInputJSON, assistantOut, err := h.db.GetAgentTrace(conversationID)
if err != nil {
return nil, fmt.Errorf("获取ReAct数据失败: %w", err)
return nil, fmt.Errorf("获取代理轨迹失败: %w", err)
}
// 如果last_react_input为空,回退到使用消息表(与攻击链生成逻辑一致)
if reactInputJSON == "" {
return nil, fmt.Errorf("ReAct数据为空,将使用消息表")
if traceInputJSON == "" {
return nil, fmt.Errorf("代理轨迹为空,将使用消息表")
}
dataSource := "database_last_react_input"
dataSource := "database_last_agent_trace"
// 解析JSON格式的messages数组
var messagesArray []map[string]interface{}
if err := json.Unmarshal([]byte(reactInputJSON), &messagesArray); err != nil {
return nil, fmt.Errorf("解析ReAct输入JSON失败: %w", err)
if err := json.Unmarshal([]byte(traceInputJSON), &messagesArray); err != nil {
return nil, fmt.Errorf("解析代理轨迹 JSON 失败: %w", err)
}
messageCount := len(messagesArray)
h.logger.Info("使用保存的ReAct数据恢复历史上下文",
h.logger.Info("使用保存的代理轨迹恢复历史上下文",
zap.String("conversationId", conversationID),
zap.String("dataSource", dataSource),
zap.Int("reactInputSize", len(reactInputJSON)),
zap.Int("traceInputSize", len(traceInputJSON)),
zap.Int("messageCount", messageCount),
zap.Int("reactOutputSize", len(reactOutput)),
zap.Int("assistantOutSize", len(assistantOut)),
)
// fmt.Println("messagesArray:", messagesArray)//debug
@@ -2750,53 +2756,44 @@ func (h *AgentHandler) loadHistoryFromReActData(conversationID string) ([]agent.
agentMessages = append(agentMessages, msg)
}
// 如果存在last_react_output,需要将其作为最后一条assistant消息
// 因为last_react_input是在迭代开始前保存的,不包含最后一轮的最终输出
if reactOutput != "" {
// 检查最后一条消息是否是assistant消息且没有tool_calls
// 如果有tool_calls,说明后面应该还有tool消息和最终的assistant回复
// 存在 last_react_output(助手摘要),合并为最后一条 assistant(与保存格式一致)
if assistantOut != "" {
if len(agentMessages) > 0 {
lastMsg := &agentMessages[len(agentMessages)-1]
if strings.EqualFold(lastMsg.Role, "assistant") && len(lastMsg.ToolCalls) == 0 {
// 最后一条是assistant消息且没有tool_calls,用最终输出更新其content
lastMsg.Content = reactOutput
lastMsg.Content = assistantOut
} else {
// 最后一条不是assistant消息,或者有tool_calls,添加最终输出作为新的assistant消息
agentMessages = append(agentMessages, agent.ChatMessage{
Role: "assistant",
Content: reactOutput,
Content: assistantOut,
})
}
} else {
// 如果没有消息,直接添加最终输出
agentMessages = append(agentMessages, agent.ChatMessage{
Role: "assistant",
Content: reactOutput,
Content: assistantOut,
})
}
}
if len(agentMessages) == 0 {
return nil, fmt.Errorf("从ReAct数据解析的消息为空")
return nil, fmt.Errorf("从代理轨迹解析的消息为空")
}
// 修复可能存在的失配tool消息,避免OpenAI报错
// 这可以防止出现"messages with role 'tool' must be a response to a preceeding message with 'tool_calls'"错误
if h.agent != nil {
if fixed := h.agent.RepairOrphanToolMessages(&agentMessages); fixed {
h.logger.Info("修复了从ReAct数据恢复的历史消息中的失配tool消息",
h.logger.Info("修复了从代理轨迹恢复的历史消息中的失配 tool 消息",
zap.String("conversationId", conversationID),
)
}
}
h.logger.Info("从ReAct数据恢复历史消息完成",
h.logger.Info("从代理轨迹恢复历史消息完成",
zap.String("conversationId", conversationID),
zap.String("dataSource", dataSource),
zap.Int("originalMessageCount", messageCount),
zap.Int("finalMessageCount", len(agentMessages)),
zap.Bool("hasReactOutput", reactOutput != ""),
zap.Bool("hasAssistantOut", assistantOut != ""),
)
fmt.Println("agentMessages:", agentMessages) //debug
return agentMessages, nil
}
+2 -3
View File
@@ -83,7 +83,7 @@ func (h *AttackChainHandler) GetAttackChain(c *gin.Context) {
// 使用锁机制防止同一对话的并发生成
lockInterface, _ := h.generatingLocks.LoadOrStore(conversationID, &sync.Mutex{})
lock := lockInterface.(*sync.Mutex)
// 尝试获取锁,如果正在生成则返回错误
acquired := lock.TryLock()
if !acquired {
@@ -144,7 +144,7 @@ func (h *AttackChainHandler) RegenerateAttackChain(c *gin.Context) {
// 使用锁机制防止并发生成
lockInterface, _ := h.generatingLocks.LoadOrStore(conversationID, &sync.Mutex{})
lock := lockInterface.(*sync.Mutex)
acquired := lock.TryLock()
if !acquired {
h.logger.Info("攻击链正在生成中,请稍后再试", zap.String("conversationId", conversationID))
@@ -170,4 +170,3 @@ func (h *AttackChainHandler) RegenerateAttackChain(c *gin.Context) {
c.JSON(http.StatusOK, chain)
}
+39 -14
View File
@@ -17,6 +17,7 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
@@ -90,6 +91,7 @@ type AttackChainUpdater interface {
type AgentUpdater interface {
UpdateConfig(cfg *config.OpenAIConfig)
UpdateMaxIterations(maxIterations int)
UpdateToolDescriptionMode(mode string)
}
// NewConfigHandler 创建新的配置处理器
@@ -232,13 +234,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
if configToolMap[mcpTool.Name] {
continue
}
description := mcpTool.ShortDescription
if description == "" {
description = mcpTool.Description
}
if len(description) > 10000 {
description = description[:10000] + "..."
}
description := h.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description)
tools = append(tools, ToolConfigInfo{
Name: mcpTool.Name,
Description: description,
@@ -275,6 +271,11 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
SubAgentCount: subAgentCount,
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
ToolSearchAlwaysVisibleTools: append([]string(nil), h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools...),
ToolSearchAlwaysVisibleEffectiveTools: mergeToolNameLists(
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools,
builtin.GetAllBuiltinTools(),
),
}
c.JSON(http.StatusOK, GetConfigResponse{
@@ -430,13 +431,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
continue
}
description := mcpTool.ShortDescription
if description == "" {
description = mcpTool.Description
}
if len(description) > 10000 {
description = description[:10000] + "..."
}
description := h.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description)
toolInfo := ToolConfigInfo{
Name: mcpTool.Name,
@@ -689,11 +684,13 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
}
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(req.MultiAgent.ToolSearchAlwaysVisibleTools)
h.logger.Info("更新多代理配置",
zap.Bool("enabled", h.config.MultiAgent.Enabled),
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
zap.Bool("batch_use_multi_agent", h.config.MultiAgent.BatchUseMultiAgent),
zap.Int("plan_execute_loop_max_iterations", h.config.MultiAgent.PlanExecuteLoopMaxIterations),
zap.Int("tool_search_always_visible_tools", len(h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools)),
)
}
@@ -1061,6 +1058,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
if h.agent != nil {
h.agent.UpdateConfig(&h.config.OpenAI)
h.agent.UpdateMaxIterations(h.config.Agent.MaxIterations)
h.agent.UpdateToolDescriptionMode(h.config.Security.ToolDescriptionMode)
h.logger.Info("Agent配置已更新")
}
@@ -1383,6 +1381,33 @@ func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
setBoolInMap(maNode, "robot_use_multi_agent", cfg.RobotUseMultiAgent)
setBoolInMap(maNode, "batch_use_multi_agent", cfg.BatchUseMultiAgent)
setIntInMap(maNode, "plan_execute_loop_max_iterations", cfg.PlanExecuteLoopMaxIterations)
mwNode := ensureMap(maNode, "eino_middleware")
setFlowStringSliceInMap(mwNode, "tool_search_always_visible_tools", dedupeToolNameList(cfg.EinoMiddleware.ToolSearchAlwaysVisibleTools))
}
func dedupeToolNameList(in []string) []string {
if len(in) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, name := range in {
n := strings.TrimSpace(name)
if n == "" {
continue
}
key := strings.ToLower(n)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, n)
}
return out
}
func mergeToolNameLists(a, b []string) []string {
return dedupeToolNameList(append(append([]string{}, a...), b...))
}
func ensureMap(parent *yaml.Node, path ...string) *yaml.Node {
-1
View File
@@ -230,4 +230,3 @@ func (h *ConversationHandler) DeleteConversationTurn(c *gin.Context) {
"message": "ok",
})
}
+12 -8
View File
@@ -175,6 +175,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
)
if runErr != nil {
h.persistEinoAgentTraceForResume(conversationID, result)
cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled"
@@ -239,9 +240,9 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
)
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存代理轨迹失败", zap.Error(err))
}
}
@@ -281,8 +282,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
}
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
progressCallback := h.createProgressCallback(baseCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, progressCallbackRaw)
baseCtx = multiagent.WithHITLToolInterceptor(baseCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
defer timeoutCancel()
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, progressCallbackRaw)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
})
@@ -292,7 +295,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
}
result, runErr := multiagent.RunEinoSingleChatModelAgent(
baseCtx,
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
@@ -304,6 +307,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
progressCallback,
)
if runErr != nil {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return
}
@@ -321,8 +325,8 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
prep.AssistantMessageID,
)
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
_ = h.db.SaveReActData(prep.ConversationID, result.LastReActInput, result.LastReActOutput)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
}
c.JSON(http.StatusOK, gin.H{
+10 -10
View File
@@ -247,7 +247,7 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
// 先添加一个配置
configObj := config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
}
handler.manager.AddOrUpdateConfig("test-delete", configObj)
@@ -276,11 +276,11 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
// 添加多个配置
handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
})
handler.manager.AddOrUpdateConfig("test2", config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
URL: "http://127.0.0.1:8081/mcp",
ExternalMCPEnable: false,
})
@@ -319,15 +319,15 @@ func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
// 添加配置
handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
})
handler.manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
URL: "http://127.0.0.1:8081/mcp",
ExternalMCPEnable: true,
})
handler.manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
})
req := httptest.NewRequest("GET", "/api/external-mcp/stats", nil)
@@ -360,7 +360,7 @@ func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) {
// 添加一个禁用的配置
handler.manager.AddOrUpdateConfig("test-start-stop", config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
})
// 测试启动(可能会失败,因为没有真实的服务器)
@@ -416,7 +416,7 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) {
router, _, _ := setupTestRouter()
configObj := config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
}
@@ -459,14 +459,14 @@ func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) {
// 先添加配置
config1 := config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
}
handler.manager.AddOrUpdateConfig("test-update", config1)
// 更新配置
config2 := config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
URL: "http://127.0.0.1:8081/mcp",
ExternalMCPEnable: true,
}
+51 -1
View File
@@ -88,7 +88,20 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
timeout_seconds INTEGER NOT NULL DEFAULT 300,
updated_at DATETIME NOT NULL
);`)
return err
if err != nil {
return err
}
// On startup, cancel all orphaned pending interrupts from previous process.
// Their in-memory channels are gone, so they can never be resolved.
res, err := m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP WHERE status='pending'`)
if err != nil {
m.logger.Warn("failed to cancel orphaned HITL interrupts", zap.Error(err))
} else if n, _ := res.RowsAffected(); n > 0 {
m.logger.Info("cancelled orphaned HITL interrupts from previous process", zap.Int64("count", n))
}
return nil
}
func normalizeHitlMode(mode string) string {
@@ -587,6 +600,43 @@ func (h *AgentHandler) DecideHITLInterrupt(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *AgentHandler) DismissHITLInterrupt(c *gin.Context) {
var req struct {
InterruptID string `json:"interruptId" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if h.hitlManager == nil {
c.JSON(500, gin.H{"error": "hitl manager unavailable"})
return
}
res, err := h.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
decision_comment='dismissed by user', decided_at=CURRENT_TIMESTAMP
WHERE id=? AND status='pending'`, req.InterruptID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
n, _ := res.RowsAffected()
if n == 0 {
c.JSON(404, gin.H{"error": "interrupt not found or already resolved"})
return
}
// Also drain from in-memory map if present
h.hitlManager.mu.Lock()
if p, ok := h.hitlManager.pending[req.InterruptID]; ok {
delete(h.hitlManager.pending, req.InterruptID)
select {
case p.decideCh <- hitlDecision{Decision: "reject", Comment: "dismissed by user"}:
default:
}
}
h.hitlManager.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *AgentHandler) interceptHITLForEinoTool(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{}), toolName, arguments string) (string, error) {
payload := map[string]interface{}{
"toolName": toolName,
+10 -10
View File
@@ -131,16 +131,16 @@ func (h *MarkdownAgentsHandler) GetMarkdownAgent(c *gin.Context) {
}
type markdownAgentBody struct {
Filename string `json:"filename"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tools []string `json:"tools"`
Instruction string `json:"instruction"`
BindRole string `json:"bind_role"`
MaxIterations int `json:"max_iterations"`
Kind string `json:"kind"`
Raw string `json:"raw"`
Filename string `json:"filename"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tools []string `json:"tools"`
Instruction string `json:"instruction"`
BindRole string `json:"bind_role"`
MaxIterations int `json:"max_iterations"`
Kind string `json:"kind"`
Raw string `json:"raw"`
}
// CreateMarkdownAgent POST /api/multi-agent/markdown-agents
+5 -8
View File
@@ -42,11 +42,11 @@ func (h *MonitorHandler) SetExternalMCPManager(mgr *mcp.ExternalMCPManager) {
type MonitorResponse struct {
Executions []*mcp.ToolExecution `json:"executions"`
Stats map[string]*mcp.ToolStats `json:"stats"`
Timestamp time.Time `json:"timestamp"`
Total int `json:"total,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
Timestamp time.Time `json:"timestamp"`
Total int `json:"total,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
}
// Monitor 获取监控信息
@@ -213,7 +213,6 @@ func (h *MonitorHandler) loadStats() map[string]*mcp.ToolStats {
return stats
}
// GetExecution 获取特定执行记录
func (h *MonitorHandler) GetExecution(c *gin.Context) {
id := c.Param("id")
@@ -416,5 +415,3 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
}
+29 -9
View File
@@ -40,6 +40,9 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
event := StreamEvent{Type: "error", Message: "请求参数错误: " + err.Error()}
b, _ := json.Marshal(event)
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
done := StreamEvent{Type: "done", Message: ""}
db, _ := json.Marshal(done)
fmt.Fprintf(c.Writer, "data: %s\n\n", db)
c.Writer.Flush()
return
}
@@ -182,6 +185,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
)
if runErr != nil {
h.persistEinoAgentTraceForResume(conversationID, result)
cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled"
@@ -246,9 +250,9 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
)
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存代理轨迹失败", zap.Error(err))
}
}
@@ -293,13 +297,15 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
progressCallback := h.createProgressCallback(baseCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil)
baseCtx = multiagent.WithHITLToolInterceptor(baseCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
defer timeoutCancel()
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
})
result, runErr := multiagent.RunDeepAgent(
baseCtx,
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
@@ -313,6 +319,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
strings.TrimSpace(req.Orchestration),
)
if runErr != nil {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
if prep.AssistantMessageID != "" {
@@ -336,9 +343,9 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
)
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(prep.ConversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存代理轨迹失败", zap.Error(err))
}
}
@@ -350,6 +357,19 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
})
}
// persistEinoAgentTraceForResume 在 Eino 运行异常结束时写入代理轨迹(库列 last_react_*),供下一请求 loadHistoryFromAgentTrace 软续跑。
func (h *AgentHandler) persistEinoAgentTraceForResume(conversationID string, result *multiagent.RunResult) {
if h == nil || result == nil {
return
}
if result.LastAgentTraceInput == "" && result.LastAgentTraceOutput == "" {
return
}
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存 Eino 续跑上下文失败", zap.String("conversationId", conversationID), zap.Error(err))
}
}
func multiAgentHTTPErrorStatus(err error) (int, string) {
msg := err.Error()
switch {
+1 -1
View File
@@ -49,7 +49,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
}
}
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil {
historyMessages, getErr := h.db.GetMessages(conversationID)
if getErr != nil {
+27 -27
View File
@@ -4445,7 +4445,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"messageId"},
"properties": map[string]interface{}{
"messageId": map[string]interface{}{
@@ -4689,7 +4689,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"scheduleEnabled"},
"properties": map[string]interface{}{
"scheduleEnabled": map[string]interface{}{"type": "boolean", "description": "是否启用自动调度"},
@@ -4761,7 +4761,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"query"},
"properties": map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "FOFA查询语法", "example": "domain=\"example.com\""},
@@ -4810,7 +4810,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"text"},
"properties": map[string]interface{}{
"text": map[string]interface{}{"type": "string", "description": "自然语言描述", "example": "查找使用WordPress的网站"},
@@ -4853,7 +4853,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"api_key", "model"},
"properties": map[string]interface{}{
"provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude", "example": "openai"},
@@ -4900,7 +4900,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"command"},
"properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "要执行的命令"},
@@ -4943,7 +4943,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"command"},
"properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "要执行的命令"},
@@ -5027,7 +5027,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"url"},
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "WebShell URL"},
@@ -5231,7 +5231,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"url", "command"},
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "WebShell URL"},
@@ -5277,7 +5277,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"url", "action", "path"},
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "WebShell URL"},
@@ -5339,14 +5339,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"},
"relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
},
},
},
@@ -5369,7 +5369,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"multipart/form-data": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"file"},
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "format": "binary", "description": "上传的文件"},
@@ -5410,7 +5410,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"path"},
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件相对路径"},
@@ -5485,7 +5485,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"path", "content"},
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件相对路径"},
@@ -5512,7 +5512,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"name"},
"properties": map[string]interface{}{
"parent": map[string]interface{}{"type": "string", "description": "父目录相对路径"},
@@ -5552,7 +5552,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"path", "newName"},
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "当前文件相对路径"},
@@ -5646,7 +5646,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"platform", "text"},
"properties": map[string]interface{}{
"platform": map[string]interface{}{"type": "string", "description": "平台类型", "enum": []string{"dingtalk", "lark", "wecom"}},
@@ -5712,7 +5712,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"name"},
"properties": map[string]interface{}{
"filename": map[string]interface{}{"type": "string", "description": "文件名(可选,自动生成)"},
@@ -5932,7 +5932,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"path"},
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件相对路径"},
@@ -5974,7 +5974,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"ids"},
"properties": map[string]interface{}{
"ids": map[string]interface{}{
@@ -6197,7 +6197,7 @@ func (h *OpenAPIHandler) GetConversationResults(c *gin.Context) {
}
// 获取漏洞列表
vulnList, err := h.db.ListVulnerabilities(1000, 0, "", conversationID, "", "")
vulnList, err := h.db.ListVulnerabilities(1000, 0, "", conversationID, "", "", "", "", "")
if err != nil {
h.logger.Warn("获取漏洞列表失败", zap.Error(err))
vulnList = []*database.Vulnerability{}
+6 -6
View File
@@ -26,7 +26,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"创建分组": "createGroup", "列出分组": "listGroups", "获取分组": "getGroup", "更新分组": "updateGroup",
"删除分组": "deleteGroup", "获取分组中的对话": "getGroupConversations", "添加对话到分组": "addConversationToGroup",
"从分组移除对话": "removeConversationFromGroup",
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
"获取漏洞": "getVulnerability", "更新漏洞": "updateVulnerability", "删除漏洞": "deleteVulnerability",
"列出角色": "listRoles", "创建角色": "createRole", "获取角色": "getRole", "更新角色": "updateRole", "删除角色": "deleteRole",
"获取可用Skills列表": "getAvailableSkills", "列出Skills": "listSkills", "创建Skill": "createSkill",
@@ -52,9 +52,9 @@ var apiDocI18nSummaryToKey = map[string]string{
"重跑批量任务队列": "rerunBatchQueue", "修改队列元数据": "updateBatchQueueMetadata",
"修改队列调度配置": "updateBatchQueueSchedule", "开关Cron自动调度": "setBatchQueueScheduleEnabled",
"获取所有分组映射": "getAllGroupMappings",
"FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse",
"FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse",
"测试OpenAI API连接": "testOpenAI",
"执行终端命令": "terminalRun", "流式执行终端命令": "terminalRunStream", "WebSocket终端": "terminalWS",
"执行终端命令": "terminalRun", "流式执行终端命令": "terminalRunStream", "WebSocket终端": "terminalWS",
"列出WebShell连接": "listWebshellConnections", "创建WebShell连接": "createWebshellConnection",
"更新WebShell连接": "updateWebshellConnection", "删除WebShell连接": "deleteWebshellConnection",
"获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState",
@@ -69,7 +69,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"获取Markdown代理详情": "getMarkdownAgent", "更新Markdown代理": "updateMarkdownAgent", "删除Markdown代理": "deleteMarkdownAgent",
"列出技能包文件": "listSkillPackageFiles", "获取技能包文件内容": "getSkillPackageFile", "写入技能包文件": "putSkillPackageFile",
"批量获取工具名称": "batchGetToolNames",
"获取知识库统计": "getKnowledgeStats",
"获取知识库统计": "getKnowledgeStats",
}
var apiDocI18nResponseDescToKey = map[string]string{
@@ -78,7 +78,7 @@ var apiDocI18nResponseDescToKey = map[string]string{
"对话不存在或结果不存在": "conversationOrResultNotFound", "请求参数错误(如task为空)": "badRequestTaskEmpty",
"请求参数错误或分组名称已存在": "badRequestGroupNameExists", "分组不存在": "groupNotFound",
"请求参数错误(如配置格式不正确、缺少必需字段等)": "badRequestConfig",
"请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed",
"请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed",
"登录成功": "loginSuccess", "密码错误": "invalidPassword", "登出成功": "logoutSuccess",
"密码修改成功": "passwordChanged", "Token有效": "tokenValid", "Token无效或已过期": "tokenInvalid",
"对话创建成功": "conversationCreated", "服务器内部错误": "internalError", "更新成功": "updateSuccess",
@@ -89,7 +89,7 @@ var apiDocI18nResponseDescToKey = map[string]string{
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events": "streamResponse",
// 新增缺失端点响应
"参数错误或删除失败": "badRequestOrDeleteFailed",
"参数错误": "paramError", "仅已完成或已取消的队列可以重跑": "onlyCompletedOrCancelledCanRerun",
"参数错误": "paramError", "仅已完成或已取消的队列可以重跑": "onlyCompletedOrCancelledCanRerun",
"参数错误或队列正在运行中": "badRequestOrQueueRunning", "设置成功": "setSuccess",
"搜索成功": "searchSuccess", "解析成功": "parseSuccess", "测试结果": "testResult",
"执行完成": "executionDone", "SSE事件流": "sseEventStream", "WebSocket连接已建立": "wsEstablished",
+14 -14
View File
@@ -28,20 +28,20 @@ import (
)
const (
robotCmdHelp = "帮助"
robotCmdList = "列表"
robotCmdListAlt = "对话列表"
robotCmdSwitch = "切换"
robotCmdContinue = "继续"
robotCmdNew = "新对话"
robotCmdClear = "清空"
robotCmdCurrent = "当前"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
robotCmdHelp = "帮助"
robotCmdList = "列表"
robotCmdListAlt = "对话列表"
robotCmdSwitch = "切换"
robotCmdContinue = "继续"
robotCmdNew = "新对话"
robotCmdClear = "清空"
robotCmdCurrent = "当前"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
)
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
+13 -13
View File
@@ -65,19 +65,19 @@ func (h *SkillsHandler) GetSkills(c *gin.Context) {
allSkillsInfo := make([]map[string]interface{}, 0, len(allSummaries))
for _, s := range allSummaries {
skillInfo := map[string]interface{}{
"id": s.ID,
"name": s.Name,
"dir_name": s.DirName,
"description": s.Description,
"version": s.Version,
"path": s.Path,
"tags": s.Tags,
"triggers": s.Triggers,
"script_count": s.ScriptCount,
"file_count": s.FileCount,
"progressive": s.Progressive,
"file_size": s.FileSize,
"mod_time": s.ModTime,
"id": s.ID,
"name": s.Name,
"dir_name": s.DirName,
"description": s.Description,
"version": s.Version,
"path": s.Path,
"tags": s.Tags,
"triggers": s.Triggers,
"script_count": s.ScriptCount,
"file_count": s.FileCount,
"progressive": s.Progressive,
"file_size": s.FileSize,
"mod_time": s.ModTime,
}
allSkillsInfo = append(allSkillsInfo, skillInfo)
}
-1
View File
@@ -109,4 +109,3 @@ func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
<-doneChan
}
+222 -23
View File
@@ -1,8 +1,11 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
@@ -25,7 +28,9 @@ func NewVulnerabilityHandler(db *database.DB, logger *zap.Logger) *Vulnerability
// CreateVulnerabilityRequest 创建漏洞请求
type CreateVulnerabilityRequest struct {
ConversationID string `json:"conversation_id" binding:"required"`
ConversationID string `json:"conversation_id" binding:"required"`
ConversationTag string `json:"conversation_tag"`
TaskTag string `json:"task_tag"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Severity string `json:"severity" binding:"required"`
@@ -46,16 +51,18 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) {
}
vuln := &database.Vulnerability{
ConversationID: req.ConversationID,
Title: req.Title,
Description: req.Description,
Severity: req.Severity,
Status: req.Status,
Type: req.Type,
Target: req.Target,
Proof: req.Proof,
Impact: req.Impact,
Recommendation: req.Recommendation,
ConversationID: req.ConversationID,
ConversationTag: req.ConversationTag,
TaskTag: req.TaskTag,
Title: req.Title,
Description: req.Description,
Severity: req.Severity,
Status: req.Status,
Type: req.Type,
Target: req.Target,
Proof: req.Proof,
Impact: req.Impact,
Recommendation: req.Recommendation,
}
created, err := h.db.CreateVulnerability(vuln)
@@ -100,6 +107,9 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
conversationID := c.Query("conversation_id")
severity := c.Query("severity")
status := c.Query("status")
taskID := c.Query("task_id")
conversationTag := c.Query("conversation_tag")
taskTag := c.Query("task_tag")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
@@ -121,7 +131,7 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
}
// 获取总数
total, err := h.db.CountVulnerabilities(id, conversationID, severity, status)
total, err := h.db.CountVulnerabilities(id, conversationID, severity, status, taskID, conversationTag, taskTag)
if err != nil {
h.logger.Error("获取漏洞总数失败", zap.Error(err))
// 继续执行,使用0作为总数
@@ -129,7 +139,7 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
}
// 获取漏洞列表
vulnerabilities, err := h.db.ListVulnerabilities(limit, offset, id, conversationID, severity, status)
vulnerabilities, err := h.db.ListVulnerabilities(limit, offset, id, conversationID, severity, status, taskID, conversationTag, taskTag)
if err != nil {
h.logger.Error("获取漏洞列表失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -160,15 +170,17 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
// UpdateVulnerabilityRequest 更新漏洞请求
type UpdateVulnerabilityRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
Impact string `json:"impact"`
Recommendation string `json:"recommendation"`
ConversationTag string `json:"conversation_tag"`
TaskTag string `json:"task_tag"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
Impact string `json:"impact"`
Recommendation string `json:"recommendation"`
}
// UpdateVulnerability 更新漏洞
@@ -189,6 +201,12 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
}
// 更新字段
if req.ConversationTag != "" {
existing.ConversationTag = req.ConversationTag
}
if req.TaskTag != "" {
existing.TaskTag = req.TaskTag
}
if req.Title != "" {
existing.Title = req.Title
}
@@ -250,8 +268,9 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) {
// GetVulnerabilityStats 获取漏洞统计
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
conversationID := c.Query("conversation_id")
taskID := c.Query("task_id")
stats, err := h.db.GetVulnerabilityStats(conversationID)
stats, err := h.db.GetVulnerabilityStats(conversationID, taskID)
if err != nil {
h.logger.Error("获取漏洞统计失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -261,3 +280,183 @@ func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
c.JSON(http.StatusOK, stats)
}
// GetVulnerabilityFilterOptions 获取漏洞筛选建议项
func (h *VulnerabilityHandler) GetVulnerabilityFilterOptions(c *gin.Context) {
options, err := h.db.GetVulnerabilityFilterOptions()
if err != nil {
h.logger.Error("获取漏洞筛选建议失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, options)
}
// ExportVulnerabilities 导出漏洞(支持按对话/任务分组,汇总或拆分)
func (h *VulnerabilityHandler) ExportVulnerabilities(c *gin.Context) {
groupBy := c.DefaultQuery("group_by", "conversation")
mode := c.DefaultQuery("mode", "summary")
if groupBy != "conversation" && groupBy != "task" {
c.JSON(http.StatusBadRequest, gin.H{"error": "group_by 仅支持 conversation 或 task"})
return
}
if mode != "summary" && mode != "split" {
c.JSON(http.StatusBadRequest, gin.H{"error": "mode 仅支持 summary 或 split"})
return
}
id := c.Query("id")
conversationID := c.Query("conversation_id")
severity := c.Query("severity")
status := c.Query("status")
taskID := c.Query("task_id")
conversationTag := c.Query("conversation_tag")
taskTag := c.Query("task_tag")
total, err := h.db.CountVulnerabilities(id, conversationID, severity, status, taskID, conversationTag, taskTag)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if total == 0 {
c.JSON(http.StatusOK, gin.H{"mode": mode, "group_by": groupBy, "total": 0, "files": []any{}})
return
}
items, err := h.db.ListVulnerabilities(total, 0, id, conversationID, severity, status, taskID, conversationTag, taskTag)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
type exportFile struct {
FileName string `json:"filename"`
Content string `json:"content"`
}
grouped := map[string][]*database.Vulnerability{}
for _, v := range items {
key := v.ConversationID
if groupBy == "conversation" {
if strings.TrimSpace(v.ConversationTag) != "" {
key = strings.TrimSpace(v.ConversationTag)
}
} else {
key = firstNonEmpty(v.TaskTag, v.TaskID, v.TaskQueueID, "unassigned-task")
}
grouped[key] = append(grouped[key], v)
}
files := make([]exportFile, 0)
nowStr := time.Now().Format("20060102-150405")
if mode == "summary" {
var b strings.Builder
b.WriteString("# 漏洞批量导出报告\n\n")
b.WriteString(fmt.Sprintf("- 导出时间: %s\n", time.Now().Format("2006-01-02 15:04:05")))
b.WriteString(fmt.Sprintf("- 分组维度: %s\n", groupBy))
b.WriteString(fmt.Sprintf("- 漏洞总数: %d\n", len(items)))
b.WriteString(fmt.Sprintf("- 分组数: %d\n\n", len(grouped)))
for group, list := range grouped {
b.WriteString(fmt.Sprintf("## %s (%d)\n\n", group, len(list)))
for _, v := range list {
appendVulnerabilityMarkdown(&b, v, "###")
}
}
files = append(files, exportFile{
FileName: fmt.Sprintf("vulnerability-report-%s-%s.md", groupBy, nowStr),
Content: b.String(),
})
} else {
for group, list := range grouped {
var b strings.Builder
b.WriteString(fmt.Sprintf("# 漏洞报告 - %s\n\n", group))
b.WriteString(fmt.Sprintf("- 导出时间: %s\n", time.Now().Format("2006-01-02 15:04:05")))
b.WriteString(fmt.Sprintf("- 漏洞数量: %d\n\n", len(list)))
for _, v := range list {
appendVulnerabilityMarkdown(&b, v, "##")
}
files = append(files, exportFile{
FileName: fmt.Sprintf("vulnerability-%s-%s.md", sanitizeExportName(group), nowStr),
Content: b.String(),
})
}
}
c.JSON(http.StatusOK, gin.H{
"mode": mode,
"group_by": groupBy,
"total": len(items),
"files": files,
})
}
// appendVulnerabilityMarkdown 单条漏洞的 Markdown 片段(与单文件下载字段对齐,缺省字段不写)
func appendVulnerabilityMarkdown(b *strings.Builder, v *database.Vulnerability, titleHeading string) {
b.WriteString(fmt.Sprintf("%s %s\n\n", titleHeading, v.Title))
b.WriteString(fmt.Sprintf("- 漏洞ID: `%s`\n", v.ID))
b.WriteString(fmt.Sprintf("- 严重程度: %s\n", v.Severity))
b.WriteString(fmt.Sprintf("- 状态: %s\n", v.Status))
if v.Type != "" {
b.WriteString(fmt.Sprintf("- 类型: %s\n", v.Type))
}
if v.Target != "" {
b.WriteString(fmt.Sprintf("- 目标: %s\n", v.Target))
}
b.WriteString(fmt.Sprintf("- 对话ID: `%s`\n", v.ConversationID))
if v.ConversationTag != "" {
b.WriteString(fmt.Sprintf("- 对话标签: %s\n", v.ConversationTag))
}
if v.TaskTag != "" {
b.WriteString(fmt.Sprintf("- 任务标签: %s\n", v.TaskTag))
}
if v.TaskID != "" {
b.WriteString(fmt.Sprintf("- 任务ID: `%s`\n", v.TaskID))
}
if v.TaskQueueID != "" {
b.WriteString(fmt.Sprintf("- 任务队列ID: `%s`\n", v.TaskQueueID))
}
if !v.CreatedAt.IsZero() {
b.WriteString(fmt.Sprintf("- 创建时间: %s\n", v.CreatedAt.Format("2006-01-02 15:04:05")))
}
if !v.UpdatedAt.IsZero() {
b.WriteString(fmt.Sprintf("- 更新时间: %s\n", v.UpdatedAt.Format("2006-01-02 15:04:05")))
}
if v.Description != "" {
b.WriteString("\n#### 描述\n\n")
b.WriteString(v.Description)
b.WriteString("\n")
}
if v.Proof != "" {
b.WriteString("\n#### 证明(POC\n\n```\n")
b.WriteString(v.Proof)
b.WriteString("\n```\n")
}
if v.Impact != "" {
b.WriteString("\n#### 影响\n\n")
b.WriteString(v.Impact)
b.WriteString("\n")
}
if v.Recommendation != "" {
b.WriteString("\n#### 修复建议\n\n")
b.WriteString(v.Recommendation)
b.WriteString("\n")
}
b.WriteString("\n")
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed != "" {
return trimmed
}
}
return ""
}
func sanitizeExportName(raw string) string {
name := strings.TrimSpace(raw)
if name == "" {
return "unknown"
}
replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "*", "-", "?", "-", "\"", "-", "<", "-", ">", "-", "|", "-")
return replacer.Replace(name)
}
+3 -3
View File
@@ -16,9 +16,9 @@ const (
// DSL keys for [VectorEinoRetriever.Retrieve] via [retriever.WithDSLInfo].
const (
DSLRiskType = "risk_type"
DSLSimilarityThreshold = "similarity_threshold"
DSLSubIndexFilter = "sub_index_filter"
DSLRiskType = "risk_type"
DSLSimilarityThreshold = "similarity_threshold"
DSLSubIndexFilter = "sub_index_filter"
)
// FormatEmbeddingInput matches the historical indexing format so existing embeddings
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/components/document"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
)
+9 -9
View File
@@ -11,9 +11,9 @@ import (
"cyberstrike-ai/internal/config"
fileloader "github.com/cloudwego/eino-ext/components/document/loader/file"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/components/document"
"github.com/cloudwego/eino/components/indexer"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
@@ -35,14 +35,14 @@ type Indexer struct {
lastErrorTime time.Time
errorCount int
rebuildMu sync.RWMutex
isRebuilding bool
rebuildTotalItems int
rebuildCurrent int
rebuildFailed int
rebuildStartTime time.Time
rebuildLastItemID string
rebuildLastChunks int
rebuildMu sync.RWMutex
isRebuilding bool
rebuildTotalItems int
rebuildCurrent int
rebuildFailed int
rebuildStartTime time.Time
rebuildLastItemID string
rebuildLastChunks int
}
// NewIndexer 创建索引器并编译 Eino 索引链;kcfg 为完整知识库配置(含 indexing 与路径相关行为)。
+3 -3
View File
@@ -108,9 +108,9 @@ func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
// CategoryWithItems 分类及其下的知识项(用于按分类分页)
type CategoryWithItems struct {
Category string `json:"category"` // 分类名称
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
Category string `json:"category"` // 分类名称
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
}
// SearchRequest 搜索请求
+10 -10
View File
@@ -55,14 +55,14 @@ func New(level, output string) *Logger {
}
func (l *Logger) Fatal(msg string, fields ...interface{}) {
zapFields := make([]zap.Field, 0, len(fields))
for _, f := range fields {
switch v := f.(type) {
case error:
zapFields = append(zapFields, zap.Error(v))
default:
zapFields = append(zapFields, zap.Any("field", v))
}
}
l.Logger.Fatal(msg, zapFields...)
zapFields := make([]zap.Field, 0, len(fields))
for _, f := range fields {
switch v := f.(type) {
case error:
zapFields = append(zapFields, zap.Error(v))
default:
zapFields = append(zapFields, zap.Any("field", v))
}
}
l.Logger.Fatal(msg, zapFields...)
}
+10 -10
View File
@@ -62,7 +62,7 @@ func TestExternalMCPManager_RemoveConfig(t *testing.T) {
manager := NewExternalMCPManager(logger)
cfg := config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: false,
}
@@ -86,17 +86,17 @@ func TestExternalMCPManager_GetStats(t *testing.T) {
// 添加多个配置
manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
})
manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
URL: "http://127.0.0.1:8081/mcp",
ExternalMCPEnable: true,
})
manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: false,
})
@@ -122,11 +122,11 @@ func TestExternalMCPManager_LoadConfigs(t *testing.T) {
externalMCPConfig := config.ExternalMCPConfig{
Servers: map[string]config.ExternalMCPServerConfig{
"loaded1": {
Command: "python3",
Command: "python3",
ExternalMCPEnable: true,
},
"loaded2": {
URL: "http://127.0.0.1:8081/mcp",
URL: "http://127.0.0.1:8081/mcp",
ExternalMCPEnable: false,
},
},
@@ -153,9 +153,9 @@ func TestLazySDKClient_InitializeFails(t *testing.T) {
logger := zap.NewNop()
// 使用不存在的 HTTP 地址,Initialize 应失败
cfg := config.ExternalMCPServerConfig{
Type: "http",
URL: "http://127.0.0.1:19999/nonexistent",
Timeout: 2,
Type: "http",
URL: "http://127.0.0.1:19999/nonexistent",
Timeout: 2,
}
c := newLazySDKClient(cfg, logger)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -176,7 +176,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
// 添加一个禁用的配置
cfg := config.ExternalMCPServerConfig{
Command: "python3",
Command: "python3",
ExternalMCPEnable: false,
}
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
package multiagent
import (
"context"
"strings"
"cyberstrike-ai/internal/agent"
"github.com/bytedance/sonic"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
type einoModelInputTelemetryMiddleware struct {
adk.BaseChatModelAgentMiddleware
logger *zap.Logger
modelName string
conversationID string
phase string
}
func newEinoModelInputTelemetryMiddleware(
logger *zap.Logger,
modelName string,
conversationID string,
phase string,
) adk.ChatModelAgentMiddleware {
if logger == nil {
return nil
}
return &einoModelInputTelemetryMiddleware{
logger: logger,
modelName: strings.TrimSpace(modelName),
conversationID: strings.TrimSpace(conversationID),
phase: strings.TrimSpace(phase),
}
}
func (m *einoModelInputTelemetryMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.ChatModelAgentState,
mc *adk.ModelContext,
) (context.Context, *adk.ChatModelAgentState, error) {
if m == nil || m.logger == nil || state == nil {
return ctx, state, nil
}
tokens := estimateTokensForMessagesAndTools(ctx, m.modelName, state.Messages, mcTools(mc))
m.logger.Info("eino model input estimated",
zap.String("phase", m.phase),
zap.String("conversation_id", m.conversationID),
zap.Int("messages", len(state.Messages)),
zap.Int("tools", len(mcTools(mc))),
zap.Int("input_tokens_estimated", tokens),
)
return ctx, state, nil
}
func mcTools(mc *adk.ModelContext) []*schema.ToolInfo {
if mc == nil || len(mc.Tools) == 0 {
return nil
}
return mc.Tools
}
func estimateTokensForMessagesAndTools(
_ context.Context,
modelName string,
messages []adk.Message,
tools []*schema.ToolInfo,
) int {
var sb strings.Builder
for _, msg := range messages {
if msg == nil {
continue
}
sb.WriteString(string(msg.Role))
sb.WriteByte('\n')
sb.WriteString(msg.Content)
sb.WriteByte('\n')
if msg.ReasoningContent != "" {
sb.WriteString(msg.ReasoningContent)
sb.WriteByte('\n')
}
if len(msg.ToolCalls) > 0 {
if b, err := sonic.Marshal(msg.ToolCalls); err == nil {
sb.Write(b)
sb.WriteByte('\n')
}
}
}
for _, tl := range tools {
if tl == nil {
continue
}
cp := *tl
cp.Extra = nil
if text, err := sonic.MarshalString(cp); err == nil {
sb.WriteString(text)
sb.WriteByte('\n')
}
}
text := sb.String()
if text == "" {
return 0
}
tc := agent.NewTikTokenCounter()
if n, err := tc.Count(modelName, text); err == nil {
return n
}
return (len(text) + 3) / 4
}
func logPlanExecuteModelInputEstimate(
logger *zap.Logger,
modelName string,
conversationID string,
phase string,
msgs []adk.Message,
) {
if logger == nil {
return
}
tokens := estimateTokensForMessagesAndTools(context.Background(), modelName, msgs, nil)
logger.Info("eino model input estimated",
zap.String("phase", phase),
zap.String("conversation_id", strings.TrimSpace(conversationID)),
zap.Int("messages", len(msgs)),
zap.Int("tools", 0),
zap.Int("input_tokens_estimated", tokens),
)
}
+64 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp/builtin"
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk"
@@ -65,6 +66,66 @@ func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []t
return append([]tool.BaseTool(nil), all[:alwaysVisible]...), append([]tool.BaseTool(nil), all[alwaysVisible:]...), true
}
func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbackAlwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
nameSet := make(map[string]struct{}, len(names))
for _, n := range names {
n = strings.TrimSpace(strings.ToLower(n))
if n == "" {
continue
}
nameSet[n] = struct{}{}
}
if len(nameSet) == 0 {
return splitToolsForToolSearch(all, fallbackAlwaysVisible)
}
static = make([]tool.BaseTool, 0, len(all))
dynamic = make([]tool.BaseTool, 0, len(all))
for _, t := range all {
if t == nil {
continue
}
info, err := t.Info(context.Background())
name := ""
if err == nil && info != nil {
name = strings.TrimSpace(strings.ToLower(info.Name))
}
if _, keep := nameSet[name]; keep {
static = append(static, t)
continue
}
dynamic = append(dynamic, t)
}
if len(static) == 0 || len(dynamic) == 0 {
// fallback: preserve previous behavior when whitelist misses all or includes all.
return splitToolsForToolSearch(all, fallbackAlwaysVisible)
}
return static, dynamic, true
}
func mergeAlwaysVisibleToolNames(configured []string) []string {
merged := make([]string, 0, len(configured)+32)
seen := make(map[string]struct{}, len(configured)+32)
add := func(name string) {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
return
}
if _, ok := seen[n]; ok {
return
}
seen[n] = struct{}{}
merged = append(merged, n)
}
for _, n := range configured {
add(n)
}
// Always include hardcoded backend builtin MCP tools from constants.
for _, n := range builtin.GetAllBuiltinTools() {
add(n)
}
return merged
}
func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddlewareConfig, convID string, loc *localbk.Local, logger *zap.Logger) (adk.ChatModelAgentMiddleware, error) {
if loc == nil {
return nil, fmt.Errorf("reduction: local backend nil")
@@ -87,6 +148,8 @@ func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddl
RootDir: root,
ReadFileToolName: "read_file",
ClearExcludeTools: excl,
MaxLengthForTrunc: mw.ReductionMaxLengthForTruncEffective(),
MaxTokensForClear: int64(mw.ReductionMaxTokensForClearEffective()),
})
if err != nil {
return nil, err
@@ -142,7 +205,7 @@ func prependEinoMiddlewares(
alwaysVis = 12
}
if mw.ToolSearchEnable && len(tools) >= minTools {
static, dynamic, split := splitToolsForToolSearch(tools, alwaysVis)
static, dynamic, split := splitToolsForToolSearchByNames(tools, mergeAlwaysVisibleToolNames(mw.ToolSearchAlwaysVisibleTools), alwaysVis)
if split && len(dynamic) > 0 {
ts, terr := toolsearch.New(ctx, &toolsearch.Config{DynamicTools: dynamic})
if terr != nil {
@@ -0,0 +1,38 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
)
func applyBeforeModelRewriteHandlers(
ctx context.Context,
msgs []adk.Message,
handlers []adk.ChatModelAgentMiddleware,
) ([]adk.Message, error) {
if len(msgs) == 0 || len(handlers) == 0 {
return msgs, nil
}
state := &adk.ChatModelAgentState{Messages: msgs}
modelCtx := &adk.ModelContext{}
curCtx := ctx
for _, h := range handlers {
if h == nil {
continue
}
nextCtx, nextState, err := h.BeforeModelRewriteState(curCtx, state, modelCtx)
if err != nil {
return nil, fmt.Errorf("before model rewrite: %w", err)
}
if nextCtx != nil {
curCtx = nextCtx
}
if nextState != nil {
state = nextState
}
}
return state.Messages, nil
}
+168 -20
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino-ext/components/model/openai"
@@ -25,7 +26,12 @@ type PlanExecuteRootArgs struct {
LoopMaxIter int
// AppCfg / Logger 非空时为 Executor 挂载与 Deep/Supervisor 一致的 Eino summarization 中间件。
AppCfg *config.Config
MwCfg *config.MultiAgentEinoMiddlewareConfig
// ConversationID is used for transcript/isolation paths in middleware.
ConversationID string
Logger *zap.Logger
// ModelName is used for model input token estimation logs.
ModelName string
// ExecPreMiddlewares 是由 prependEinoMiddlewares 构建的前置中间件(patchtoolcalls, reduction, toolsearch, plantask),
// 与 Deep/Supervisor 主代理的 mainOrchestratorPre 一致。
ExecPreMiddlewares []adk.ChatModelAgentMiddleware
@@ -33,6 +39,8 @@ type PlanExecuteRootArgs struct {
SkillMiddleware adk.ChatModelAgentMiddleware
// FilesystemMiddleware 是 Eino filesystem 中间件,当 eino_skills.filesystem_tools 启用时提供本机文件读写与 Shell 能力(可选)。
FilesystemMiddleware adk.ChatModelAgentMiddleware
// PlannerReplannerRewriteHandlers applies BeforeModelRewriteState pipeline for planner/replanner input.
PlannerReplannerRewriteHandlers []adk.ChatModelAgentMiddleware
}
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
@@ -50,7 +58,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
plannerCfg := &planexecute.PlannerConfig{
ToolCallingChatModel: tcm,
}
if fn := planExecutePlannerGenInput(a.OrchInstruction); fn != nil {
if fn := planExecutePlannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers); fn != nil {
plannerCfg.GenInputFn = fn
}
planner, err := planexecute.NewPlanner(ctx, plannerCfg)
@@ -59,7 +67,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
}
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
ChatModel: tcm,
GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction),
GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers),
})
if err != nil {
return nil, fmt.Errorf("plan_execute replanner: %w", err)
@@ -81,17 +89,20 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
}
// 4. summarization(最后,与 Deep/Supervisor 一致)
if a.AppCfg != nil {
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.Logger)
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.Logger)
if sumErr != nil {
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
}
execHandlers = append(execHandlers, sumMw)
}
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
execHandlers = append(execHandlers, teleMw)
}
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
Model: a.ExecModel,
ToolsConfig: a.ToolsCfg,
MaxIterations: a.ExecMaxIter,
GenInputFn: planExecuteExecutorGenInput(a.OrchInstruction),
GenInputFn: planExecuteExecutorGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID),
}, execHandlers)
if err != nil {
return nil, fmt.Errorf("plan_execute executor: %w", err)
@@ -110,20 +121,42 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
// planExecutePlannerGenInput 将 orchestrator instruction 作为 SystemMessage 注入 planner 输入。
// 返回 nil 时 Eino 使用内置默认 planner prompt。
func planExecutePlannerGenInput(orchInstruction string) planexecute.GenPlannerModelInputFn {
func planExecutePlannerGenInput(
orchInstruction string,
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
logger *zap.Logger,
modelName string,
conversationID string,
rewriteHandlers []adk.ChatModelAgentMiddleware,
) planexecute.GenPlannerModelInputFn {
oi := strings.TrimSpace(orchInstruction)
if oi == "" {
if oi == "" && appCfg == nil {
return nil
}
return func(ctx context.Context, userInput []adk.Message) ([]adk.Message, error) {
userInput = capPlanExecuteUserInputMessages(userInput, appCfg, mwCfg)
msgs := make([]adk.Message, 0, 1+len(userInput))
msgs = append(msgs, schema.SystemMessage(oi))
if oi != "" {
msgs = append(msgs, schema.SystemMessage(oi))
}
msgs = append(msgs, userInput...)
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
msgs = rewritten
}
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_planner", msgs)
return msgs, nil
}
}
func planExecuteExecutorGenInput(orchInstruction string) planexecute.GenModelInputFn {
func planExecuteExecutorGenInput(
orchInstruction string,
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
logger *zap.Logger,
modelName string,
conversationID string,
) planexecute.GenModelInputFn {
oi := strings.TrimSpace(orchInstruction)
return func(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
planContent, err := in.Plan.MarshalJSON()
@@ -131,9 +164,9 @@ func planExecuteExecutorGenInput(orchInstruction string) planexecute.GenModelInp
return nil, err
}
userMsgs, err := planexecute.ExecutorPrompt.Format(ctx, map[string]any{
"input": planExecuteFormatInput(in.UserInput),
"input": planExecuteFormatInput(capPlanExecuteUserInputMessages(in.UserInput, appCfg, mwCfg)),
"plan": string(planContent),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps, appCfg, mwCfg),
"step": in.Plan.FirstStep(),
})
if err != nil {
@@ -142,6 +175,7 @@ func planExecuteExecutorGenInput(orchInstruction string) planexecute.GenModelInp
if oi != "" {
userMsgs = append([]adk.Message{schema.SystemMessage(oi)}, userMsgs...)
}
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_executor_gen_input", userMsgs)
return userMsgs, nil
}
}
@@ -155,18 +189,22 @@ func planExecuteFormatInput(input []adk.Message) string {
return sb.String()
}
func planExecuteFormatExecutedSteps(results []planexecute.ExecutedStep) string {
capped := capPlanExecuteExecutedSteps(results)
var sb strings.Builder
for _, result := range capped {
sb.WriteString(fmt.Sprintf("Step: %s\nResult: %s\n\n", result.Step, result.Result))
}
return sb.String()
func planExecuteFormatExecutedSteps(results []planexecute.ExecutedStep, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) string {
capped := capPlanExecuteExecutedStepsWithConfig(results, mwCfg)
return renderPlanExecuteStepsByBudget(capped, appCfg, mwCfg)
}
// planExecuteReplannerGenInput 与 Eino 默认 Replanner 输入一致,但 executed_steps 经 cap 后再写入 prompt
// 且在 orchInstruction 非空时 prepend SystemMessage 使 replanner 也能接收全局指令。
func planExecuteReplannerGenInput(orchInstruction string) planexecute.GenModelInputFn {
func planExecuteReplannerGenInput(
orchInstruction string,
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
logger *zap.Logger,
modelName string,
conversationID string,
rewriteHandlers []adk.ChatModelAgentMiddleware,
) planexecute.GenModelInputFn {
oi := strings.TrimSpace(orchInstruction)
return func(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
planContent, err := in.Plan.MarshalJSON()
@@ -175,8 +213,8 @@ func planExecuteReplannerGenInput(orchInstruction string) planexecute.GenModelIn
}
msgs, err := planexecute.ReplannerPrompt.Format(ctx, map[string]any{
"plan": string(planContent),
"input": planExecuteFormatInput(in.UserInput),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps),
"input": planExecuteFormatInput(capPlanExecuteUserInputMessages(in.UserInput, appCfg, mwCfg)),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps, appCfg, mwCfg),
"plan_tool": planexecute.PlanToolInfo.Name,
"respond_tool": planexecute.RespondToolInfo.Name,
})
@@ -186,10 +224,120 @@ func planExecuteReplannerGenInput(orchInstruction string) planexecute.GenModelIn
if oi != "" {
msgs = append([]adk.Message{schema.SystemMessage(oi)}, msgs...)
}
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
msgs = rewritten
}
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_replanner", msgs)
return msgs, nil
}
}
func capPlanExecuteUserInputMessages(input []adk.Message, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
if len(input) == 0 {
return input
}
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
}
}
// Reserve most tokens for planner/replanner prompt and tool schema.
ratio := 0.35
if mwCfg != nil {
ratio = mwCfg.PlanExecuteUserInputBudgetRatioEffective()
}
budget := int(float64(maxTotal) * ratio)
if budget < 4096 {
budget = 4096
}
tc := agent.NewTikTokenCounter()
out := make([]adk.Message, 0, len(input))
used := 0
for i := len(input) - 1; i >= 0; i-- {
msg := input[i]
if msg == nil {
continue
}
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
out = append(out, msg)
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
if len(out) == 0 {
// Keep the latest user message at least.
return []adk.Message{input[len(input)-1]}
}
return out
}
func renderPlanExecuteStepsByBudget(steps []planexecute.ExecutedStep, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) string {
if len(steps) == 0 {
return ""
}
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.2
if mwCfg != nil {
ratio = mwCfg.PlanExecuteExecutedStepsBudgetRatioEffective()
}
budget := int(float64(maxTotal) * ratio)
if budget < 3072 {
budget = 3072
}
tc := agent.NewTikTokenCounter()
var kept []string
used := 0
skipped := 0
for i := len(steps) - 1; i >= 0; i-- {
block := fmt.Sprintf("Step: %s\nResult: %s\n\n", steps[i].Step, steps[i].Result)
n, err := tc.Count(modelName, block)
if err != nil {
n = (len(block) + 3) / 4
}
if n <= 0 {
n = 1
}
if used+n > budget {
skipped = i + 1
break
}
used += n
kept = append(kept, block)
}
var sb strings.Builder
if skipped > 0 {
sb.WriteString(fmt.Sprintf("Earlier executed steps omitted due to context budget: %d steps.\n\n", skipped))
}
for i := len(kept) - 1; i >= 0; i-- {
sb.WriteString(kept[i])
}
return sb.String()
}
// planExecuteStreamsMainAssistant 将规划/执行/重规划各阶段助手流式输出映射到主对话区。
func planExecuteStreamsMainAssistant(agent string) bool {
if agent == "" {
+24 -3
View File
@@ -125,7 +125,7 @@ func RunEinoSingleChatModelAgent(
return nil, fmt.Errorf("eino single 模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, logger)
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
if err != nil {
return nil, fmt.Errorf("eino single summarization: %w", err)
}
@@ -145,6 +145,9 @@ func RunEinoSingleChatModelAgent(
handlers = append(handlers, einoSkillMW)
}
handlers = append(handlers, mainSumMw)
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "eino_single"); teleMw != nil {
handlers = append(handlers, teleMw)
}
maxIter := ma.MaxIteration
if maxIter <= 0 {
@@ -165,11 +168,29 @@ func RunEinoSingleChatModelAgent(
},
EmitInternalEvents: true,
}
ins := injectToolNamesOnlyInstruction(ctx, ag.EinoSingleAgentSystemInstruction(), mainTools)
if logger != nil {
names := collectToolNames(ctx, mainTools)
mountedNames := collectToolNames(ctx, mainToolsForCfg)
hasToolSearch := false
for _, n := range names {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
logger.Info("eino tool-name injection",
zap.String("scope", "eino_single"),
zap.Int("tool_names", len(names)),
zap.Int("mounted_tool_names", len(mountedNames)),
zap.Bool("has_tool_search", hasToolSearch),
)
}
chatCfg := &adk.ChatModelAgentConfig{
Name: einoSingleAgentName,
Description: "Eino ADK ChatModelAgent with MCP tools for authorized security testing.",
Instruction: ag.EinoSingleAgentSystemInstruction(),
Instruction: ins,
Model: mainModel,
ToolsConfig: mainToolsCfg,
MaxIterations: maxIter,
@@ -188,7 +209,7 @@ func RunEinoSingleChatModelAgent(
return nil, fmt.Errorf("eino single NewChatModelAgent: %w", err)
}
baseMsgs := historyToMessages(history)
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
streamsMainAssistant := func(agent string) bool {
+145 -3
View File
@@ -3,6 +3,8 @@ package multiagent
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"cyberstrike-ai/internal/agent"
@@ -32,6 +34,8 @@ func newEinoSummarizationMiddleware(
ctx context.Context,
summaryModel model.BaseChatModel,
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
conversationID string,
logger *zap.Logger,
) (adk.ChatModelAgentMiddleware, error) {
if summaryModel == nil || appCfg == nil {
@@ -41,7 +45,14 @@ func newEinoSummarizationMiddleware(
if maxTotal <= 0 {
maxTotal = 120000
}
trigger := int(float64(maxTotal) * 0.9)
triggerRatio := 0.8
emitInternalEvents := true
if mwCfg != nil {
triggerRatio = mwCfg.SummarizationTriggerRatioEffective()
emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective()
}
// Keep enough safety margin for tokenizer/model-side accounting mismatch.
trigger := int(float64(maxTotal) * triggerRatio)
if trigger < 4096 {
trigger = maxTotal
if trigger < 4096 {
@@ -57,28 +68,57 @@ func newEinoSummarizationMiddleware(
if modelName == "" {
modelName = "gpt-4o"
}
tokenCounter := einoSummarizationTokenCounter(modelName)
recentTrailMax := trigger / 4
if recentTrailMax < 2048 {
recentTrailMax = 2048
}
if recentTrailMax > trigger/2 {
recentTrailMax = trigger / 2
}
transcriptPath := ""
if conv := strings.TrimSpace(conversationID); conv != "" {
baseRoot := filepath.Join(os.TempDir(), "cyberstrike-summarization")
if dbPath := strings.TrimSpace(appCfg.Database.Path); dbPath != "" {
// Persist with the same lifecycle as local conversation storage.
baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts", sanitizeEinoPathSegment(conv), "summarization")
}
base := baseRoot
if mkErr := os.MkdirAll(base, 0o755); mkErr == nil {
transcriptPath = filepath.Join(base, "transcript.txt")
}
}
mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel,
Trigger: &summarization.TriggerCondition{
ContextTokens: trigger,
},
TokenCounter: einoSummarizationTokenCounter(modelName),
TokenCounter: tokenCounter,
UserInstruction: einoSummarizeUserInstruction,
EmitInternalEvents: false,
EmitInternalEvents: emitInternalEvents,
TranscriptFilePath: transcriptPath,
PreserveUserMessages: &summarization.PreserveUserMessages{
Enabled: true,
MaxTokens: preserveMax,
},
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
},
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
if logger == nil {
return nil
}
beforeTokens, _ := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: before.Messages})
afterTokens, _ := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: after.Messages})
logger.Info("eino summarization 已压缩上下文",
zap.Int("messages_before", len(before.Messages)),
zap.Int("messages_after", len(after.Messages)),
zap.Int("tokens_before_estimated", beforeTokens),
zap.Int("tokens_after_estimated", afterTokens),
zap.Int("max_total_tokens", maxTotal),
zap.Int("trigger_context_tokens", trigger),
zap.String("transcript_file", transcriptPath),
)
return nil
},
@@ -89,6 +129,108 @@ func newEinoSummarizationMiddleware(
return mw, nil
}
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
func summarizeFinalizeWithRecentAssistantToolTrail(
ctx context.Context,
originalMessages []adk.Message,
summary adk.Message,
tokenCounter summarization.TokenCounterFunc,
recentTrailTokenBudget int,
) ([]adk.Message, error) {
systemMsgs := make([]adk.Message, 0, len(originalMessages))
nonSystem := make([]adk.Message, 0, len(originalMessages))
for _, msg := range originalMessages {
if msg == nil {
continue
}
if msg.Role == schema.System {
systemMsgs = append(systemMsgs, msg)
continue
}
nonSystem = append(nonSystem, msg)
}
if recentTrailTokenBudget <= 0 || len(nonSystem) == 0 {
out := make([]adk.Message, 0, len(systemMsgs)+1)
out = append(out, systemMsgs...)
out = append(out, summary)
return out, nil
}
selectedReverse := make([]adk.Message, 0, 8)
seen := make(map[adk.Message]struct{})
totalTokens := 0
assistantToolKept := 0
const minAssistantToolTrail = 4
tryKeep := func(msg adk.Message) (bool, error) {
if msg == nil {
return false, nil
}
if _, ok := seen[msg]; ok {
return false, nil
}
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: []adk.Message{msg}})
if err != nil {
return false, err
}
if n <= 0 {
n = 1
}
if totalTokens+n > recentTrailTokenBudget {
return false, nil
}
totalTokens += n
selectedReverse = append(selectedReverse, msg)
seen[msg] = struct{}{}
return true, nil
}
// 优先保留最近 assistant/tool,确保执行轨迹可续跑。
for i := len(nonSystem) - 1; i >= 0; i-- {
msg := nonSystem[i]
if msg.Role != schema.Assistant && msg.Role != schema.Tool {
continue
}
ok, err := tryKeep(msg)
if err != nil {
return nil, err
}
if ok {
assistantToolKept++
}
if assistantToolKept >= minAssistantToolTrail {
break
}
}
// 在预算内回填更多最近消息,保持短链路上下文。
for i := len(nonSystem) - 1; i >= 0; i-- {
_, exists := seen[nonSystem[i]]
if exists {
continue
}
ok, err := tryKeep(nonSystem[i])
if err != nil {
return nil, err
}
if !ok {
break
}
}
selected := make([]adk.Message, 0, len(selectedReverse))
for i := len(selectedReverse) - 1; i >= 0; i-- {
selected = append(selected, selectedReverse[i])
}
out := make([]adk.Message, 0, len(systemMsgs)+1+len(selected))
out = append(out, systemMsgs...)
out = append(out, summary)
out = append(out, selected...)
return out, nil
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
@@ -0,0 +1,73 @@
package multiagent
import (
"context"
"strings"
"github.com/cloudwego/eino/components/tool"
)
// injectToolNamesOnlyInstruction prepends a compact tool-name-only section into
// the system instruction so the model can reference current callable names.
func injectToolNamesOnlyInstruction(ctx context.Context, instruction string, tools []tool.BaseTool) string {
names := collectToolNames(ctx, tools)
if len(names) == 0 {
return strings.TrimSpace(instruction)
}
hasToolSearch := false
for _, n := range names {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
var sb strings.Builder
sb.WriteString("以下是当前会话中可调用的工具名称列表(仅名称,无参数定义):\n")
for _, name := range names {
sb.WriteString("- ")
sb.WriteString(name)
sb.WriteByte('\n')
}
sb.WriteString("\n使用规则:\n")
sb.WriteString("1) 上述仅为名称列表,不包含参数定义。\n")
if hasToolSearch {
sb.WriteString("2) 在调用具体工具前,应先使用 tool_search 查看工具详情与参数要求,再发起调用。\n")
} else {
sb.WriteString("2) 调用具体工具前,请先确认该工具的参数要求;不确定时先澄清再调用。\n")
}
sb.WriteString("3) 不要臆造不存在的工具名。\n\n")
if s := strings.TrimSpace(instruction); s != "" {
sb.WriteString(s)
}
return sb.String()
}
func collectToolNames(ctx context.Context, tools []tool.BaseTool) []string {
if len(tools) == 0 {
return nil
}
seen := make(map[string]struct{}, len(tools))
out := make([]string, 0, len(tools))
for _, t := range tools {
if t == nil {
continue
}
info, err := t.Info(ctx)
if err != nil || info == nil {
continue
}
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, name)
}
return out
}
-1
View File
@@ -59,4 +59,3 @@ func (m *noNestedTaskMiddleware) WrapInvokableToolCall(
return endpoint(ctx2, argumentsInJSON, opts...)
}, nil
}
+1 -1
View File
@@ -71,7 +71,7 @@ func planExecuteDefaultGenExecutorInput(ctx context.Context, in *planexecute.Exe
return planexecute.ExecutorPrompt.Format(ctx, map[string]any{
"input": planExecuteFormatInput(in.UserInput),
"plan": string(planContent),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps, nil, nil),
"step": in.Plan.FirstStep(),
})
}
+22 -7
View File
@@ -5,6 +5,8 @@ import (
"strings"
"unicode/utf8"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
)
@@ -12,8 +14,11 @@ import (
// 此处仅约束「写入模型 prompt 的视图」,不修改 Eino session 中的原始 ExecutedSteps。
const (
planExecuteMaxStepResultRunes = 12000
planExecuteKeepLastSteps = 16
defaultPlanExecuteMaxStepResultRunes = 4000
defaultPlanExecuteKeepLastSteps = 8
// Backward-compatible aliases for tests and existing references.
planExecuteMaxStepResultRunes = defaultPlanExecuteMaxStepResultRunes
planExecuteKeepLastSteps = defaultPlanExecuteKeepLastSteps
)
func truncateRunesWithSuffix(s string, maxRunes int, suffix string) string {
@@ -29,16 +34,26 @@ func truncateRunesWithSuffix(s string, maxRunes int, suffix string) string {
// capPlanExecuteExecutedSteps 折叠较早步骤、截断单步过长结果,供 prompt 使用。
func capPlanExecuteExecutedSteps(steps []planexecute.ExecutedStep) []planexecute.ExecutedStep {
return capPlanExecuteExecutedStepsWithConfig(steps, nil)
}
func capPlanExecuteExecutedStepsWithConfig(steps []planexecute.ExecutedStep, mwCfg *config.MultiAgentEinoMiddlewareConfig) []planexecute.ExecutedStep {
if len(steps) == 0 {
return steps
}
maxStepResultRunes := defaultPlanExecuteMaxStepResultRunes
keepLastSteps := defaultPlanExecuteKeepLastSteps
if mwCfg != nil {
maxStepResultRunes = mwCfg.PlanExecuteMaxStepResultRunesEffective()
keepLastSteps = mwCfg.PlanExecuteKeepLastStepsEffective()
}
out := make([]planexecute.ExecutedStep, 0, len(steps)+1)
start := 0
if len(steps) > planExecuteKeepLastSteps {
start = len(steps) - planExecuteKeepLastSteps
if len(steps) > keepLastSteps {
start = len(steps) - keepLastSteps
var b strings.Builder
b.WriteString(fmt.Sprintf("(上文已完成 %d 步;此处仅保留步骤标题以节省上下文,完整输出已省略。后续 %d 步仍保留正文。)\n",
start, planExecuteKeepLastSteps))
start, keepLastSteps))
for i := 0; i < start; i++ {
b.WriteString(fmt.Sprintf("- %s\n", steps[i].Step))
}
@@ -50,8 +65,8 @@ func capPlanExecuteExecutedSteps(steps []planexecute.ExecutedStep) []planexecute
suffix := "\n…[step result truncated]"
for i := start; i < len(steps); i++ {
e := steps[i]
if utf8.RuneCountInString(e.Result) > planExecuteMaxStepResultRunes {
e.Result = truncateRunesWithSuffix(e.Result, planExecuteMaxStepResultRunes, suffix)
if utf8.RuneCountInString(e.Result) > maxStepResultRunes {
e.Result = truncateRunesWithSuffix(e.Result, maxStepResultRunes, suffix)
}
out = append(out, e)
}
+111 -14
View File
@@ -30,10 +30,10 @@ import (
// RunResult 与单 Agent 循环结果字段对齐,便于复用存储与 SSE 收尾逻辑。
type RunResult struct {
Response string
MCPExecutionIDs []string
LastReActInput string
LastReActOutput string
Response string
MCPExecutionIDs []string
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
}
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
@@ -237,7 +237,7 @@ func RunDeepAgent(
subMax = subDefaultIter
}
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, logger)
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
if err != nil {
return nil, fmt.Errorf("子代理 %q summarization 中间件: %w", id, err)
}
@@ -257,11 +257,33 @@ func RunDeepAgent(
subHandlers = append(subHandlers, einoSkillMW)
}
subHandlers = append(subHandlers, subSumMw)
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "sub_agent"); teleMw != nil {
subHandlers = append(subHandlers, teleMw)
}
subInstrFinal := injectToolNamesOnlyInstruction(ctx, instr, subTools)
if logger != nil {
subNames := collectToolNames(ctx, subTools)
mountedNames := collectToolNames(ctx, subToolsForCfg)
hasToolSearch := false
for _, n := range subNames {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
logger.Info("eino tool-name injection",
zap.String("scope", "sub_agent"),
zap.String("agent", id),
zap.Int("tool_names", len(subNames)),
zap.Int("mounted_tool_names", len(mountedNames)),
zap.Bool("has_tool_search", hasToolSearch),
)
}
sa, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: id,
Description: desc,
Instruction: instr,
Instruction: subInstrFinal,
Model: subModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
@@ -289,7 +311,7 @@ func RunDeepAgent(
return nil, fmt.Errorf("多代理主模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, logger)
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
if err != nil {
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
}
@@ -313,6 +335,25 @@ func RunDeepAgent(
orchDescription = d
}
}
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools)
if logger != nil {
mainNames := collectToolNames(ctx, mainTools)
mountedNames := collectToolNames(ctx, mainToolsForCfg)
hasToolSearch := false
for _, n := range mainNames {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
logger.Info("eino tool-name injection",
zap.String("scope", "orchestrator"),
zap.String("orchestration", orchMode),
zap.Int("tool_names", len(mainNames)),
zap.Int("mounted_tool_names", len(mountedNames)),
zap.Bool("has_tool_search", hasToolSearch),
)
}
supInstr := strings.TrimSpace(orchInstruction)
if orchMode == "supervisor" {
@@ -352,6 +393,9 @@ func RunDeepAgent(
deepHandlers = append(deepHandlers, einoSkillMW)
}
deepHandlers = append(deepHandlers, mainSumMw)
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
deepHandlers = append(deepHandlers, teleMw)
}
supHandlers := []adk.ChatModelAgentMiddleware{}
if len(mainOrchestratorPre) > 0 {
@@ -361,6 +405,9 @@ func RunDeepAgent(
supHandlers = append(supHandlers, einoSkillMW)
}
supHandlers = append(supHandlers, mainSumMw)
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
supHandlers = append(supHandlers, teleMw)
}
mainToolsCfg := adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
@@ -399,10 +446,17 @@ func RunDeepAgent(
ExecMaxIter: deepMaxIter,
LoopMaxIter: ma.PlanExecuteLoopMaxIterations,
AppCfg: appCfg,
MwCfg: &ma.EinoMiddleware,
ConversationID: conversationID,
Logger: logger,
ModelName: appCfg.OpenAI.Model,
ExecPreMiddlewares: mainOrchestratorPre,
SkillMiddleware: einoSkillMW,
FilesystemMiddleware: peFsMw,
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
mainSumMw,
newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "plan_execute_planner_replanner_rewrite"),
},
})
if perr != nil {
return nil, perr
@@ -468,7 +522,7 @@ func RunDeepAgent(
da = dDeep
}
baseMsgs := historyToMessages(history)
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
streamsMainAssistant := func(agent string) bool {
@@ -505,34 +559,77 @@ func RunDeepAgent(
}, baseMsgs)
}
func historyToMessages(history []agent.ChatMessage) []adk.Message {
func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
if len(history) == 0 {
return nil
}
// 放宽条数上限:跨轮历史交给 Eino Summarization(阈值对齐 openai.max_total_tokens)在调用模型前压缩,避免在入队前硬截断为 40 条。
const maxHistoryMessages = 300
// Keep a bounded tail first; then enforce a token budget.
const maxHistoryMessages = 200
start := 0
if len(history) > maxHistoryMessages {
start = len(history) - maxHistoryMessages
}
out := make([]adk.Message, 0, len(history[start:]))
raw := make([]adk.Message, 0, len(history[start:]))
for _, h := range history[start:] {
switch h.Role {
case "user":
if strings.TrimSpace(h.Content) != "" {
out = append(out, schema.UserMessage(h.Content))
raw = append(raw, schema.UserMessage(h.Content))
}
case "assistant":
if strings.TrimSpace(h.Content) == "" && len(h.ToolCalls) > 0 {
continue
}
if strings.TrimSpace(h.Content) != "" {
out = append(out, schema.AssistantMessage(h.Content, nil))
raw = append(raw, schema.AssistantMessage(h.Content, nil))
}
default:
continue
}
}
if len(raw) == 0 {
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
}
@@ -1,51 +0,0 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// maxToolCallRecoveryAttempts 含首次运行:首次 + 自动重试次数。
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
// 该常量同时用于 JSON 参数错误和工具执行错误(如子代理名称不存在)的恢复重试。
const maxToolCallRecoveryAttempts = 5
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
func toolCallArgumentsJSONRetryHint() *schema.Message {
return schema.UserMessage(`[系统提示] 上一次输出中工具调用的 function.arguments 不是合法 JSON接口已拒绝请重新生成每个 tool call arguments 必须是完整可解析的 JSON 对象字符串键名用双引号无多余逗号括号配对不要输出截断或不完整的 JSON
[System] Your previous tool call used invalid JSON in function.arguments and was rejected by the API. Regenerate with strictly valid JSON objects only (double-quoted keys, matched braces, no trailing commas).`)
}
// toolCallArgumentsJSONRecoveryTimelineMessage 供 eino_recovery 事件落库与前端时间线展示。
func toolCallArgumentsJSONRecoveryTimelineMessage(attempt int) string {
return fmt.Sprintf(
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"The API rejected invalid JSON in tool arguments. A system hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
// isRecoverableToolCallArgumentsJSONError 判断是否为「工具参数非合法 JSON」类流式错误,可通过追加提示后重跑一轮。
func isRecoverableToolCallArgumentsJSONError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
if !strings.Contains(s, "json") {
return false
}
if strings.Contains(s, "function.arguments") || strings.Contains(s, "function arguments") {
return true
}
if strings.Contains(s, "invalidparameter") && strings.Contains(s, "json") {
return true
}
if strings.Contains(s, "must be in json format") {
return true
}
return false
}
@@ -1,17 +0,0 @@
package multiagent
import (
"errors"
"testing"
)
func TestIsRecoverableToolCallArgumentsJSONError(t *testing.T) {
yes := errors.New(`failed to receive stream chunk: error, <400> InternalError.Algo.InvalidParameter: The "function.arguments" parameter of the code model must be in JSON format.`)
if !isRecoverableToolCallArgumentsJSONError(yes) {
t.Fatal("expected recoverable for function.arguments + JSON")
}
no := errors.New("unrelated network failure")
if isRecoverableToolCallArgumentsJSONError(no) {
t.Fatal("expected not recoverable")
}
}
@@ -1,44 +0,0 @@
package multiagent
import (
"fmt"
"github.com/cloudwego/eino/schema"
)
// toolExecutionRetryHint returns a user message appended to the conversation to prompt
// the LLM to adjust after a tool execution error (tool not found, binary missing,
// runtime failure, network error, etc.).
func toolExecutionRetryHint() *schema.Message {
return schema.UserMessage(`[System] Your previous tool call failed. Possible causes:
- The tool or sub-agent name does not exist (typo or unregistered name).
- The tool call arguments were not valid JSON.
- The tool's underlying binary is not installed or not in PATH.
- The tool encountered a runtime error (timeout, network failure, permission denied, etc.).
Please review the error message above, check available tools, and either:
1. Retry with corrected arguments or a different tool, OR
2. Inform the user about the limitation and proceed with an alternative approach.
[系统提示] 上一次工具调用失败可能原因
- 工具名或子代理名称不存在拼写错误或未注册
- 工具调用参数不是合法 JSON
- 工具依赖的底层二进制程序未安装或不在 PATH
- 工具运行时遇到错误超时网络故障权限不足等
请根据上述错误信息检查可用工具然后
1. 修正参数或改用其他工具重试或者
2. 告知用户当前限制并采用替代方案继续`)
}
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
// displayed in the UI timeline when a tool execution error triggers a retry.
func toolExecutionRecoveryTimelineMessage(attempt int) string {
return fmt.Sprintf(
"工具调用执行失败。已向对话追加纠错提示并要求模型调整策略。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"Tool call execution failed. "+
"A corrective hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
+7 -7
View File
@@ -192,13 +192,13 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
fnName, _ := fn["name"].(string)
fnArgs, _ := fn["arguments"]
// 防御:缺少 name 或 id 的 tool_call 会被 Claude 拒绝
if strings.TrimSpace(fnName) == "" {
fnName = "unknown_function"
}
if strings.TrimSpace(tcID) == "" {
tcID = fmt.Sprintf("call_%d", time.Now().UnixNano())
}
// 防御:缺少 name 或 id 的 tool_call 会被 Claude 拒绝
if strings.TrimSpace(fnName) == "" {
fnName = "unknown_function"
}
if strings.TrimSpace(tcID) == "" {
tcID = fmt.Sprintf("call_%d", time.Now().UnixNano())
}
var inputRaw json.RawMessage
switch v := fnArgs.(type) {
+14 -14
View File
@@ -281,9 +281,9 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
// StreamToolCall 流式工具调用的累积结果(arguments 以字符串形式拼接,留给上层再解析为 JSON)。
type StreamToolCall struct {
Index int
ID string
Type string
Index int
ID string
Type string
FunctionName string
FunctionArgsStr string
}
@@ -348,10 +348,10 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
Arguments string `json:"arguments,omitempty"`
}
type toolCallDelta struct {
Index int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function toolCallFunctionDelta `json:"function,omitempty"`
Index int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function toolCallFunctionDelta `json:"function,omitempty"`
}
type streamDelta2 struct {
Content string `json:"content,omitempty"`
@@ -371,10 +371,10 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
}
type toolCallAccum struct {
id string
typ string
name string
args strings.Builder
id string
typ string
name string
args strings.Builder
}
toolCallAccums := make(map[int]*toolCallAccum)
@@ -475,9 +475,9 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
for _, idx := range indices {
acc := toolCallAccums[idx]
tc := StreamToolCall{
Index: idx,
ID: acc.id,
Type: acc.typ,
Index: idx,
ID: acc.id,
Type: acc.typ,
FunctionName: acc.name,
FunctionArgsStr: acc.args.String(),
}
+40 -41
View File
@@ -19,11 +19,11 @@ import (
func setupTestExecutor(t *testing.T) (*Executor, *mcp.Server) {
logger := zap.NewNop()
mcpServer := mcp.NewServer(logger)
cfg := &config.SecurityConfig{
Tools: []config.ToolConfig{},
}
executor := NewExecutor(cfg, mcpServer, logger)
return executor, mcpServer
}
@@ -32,12 +32,12 @@ func setupTestExecutor(t *testing.T) (*Executor, *mcp.Server) {
func setupTestStorage(t *testing.T) *storage.FileResultStorage {
tmpDir := filepath.Join(os.TempDir(), "test_executor_storage_"+time.Now().Format("20060102_150405"))
logger := zap.NewNop()
storage, err := storage.NewFileResultStorage(tmpDir, logger)
if err != nil {
t.Fatalf("创建测试存储失败: %v", err)
}
return storage
}
@@ -45,46 +45,46 @@ func TestExecutor_ExecuteInternalTool_QueryExecutionResult(t *testing.T) {
executor, _ := setupTestExecutor(t)
testStorage := setupTestStorage(t)
executor.SetResultStorage(testStorage)
// 准备测试数据
executionID := "test_exec_001"
toolName := "nmap_scan"
result := "Line 1: Port 22 open\nLine 2: Port 80 open\nLine 3: Port 443 open\nLine 4: error occurred"
// 保存测试结果
err := testStorage.SaveResult(executionID, toolName, result)
if err != nil {
t.Fatalf("保存测试结果失败: %v", err)
}
ctx := context.Background()
// 测试1: 基本查询(第一页)
args := map[string]interface{}{
"execution_id": executionID,
"page": float64(1),
"limit": float64(2),
}
toolResult, err := executor.executeQueryExecutionResult(ctx, args)
if err != nil {
t.Fatalf("执行查询失败: %v", err)
}
if toolResult.IsError {
t.Fatalf("查询应该成功,但返回了错误: %s", toolResult.Content[0].Text)
}
// 验证结果包含预期内容
resultText := toolResult.Content[0].Text
if !strings.Contains(resultText, executionID) {
t.Errorf("结果中应该包含执行ID: %s", executionID)
}
if !strings.Contains(resultText, "第 1/") {
t.Errorf("结果中应该包含分页信息")
}
// 测试2: 搜索功能
args2 := map[string]interface{}{
"execution_id": executionID,
@@ -92,21 +92,21 @@ func TestExecutor_ExecuteInternalTool_QueryExecutionResult(t *testing.T) {
"page": float64(1),
"limit": float64(10),
}
toolResult2, err := executor.executeQueryExecutionResult(ctx, args2)
if err != nil {
t.Fatalf("执行搜索失败: %v", err)
}
if toolResult2.IsError {
t.Fatalf("搜索应该成功,但返回了错误: %s", toolResult2.Content[0].Text)
}
resultText2 := toolResult2.Content[0].Text
if !strings.Contains(resultText2, "error") {
t.Errorf("搜索结果中应该包含关键词: error")
}
// 测试3: 过滤功能
args3 := map[string]interface{}{
"execution_id": executionID,
@@ -114,46 +114,46 @@ func TestExecutor_ExecuteInternalTool_QueryExecutionResult(t *testing.T) {
"page": float64(1),
"limit": float64(10),
}
toolResult3, err := executor.executeQueryExecutionResult(ctx, args3)
if err != nil {
t.Fatalf("执行过滤失败: %v", err)
}
if toolResult3.IsError {
t.Fatalf("过滤应该成功,但返回了错误: %s", toolResult3.Content[0].Text)
}
resultText3 := toolResult3.Content[0].Text
if !strings.Contains(resultText3, "Port") {
t.Errorf("过滤结果中应该包含关键词: Port")
}
// 测试4: 缺少必需参数
args4 := map[string]interface{}{
"page": float64(1),
}
toolResult4, err := executor.executeQueryExecutionResult(ctx, args4)
if err != nil {
t.Fatalf("执行查询失败: %v", err)
}
if !toolResult4.IsError {
t.Fatal("缺少execution_id应该返回错误")
}
// 测试5: 不存在的执行ID
args5 := map[string]interface{}{
"execution_id": "nonexistent_id",
"page": float64(1),
}
toolResult5, err := executor.executeQueryExecutionResult(ctx, args5)
if err != nil {
t.Fatalf("执行查询失败: %v", err)
}
if !toolResult5.IsError {
t.Fatal("不存在的执行ID应该返回错误")
}
@@ -161,22 +161,22 @@ func TestExecutor_ExecuteInternalTool_QueryExecutionResult(t *testing.T) {
func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
executor, _ := setupTestExecutor(t)
ctx := context.Background()
args := map[string]interface{}{
"test": "value",
}
// 测试未知的内部工具类型
toolResult, err := executor.executeInternalTool(ctx, "unknown_tool", "internal:unknown_tool", args)
if err != nil {
t.Fatalf("执行内部工具失败: %v", err)
}
if !toolResult.IsError {
t.Fatal("未知的工具类型应该返回错误")
}
if !strings.Contains(toolResult.Content[0].Text, "未知的内部工具类型") {
t.Errorf("错误消息应该包含'未知的内部工具类型'")
}
@@ -185,21 +185,21 @@ func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
func TestExecutor_ExecuteInternalTool_NoStorage(t *testing.T) {
executor, _ := setupTestExecutor(t)
// 不设置存储,测试未初始化的情况
ctx := context.Background()
args := map[string]interface{}{
"execution_id": "test_id",
}
toolResult, err := executor.executeQueryExecutionResult(ctx, args)
if err != nil {
t.Fatalf("执行查询失败: %v", err)
}
if !toolResult.IsError {
t.Fatal("未初始化的存储应该返回错误")
}
if !strings.Contains(toolResult.Content[0].Text, "结果存储未初始化") {
t.Errorf("错误消息应该包含'结果存储未初始化'")
}
@@ -207,7 +207,7 @@ func TestExecutor_ExecuteInternalTool_NoStorage(t *testing.T) {
func TestPaginateLines(t *testing.T) {
lines := []string{"Line 1", "Line 2", "Line 3", "Line 4", "Line 5"}
// 测试第一页
page := paginateLines(lines, 1, 2)
if page.Page != 1 {
@@ -225,7 +225,7 @@ func TestPaginateLines(t *testing.T) {
if len(page.Lines) != 2 {
t.Errorf("第一页行数不匹配。期望: 2, 实际: %d", len(page.Lines))
}
// 测试第二页
page2 := paginateLines(lines, 2, 2)
if len(page2.Lines) != 2 {
@@ -234,13 +234,13 @@ func TestPaginateLines(t *testing.T) {
if page2.Lines[0] != "Line 3" {
t.Errorf("第二页第一行不匹配。期望: Line 3, 实际: %s", page2.Lines[0])
}
// 测试最后一页
page3 := paginateLines(lines, 3, 2)
if len(page3.Lines) != 1 {
t.Errorf("第三页行数不匹配。期望: 1, 实际: %d", len(page3.Lines))
}
// 测试超出范围的页码(应该返回最后一页)
page4 := paginateLines(lines, 4, 2)
if page4.Page != 3 {
@@ -249,13 +249,13 @@ func TestPaginateLines(t *testing.T) {
if len(page4.Lines) != 1 {
t.Errorf("最后一页应该只有1行。实际: %d行", len(page4.Lines))
}
// 测试无效页码(小于1
page0 := paginateLines(lines, 0, 2)
if page0.Page != 1 {
t.Errorf("无效页码应该被修正为1。实际: %d", page0.Page)
}
// 测试空列表
emptyPage := paginateLines([]string{}, 1, 10)
if emptyPage.TotalLines != 0 {
@@ -265,4 +265,3 @@ func TestPaginateLines(t *testing.T) {
t.Errorf("空列表应该返回空结果。实际: %d行", len(emptyPage.Lines))
}
}
+4 -4
View File
@@ -16,10 +16,10 @@ type rateLimitEntry struct {
// RateLimiter 基于 IP 的滑动窗口速率限制器
type RateLimiter struct {
mu sync.Mutex
entries map[string]*rateLimitEntry
limit int // 窗口内允许的最大请求数
window time.Duration // 窗口时长
mu sync.Mutex
entries map[string]*rateLimitEntry
limit int // 窗口内允许的最大请求数
window time.Duration // 窗口时长
}
// NewRateLimiter 创建速率限制器
-1
View File
@@ -162,4 +162,3 @@ func truncateRunes(s string, max int) string {
}
return string(r[:max]) + "…"
}
+6 -6
View File
@@ -49,12 +49,12 @@ func ParseSkillMD(raw []byte) (*SkillManifest, string, error) {
}
type skillFrontMatterExport struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
License string `yaml:"license,omitempty"`
Compatibility string `yaml:"compatibility,omitempty"`
Metadata map[string]any `yaml:"metadata,omitempty"`
AllowedTools string `yaml:"allowed-tools,omitempty"`
Name string `yaml:"name"`
Description string `yaml:"description"`
License string `yaml:"license,omitempty"`
Compatibility string `yaml:"compatibility,omitempty"`
Metadata map[string]any `yaml:"metadata,omitempty"`
AllowedTools string `yaml:"allowed-tools,omitempty"`
}
// BuildSkillMD serializes SKILL.md per agentskills.io.
+4 -4
View File
@@ -9,10 +9,10 @@ import (
)
const (
maxPackageFiles = 4000
maxPackageDepth = 24
maxScriptsDepth = 24
defaultMaxRead = 10 << 20
maxPackageFiles = 4000
maxPackageDepth = 24
maxScriptsDepth = 24
defaultMaxRead = 10 << 20
)
// SafeRelPath resolves rel inside root (no ..).
@@ -64,7 +64,7 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
String prompt = HttpMessageFormatter.toPrompt(helpers, msg, instruction);
String title = HttpMessageFormatter.getRequestTitle(helpers, msg);
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
String agentModeStr = cfg.agentMode.displayName;
String runId = tab.startNewRun(title, agentModeStr, msg);
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
@@ -26,8 +26,21 @@ final class CyberStrikeAIClient {
}
enum AgentMode {
SINGLE,
MULTI
NATIVE_REACT("Native ReAct", "/api/agent-loop/stream", null),
EINO_SINGLE("Eino Single (ADK)", "/api/eino-agent/stream", null),
DEEP("Deep (DeepAgent)", "/api/multi-agent/stream", "deep"),
PLAN_EXECUTE("Plan-Execute", "/api/multi-agent/stream", "plan_execute"),
SUPERVISOR("Supervisor", "/api/multi-agent/stream", "supervisor");
final String displayName;
final String streamPath;
final String orchestration;
AgentMode(String displayName, String streamPath, String orchestration) {
this.displayName = displayName;
this.streamPath = streamPath;
this.orchestration = orchestration;
}
}
interface StreamListener {
@@ -94,13 +107,15 @@ final class CyberStrikeAIClient {
}
void streamTest(Config cfg, String token, String message, StreamListener listener) {
String path = (cfg.agentMode == AgentMode.MULTI) ? "/api/multi-agent/stream" : "/api/agent-loop/stream";
String urlStr = cfg.baseUrl + path;
String urlStr = cfg.baseUrl + cfg.agentMode.streamPath;
Map<String, Object> payload = new HashMap<>();
payload.put("message", message);
payload.put("conversationId", "");
payload.put("role", "");
if (cfg.agentMode.orchestration != null) {
payload.put("orchestration", cfg.agentMode.orchestration);
}
new Thread(() -> {
HttpURLConnection conn = null;
@@ -184,11 +199,16 @@ final class CyberStrikeAIClient {
String message = payload.get("message") != null ? String.valueOf(payload.get("message")) : "";
String conversationId = payload.get("conversationId") != null ? String.valueOf(payload.get("conversationId")) : "";
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : "";
return "{"
+ "\"message\":\"" + escapeJson(message) + "\","
+ "\"conversationId\":\"" + escapeJson(conversationId) + "\","
+ "\"role\":\"" + escapeJson(role) + "\""
+ "}";
StringBuilder sb = new StringBuilder();
sb.append("{");
sb.append("\"message\":\"").append(escapeJson(message)).append("\",");
sb.append("\"conversationId\":\"").append(escapeJson(conversationId)).append("\",");
sb.append("\"role\":\"").append(escapeJson(role)).append("\"");
if (payload.containsKey("orchestration") && payload.get("orchestration") != null) {
sb.append(",\"orchestration\":\"").append(escapeJson(String.valueOf(payload.get("orchestration")))).append("\"");
}
sb.append("}");
return sb.toString();
}
private static String escapeJson(String s) {
@@ -15,7 +15,9 @@ final class CyberStrikeAITab implements ITab {
private final JTextField hostField = new JTextField("127.0.0.1");
private final JTextField portField = new JTextField("8080");
private final JPasswordField passwordField = new JPasswordField();
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{"Single Agent", "Multi Agent"});
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
});
private final JButton validateButton = new JButton("Validate");
private final JButton clearButton = new JButton("Clear Output");
private final JButton stopButton = new JButton("Stop");
@@ -98,7 +100,7 @@ final class CyberStrikeAITab implements ITab {
hostField.setColumns(14);
portField.setColumns(6);
passwordField.setColumns(12);
agentModeBox.setPreferredSize(new Dimension(160, agentModeBox.getPreferredSize().height));
agentModeBox.setPreferredSize(new Dimension(200, agentModeBox.getPreferredSize().height));
JPanel row1 = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
row1.add(new JLabel("Host"));
@@ -475,14 +477,17 @@ final class CyberStrikeAITab implements ITab {
renderMarkdownBox.addActionListener(e -> refreshOutputView());
}
private static final CyberStrikeAIClient.AgentMode[] AGENT_MODES = CyberStrikeAIClient.AgentMode.values();
CyberStrikeAIClient.Config currentConfig() {
String host = hostField.getText().trim();
String port = portField.getText().trim();
String password = new String(passwordField.getPassword());
String baseUrl = "http://" + host + ":" + port;
CyberStrikeAIClient.AgentMode mode = agentModeBox.getSelectedIndex() == 1
? CyberStrikeAIClient.AgentMode.MULTI
: CyberStrikeAIClient.AgentMode.SINGLE;
int idx = agentModeBox.getSelectedIndex();
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
? AGENT_MODES[idx]
: CyberStrikeAIClient.AgentMode.NATIVE_REACT;
return new CyberStrikeAIClient.Config(baseUrl, password, mode);
}
-15
View File
@@ -2,19 +2,4 @@ name: API安全测试
description: API安全测试专家,专注于API接口安全检测
user_prompt: 你是一个专业的API安全测试专家。请使用专业的API测试工具对目标API接口进行全面的安全检测,包括GraphQL安全、API参数fuzzing、JWT分析、API架构分析等工作。
icon: "\U0001F4E1"
tools:
- api-fuzzer
- api-schema-analyzer
- graphql-scanner
- arjun
- jwt-analyzer
- http-intruder
- http-framework-test
- burpsuite
- httpx
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-28
View File
@@ -2,32 +2,4 @@ name: CTF
description: CTF竞赛专家,擅长解题和漏洞利用
user_prompt: 你是一个CTF竞赛专家。请使用CTF解题思维和方法,快速定位和利用漏洞,解决各类CTF题目。
icon: "\U0001F3C6"
tools:
- amass
- anew
- angr
- api-fuzzer
- api-schema-analyzer
- arjun
- arp-scan
- autorecon
- binwalk
- bloodhound
- burpsuite
- cat
- checkov
- checksec
- cloudmapper
- create-file
- cyberchef
- dalfox
- delete-file
- httpx
- http-framework-test
- exec
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-20
View File
@@ -2,24 +2,4 @@ name: Web应用扫描
description: Web应用漏洞扫描专家,全面的Web安全检测
user_prompt: 你是一个专业的Web应用漏洞扫描专家。请使用各种Web扫描工具对目标Web应用进行全面的安全检测,包括目录枚举、文件扫描、漏洞识别等工作。
icon: "\U0001F310"
tools:
- dirsearch
- dirb
- gobuster
- feroxbuster
- ffuf
- wfuzz
- sqlmap
- dalfox
- xsser
- nikto
- nuclei
- wpscan
- httpx
- http-framework-test
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-14
View File
@@ -2,18 +2,4 @@ name: Web框架测试
description: Web框架安全测试专家,专注于Web应用框架漏洞检测
user_prompt: 你是一个专业的Web框架安全测试专家。请使用专业的工具对Web应用框架进行安全测试,识别框架相关的安全漏洞和配置问题。
icon: "\U0001F310"
tools:
- http-framework-test
- nikto
- nuclei
- wafw00f
- wpscan
- httpx
- burpsuite
- zap
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-26
View File
@@ -2,30 +2,4 @@ name: 二进制分析
description: 二进制分析与利用专家,擅长逆向工程和密码破解
user_prompt: 你是一个专业的二进制分析与利用专家。请使用逆向工程工具分析二进制文件,识别漏洞,进行利用开发。同时擅长密码破解、哈希分析等技术。
icon: "\U0001F52C"
tools:
- dirsearch
- docker-bench-security
- exec
- execute-python-script
- install-python-package
- ghidra
- graphql-scanner
- hakrawler
- hash-identifier
- hashcat
- hashpump
- http-framework-test
- httpx
- gdb
- radare2
- objdump
- strings
- binwalk
- ropper
- ropgadget
- john
- cyberchef
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-12
View File
@@ -2,16 +2,4 @@ name: 云安全审计
description: 云安全审计专家,多云环境安全检测
user_prompt: 你是一个专业的云安全审计专家。请使用专业的云安全工具对AWS、Azure、GCP等云环境进行全面的安全审计,包括配置检查、合规性评估、权限审计、安全最佳实践验证等工作。
icon:
tools:
- prowler
- scout-suite
- cloudmapper
- pacu
- terrascan
- checkov
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-26
View File
@@ -2,30 +2,4 @@ name: 信息收集
description: 资产发现与信息搜集专家
user_prompt: 你是一个专业的信息收集专家。请使用各种信息收集技术和工具,对目标进行全面的资产发现、子域名枚举、端口扫描、服务识别等信息收集工作。
icon: "\U0001F50D"
tools:
- amass
- subfinder
- dnsenum
- fierce
- fofa_search
- zoomeye_search
- nmap
- masscan
- rustscan
- arp-scan
- nbtscan
- httpx
- http-framework-test
- katana
- hakrawler
- waybackurls
- paramspider
- gau
- uro
- qsreplace
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-18
View File
@@ -2,22 +2,4 @@ name: 后渗透测试
description: 后渗透测试专家,权限维持与横向移动
user_prompt: 你是一个专业的后渗透测试专家。请使用专业的后渗透工具在获得初始访问权限后进行权限提升、横向移动、权限维持、数据收集等后渗透测试工作。
icon: "\U0001F575"
tools:
- linpeas
- winpeas
- mimikatz
- bloodhound
- impacket
- responder
- netexec
- rpcclient
- smbmap
- enum4linux
- enum4linux-ng
- exec
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-13
View File
@@ -2,17 +2,4 @@ name: 容器安全
description: 容器与Kubernetes安全专家,容器环境安全检测
user_prompt: 你是一个专业的容器与Kubernetes安全专家。请使用专业的容器安全工具对Docker容器和Kubernetes集群进行全面的安全检测,包括镜像漏洞扫描、配置检查、运行时安全等工作。
icon: "\U0001F6E1"
tools:
- trivy
- clair
- docker-bench-security
- kube-bench
- kube-hunter
- falco
- exec
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-19
View File
@@ -2,23 +2,4 @@ name: 数字取证
description: 数字取证与隐写分析专家,文件与内存取证
user_prompt: 你是一个专业的数字取证与隐写分析专家。请使用专业的取证工具对文件、磁盘镜像、内存转储进行分析,提取证据信息。同时擅长隐写分析、数据恢复、元数据提取等技术。
icon: "\U0001F50E"
tools:
- volatility
- volatility3
- foremost
- steghide
- stegsolve
- zsteg
- exiftool
- binwalk
- strings
- xxd
- fcrackzip
- pdfcrack
- exec
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-28
View File
@@ -2,32 +2,4 @@ name: 渗透测试
description: 专业渗透测试专家,全面深入的漏洞检测
user_prompt: 你是一个专业的网络安全渗透测试专家。请使用专业的渗透测试方法和工具,对目标进行全面的安全测试,包括但不限于SQL注入、XSS、CSRF、文件包含、命令执行等常见漏洞。
icon: "\U0001F3AF"
tools:
- http-framework-test
- httpx
- amass
- anew
- angr
- api-fuzzer
- api-schema-analyzer
- arjun
- arp-scan
- autorecon
- binwalk
- bloodhound
- burpsuite
- cat
- checkov
- checksec
- cloudmapper
- create-file
- cyberchef
- dalfox
- delete-file
- exec
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
-18
View File
@@ -2,22 +2,4 @@ name: 综合漏洞扫描
description: 综合漏洞扫描专家,多类型漏洞检测
user_prompt: 你是一个专业的综合漏洞扫描专家。请使用各种漏洞扫描工具对目标进行全面的安全检测,包括Web漏洞、网络服务漏洞、配置缺陷等多种类型的漏洞识别和分析。
icon:
tools:
- nuclei
- nikto
- sqlmap
- nmap
- masscan
- rustscan
- wafw00f
- dalfox
- xsser
- jaeles
- httpx
- http-framework-test
- execute-python-script
- install-python-package
- record_vulnerability
- list_knowledge_risk_types
- search_knowledge_base
enabled: true
+232 -21
View File
@@ -964,7 +964,33 @@ header {
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
cursor: pointer;
user-select: none;
}
.hitl-sidebar-header-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.hitl-sidebar-body {
overflow: hidden;
max-height: 500px;
opacity: 1;
margin-top: 10px;
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.3s ease;
}
.hitl-sidebar-collapsed .hitl-sidebar-body {
max-height: 0;
opacity: 0;
margin-top: 0;
}
.hitl-sidebar-collapsed .hitl-apply-btn {
display: none;
}
.hitl-sidebar-heading {
@@ -1144,12 +1170,18 @@ header {
.hitl-config-input {
width: 100%;
height: 38px;
border: 1px solid var(--border-color);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 0 10px;
padding: 0 12px;
font-size: 14px;
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
}
.hitl-config-input:focus {
outline: none;
border-color: var(--accent-color, #0066ff);
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
}
.hitl-pending-list {
@@ -1159,43 +1191,133 @@ header {
}
.hitl-pending-item {
border: 1px solid rgba(99, 102, 241, 0.25);
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px;
background: rgba(15, 23, 42, 0.45);
padding: 16px;
background: #f8fbff;
transition: box-shadow 0.2s;
}
.hitl-pending-item:hover {
box-shadow: 0 2px 12px rgba(0, 102, 255, 0.08);
}
.hitl-pending-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.hitl-pending-item-title {
display: flex;
align-items: center;
gap: 8px;
}
.hitl-tool-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--accent-color, #0066ff);
color: #fff;
}
.hitl-mode-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
background: #e0e7ff;
color: #4338ca;
}
.hitl-mode-tag--review_edit {
background: #fef3c7;
color: #92400e;
}
.hitl-pending-meta {
font-size: 12px;
color: #94a3b8;
margin-bottom: 8px;
word-break: break-all;
}
.hitl-pending-payload {
white-space: pre-wrap;
word-break: break-all;
max-height: 160px;
overflow: auto;
margin: 0 0 4px 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
border: 1px solid #e2e8f0;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #334155;
line-height: 1.5;
}
.hitl-pending-item-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.hitl-dismiss-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: var(--text-secondary, #64748b);
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.hitl-dismiss-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.hitl-pending-actions {
display: flex;
gap: 8px;
margin-top: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
}
.hitl-edit-args {
width: 100%;
min-height: 76px;
margin-top: 8px;
border: 1px solid rgba(148, 163, 184, 0.35);
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
color: #1f2937;
padding: 8px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.hitl-edit-args:focus {
outline: none;
border-color: var(--accent-color, #0066ff);
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
}
.hitl-input-help {
margin-top: 6px;
margin-bottom: 6px;
font-size: 12px;
color: #64748b;
color: var(--accent-color, #0066ff);
line-height: 1.4;
}
@@ -4350,6 +4472,25 @@ header {
flex-wrap: wrap;
}
.tool-resident-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2px 8px;
background: var(--bg-secondary);
cursor: pointer;
}
.tool-resident-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
margin: 0;
}
.external-tool-badge {
display: inline-flex;
align-items: center;
@@ -8848,7 +8989,7 @@ header {
/* 任务管理 · 队列卡片:单行主网格 + 进度列内统计,降低高度 */
.batch-queue-item__inner--grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(128px, auto) minmax(88px, 14%) 44px;
grid-template-columns: minmax(0, 1fr) minmax(128px, auto) minmax(88px, 14%) minmax(40px, max-content);
grid-template-rows: auto;
grid-template-areas: "lead cluster progress actions";
column-gap: 22px;
@@ -8929,6 +9070,12 @@ header {
justify-self: end;
align-self: center;
padding-left: 6px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.batch-queue-item__idline--lead {
@@ -9015,6 +9162,12 @@ header {
}
.batch-queue-icon-btn:hover {
color: var(--accent-color, #0066ff);
border-color: rgba(0, 102, 255, 0.35);
background: rgba(0, 102, 255, 0.08);
}
.batch-queue-icon-btn--danger:hover {
color: var(--error-color, #dc3545);
border-color: rgba(220, 53, 69, 0.35);
background: rgba(220, 53, 69, 0.06);
@@ -13453,29 +13606,87 @@ header {
.vulnerability-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 6px;
align-items: start;
}
.detail-item {
/* 元数据条数为奇数时,最后一项占满一行,长 URL/队列 ID 更易读 */
.vulnerability-details .vuln-detail-field:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.vulnerability-details {
grid-template-columns: 1fr;
}
.vulnerability-details .vuln-detail-field:last-child:nth-child(odd) {
grid-column: auto;
}
}
/* 漏洞详情字段:标签与值分行,长 ID/URL 可换行、可选中复制 */
.vuln-detail-field {
min-width: 0;
font-size: 0.875rem;
}
.detail-item strong {
.vuln-detail-field__label {
color: var(--text-secondary);
margin-right: 4px;
font-weight: 600;
font-size: 0.75rem;
margin-bottom: 6px;
text-transform: none;
letter-spacing: normal;
}
.detail-item code {
.vuln-detail-field__row {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.vuln-detail-field-value {
flex: 1;
min-width: 0;
margin: 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
border: 1px solid var(--border-color);
font-size: 0.8125rem;
line-height: 1.45;
word-break: break-word;
overflow-wrap: anywhere;
white-space: pre-wrap;
user-select: text;
-webkit-user-select: text;
color: var(--text-primary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
}
.vuln-detail-field__copy {
flex-shrink: 0;
margin-top: 2px;
padding: 6px;
line-height: 0;
border-radius: 6px;
color: var(--text-secondary);
border: 1px solid transparent;
background: transparent;
cursor: pointer;
}
.vuln-detail-field__copy:hover {
color: var(--accent-color);
background: var(--bg-primary);
border-color: var(--border-color);
}
.vulnerability-proof,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 85 KiB

+68 -3
View File
@@ -178,7 +178,12 @@
"taskCancelled": "Task cancelled",
"unknownTool": "Unknown tool",
"einoAgentReplyTitle": "Sub-agent reply",
"einoRecoveryTitle": "🔄 Invalid tool JSON · run {{n}}/{{max}} (hint appended)",
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
"einoPendingOrphanedTitle": "🧹 Tool call reconciliation",
"einoPendingOrphanedMessage": "Detected {{count}} unclosed tool call(s); marked as failed and finalized automatically.",
"noDescription": "No description",
"noResponseData": "No response data",
"loading": "Loading...",
@@ -234,7 +239,7 @@
},
"hitl": {
"pageTitle": "HITL approvals",
"pendingTitle": "Pending interrupts"
"pendingTitle": "Pending approvals"
},
"progress": {
"callingAI": "Calling AI model...",
@@ -298,6 +303,8 @@
"clearHistory": "Clear history",
"cancelTask": "Cancel task",
"viewConversation": "View conversation",
"viewVulnerabilities": "View vulnerabilities",
"viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue",
"retryTask": "Retry",
"conversationIdLabel": "Conversation ID",
"statusPending": "Pending",
@@ -569,6 +576,10 @@
"addExternal": "Add external MCP",
"toolConfig": "MCP tool config",
"saveToolConfig": "Save tool config",
"alwaysVisibleLabel": "Pinned",
"alwaysVisibleHint": "Always keep visible in Tool Search results",
"alwaysVisibleBuiltinLabel": "Builtin default",
"alwaysVisibleBuiltinHint": "Backend builtin tool is pinned by default and cannot be disabled",
"externalConfig": "External MCP config",
"loadingTools": "Loading tools...",
"loadToolsTimeout": "Tools load timeout. External MCP may be slow. Click Refresh to retry or check connection.",
@@ -1307,6 +1318,12 @@
"clear": "Clear",
"vulnId": "Vuln ID",
"conversationId": "Conversation ID",
"taskOrQueueId": "Task / queue ID",
"filterTaskOrQueue": "Filter by task or queue ID",
"conversationTag": "Conversation tag",
"filterConversationTag": "Filter by conversation tag",
"taskTag": "Task tag",
"filterTaskTag": "Filter by task tag",
"severity": "Severity",
"status": "Status",
"statusOpen": "Open",
@@ -1316,7 +1333,31 @@
"searchVulnId": "Search vuln ID",
"filterConversation": "Filter by conversation",
"loading": "Loading...",
"noRecords": "No vulnerability records"
"loadListFailed": "Failed to load",
"noRecords": "No vulnerability records",
"batchExport": "Batch export",
"downloadMarkdownTitle": "Download Markdown",
"exportNoResults": "No vulnerabilities match the current filters",
"exportStarted": "Started downloading {{count}} file(s)",
"exportFailed": "Export failed",
"saveRequiredFields": "Please fill in conversation ID, title, and severity",
"saveFailed": "Save failed",
"fetchFailed": "Failed to fetch vulnerability",
"deleteFailed": "Delete failed",
"detailVulnId": "Vuln ID",
"detailType": "Type",
"detailTarget": "Target",
"detailConversationId": "Conversation ID",
"detailTaskId": "Task ID",
"detailTaskQueueId": "Task queue ID",
"detailConversationTag": "Conversation tag",
"detailTaskTag": "Task tag",
"detailProof": "Proof",
"detailImpact": "Impact",
"detailRecommendation": "Remediation",
"downloadOkTitle": "Downloaded",
"exportFailedMessage": "Export failed",
"downloadFailed": "Download failed"
},
"tasksPage": {
"statusFilter": "Status filter",
@@ -1667,6 +1708,7 @@
},
"contextMenu": {
"viewAttackChain": "View attack chain",
"viewVulnerabilities": "View vulnerabilities",
"downloadMarkdown": "Download Markdown",
"downloadMarkdownSummary": "Summary",
"downloadMarkdownFull": "Full",
@@ -1762,6 +1804,10 @@
"vulnerabilityModal": {
"conversationId": "Conversation ID",
"conversationIdPlaceholder": "Enter conversation ID",
"conversationTag": "Conversation tag",
"conversationTagPlaceholder": "e.g. engagement A, weekly report",
"taskTag": "Task tag",
"taskTagPlaceholder": "e.g. batch scan Q2, retest",
"title": "Title",
"titlePlaceholder": "Vulnerability title",
"description": "Description",
@@ -1789,6 +1835,25 @@
"recommendation": "Recommendation",
"recommendationPlaceholder": "Remediation"
},
"vulnerabilityMd": {
"headingBasic": "Basic information",
"labelId": "Vulnerability ID",
"labelSeverity": "Severity",
"labelStatus": "Status",
"labelType": "Type",
"labelTarget": "Target",
"labelConversationId": "Conversation ID",
"labelTaskId": "Task ID",
"labelTaskQueueId": "Task queue ID",
"labelConversationTag": "Conversation tag",
"labelTaskTag": "Task tag",
"labelCreated": "Created at",
"labelUpdated": "Updated at",
"headingDescription": "Description",
"headingProof": "Proof (POC)",
"headingImpact": "Impact",
"headingRecommendation": "Remediation"
},
"roleModal": {
"addRole": "Add role",
"editRole": "Edit role",
+68 -3
View File
@@ -178,7 +178,12 @@
"taskCancelled": "任务已取消",
"unknownTool": "未知工具",
"einoAgentReplyTitle": "子代理回复",
"einoRecoveryTitle": "🔄 工具参数无效 · 第 {{n}}/{{max}} 轮(已追加提示",
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}",
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
"einoPendingOrphanedTitle": "🧹 工具调用收尾补偿",
"einoPendingOrphanedMessage": "检测到 {{count}} 个未闭合工具调用,已自动标记为失败并收尾。",
"noDescription": "暂无描述",
"noResponseData": "暂无响应数据",
"loading": "加载中...",
@@ -234,7 +239,7 @@
},
"hitl": {
"pageTitle": "人机协同审批",
"pendingTitle": "待处理中断"
"pendingTitle": "待处理审批"
},
"progress": {
"callingAI": "正在调用AI模型...",
@@ -298,6 +303,8 @@
"clearHistory": "清空历史",
"cancelTask": "取消任务",
"viewConversation": "查看对话",
"viewVulnerabilities": "查看漏洞",
"viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列",
"retryTask": "重试",
"conversationIdLabel": "对话ID",
"statusPending": "待执行",
@@ -569,6 +576,10 @@
"addExternal": "添加外部MCP",
"toolConfig": "MCP 工具配置",
"saveToolConfig": "保存工具配置",
"alwaysVisibleLabel": "常驻",
"alwaysVisibleHint": "始终常驻在 Tool Search 可见列表(不被 tool_search 隐藏)",
"alwaysVisibleBuiltinLabel": "内置默认",
"alwaysVisibleBuiltinHint": "后端内置工具默认常驻,不可关闭",
"externalConfig": "外部 MCP 配置",
"loadingTools": "正在加载工具列表...",
"loadToolsTimeout": "加载工具列表超时,可能是外部MCP连接较慢。请点击\"刷新\"按钮重试,或检查外部MCP连接状态。",
@@ -1307,6 +1318,12 @@
"clear": "清除",
"vulnId": "漏洞ID",
"conversationId": "会话ID",
"taskOrQueueId": "任务ID/队列ID",
"filterTaskOrQueue": "筛选任务ID或队列ID",
"conversationTag": "对话标签",
"filterConversationTag": "筛选对话标签",
"taskTag": "任务标签",
"filterTaskTag": "筛选任务标签",
"severity": "严重程度",
"status": "状态",
"statusOpen": "待处理",
@@ -1316,7 +1333,31 @@
"searchVulnId": "搜索漏洞ID",
"filterConversation": "筛选特定会话",
"loading": "加载中...",
"noRecords": "暂无漏洞记录"
"loadListFailed": "加载失败",
"noRecords": "暂无漏洞记录",
"batchExport": "批量导出",
"downloadMarkdownTitle": "下载 Markdown",
"exportNoResults": "当前筛选条件下无可导出漏洞",
"exportStarted": "已开始下载 {{count}} 份报告",
"exportFailed": "导出失败",
"saveRequiredFields": "请填写必填字段:会话ID、标题和严重程度",
"saveFailed": "保存失败",
"fetchFailed": "获取漏洞失败",
"deleteFailed": "删除失败",
"detailVulnId": "漏洞ID",
"detailType": "类型",
"detailTarget": "目标",
"detailConversationId": "会话ID",
"detailTaskId": "任务ID",
"detailTaskQueueId": "任务队列ID",
"detailConversationTag": "对话标签",
"detailTaskTag": "任务标签",
"detailProof": "证明",
"detailImpact": "影响",
"detailRecommendation": "修复建议",
"downloadOkTitle": "下载成功",
"exportFailedMessage": "导出失败",
"downloadFailed": "下载失败"
},
"tasksPage": {
"statusFilter": "状态筛选",
@@ -1667,6 +1708,7 @@
},
"contextMenu": {
"viewAttackChain": "查看攻击链",
"viewVulnerabilities": "查看漏洞",
"downloadMarkdown": "下载 Markdown",
"downloadMarkdownSummary": "简版",
"downloadMarkdownFull": "完整版",
@@ -1762,6 +1804,10 @@
"vulnerabilityModal": {
"conversationId": "会话ID",
"conversationIdPlaceholder": "输入会话ID",
"conversationTag": "对话标签",
"conversationTagPlaceholder": "如:红队演练A、客户A周报",
"taskTag": "任务标签",
"taskTagPlaceholder": "如:批量扫描Q2、专项复测",
"title": "标题",
"titlePlaceholder": "漏洞标题",
"description": "描述",
@@ -1789,6 +1835,25 @@
"recommendation": "修复建议",
"recommendationPlaceholder": "修复建议"
},
"vulnerabilityMd": {
"headingBasic": "基本信息",
"labelId": "漏洞ID",
"labelSeverity": "严重程度",
"labelStatus": "状态",
"labelType": "类型",
"labelTarget": "目标",
"labelConversationId": "会话ID",
"labelTaskId": "任务ID",
"labelTaskQueueId": "任务队列ID",
"labelConversationTag": "对话标签",
"labelTaskTag": "任务标签",
"labelCreated": "创建时间",
"labelUpdated": "更新时间",
"headingDescription": "描述",
"headingProof": "证明(POC",
"headingImpact": "影响",
"headingRecommendation": "修复建议"
},
"roleModal": {
"addRole": "添加角色",
"editRole": "编辑角色",
+90 -41
View File
@@ -399,6 +399,23 @@ if (typeof window !== 'undefined') {
window.updateHitlStatusUI = updateHitlStatusUI;
}
function toggleHitlSidebarCard() {
var card = document.getElementById('hitl-sidebar-card');
if (!card) return;
card.classList.toggle('hitl-sidebar-collapsed');
try {
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
} catch (e) {}
}
window.toggleHitlSidebarCard = toggleHitlSidebarCard;
document.addEventListener('DOMContentLoaded', function () {
var card = document.getElementById('hitl-sidebar-card');
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
card.classList.remove('hitl-sidebar-collapsed');
}
});
function getAgentModeLabelForValue(mode) {
if (typeof window.t === 'function') {
switch (mode) {
@@ -748,50 +765,59 @@ async function sendMessage() {
if (!response.ok) {
throw new Error('请求失败: ' + response.status);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留最后一个不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
window.__csAgentLiveStream = {
active: true,
conversationId: currentConversationId || null,
progressId: progressId
};
try {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留最后一个不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
}
}
// 处理剩余的buffer
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
// 处理剩余的buffer
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
}
} finally {
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
}
// 消息发送成功后,再次确保草稿被清除
clearChatDraft();
try {
@@ -2200,10 +2226,6 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + execLine;
} else if (eventType === 'eino_agent_reply') {
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
} else if (eventType === 'eino_recovery') {
const ri = data.runIndex != null ? data.runIndex : (data.einoRetry != null ? data.einoRetry + 1 : 1);
const mx = data.maxRuns != null ? data.maxRuns : 3;
itemTitle = (typeof window.t === 'function' ? window.t('chat.einoRecoveryTitle', { n: ri, max: mx }) : ('🔄 第 ' + ri + '/' + mx + ' 轮(已追加提示)'));
} else if (eventType === 'knowledge_retrieval') {
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
} else if (eventType === 'error') {
@@ -2905,6 +2927,22 @@ async function loadConversation(conversationId) {
await window.restoreHitlInlineForConversation(conversationId);
}
}
// 页面刷新后主流式连接会中断;若该会话仍在后端运行,自动挂载 task-events 补流继续更新前端迭代进度。
const skipReplay = typeof window.shouldSkipTaskEventReplayAttach === 'function'
&& window.shouldSkipTaskEventReplayAttach(conversationId);
if (
seq === loadConversationRequestSeq &&
currentConversationId === conversationId &&
typeof window.attachRunningTaskEventStream === 'function' &&
!skipReplay
) {
Promise.resolve()
.then(() => window.attachRunningTaskEventStream(conversationId))
.catch((e) => {
console.warn('attachRunningTaskEventStream on loadConversation failed', e);
});
}
} catch (error) {
console.error('加载对话失败:', error);
alert('加载对话失败: ' + error.message);
@@ -6083,6 +6121,17 @@ async function downloadConversationMarkdownFromContext(includeToolDetails = fals
closeContextMenu();
}
// 从上下文菜单跳转到漏洞管理,并按当前对话 ID 筛选
function navigateToVulnerabilitiesForContextConversation() {
const convId = contextMenuConversationId;
if (!convId) {
closeContextMenu();
return;
}
closeContextMenu();
window.location.hash = 'vulnerabilities?conversation_id=' + encodeURIComponent(convId);
}
// 从上下文菜单删除对话
function deleteConversationFromContext() {
const convId = contextMenuConversationId;
+34 -10
View File
@@ -282,21 +282,29 @@ async function refreshHitlPending() {
const preview = payload.length > 280 ? (payload.slice(0, 280) + '...') : payload;
const mode = String(item.mode || '').trim().toLowerCase();
const allowEdit = mode === 'review_edit';
var escId = escapeHtml(String(item.id || ''));
var qId = JSON.stringify(String(item.id || '')).replace(/"/g, '&quot;');
var qConv = JSON.stringify(String(item.conversationId || '')).replace(/"/g, '&quot;');
return (
'<div class="hitl-pending-item">' +
'<div class="hitl-pending-item-header">' +
'<strong>' + escapeHtml(item.toolName || '-') + '</strong>' +
'<span>' + escapeHtml(item.mode || '-') + '</span>' +
'<div class="hitl-pending-item-title">' +
'<span class="hitl-tool-badge">' + escapeHtml(item.toolName || '-') + '</span>' +
'<span class="hitl-mode-tag hitl-mode-tag--' + escapeHtml(mode) + '">' + escapeHtml(item.mode || '-') + '</span>' +
'</div>' +
'<div><small>conversation: ' + escapeHtml(item.conversationId || '-') + '</small></div>' +
'<pre style="white-space:pre-wrap;max-height:160px;overflow:auto;">' + escapeHtml(preview) + '</pre>' +
'<button class="hitl-dismiss-btn" title="忽略" onclick="dismissHitlItem(' + qId + ')">&times;</button>' +
'</div>' +
'<div class="hitl-pending-meta">会话:' + escapeHtml(item.conversationId || '-') + '</div>' +
'<pre class="hitl-pending-payload">' + escapeHtml(preview) + '</pre>' +
(allowEdit
? ('<div class="hitl-input-help">审查编辑模式:可填写 JSON 对象覆盖参数示例:{"command":"ls -la"}</div>' +
'<textarea id="hitl-edit-' + escapeHtml(String(item.id || '')) + '" class="hitl-edit-args" placeholder=\'{"command":"ls -la"}\'></textarea>')
? ('<div class="hitl-input-help">审查编辑模式:可填写 JSON 对象覆盖参数示例:{"command":"ls -la"}</div>' +
'<textarea id="hitl-edit-' + escId + '" class="hitl-edit-args" placeholder=\'{"command":"ls -la"}\'></textarea>')
: '<div class="hitl-input-help">审批模式:仅通过/拒绝,不支持改参。</div>') +
'<div class="hitl-input-help">备注(可选):建议写审批依据。</div>' +
'<input id="hitl-comment-' + escId + '" class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">' +
'<div class="hitl-pending-actions">' +
'<button class="btn-primary" onclick="submitHitlDecision(' + JSON.stringify(String(item.id || '')) + ',\'approve\',' + JSON.stringify(String(item.conversationId || '')) + ')">通过</button>' +
'<button class="btn-secondary" onclick="submitHitlDecision(' + JSON.stringify(String(item.id || '')) + ',\'reject\',' + JSON.stringify(String(item.conversationId || '')) + ')">拒绝</button>' +
'<button class="btn-secondary" onclick="submitHitlDecision(' + qId + ',&quot;reject&quot;,' + qConv + ')">拒绝</button>' +
'<button class="btn-primary" onclick="submitHitlDecision(' + qId + ',&quot;approve&quot;,' + qConv + ')">通过</button>' +
'</div>' +
'</div>'
);
@@ -307,7 +315,8 @@ async function refreshHitlPending() {
}
async function submitHitlDecision(interruptId, decision, conversationIdOpt) {
const comment = prompt('审批备注(可选)') || '';
const commentBox = document.getElementById('hitl-comment-' + interruptId);
const comment = (commentBox && commentBox.value) ? commentBox.value.trim() : '';
let editedArguments = null;
const editBox = document.getElementById('hitl-edit-' + interruptId);
if (editBox && editBox.value && editBox.value.trim()) {
@@ -332,7 +341,7 @@ async function submitHitlDecisionWithPayload(interruptId, decision, comment, edi
if (!resp.ok) {
const errText = await readHitlApiError(resp);
if (resp.status === 409 && (errText.indexOf('already resolved') >= 0 || errText.indexOf('not found') >= 0)) {
refreshHitlPending();
await dismissHitlItem(interruptId, true);
return true;
}
alert('提交失败:' + errText);
@@ -363,9 +372,24 @@ async function readHitlApiError(resp) {
}
}
async function dismissHitlItem(interruptId, silent) {
try {
await hitlApiFetch('/api/hitl/dismiss', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interruptId: interruptId })
});
} catch (e) {
if (!silent) { console.warn('dismissHitlItem', e); }
}
refreshHitlPending();
}
window.refreshHitlPending = refreshHitlPending;
window.submitHitlDecision = submitHitlDecision;
window.submitHitlDecisionWithPayload = submitHitlDecisionWithPayload;
window.dismissHitlItem = dismissHitlItem;
window.followAgentRunAfterHitlDecision = followAgentRunAfterHitlDecision;
window.addEventListener('hitl-interrupt', function () {
+170 -100
View File
@@ -3,6 +3,36 @@ let activeTaskInterval = null;
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
/**
* 主对话 POST 流仍在读取时禁止再挂 task-events 补流否则同一事件会画两遍 HITL 是否开启无关
* window.__csAgentLiveStream chat.js sendMessage 在读到 body 后设置 finally 中清除
*/
function syncAgentLiveStreamConversationId(cid) {
if (!cid) return;
try {
const live = window.__csAgentLiveStream;
if (live && live.active) {
live.conversationId = cid;
}
} catch (e) { /* ignore */ }
}
function shouldSkipTaskEventReplayAttach(conversationId) {
try {
const live = window.__csAgentLiveStream;
if (!live || !live.active || !live.progressId) return false;
if (!document.getElementById(live.progressId)) return false;
// 新会话:conversation 事件尚未到达前 conversationId 可能仍为 null,一律不补挂
if (live.conversationId == null) return true;
return live.conversationId === conversationId;
} catch (e) {
return false;
}
}
if (typeof window !== 'undefined') {
window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach;
}
// 当前界面语言对应的 BCP 47 标签(与时间格式化一致)
function getCurrentTimeLocale() {
if (typeof window.__locale === 'string' && window.__locale.length) {
@@ -934,6 +964,7 @@ function handleStreamEvent(event, progressElement, progressId,
// 更新当前对话ID
currentConversationId = event.data.conversationId;
syncAgentLiveStreamConversationId(event.data.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
loadActiveTasks();
@@ -1102,20 +1133,45 @@ function handleStreamEvent(event, progressElement, progressId,
});
break;
case 'eino_recovery': {
case 'eino_stream_error': {
const d = event.data || {};
const runIdx = d.runIndex != null ? d.runIndex : (d.einoRetry != null ? d.einoRetry + 1 : 1);
const maxRuns = d.maxRuns != null ? d.maxRuns : 3;
const agent = d.einoAgent ? String(d.einoAgent) : '';
const title = typeof window.t === 'function'
? window.t('chat.einoRecoveryTitle', { n: runIdx, max: maxRuns })
: ('🔄 工具参数无效 · 第 ' + runIdx + '/' + maxRuns + ' 轮(已追加提示)');
addTimelineItem(timeline, 'eino_recovery', {
? window.t('chat.einoStreamErrorTitle', { agent: agent || '-' })
: (agent ? ('⚠️ Eino 流式中断(' + agent + '') : '⚠️ Eino 流式中断');
addTimelineItem(timeline, 'warning', {
title: title,
message: event.message || '',
message: event.message || (typeof window.t === 'function'
? window.t('chat.einoStreamErrorMessage')
: '流式读取异常,系统将按策略重试或结束。'),
data: d
});
break;
}
case 'iteration_limit_reached': {
addTimelineItem(timeline, 'warning', {
title: typeof window.t === 'function' ? window.t('chat.iterationLimitReachedTitle') : '⛔ 达到迭代上限',
message: event.message || (typeof window.t === 'function'
? window.t('chat.iterationLimitReachedMessage')
: '已达到最大迭代次数,任务已停止继续自动迭代。'),
data: event.data
});
// If the backend triggers a recovery run, any "running" tool_call items in this progress
// should be closed to avoid being stuck forever.
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
break;
}
case 'eino_pending_orphaned': {
const d = event.data || {};
const count = Number(d.pendingCount || 0);
const countText = Number.isFinite(count) && count > 0 ? String(count) : '?';
addTimelineItem(timeline, 'warning', {
title: typeof window.t === 'function' ? window.t('chat.einoPendingOrphanedTitle') : '🧹 工具调用收尾补偿',
message: event.message || (typeof window.t === 'function'
? window.t('chat.einoPendingOrphanedMessage', { count: countText })
: ('检测到 ' + countText + ' 个未闭合工具调用,已自动标记为失败并收尾。')),
data: d
});
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
break;
}
@@ -1429,6 +1485,7 @@ function handleStreamEvent(event, progressElement, progressId,
break;
}
currentConversationId = responseData.conversationId;
syncAgentLiveStreamConversationId(responseData.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, responseData.conversationId);
@@ -1509,6 +1566,7 @@ function handleStreamEvent(event, progressElement, progressId,
}
currentConversationId = responseData.conversationId;
syncAgentLiveStreamConversationId(responseData.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, responseData.conversationId);
@@ -1639,6 +1697,7 @@ function handleStreamEvent(event, progressElement, progressId,
// 更新对话ID
if (event.data && event.data.conversationId) {
currentConversationId = event.data.conversationId;
syncAgentLiveStreamConversationId(event.data.conversationId);
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, event.data.conversationId);
@@ -1939,90 +1998,120 @@ async function refreshLastAssistantProcessDetails(conversationId) {
window.refreshLastAssistantProcessDetails = refreshLastAssistantProcessDetails;
const taskEventReplayAttachState = {
conversationId: null,
inFlightPromise: null
};
/**
* 订阅运行中任务的 SSE 镜像GET /api/agent-loop/task-events用于 HITL 通过后主连接已断开时接续 UI
*/
async function attachRunningTaskEventStream(conversationId) {
if (!conversationId || typeof apiFetch !== 'function') return false;
try {
const check = await apiFetch('/api/agent-loop/tasks');
if (!check.ok) return false;
const j = await check.json().catch(function () { return {}; });
const active = (j.tasks || []).some(function (t) {
return t && t.conversationId === conversationId && (t.status === 'running' || t.status === 'cancelling');
});
if (!active) return false;
if (
taskEventReplayAttachState.inFlightPromise &&
taskEventReplayAttachState.conversationId === conversationId
) {
return taskEventReplayAttachState.inFlightPromise;
}
if (shouldSkipTaskEventReplayAttach(conversationId)) {
return false;
}
const asEl = findLastAssistantMessageElInChat();
if (!asEl || !asEl.id) return false;
const backendId = asEl.dataset && asEl.dataset.backendMessageId;
if (backendId && typeof renderProcessDetails === 'function') {
const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details');
const jd = await res.json().catch(function () { return {}; });
if (res.ok && Array.isArray(jd.processDetails)) {
renderProcessDetails(asEl.id, jd.processDetails);
}
}
expandProcessDetailsTimeline(asEl.id);
const attachPromise = (async function () {
try {
const check = await apiFetch('/api/agent-loop/tasks');
if (!check.ok) return false;
const j = await check.json().catch(function () { return {}; });
const active = (j.tasks || []).some(function (t) {
return t && t.conversationId === conversationId && (t.status === 'running' || t.status === 'cancelling');
});
if (!active) return false;
const progressId = taskReplayProgressId(conversationId);
beginCsTaskReplay(progressId, asEl.id, conversationId);
const url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId);
const response = await apiFetch(url, {
method: 'GET',
headers: { Accept: 'text/event-stream' }
});
if (!response.ok) {
clearCsTaskReplay();
if (progressTaskState.has(progressId)) {
progressTaskState.delete(progressId);
}
return false;
}
let mcpIds = [];
const assistantDomId = asEl.id;
const getAssistantIdFn = function () { return assistantDomId; };
const setAssistantIdFn = function () {};
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; });
} catch (e) {
console.error('task-events parse', e);
const asEl = findLastAssistantMessageElInChat();
if (!asEl || !asEl.id) return false;
const backendId = asEl.dataset && asEl.dataset.backendMessageId;
if (backendId && typeof renderProcessDetails === 'function') {
const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details');
const jd = await res.json().catch(function () { return {}; });
if (res.ok && Array.isArray(jd.processDetails)) {
renderProcessDetails(asEl.id, jd.processDetails);
// renderProcessDetails 会重建时间线节点,需重新挂载 HITL 审批入口
if (typeof window.restoreHitlInlineForConversation === 'function') {
await window.restoreHitlInlineForConversation(conversationId);
}
}
}
}
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
expandProcessDetailsTimeline(asEl.id);
const progressId = taskReplayProgressId(conversationId);
beginCsTaskReplay(progressId, asEl.id, conversationId);
const url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId);
const response = await apiFetch(url, {
method: 'GET',
headers: { Accept: 'text/event-stream' }
});
if (!response.ok) {
clearCsTaskReplay();
if (progressTaskState.has(progressId)) {
progressTaskState.delete(progressId);
}
return false;
}
let mcpIds = [];
const assistantDomId = asEl.id;
const getAssistantIdFn = function () { return assistantDomId; };
const setAssistantIdFn = function () {};
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; });
} catch (e) {
console.error('task-events parse', e);
}
}
}
}
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
clearCsTaskReplay();
}
if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
}
if (typeof loadActiveTasks === 'function') loadActiveTasks();
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
await window.loadConversation(conversationId);
}
return true;
} catch (e) {
console.warn('attachRunningTaskEventStream', e);
clearCsTaskReplay();
return false;
} finally {
if (taskEventReplayAttachState.inFlightPromise === attachPromise) {
taskEventReplayAttachState.inFlightPromise = null;
taskEventReplayAttachState.conversationId = null;
}
}
if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
}
if (typeof loadActiveTasks === 'function') loadActiveTasks();
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
await window.loadConversation(conversationId);
}
return true;
} catch (e) {
console.warn('attachRunningTaskEventStream', e);
clearCsTaskReplay();
return false;
}
})();
taskEventReplayAttachState.conversationId = conversationId;
taskEventReplayAttachState.inFlightPromise = attachPromise;
return attachPromise;
}
window.attachRunningTaskEventStream = attachRunningTaskEventStream;
@@ -2083,15 +2172,6 @@ function addTimelineItem(timeline, type, options) {
if (type === 'progress' && options.message) {
item.dataset.progressMessage = options.message;
}
if (type === 'eino_recovery' && options.data) {
const d = options.data;
if (d.runIndex != null) {
item.dataset.recoveryRunIndex = String(d.runIndex);
}
if (d.maxRuns != null) {
item.dataset.recoveryMaxRuns = String(d.maxRuns);
}
}
if (type === 'tool_calls_detected' && options.data && options.data.count != null) {
item.dataset.toolCallsCount = String(options.data.count);
}
@@ -2202,12 +2282,6 @@ function addTimelineItem(timeline, type, options) {
</div>
</div>
`;
} else if (type === 'eino_recovery' && options.message) {
content += `
<div class="timeline-item-content timeline-eino-recovery">
${escapeHtml(options.message).replace(/\n/g, '<br>')}
</div>
`;
} else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += `
@@ -3090,10 +3164,6 @@ function refreshProgressAndTimelineI18n() {
titleSpan.textContent = ap + icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name }));
} else if (type === 'eino_agent_reply') {
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
} else if (type === 'eino_recovery' && item.dataset.recoveryRunIndex) {
const n = parseInt(item.dataset.recoveryRunIndex, 10) || 1;
const mx = parseInt(item.dataset.recoveryMaxRuns, 10) || 3;
titleSpan.textContent = _t('chat.einoRecoveryTitle', { n: n, max: mx });
} else if (type === 'cancelled') {
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
+31 -19
View File
@@ -1,19 +1,19 @@
// 页面路由管理
let currentPage = 'dashboard';
/** 仅当停留在 chat 时保留 ?conversation= 等查询串,其它页面只使用 pageId */
/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id= */
function buildHashForPage(pageId) {
if (pageId !== 'chat') {
if (pageId !== 'chat' && pageId !== 'vulnerabilities') {
return pageId;
}
const full = window.location.hash.slice(1);
const parts = full.split('?');
const curPage = parts[0];
const q = parts.length > 1 ? parts.slice(1).join('?') : '';
if (curPage === 'chat' && q) {
return 'chat?' + q;
if (curPage === pageId && q) {
return pageId + '?' + q;
}
return 'chat';
return pageId;
}
let chatConversationFromHashSeq = 0;
@@ -301,26 +301,38 @@ async function initPage(pageId) {
break;
case 'mcp-management':
// 初始化MCP管理
const startLoadMcpTools = () => {
// 加载工具列表(MCP工具配置已移到MCP管理页面)
// 使用异步加载,避免阻塞页面渲染
if (typeof loadToolsList === 'function') {
// 确保工具分页设置已初始化
if (typeof getToolsPageSize === 'function' && typeof toolsPagination !== 'undefined') {
toolsPagination.pageSize = getToolsPageSize();
}
// 延迟加载,让页面先渲染
setTimeout(() => {
loadToolsList(1, '').catch(err => {
console.error('加载工具列表失败:', err);
});
}, 100);
}
};
// 先拉取全局配置,确保 tool_search 常驻状态按后端生效集合展示
if (typeof loadConfig === 'function') {
loadConfig(false)
.catch(err => {
console.warn('加载配置失败(将继续加载工具列表):', err);
})
.finally(startLoadMcpTools);
} else {
startLoadMcpTools();
}
// 先加载外部MCP列表(快速),然后加载工具列表
if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs().catch(err => {
console.warn('加载外部MCP列表失败:', err);
});
}
// 加载工具列表(MCP工具配置已移到MCP管理页面)
// 使用异步加载,避免阻塞页面渲染
if (typeof loadToolsList === 'function') {
// 确保工具分页设置已初始化
if (typeof getToolsPageSize === 'function' && typeof toolsPagination !== 'undefined') {
toolsPagination.pageSize = getToolsPageSize();
}
// 延迟加载,让页面先渲染
setTimeout(() => {
loadToolsList(1, '').catch(err => {
console.error('加载工具列表失败:', err);
});
}, 100);
}
break;
case 'vulnerabilities':
// 初始化漏洞管理页面
+36
View File
@@ -1,6 +1,8 @@
// 设置相关功能
let currentConfig = null;
let allTools = [];
let alwaysVisibleToolNames = new Set();
let alwaysVisibleBuiltinToolNames = new Set();
// 全局工具状态映射,用于保存用户在所有页面的修改
// key: 唯一工具标识符(toolKey),value: { enabled: boolean, is_external: boolean, external_mcp: string }
let toolStateMap = new Map();
@@ -100,6 +102,14 @@ async function loadConfig(loadTools = true) {
}
currentConfig = await response.json();
const alwaysVisibleList = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
const alwaysVisibleConfigured = currentConfig?.multi_agent?.tool_search_always_visible_tools;
alwaysVisibleToolNames = new Set(Array.isArray(alwaysVisibleList) ? alwaysVisibleList.filter(Boolean) : []);
alwaysVisibleBuiltinToolNames = new Set(
alwaysVisibleToolNames.size > 0 && Array.isArray(alwaysVisibleConfigured)
? Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleConfigured.includes(name))
: []
);
// 填充OpenAI配置
const providerEl = document.getElementById('openai-provider');
@@ -498,6 +508,8 @@ function renderToolsList() {
is_external: tool.is_external || false,
external_mcp: tool.external_mcp || ''
};
const alwaysVisibleChecked = alwaysVisibleToolNames.has(tool.name);
const alwaysVisibleLocked = alwaysVisibleBuiltinToolNames.has(tool.name);
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
let externalBadge = '';
@@ -521,6 +533,11 @@ function renderToolsList() {
<div class="tool-item-name">
${escapeHtml(tool.name)}
${externalBadge}
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(tool.name)}', this.checked)" />
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
</label>
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
<span class="tool-expand-icon"></span>
</div>
<div class="tool-item-desc">${escapeHtml(tool.description || (typeof window.t === 'function' ? window.t('mcp.noDescription') : '无描述'))}</div>
@@ -716,6 +733,16 @@ function handleToolCheckboxChange(toolKey, enabled) {
updateToolsStats();
}
function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
const name = (toolName || '').trim();
if (!name) return;
if (alwaysVisible) {
alwaysVisibleToolNames.add(name);
} else {
alwaysVisibleToolNames.delete(name);
}
}
// 全选工具
function selectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
@@ -886,9 +913,11 @@ async function updateToolsStats() {
}
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
const pinnedCount = alwaysVisibleToolNames.size;
statsEl.innerHTML = `
<span title="${tStats('mcp.currentPageEnabled')}"> ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
<span title="${tStats('mcp.alwaysVisibleHint')}">📌 ${tStats('mcp.alwaysVisibleLabel')}: <strong>${pinnedCount}</strong></span>
`;
}
@@ -1230,6 +1259,13 @@ async function saveToolsConfig() {
const config = {
openai: currentConfig.openai || {},
agent: currentConfig.agent || {},
multi_agent: {
enabled: currentConfig?.multi_agent?.enabled === true,
robot_use_multi_agent: currentConfig?.multi_agent?.robot_use_multi_agent === true,
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name))
},
tools: []
};
+16 -2
View File
@@ -531,6 +531,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
${isHistory && completedText ? completedText : timeText}
</span>
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">` + _t('tasks.cancelTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="navigateToVulnerabilitiesFromTasksPage('conversation', '${task.conversationId}')">` + _t('tasks.viewVulnerabilities') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">` + _t('tasks.viewConversation') + `</button>` : ''}
</div>
</div>
@@ -708,6 +709,17 @@ function viewConversation(conversationId) {
}
}
// 跳转漏洞管理并按对话 ID 或批量队列 ID 筛选(队列 ID 走 task_id,与列表筛选项一致)
function navigateToVulnerabilitiesFromTasksPage(kind, id) {
if (!id) return;
const enc = encodeURIComponent(id);
if (kind === 'queue') {
window.location.hash = 'vulnerabilities?task_id=' + enc;
} else if (kind === 'conversation') {
window.location.hash = 'vulnerabilities?conversation_id=' + enc;
}
}
// 刷新任务列表
async function refreshTasks() {
await loadTasks();
@@ -1134,6 +1146,8 @@ function renderBatchQueues() {
const progress = stats.total > 0 ? Math.round((stats.completed + stats.failed + stats.cancelled) / stats.total * 100) : 0;
// 允许删除待执行、已完成或已取消状态的队列
const canDelete = queue.status === 'pending' || queue.status === 'completed' || queue.status === 'cancelled';
// 操作列常驻「查看漏洞」,不再使用 --no-actions 隐藏整列(否则无法从运行中队列跳转漏洞页)
const noActionsClass = '';
const loadedRoles = batchQueuesState.loadedRoles || [];
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
@@ -1157,7 +1171,6 @@ function renderBatchQueues() {
: `<h4 class="batch-queue-card-title batch-queue-card-title--muted">${escapeHtml(_t('tasks.batchQueueUntitled'))}</h4>`;
const doneCount = stats.completed + stats.failed + stats.cancelled;
const noActionsClass = canDelete ? '' : ' batch-queue-item--no-actions';
return `
<div class="batch-queue-item batch-queue-item--compact${cardMod}${noActionsClass}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
<div class="batch-queue-item__inner batch-queue-item__inner--grid">
@@ -1182,7 +1195,8 @@ function renderBatchQueues() {
</div>
</div>
<div class="batch-queue-item__actions-col" onclick="event.stopPropagation();">
${canDelete ? `<button type="button" class="batch-queue-icon-btn" onclick="deleteBatchQueueFromList('${queue.id}')" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
<button type="button" class="batch-queue-icon-btn" onclick="navigateToVulnerabilitiesFromTasksPage('queue', '${queue.id}')" title="${escapeHtml(_t('tasks.viewVulnerabilitiesQueueTitle'))}" aria-label="${escapeHtml(_t('tasks.viewVulnerabilitiesQueueTitle'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg></button>
${canDelete ? `<button type="button" class="batch-queue-icon-btn batch-queue-icon-btn--danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
</div>
</div>
</div>
+316 -82
View File
@@ -1,5 +1,43 @@
// 漏洞管理相关功能
function vulnT(key, opts) {
if (typeof window.t === 'function') {
return window.t(key, opts);
}
return key;
}
function vulnDateLocale() {
try {
const lang = (window.__locale || '').toLowerCase();
if (lang.indexOf('zh') === 0) {
return 'zh-CN';
}
} catch (e) { /* ignore */ }
return 'en-US';
}
function vulnSeverityLabel(code) {
const m = {
critical: 'dashboard.severityCritical',
high: 'dashboard.severityHigh',
medium: 'dashboard.severityMedium',
low: 'dashboard.severityLow',
info: 'dashboard.severityInfo'
};
return m[code] ? vulnT(m[code]) : code;
}
function vulnStatusLabel(code) {
const m = {
open: 'vulnerabilityPage.statusOpen',
confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive'
};
return m[code] ? vulnT(m[code]) : code;
}
// 从localStorage读取每页显示数量,默认为20
const getVulnerabilityPageSize = () => {
const saved = localStorage.getItem('vulnerabilityPageSize');
@@ -10,6 +48,9 @@ let currentVulnerabilityId = null;
let vulnerabilityFilters = {
id: '',
conversation_id: '',
task_id: '',
conversation_tag: '',
task_tag: '',
severity: '',
status: ''
};
@@ -20,10 +61,43 @@ let vulnerabilityPagination = {
totalPages: 1
};
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= 同步筛选(对话菜单、任务管理联动)
function syncVulnerabilityFiltersFromLocationHash() {
const hash = window.location.hash.slice(1);
const hashParts = hash.split('?');
if (hashParts[0] !== 'vulnerabilities' || hashParts.length < 2) {
return;
}
const params = new URLSearchParams(hashParts.slice(1).join('?'));
const cid = (params.get('conversation_id') || '').trim();
const tid = (params.get('task_id') || '').trim();
if (!cid && !tid) {
return;
}
vulnerabilityFilters.conversation_id = '';
vulnerabilityFilters.task_id = '';
const convEl = document.getElementById('vulnerability-conversation-filter');
const taskEl = document.getElementById('vulnerability-task-filter');
if (convEl) convEl.value = '';
if (taskEl) taskEl.value = '';
if (cid) {
vulnerabilityFilters.conversation_id = cid;
if (convEl) convEl.value = cid;
}
if (tid) {
vulnerabilityFilters.task_id = tid;
if (taskEl) taskEl.value = tid;
}
vulnerabilityPagination.currentPage = 1;
}
// 初始化漏洞管理页面
function initVulnerabilityPage() {
// 从localStorage加载每页条数设置
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
syncVulnerabilityFiltersFromLocationHash();
loadVulnerabilityStats();
loadVulnerabilities();
}
@@ -41,6 +115,9 @@ async function loadVulnerabilityStats() {
if (vulnerabilityFilters.conversation_id) {
params.append('conversation_id', vulnerabilityFilters.conversation_id);
}
if (vulnerabilityFilters.task_id) {
params.append('task_id', vulnerabilityFilters.task_id);
}
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
if (!response.ok) {
@@ -82,7 +159,7 @@ function updateVulnerabilityStats(stats) {
// 加载漏洞列表
async function loadVulnerabilities(page = null) {
const listContainer = document.getElementById('vulnerabilities-list');
listContainer.innerHTML = '<div class="loading-spinner">加载中...</div>';
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
try {
// 检查apiFetch是否可用
@@ -106,6 +183,15 @@ async function loadVulnerabilities(page = null) {
if (vulnerabilityFilters.conversation_id) {
params.append('conversation_id', vulnerabilityFilters.conversation_id);
}
if (vulnerabilityFilters.task_id) {
params.append('task_id', vulnerabilityFilters.task_id);
}
if (vulnerabilityFilters.conversation_tag) {
params.append('conversation_tag', vulnerabilityFilters.conversation_tag);
}
if (vulnerabilityFilters.task_tag) {
params.append('task_tag', vulnerabilityFilters.task_tag);
}
if (vulnerabilityFilters.severity) {
params.append('severity', vulnerabilityFilters.severity);
}
@@ -148,7 +234,7 @@ async function loadVulnerabilities(page = null) {
renderVulnerabilityPagination();
} catch (error) {
console.error('加载漏洞列表失败:', error);
listContainer.innerHTML = `<div class="error-message">加载失败: ${error.message}</div>`;
listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`;
}
}
@@ -180,22 +266,12 @@ function renderVulnerabilities(vulnerabilities) {
const html = vulnerabilities.map(vuln => {
const severityClass = `severity-${vuln.severity}`;
const severityText = {
'critical': '严重',
'high': '高危',
'medium': '中危',
'low': '低危',
'info': '信息'
}[vuln.severity] || vuln.severity;
const statusText = {
'open': '待处理',
'confirmed': '已确认',
'fixed': '已修复',
'false_positive': '误报'
}[vuln.status] || vuln.status;
const createdDate = new Date(vuln.created_at).toLocaleString('zh-CN');
const severityText = vulnSeverityLabel(vuln.severity);
const statusText = vulnStatusLabel(vuln.status);
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
const dlTitle = escapeHtml(vulnT('vulnerabilityPage.downloadMarkdownTitle'));
const editTitle = escapeHtml(vulnT('common.edit'));
const deleteTitle = escapeHtml(vulnT('common.delete'));
return `
<div class="vulnerability-card ${severityClass}">
@@ -214,20 +290,20 @@ function renderVulnerabilities(vulnerabilities) {
</div>
</div>
<div class="vulnerability-actions" onclick="event.stopPropagation();">
<button class="btn-ghost" onclick="downloadVulnerabilityAsMarkdown('${vuln.id}', event)" title="下载Markdown">
<button class="btn-ghost" onclick="downloadVulnerabilityAsMarkdown('${vuln.id}', event)" title="${dlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="7 10 12 15 17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="btn-ghost" onclick="editVulnerability('${vuln.id}')" title="编辑">
<button class="btn-ghost" onclick="editVulnerability('${vuln.id}')" title="${editTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="btn-ghost" onclick="deleteVulnerability('${vuln.id}')" title="删除">
<button class="btn-ghost" onclick="deleteVulnerability('${vuln.id}')" title="${deleteTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -237,20 +313,27 @@ function renderVulnerabilities(vulnerabilities) {
<div class="vulnerability-content" id="content-${vuln.id}" style="display: none;">
${vuln.description ? `<div class="vulnerability-description">${escapeHtml(vuln.description)}</div>` : ''}
<div class="vulnerability-details">
<div class="detail-item"><strong>漏洞ID:</strong> <code>${escapeHtml(vuln.id)}</code></div>
${vuln.type ? `<div class="detail-item"><strong>类型:</strong> ${escapeHtml(vuln.type)}</div>` : ''}
${vuln.target ? `<div class="detail-item"><strong>目标:</strong> ${escapeHtml(vuln.target)}</div>` : ''}
<div class="detail-item"><strong>会话ID:</strong> <code>${escapeHtml(vuln.conversation_id)}</code></div>
${vulnDetailField(vulnT('vulnerabilityPage.detailVulnId'), vuln.id, true)}
${vuln.type ? vulnDetailField(vulnT('vulnerabilityPage.detailType'), vuln.type, false) : ''}
${vuln.target ? vulnDetailField(vulnT('vulnerabilityPage.detailTarget'), vuln.target, false) : ''}
${vulnDetailField(vulnT('vulnerabilityPage.detailConversationId'), vuln.conversation_id, true)}
${vuln.task_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskId'), vuln.task_id, true) : ''}
${vuln.task_queue_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskQueueId'), vuln.task_queue_id, true) : ''}
${vuln.conversation_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailConversationTag'), vuln.conversation_tag, false) : ''}
${vuln.task_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskTag'), vuln.task_tag, false) : ''}
</div>
${vuln.proof ? `<div class="vulnerability-proof"><strong>证明:</strong><pre>${escapeHtml(vuln.proof)}</pre></div>` : ''}
${vuln.impact ? `<div class="vulnerability-impact"><strong>影响:</strong> ${escapeHtml(vuln.impact)}</div>` : ''}
${vuln.recommendation ? `<div class="vulnerability-recommendation"><strong>修复建议:</strong> ${escapeHtml(vuln.recommendation)}</div>` : ''}
${vuln.proof ? `<div class="vulnerability-proof"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailProof'))}:</strong><pre>${escapeHtml(vuln.proof)}</pre></div>` : ''}
${vuln.impact ? `<div class="vulnerability-impact"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailImpact'))}:</strong> ${escapeHtml(vuln.impact)}</div>` : ''}
${vuln.recommendation ? `<div class="vulnerability-recommendation"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailRecommendation'))}:</strong> ${escapeHtml(vuln.recommendation)}</div>` : ''}
</div>
</div>
`;
}).join('');
listContainer.innerHTML = html;
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(listContainer);
}
}
// 渲染分页控件
@@ -277,9 +360,9 @@ function renderVulnerabilityPagination() {
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
paginationHTML += `
<div class="pagination-info">
<span>显示 ${start}-${end} / ${total} </span>
<span>${escapeHtml(vulnT('skillsPage.paginationShow', { start, end, total }))}</span>
<label class="pagination-page-size">
每页显示
${escapeHtml(vulnT('skillsPage.perPageLabel'))}
<select id="vulnerability-page-size-pagination" onchange="changeVulnerabilityPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
@@ -293,17 +376,20 @@ function renderVulnerabilityPagination() {
// 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页)
paginationHTML += `
<div class="pagination-controls">
<button class="btn-secondary" onclick="loadVulnerabilities(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button>
<button class="btn-secondary" onclick="loadVulnerabilities(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button>
<span class="pagination-page"> ${currentPage} / ${totalPages || 1} </span>
<button class="btn-secondary" onclick="loadVulnerabilities(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button>
<button class="btn-secondary" onclick="loadVulnerabilities(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>末页</button>
<button class="btn-secondary" onclick="loadVulnerabilities(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.firstPage'))}</button>
<button class="btn-secondary" onclick="loadVulnerabilities(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.prevPage'))}</button>
<span class="pagination-page">${escapeHtml(vulnT('skillsPage.pageOf', { current: currentPage, total: totalPages || 1 }))}</span>
<button class="btn-secondary" onclick="loadVulnerabilities(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.nextPage'))}</button>
<button class="btn-secondary" onclick="loadVulnerabilities(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.lastPage'))}</button>
</div>
`;
paginationHTML += '</div>';
paginationContainer.innerHTML = paginationHTML;
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(paginationContainer);
}
}
// 改变每页显示数量
@@ -334,10 +420,12 @@ async function changeVulnerabilityPageSize() {
// 显示添加漏洞模态框
function showAddVulnerabilityModal() {
currentVulnerabilityId = null;
document.getElementById('vulnerability-modal-title').textContent = (typeof window.t === 'function' ? window.t('vulnerability.addVuln') : '添加漏洞');
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.addVuln');
// 清空表单
document.getElementById('vulnerability-conversation-id').value = '';
document.getElementById('vulnerability-conversation-tag').value = '';
document.getElementById('vulnerability-task-tag').value = '';
document.getElementById('vulnerability-title').value = '';
document.getElementById('vulnerability-description').value = '';
document.getElementById('vulnerability-severity').value = '';
@@ -355,14 +443,16 @@ function showAddVulnerabilityModal() {
async function editVulnerability(id) {
try {
const response = await apiFetch(`/api/vulnerabilities/${id}`);
if (!response.ok) throw new Error('获取漏洞失败');
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
const vuln = await response.json();
currentVulnerabilityId = id;
document.getElementById('vulnerability-modal-title').textContent = (typeof window.t === 'function' ? window.t('vulnerability.editVuln') : '编辑漏洞');
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln');
// 填充表单
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
document.getElementById('vulnerability-title').value = vuln.title || '';
document.getElementById('vulnerability-description').value = vuln.description || '';
document.getElementById('vulnerability-severity').value = vuln.severity || '';
@@ -376,7 +466,7 @@ async function editVulnerability(id) {
document.getElementById('vulnerability-modal').style.display = 'block';
} catch (error) {
console.error('加载漏洞失败:', error);
alert('加载漏洞失败: ' + error.message);
alert(vulnT('vulnerability.loadFailed') + ': ' + error.message);
}
}
@@ -387,12 +477,14 @@ async function saveVulnerability() {
const severity = document.getElementById('vulnerability-severity').value;
if (!conversationId || !title || !severity) {
alert('请填写必填字段:会话ID、标题和严重程度');
alert(vulnT('vulnerabilityPage.saveRequiredFields'));
return;
}
const data = {
conversation_id: conversationId,
conversation_tag: document.getElementById('vulnerability-conversation-tag').value.trim(),
task_tag: document.getElementById('vulnerability-task-tag').value.trim(),
title: title,
description: document.getElementById('vulnerability-description').value.trim(),
severity: severity,
@@ -420,7 +512,7 @@ async function saveVulnerability() {
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '保存失败');
throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed'));
}
closeVulnerabilityModal();
@@ -430,13 +522,13 @@ async function saveVulnerability() {
loadVulnerabilities();
} catch (error) {
console.error('保存漏洞失败:', error);
alert('保存漏洞失败: ' + error.message);
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
}
}
// 删除漏洞
async function deleteVulnerability(id) {
if (!confirm('确定要删除此漏洞吗?')) {
if (!confirm(vulnT('vulnerability.deleteConfirm'))) {
return;
}
@@ -445,7 +537,7 @@ async function deleteVulnerability(id) {
method: 'DELETE'
});
if (!response.ok) throw new Error('删除失败');
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
loadVulnerabilityStats();
// 删除后,如果当前页没有数据了,回到上一页
@@ -458,7 +550,7 @@ async function deleteVulnerability(id) {
loadVulnerabilities();
} catch (error) {
console.error('删除漏洞失败:', error);
alert('删除漏洞失败: ' + error.message);
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
}
}
@@ -472,6 +564,9 @@ function closeVulnerabilityModal() {
function filterVulnerabilities() {
vulnerabilityFilters.id = document.getElementById('vulnerability-id-filter').value.trim();
vulnerabilityFilters.conversation_id = document.getElementById('vulnerability-conversation-filter').value.trim();
vulnerabilityFilters.task_id = document.getElementById('vulnerability-task-filter').value.trim();
vulnerabilityFilters.conversation_tag = document.getElementById('vulnerability-conversation-tag-filter').value.trim();
vulnerabilityFilters.task_tag = document.getElementById('vulnerability-task-tag-filter').value.trim();
vulnerabilityFilters.severity = document.getElementById('vulnerability-severity-filter').value;
vulnerabilityFilters.status = document.getElementById('vulnerability-status-filter').value;
@@ -486,12 +581,18 @@ function filterVulnerabilities() {
function clearVulnerabilityFilters() {
document.getElementById('vulnerability-id-filter').value = '';
document.getElementById('vulnerability-conversation-filter').value = '';
document.getElementById('vulnerability-task-filter').value = '';
document.getElementById('vulnerability-conversation-tag-filter').value = '';
document.getElementById('vulnerability-task-tag-filter').value = '';
document.getElementById('vulnerability-severity-filter').value = '';
document.getElementById('vulnerability-status-filter').value = '';
vulnerabilityFilters = {
id: '',
conversation_id: '',
task_id: '',
conversation_tag: '',
task_tag: '',
severity: '',
status: ''
};
@@ -532,67 +633,193 @@ function escapeHtml(text) {
return div.innerHTML;
}
// 将漏洞格式化为Markdown
/** 复制详情字段(编码由 encodeURIComponent 传入,避免引号截断) */
function vulnerabilityCopyEncoded(evt, encoded) {
if (evt && evt.stopPropagation) {
evt.stopPropagation();
}
let text = '';
try {
text = decodeURIComponent(encoded);
} catch (e) {
return;
}
const done = () => {
if (evt && evt.target && evt.target.closest) {
const btn = evt.target.closest('.vuln-detail-field__copy');
if (btn) {
const t0 = btn.getAttribute('title') || '';
btn.setAttribute('title', vulnT('common.copied'));
setTimeout(() => btn.setAttribute('title', t0), 1600);
}
}
};
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text).then(done).catch(() => {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
done();
} catch (err) {
console.error('copy failed', err);
}
});
} else {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
done();
} catch (err) {
console.error('copy failed', err);
}
}
}
function vulnDetailField(label, value, asCode) {
if (value === undefined || value === null || String(value) === '') {
return '';
}
const s = String(value);
const enc = encodeURIComponent(s);
const copyTitle = escapeHtml(vulnT('common.copy'));
const valueEl = asCode
? `<code class="vuln-detail-field-value">${escapeHtml(s)}</code>`
: `<span class="vuln-detail-field-value">${escapeHtml(s)}</span>`;
const copyBtn = `<button type="button" class="vuln-detail-field__copy" onclick="vulnerabilityCopyEncoded(event, '${enc}')" title="${copyTitle}" aria-label="${copyTitle}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>`;
return `<div class="vuln-detail-field">
<div class="vuln-detail-field__label">${escapeHtml(label)}</div>
<div class="vuln-detail-field__row">${valueEl}${copyBtn}</div>
</div>`;
}
// 将漏洞格式化为Markdown(章节标题随界面语言)
function formatVulnerabilityAsMarkdown(vuln) {
const severityText = {
'critical': '严重',
'high': '高危',
'medium': '中危',
'low': '低危',
'info': '信息'
}[vuln.severity] || vuln.severity;
const statusText = {
'open': '待处理',
'confirmed': '已确认',
'fixed': '已修复',
'false_positive': '误报'
}[vuln.status] || vuln.status;
const createdDate = new Date(vuln.created_at).toLocaleString('zh-CN');
const updatedDate = new Date(vuln.updated_at).toLocaleString('zh-CN');
const severityText = vulnSeverityLabel(vuln.severity);
const statusText = vulnStatusLabel(vuln.status);
const loc = vulnDateLocale();
const createdDate = new Date(vuln.created_at).toLocaleString(loc);
const updatedDate = new Date(vuln.updated_at).toLocaleString(loc);
const L = (k) => vulnT('vulnerabilityMd.' + k);
let markdown = `# ${vuln.title}\n\n`;
markdown += `## 基本信息\n\n`;
markdown += `- **漏洞ID**: \`${vuln.id}\`\n`;
markdown += `- **严重程度**: ${severityText}\n`;
markdown += `- **状态**: ${statusText}\n`;
markdown += `## ${L('headingBasic')}\n\n`;
markdown += `- **${L('labelId')}**: \`${vuln.id}\`\n`;
markdown += `- **${L('labelSeverity')}**: ${severityText}\n`;
markdown += `- **${L('labelStatus')}**: ${statusText}\n`;
if (vuln.type) {
markdown += `- **类型**: ${vuln.type}\n`;
markdown += `- **${L('labelType')}**: ${vuln.type}\n`;
}
if (vuln.target) {
markdown += `- **目标**: ${vuln.target}\n`;
markdown += `- **${L('labelTarget')}**: ${vuln.target}\n`;
}
markdown += `- **会话ID**: \`${vuln.conversation_id}\`\n`;
markdown += `- **创建时间**: ${createdDate}\n`;
markdown += `- **更新时间**: ${updatedDate}\n\n`;
markdown += `- **${L('labelConversationId')}**: \`${vuln.conversation_id}\`\n`;
if (vuln.task_id) {
markdown += `- **${L('labelTaskId')}**: \`${vuln.task_id}\`\n`;
}
if (vuln.task_queue_id) {
markdown += `- **${L('labelTaskQueueId')}**: \`${vuln.task_queue_id}\`\n`;
}
if (vuln.conversation_tag) {
markdown += `- **${L('labelConversationTag')}**: ${vuln.conversation_tag}\n`;
}
if (vuln.task_tag) {
markdown += `- **${L('labelTaskTag')}**: ${vuln.task_tag}\n`;
}
markdown += `- **${L('labelCreated')}**: ${createdDate}\n`;
markdown += `- **${L('labelUpdated')}**: ${updatedDate}\n\n`;
if (vuln.description) {
markdown += `## 描述\n\n${vuln.description}\n\n`;
markdown += `## ${L('headingDescription')}\n\n${vuln.description}\n\n`;
}
if (vuln.proof) {
markdown += `## 证明(POC\n\n\`\`\`\n${vuln.proof}\n\`\`\`\n\n`;
markdown += `## ${L('headingProof')}\n\n\`\`\`\n${vuln.proof}\n\`\`\`\n\n`;
}
if (vuln.impact) {
markdown += `## 影响\n\n${vuln.impact}\n\n`;
markdown += `## ${L('headingImpact')}\n\n${vuln.impact}\n\n`;
}
if (vuln.recommendation) {
markdown += `## 修复建议\n\n${vuln.recommendation}\n\n`;
markdown += `## ${L('headingRecommendation')}\n\n${vuln.recommendation}\n\n`;
}
return markdown;
}
function buildVulnerabilityFilterParams() {
const params = new URLSearchParams();
const keys = ['id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
keys.forEach((k) => {
if (vulnerabilityFilters[k]) {
params.append(k, vulnerabilityFilters[k]);
}
});
return params;
}
function triggerTextDownload(fileName, content) {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async function exportVulnerabilityReports() {
try {
const params = buildVulnerabilityFilterParams();
params.set('mode', 'summary');
params.set('group_by', 'conversation');
const response = await apiFetch(`/api/vulnerabilities/export?${params.toString()}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: vulnT('vulnerabilityPage.exportFailedMessage') }));
throw new Error(error.error || vulnT('vulnerabilityPage.exportFailedMessage'));
}
const data = await response.json();
const files = Array.isArray(data.files) ? data.files : [];
if (!files.length) {
alert(vulnT('vulnerabilityPage.exportNoResults'));
return;
}
files.forEach((file, idx) => {
setTimeout(() => triggerTextDownload(file.filename || `vulnerability-export-${idx + 1}.md`, file.content || ''), idx * 120);
});
if (files.length > 1) {
alert(vulnT('vulnerabilityPage.exportStarted', { count: files.length }));
}
} catch (error) {
console.error('导出漏洞报告失败:', error);
alert(vulnT('vulnerabilityPage.exportFailed') + ': ' + error.message);
}
}
// 下载漏洞为Markdown格式
async function downloadVulnerabilityAsMarkdown(id, event) {
try {
const response = await apiFetch(`/api/vulnerabilities/${id}`);
if (!response.ok) {
throw new Error('获取漏洞失败');
throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
}
const vuln = await response.json();
@@ -626,8 +853,8 @@ async function downloadVulnerabilityAsMarkdown(id, event) {
if (event && event.target) {
const button = event.target.closest('button');
if (button) {
const originalTitle = button.title || '下载Markdown';
button.title = '下载成功!';
const originalTitle = button.title || vulnT('vulnerabilityPage.downloadMarkdownTitle');
button.title = vulnT('vulnerabilityPage.downloadOkTitle');
setTimeout(() => {
button.title = originalTitle;
}, 2000);
@@ -635,7 +862,7 @@ async function downloadVulnerabilityAsMarkdown(id, event) {
}
} catch (error) {
console.error('下载失败:', error);
alert('下载失败: ' + error.message);
alert(vulnT('vulnerabilityPage.downloadFailed') + ': ' + error.message);
}
}
@@ -645,5 +872,12 @@ window.onclick = function(event) {
if (event.target === modal) {
closeVulnerabilityModal();
}
}
};
document.addEventListener('languagechange', function () {
const page = document.getElementById('page-vulnerabilities');
if (page && page.classList.contains('active')) {
loadVulnerabilities();
}
});
-11
View File
@@ -2881,17 +2881,6 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
} else if (_et === 'warning') {
appendTimelineItem('warning', '⚠️ ' + (_em || ''), '', _ed);
// ─── Eino recovery ───
} else if (_et === 'eino_recovery') {
var runIdx = _ed.runIndex != null ? _ed.runIndex : (_ed.einoRetry != null ? _ed.einoRetry + 1 : 1);
var maxRuns = _ed.maxRuns != null ? _ed.maxRuns : 3;
var recTitle = wsTOr('chat.einoRecoveryTitle', '') ||
('🔄 工具参数无效 · 第 ' + runIdx + '/' + maxRuns + ' 轮(已追加提示)');
if (typeof window.t === 'function') {
try { recTitle = window.t('chat.einoRecoveryTitle', { n: runIdx, max: maxRuns }); } catch (e) { /* */ }
}
appendTimelineItem('eino_recovery', recTitle, _em, _ed);
// ─── Tool calls ───
} else if (_et === 'tool_calls_detected' && _ed) {
var count = _ed.count || 0;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 85 KiB

+56 -25
View File
@@ -117,10 +117,9 @@
<div class="nav-item" data-page="hitl">
<div class="nav-item-content" data-title="人机协同" onclick="switchPage('hitl')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="7" r="3"></circle>
<circle cx="16" cy="7" r="3"></circle>
<path d="M2 20c0-3 2.5-5 6-5s6 2 6 5"></path>
<path d="M10 20h12"></path>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<polyline points="16 11 18 13 22 9"></polyline>
</svg>
<span data-i18n="nav.hitl">人机协同</span>
</div>
@@ -511,8 +510,8 @@
<div id="conversations-list" class="conversations-list"></div>
</div>
</div>
<div class="hitl-sidebar-card" id="hitl-sidebar-card">
<div class="hitl-sidebar-card-header">
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
<div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()">
<div class="hitl-sidebar-heading">
<span class="hitl-sidebar-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -525,24 +524,28 @@
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
</div>
</div>
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
<span data-i18n="chat.hitlApply">应用</span>
</button>
</div>
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
<div class="hitl-sidebar-config">
<div class="hitl-config-field">
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.hitlModeLabel">模式</label>
<select id="hitl-mode-select" class="hitl-config-select">
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
</select>
<div class="hitl-sidebar-header-actions">
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="event.stopPropagation(); window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
<span data-i18n="chat.hitlApply">应用</span>
</button>
</div>
<div class="hitl-config-field hitl-config-field--tools">
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
</div>
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
<div class="hitl-sidebar-config">
<div class="hitl-config-field">
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.hitlModeLabel">模式</label>
<select id="hitl-mode-select" class="hitl-config-select">
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
</select>
</div>
<div class="hitl-config-field hitl-config-field--tools">
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
</div>
</div>
</div>
</div>
@@ -731,7 +734,7 @@
</div>
<div class="page-content">
<div class="settings-section">
<h3 data-i18n="hitl.pendingTitle">待处理中断</h3>
<h3 data-i18n="hitl.pendingTitle">待处理审批</h3>
<div id="hitl-pending-list" class="hitl-pending-list"></div>
</div>
</div>
@@ -1094,6 +1097,18 @@
<span data-i18n="vulnerabilityPage.conversationId">会话ID</span>
<input type="text" id="vulnerability-conversation-filter" data-i18n="vulnerabilityPage.filterConversation" data-i18n-attr="placeholder" placeholder="筛选特定会话" />
</label>
<label>
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务ID/队列ID</span>
<input type="text" id="vulnerability-task-filter" data-i18n="vulnerabilityPage.filterTaskOrQueue" data-i18n-attr="placeholder" placeholder="筛选任务ID或队列ID" />
</label>
<label>
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
<input type="text" id="vulnerability-conversation-tag-filter" data-i18n="vulnerabilityPage.filterConversationTag" data-i18n-attr="placeholder" placeholder="筛选对话标签" />
</label>
<label>
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
<input type="text" id="vulnerability-task-tag-filter" data-i18n="vulnerabilityPage.filterTaskTag" data-i18n-attr="placeholder" placeholder="筛选任务标签" />
</label>
<label>
<span data-i18n="vulnerabilityPage.severity">严重程度</span>
<select id="vulnerability-severity-filter">
@@ -1117,6 +1132,7 @@
</label>
<button class="btn-secondary" onclick="filterVulnerabilities()" data-i18n="vulnerabilityPage.filter">筛选</button>
<button class="btn-secondary" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
<button class="btn-primary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
</div>
</div>
@@ -2408,6 +2424,13 @@
</div>
</div>
</div>
<div class="context-menu-item" onclick="navigateToVulnerabilitiesForContextConversation()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.viewVulnerabilities">查看漏洞</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" onclick="renameConversation()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -2596,6 +2619,14 @@
<label for="vulnerability-conversation-id"><span data-i18n="vulnerabilityModal.conversationId">会话ID</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-conversation-id" data-i18n="vulnerabilityModal.conversationIdPlaceholder" data-i18n-attr="placeholder" placeholder="输入会话ID" required />
</div>
<div class="form-group">
<label for="vulnerability-conversation-tag" data-i18n="vulnerabilityModal.conversationTag">对话标签</label>
<input type="text" id="vulnerability-conversation-tag" data-i18n="vulnerabilityModal.conversationTagPlaceholder" data-i18n-attr="placeholder" placeholder="如:红队演练A、客户A周报" />
</div>
<div class="form-group">
<label for="vulnerability-task-tag" data-i18n="vulnerabilityModal.taskTag">任务标签</label>
<input type="text" id="vulnerability-task-tag" data-i18n="vulnerabilityModal.taskTagPlaceholder" data-i18n-attr="placeholder" placeholder="如:批量扫描Q2、专项复测" />
</div>
<div class="form-group">
<label for="vulnerability-title"><span data-i18n="vulnerabilityModal.title">标题</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-title" data-i18n="vulnerabilityModal.titlePlaceholder" data-i18n-attr="placeholder" placeholder="漏洞标题" required />
@@ -2814,7 +2845,7 @@
<script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/vulnerability.js?v=4"></script>
<script src="/static/js/vulnerability.js?v=7"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>