mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-01 02:05:34 +02:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd4bbe8d76 | |||
| d80651e4d8 | |||
| f920ff0a5d | |||
| ce8b57501d | |||
| ecb38a3959 | |||
| e69fdb71ca | |||
| 6aa1631748 | |||
| 52de3b0f41 | |||
| e537e55198 | |||
| dc20b4804e | |||
| 6245d69364 | |||
| ede32951bf | |||
| 866a8ebccf | |||
| 276b3f7ef5 | |||
| 81e461db54 | |||
| 02cd488a3d | |||
| b4b2f55665 | |||
| 7aa0ebea6d | |||
| 63ef4399f8 | |||
| 553d0ed6bf | |||
| d92bbbea07 | |||
| f89ad1b42d | |||
| bbe14c1861 | |||
| 2fc37fefd1 | |||
| ded8ac5a3f | |||
| bf44cf58d3 | |||
| 6d390e80d5 | |||
| cfc49ba16f | |||
| d03f2fcf2b | |||
| 6e67684bba | |||
| 8f9d2f381a | |||
| 89c275269f | |||
| cb4900c61d | |||
| 5c192cd308 | |||
| 8571e41138 | |||
| e1a74b29b1 | |||
| 39f1c72755 | |||
| dd3621e89d | |||
| 0bcb16e021 | |||
| ed64803a51 | |||
| 25e03dee84 | |||
| 58dcafd15f | |||
| 997c4e7262 | |||
| ac370b0ada | |||
| 017db2b9a8 | |||
| 86b4803683 | |||
| 4d98264fc3 | |||
| fd1de4ea94 | |||
| 41ba3baca9 | |||
| 2e908daebb | |||
| c1763e1b9a | |||
| 70e5d28619 | |||
| 49990ecb4f | |||
| c91806c0c4 | |||
| e537236bf3 | |||
| 7eeffb1933 | |||
| 0556b29d40 | |||
| be3c0cfa64 | |||
| 8e5f40d226 | |||
| 4b6719a6f3 | |||
| 7c8f3228f8 | |||
| 537843b6b8 | |||
| 4a57574cf9 | |||
| 0168530084 | |||
| 4184a7b6f0 | |||
| fb3b4dd6e5 | |||
| 7e4a8db7af | |||
| 6a72c95b9f | |||
| 447be050cd | |||
| 9b75c43f7b | |||
| a443454753 | |||
| 08822ba5df | |||
| eda75fb98f | |||
| e6978a7994 | |||
| 1db0f4740f | |||
| 6e4ff96dcd | |||
| 95470fefbc | |||
| 5e075bb198 |
+65
-3
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.46"
|
||||
version: "v1.6.48"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -102,9 +102,69 @@ agent:
|
||||
|
||||
system_prompt_path: ""
|
||||
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
||||
# 非白名单工具在审批方=审计 Agent 时,按会话 HITL 模式选用提示词:
|
||||
# approval → audit_agent_prompt
|
||||
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
|
||||
hitl:
|
||||
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
||||
retention_days: 90
|
||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||
tool_whitelist: [read_file, list_dir, glob, grep]
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||
# audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑
|
||||
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
|
||||
|
||||
audit_agent_prompt: |-
|
||||
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||
|
||||
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||
|
||||
裁决基调(默认放行):
|
||||
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
||||
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
||||
- 仅在「可能对系统造成实质影响」时 → reject
|
||||
|
||||
必须 reject 的高危情形(示例,非穷举):
|
||||
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
||||
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
||||
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
||||
- 明显越权:与任务/授权目标无关的破坏性操作
|
||||
|
||||
不应单独作为 reject 理由的情形:
|
||||
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
||||
- 参数略显宽泛但无明确破坏意图
|
||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
|
||||
|
||||
仅输出一行 JSON,不要 markdown 代码块:
|
||||
{"decision":"approve"|"reject","comment":"简要理由"}
|
||||
audit_agent_prompt_review_edit: |-
|
||||
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||
|
||||
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||
|
||||
裁决基调(默认放行):
|
||||
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
||||
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
||||
- 仅在「可能对系统造成实质影响」时 → reject;参数可安全收窄时优先 approve + editedArguments
|
||||
|
||||
必须 reject 的高危情形(示例,非穷举):
|
||||
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
||||
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
||||
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
||||
- 明显越权:与任务/授权目标无关的破坏性操作
|
||||
|
||||
不应单独作为 reject 理由的情形:
|
||||
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
||||
- 参数略显宽泛但无明确破坏意图(应收窄参数后 approve)
|
||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
|
||||
|
||||
仅输出一行 JSON,不要 markdown 代码块:
|
||||
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
|
||||
|
||||
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
||||
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
||||
- 只做最小必要修改以收窄范围、消除风险(如限制 path、去掉危险 flag)
|
||||
- 禁止扩大攻击面:不得扩大目标范围、提升权限或引入破坏性参数
|
||||
- 无法安全改参且存在上述高危情形时应 reject,不要勉强 approve
|
||||
# 多代理与 Eino 单代理(CloudWeGo Eino ADK;单代理入口 /api/eino-agent*,多代理 /api/multi-agent*)
|
||||
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
||||
# Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体 orchestration 中指定;机器人按 robot_default_agent_mode
|
||||
@@ -114,7 +174,8 @@ multi_agent:
|
||||
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||
plan_execute_loop_max_iterations: 0
|
||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
|
||||
user_verbatim_anchor_max_runes: 0 # 主代理 system 中逐轮保留用户原文(压缩后刷新);0=不截断(默认),>0=总字符上限,负数=禁用
|
||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||
without_write_todos: false
|
||||
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
||||
@@ -149,6 +210,7 @@ multi_agent:
|
||||
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
||||
run_retry_max_attempts: 0 # 429/5xx/网络抖动时可退避重试次数(run loop + summarization 共用 isEinoTransientRunError);0=默认 10
|
||||
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
||||
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
|
||||
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
||||
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
|
||||
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 265 KiB |
@@ -21,11 +21,13 @@ import (
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/einoobserve"
|
||||
"cyberstrike-ai/internal/handler"
|
||||
"cyberstrike-ai/internal/hitl"
|
||||
"cyberstrike-ai/internal/knowledge"
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/monitor"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
"cyberstrike-ai/internal/robot"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/skillpackage"
|
||||
@@ -67,6 +69,10 @@ type App struct {
|
||||
|
||||
// New 创建新应用
|
||||
func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
|
||||
if err := multiagent.InitADK(); err != nil {
|
||||
return nil, fmt.Errorf("初始化 Eino ADK: %w", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
@@ -104,6 +110,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
monitorRetention.PurgeExpired()
|
||||
monitor.StartRetentionLoop(monitorRetention, log.Logger)
|
||||
|
||||
hitlRetention := hitl.NewService(db, cfg, log.Logger)
|
||||
hitlRetention.PurgeExpired()
|
||||
hitl.StartRetentionLoop(hitlRetention, log.Logger)
|
||||
|
||||
// 创建MCP服务器(带数据库持久化)
|
||||
mcpServer := mcp.NewServerWithStorage(log.Logger, db)
|
||||
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
|
||||
@@ -135,6 +145,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
externalMCPMgr.StartAllEnabled()
|
||||
}
|
||||
|
||||
execReconciler := monitor.NewExecutionReconciler(db, mcpServer, externalMCPMgr, log.Logger)
|
||||
execReconciler.ReconcileOnStartup()
|
||||
monitor.StartStaleRunningReconcileLoop(execReconciler, log.Logger)
|
||||
|
||||
// 创建Agent
|
||||
maxIterations := cfg.Agent.MaxIterations
|
||||
if maxIterations <= 0 {
|
||||
@@ -354,6 +368,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||
configHandler.SetAudit(auditSvc)
|
||||
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
||||
agentHandler.SetHitlAuditStrategySaver(configHandler)
|
||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||
externalMCPHandler.SetAudit(auditSvc)
|
||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||
@@ -803,11 +818,18 @@ func setupRoutes(
|
||||
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
||||
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
||||
protected.GET("/hitl/pending", agentHandler.ListHITLPending)
|
||||
protected.GET("/hitl/logs", agentHandler.ListHITLLogs)
|
||||
protected.DELETE("/hitl/logs", agentHandler.DeleteHITLLogs)
|
||||
protected.GET("/hitl/logs/:id", agentHandler.GetHITLLog)
|
||||
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.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
|
||||
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
|
||||
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
|
||||
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
|
||||
protected.PUT("/hitl/audit-strategy", agentHandler.UpdateHITLAuditStrategy)
|
||||
// Agent Loop 取消与任务列表
|
||||
protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop)
|
||||
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
|
||||
|
||||
+197
-28
@@ -96,9 +96,12 @@ type MultiAgentConfig struct {
|
||||
// OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。
|
||||
OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"`
|
||||
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
|
||||
// SubAgentUserContextMaxRunes caps the user-context supplement appended to task descriptions for sub-agents.
|
||||
// 0 (default) uses the built-in default of 2000 runes; negative value disables injection entirely.
|
||||
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
||||
// 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
|
||||
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
||||
// UserVerbatimAnchorMaxRunes injects all user turns verbatim into system prompt (survives summarization refresh).
|
||||
// 0 (default) = no cap; >0 = total rune cap; negative disables anchor injection.
|
||||
UserVerbatimAnchorMaxRunes int `yaml:"user_verbatim_anchor_max_runes,omitempty" json:"user_verbatim_anchor_max_runes,omitempty"`
|
||||
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
||||
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
||||
@@ -107,6 +110,16 @@ type MultiAgentConfig struct {
|
||||
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
|
||||
}
|
||||
|
||||
// UserVerbatimAnchorMaxRunesEffective returns max runes for user verbatim anchor; 0 = unlimited; negative = disabled.
|
||||
func (c MultiAgentConfig) UserVerbatimAnchorMaxRunesEffective() int {
|
||||
return c.UserVerbatimAnchorMaxRunes
|
||||
}
|
||||
|
||||
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
|
||||
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
||||
return c.SubAgentUserContextMaxRunes
|
||||
}
|
||||
|
||||
// MultiAgentEinoCallbacksConfig enables Eino unified callbacks on each ADK agent run (deep / plan_execute / supervisor / eino_single).
|
||||
// Modes: log_only (zap + optional OTel; no SSE to browser), sse (adds client SSE eino_trace_* when sse_trace_to_client), full (sse rules + stream callback copies closed).
|
||||
type MultiAgentEinoCallbacksConfig struct {
|
||||
@@ -270,6 +283,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
||||
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
|
||||
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
||||
// EmptyResponseContinueMaxAttempts Run 成功但未捕获助手正文时 Handler 层退避续跑次数;0=默认 5。
|
||||
EmptyResponseContinueMaxAttempts int `yaml:"empty_response_continue_max_attempts,omitempty" json:"empty_response_continue_max_attempts,omitempty"`
|
||||
// TaskToolDescriptionPrefix when non-empty sets deep.Config TaskToolDescriptionGenerator (sub-agent names appended).
|
||||
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
|
||||
}
|
||||
@@ -490,6 +505,17 @@ type RobotWecomConfig struct {
|
||||
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
|
||||
}
|
||||
|
||||
// ValidateWecomConfig 校验企业微信机器人配置;启用时必须配置 token,否则回调无法防伪造。
|
||||
func ValidateWecomConfig(w RobotWecomConfig) error {
|
||||
if !w.Enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(w.Token) == "" {
|
||||
return fmt.Errorf("robots.wecom.enabled 为 true 时必须配置 robots.wecom.token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RobotDingtalkConfig 钉钉机器人配置
|
||||
type RobotDingtalkConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
@@ -614,10 +640,100 @@ type AgentConfig struct {
|
||||
}
|
||||
|
||||
// HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。
|
||||
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效;其他字段若仅改文件仍需重启。
|
||||
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效。
|
||||
// audit_agent_prompt / audit_agent_prompt_review_edit 可在人机协同页编辑并立即生效;空则使用内置默认。
|
||||
type HitlConfig struct {
|
||||
// ToolWhitelist 全局免审批工具名(与每条会话配置的 sensitiveTools 语义相同:白名单内工具不触发 HITL)。
|
||||
// ToolWhitelist 全局免审批工具名(与白名单内工具不触发 HITL 审批)。
|
||||
ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"`
|
||||
// AuditAgentPrompt 审批模式(approval)下审计 Agent 系统提示词。
|
||||
AuditAgentPrompt string `yaml:"audit_agent_prompt,omitempty" json:"audit_agent_prompt,omitempty"`
|
||||
// AuditAgentPromptReviewEdit 审查编辑模式(review_edit)下审计 Agent 系统提示词。
|
||||
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
|
||||
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
|
||||
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||
}
|
||||
|
||||
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
|
||||
func (h HitlConfig) RetentionDaysEffective() int {
|
||||
if h.RetentionDays == nil {
|
||||
return 90
|
||||
}
|
||||
if *h.RetentionDays < 0 {
|
||||
return 0
|
||||
}
|
||||
return *h.RetentionDays
|
||||
}
|
||||
|
||||
const hitlAuditAgentPromptBase = `你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||
|
||||
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||
|
||||
裁决基调(默认放行):
|
||||
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
||||
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
||||
- 仅在「可能对系统造成实质影响」时 → reject
|
||||
|
||||
必须 reject 的高危情形(示例,非穷举):
|
||||
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
||||
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
||||
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
||||
- 明显越权:与任务/授权目标无关的破坏性操作
|
||||
|
||||
不应单独作为 reject 理由的情形:
|
||||
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
||||
- 参数略显宽泛但无明确破坏意图(审查编辑模式可收窄参数后 approve)
|
||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点`
|
||||
|
||||
const hitlAuditAgentPromptApprovalOutput = `
|
||||
仅输出一行 JSON,不要 markdown 代码块:
|
||||
{"decision":"approve"|"reject","comment":"简要理由"}`
|
||||
|
||||
const hitlAuditAgentPromptReviewEditOutput = `
|
||||
仅输出一行 JSON,不要 markdown 代码块:
|
||||
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
|
||||
|
||||
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
||||
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
||||
- 只做最小必要修改以收窄范围、消除风险(如限制 path、去掉危险 flag)
|
||||
- 禁止扩大攻击面:不得扩大目标范围、提升权限或引入破坏性参数
|
||||
- 无法安全改参时应 reject,不要勉强 approve`
|
||||
|
||||
// DefaultHitlAuditAgentPrompt 内置审批模式审计 Agent 提示词。
|
||||
func DefaultHitlAuditAgentPrompt() string {
|
||||
return hitlAuditAgentPromptBase + hitlAuditAgentPromptApprovalOutput
|
||||
}
|
||||
|
||||
// DefaultHitlAuditAgentPromptReviewEdit 内置审查编辑模式审计 Agent 提示词。
|
||||
func DefaultHitlAuditAgentPromptReviewEdit() string {
|
||||
return hitlAuditAgentPromptBase + hitlAuditAgentPromptReviewEditOutput
|
||||
}
|
||||
|
||||
// EffectiveAuditAgentPrompt 返回审批模式生效的审计 Agent 提示词。
|
||||
func (c HitlConfig) EffectiveAuditAgentPrompt() string {
|
||||
return c.EffectiveAuditAgentPromptForMode("approval")
|
||||
}
|
||||
|
||||
// EffectiveAuditAgentPromptForMode 按 HITL 模式返回生效的审计 Agent 提示词。
|
||||
func (c HitlConfig) EffectiveAuditAgentPromptForMode(mode string) string {
|
||||
if normalizeHitlModeForPrompt(mode) == "review_edit" {
|
||||
if s := strings.TrimSpace(c.AuditAgentPromptReviewEdit); s != "" {
|
||||
return s
|
||||
}
|
||||
return DefaultHitlAuditAgentPromptReviewEdit()
|
||||
}
|
||||
if s := strings.TrimSpace(c.AuditAgentPrompt); s != "" {
|
||||
return s
|
||||
}
|
||||
return DefaultHitlAuditAgentPrompt()
|
||||
}
|
||||
|
||||
func normalizeHitlModeForPrompt(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "review_edit":
|
||||
return "review_edit"
|
||||
default:
|
||||
return "approval"
|
||||
}
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
@@ -804,33 +920,13 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
// 如果配置了工具目录,从目录加载工具配置
|
||||
if cfg.Security.ToolsDir != "" {
|
||||
configDir := filepath.Dir(path)
|
||||
toolsDir := cfg.Security.ToolsDir
|
||||
|
||||
// 如果是相对路径,相对于配置文件所在目录
|
||||
if !filepath.IsAbs(toolsDir) {
|
||||
toolsDir = filepath.Join(configDir, toolsDir)
|
||||
}
|
||||
|
||||
tools, err := LoadToolsFromDir(toolsDir)
|
||||
inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...)
|
||||
toolsDir := ResolveToolsDir(cfg.Security.ToolsDir, path)
|
||||
merged, err := MergeToolsFromDir(toolsDir, inlineTools)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("从工具目录加载工具配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 合并工具配置:目录中的工具优先,主配置中的工具作为补充
|
||||
existingTools := make(map[string]bool)
|
||||
for _, tool := range tools {
|
||||
existingTools[tool.Name] = true
|
||||
}
|
||||
|
||||
// 添加主配置中不存在于目录中的工具(向后兼容)
|
||||
for _, tool := range cfg.Security.Tools {
|
||||
if !existingTools[tool.Name] {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Security.Tools = tools
|
||||
cfg.Security.Tools = merged
|
||||
}
|
||||
|
||||
// 外部 MCP:迁移 + 环境变量展开
|
||||
@@ -874,6 +970,10 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateWecomConfig(cfg.Robots.Wecom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -1098,6 +1198,75 @@ func PrintMCPConfigJSON(mcp MCPConfig) {
|
||||
fmt.Println("----------------------------------------------------------------")
|
||||
}
|
||||
|
||||
// ResolveToolsDir 将 tools_dir 解析为绝对路径(相对路径相对于 configPath 所在目录)。
|
||||
func ResolveToolsDir(toolsDir, configPath string) string {
|
||||
toolsDir = strings.TrimSpace(toolsDir)
|
||||
if toolsDir == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(toolsDir) {
|
||||
return toolsDir
|
||||
}
|
||||
return filepath.Join(filepath.Dir(configPath), toolsDir)
|
||||
}
|
||||
|
||||
// MergeToolsFromDir 从目录加载工具并与 inline 列表合并:目录中的工具优先,主配置中的工具作为补充。
|
||||
func MergeToolsFromDir(toolsDir string, inlineTools []ToolConfig) ([]ToolConfig, error) {
|
||||
dirTools, err := LoadToolsFromDir(toolsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing := make(map[string]bool, len(dirTools))
|
||||
for _, tool := range dirTools {
|
||||
existing[tool.Name] = true
|
||||
}
|
||||
merged := append([]ToolConfig(nil), dirTools...)
|
||||
for _, tool := range inlineTools {
|
||||
if !existing[tool.Name] {
|
||||
merged = append(merged, tool)
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// loadInlineSecurityToolsFromYAML 读取 config.yaml 中 security.tools(不含 tools_dir 扫描结果)。
|
||||
func loadInlineSecurityToolsFromYAML(configPath string) ([]ToolConfig, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
var partial struct {
|
||||
Security struct {
|
||||
Tools []ToolConfig `yaml:"tools"`
|
||||
} `yaml:"security"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &partial); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
if partial.Security.Tools == nil {
|
||||
return []ToolConfig{}, nil
|
||||
}
|
||||
return partial.Security.Tools, nil
|
||||
}
|
||||
|
||||
// ReloadSecurityToolsFromDir 从 tools_dir 重新加载工具并更新 cfg.Security.Tools(ApplyConfig 热重载用)。
|
||||
func ReloadSecurityToolsFromDir(cfg *Config, configPath string) error {
|
||||
if cfg == nil || strings.TrimSpace(cfg.Security.ToolsDir) == "" {
|
||||
return nil
|
||||
}
|
||||
inlineTools, err := loadInlineSecurityToolsFromYAML(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolsDir := ResolveToolsDir(cfg.Security.ToolsDir, configPath)
|
||||
merged, err := MergeToolsFromDir(toolsDir, inlineTools)
|
||||
if err != nil {
|
||||
return fmt.Errorf("从工具目录加载工具配置失败: %w", err)
|
||||
}
|
||||
cfg.Security.Tools = merged
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadToolsFromDir 从目录加载所有工具配置文件
|
||||
func LoadToolsFromDir(dir string) ([]ToolConfig, error) {
|
||||
var tools []ToolConfig
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateWecomConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg RobotWecomConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "disabled without token",
|
||||
cfg: RobotWecomConfig{Enabled: false, Token: ""},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "enabled with token",
|
||||
cfg: RobotWecomConfig{Enabled: true, Token: "secret"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "enabled without token",
|
||||
cfg: RobotWecomConfig{Enabled: true, Token: ""},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "enabled with whitespace token",
|
||||
cfg: RobotWecomConfig{Enabled: true, Token: " "},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := ValidateWecomConfig(tt.cfg)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ValidateWecomConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReloadSecurityToolsFromDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
toolsDir := filepath.Join(root, "tools")
|
||||
if err := os.MkdirAll(toolsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(`security:
|
||||
tools_dir: tools
|
||||
tools:
|
||||
- name: inline-only
|
||||
command: inline-cmd
|
||||
enabled: true
|
||||
description: inline tool
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeTool := func(name, command string) {
|
||||
t.Helper()
|
||||
content := "name: " + name + "\ncommand: " + command + "\nenabled: true\ndescription: test\n"
|
||||
if err := os.WriteFile(filepath.Join(toolsDir, name+".yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
writeTool("alpha", "alpha-cmd")
|
||||
|
||||
cfg := &Config{
|
||||
Security: SecurityConfig{
|
||||
ToolsDir: "tools",
|
||||
Tools: []ToolConfig{
|
||||
{Name: "stale", Command: "stale-cmd", Enabled: true, Description: "should be removed"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := ReloadSecurityToolsFromDir(cfg, configPath); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if len(cfg.Security.Tools) != 2 {
|
||||
t.Fatalf("expected 2 tools, got %d", len(cfg.Security.Tools))
|
||||
}
|
||||
|
||||
names := map[string]string{}
|
||||
for _, tool := range cfg.Security.Tools {
|
||||
names[tool.Name] = tool.Command
|
||||
}
|
||||
if names["alpha"] != "alpha-cmd" {
|
||||
t.Fatalf("alpha tool missing or wrong command: %#v", names)
|
||||
}
|
||||
if names["inline-only"] != "inline-cmd" {
|
||||
t.Fatalf("inline-only tool missing: %#v", names)
|
||||
}
|
||||
if _, ok := names["stale"]; ok {
|
||||
t.Fatal("stale in-memory tool should not survive reload")
|
||||
}
|
||||
|
||||
writeTool("beta", "beta-cmd")
|
||||
if err := ReloadSecurityToolsFromDir(cfg, configPath); err != nil {
|
||||
t.Fatalf("second reload: %v", err)
|
||||
}
|
||||
if len(cfg.Security.Tools) != 3 {
|
||||
t.Fatalf("expected 3 tools after add, got %d", len(cfg.Security.Tools))
|
||||
}
|
||||
foundBeta := false
|
||||
for _, tool := range cfg.Security.Tools {
|
||||
if tool.Name == "beta" {
|
||||
foundBeta = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundBeta {
|
||||
t.Fatal("beta tool not found after second reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeToolsFromDir_DirOverridesInline(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
toolsDir := filepath.Join(root, "tools")
|
||||
if err := os.MkdirAll(toolsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "name: shared\ncommand: dir-cmd\nenabled: true\ndescription: from dir\n"
|
||||
if err := os.WriteFile(filepath.Join(toolsDir, "shared.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
inline := []ToolConfig{
|
||||
{Name: "shared", Command: "inline-cmd", Enabled: true, Description: "from inline"},
|
||||
}
|
||||
merged, err := MergeToolsFromDir(toolsDir, inline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(merged))
|
||||
}
|
||||
if merged[0].Command != "dir-cmd" {
|
||||
t.Fatalf("dir tool should win, got command %q", merged[0].Command)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,6 +14,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProjectFilterUnbound 列表 API 中 project_id=__none__ 表示仅未绑定项目的对话。
|
||||
const ProjectFilterUnbound = "__none__"
|
||||
|
||||
// Conversation 对话
|
||||
type Conversation struct {
|
||||
ID string `json:"id"`
|
||||
@@ -361,20 +365,44 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func conversationProjectIDColumn(alias string) string {
|
||||
if alias != "" {
|
||||
return alias + ".project_id"
|
||||
}
|
||||
return "project_id"
|
||||
}
|
||||
|
||||
func appendConversationProjectFilter(where string, args []interface{}, projectID, alias string) (string, []interface{}) {
|
||||
pid := strings.TrimSpace(projectID)
|
||||
if pid == "" {
|
||||
return where, args
|
||||
}
|
||||
col := conversationProjectIDColumn(alias)
|
||||
if pid == ProjectFilterUnbound {
|
||||
return where + fmt.Sprintf(" AND (%s IS NULL OR TRIM(COALESCE(%s, '')) = '')", col, col), args
|
||||
}
|
||||
return where + fmt.Sprintf(" AND %s = ?", col), append(args, pid)
|
||||
}
|
||||
|
||||
// CountConversations 统计对话数量。
|
||||
func (db *DB) CountConversations(search string) (int, error) {
|
||||
func (db *DB) CountConversations(search, projectID string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if search != "" {
|
||||
searchPattern := "%" + search + "%"
|
||||
err = db.QueryRow(
|
||||
`SELECT COUNT(*) FROM conversations c
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)`,
|
||||
searchPattern, searchPattern,
|
||||
).Scan(&count)
|
||||
where := ` WHERE (c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?))`
|
||||
args := []interface{}{searchPattern, searchPattern}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM conversations c`+where, args...).Scan(&count)
|
||||
} else {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`).Scan(&count)
|
||||
where := ""
|
||||
args := []interface{}{}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "")
|
||||
if where != "" {
|
||||
where = " WHERE" + strings.TrimPrefix(where, " AND")
|
||||
}
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`+where, args...).Scan(&count)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计对话失败: %w", err)
|
||||
@@ -395,7 +423,7 @@ func conversationOrderClause(sortBy, tableAlias string) string {
|
||||
}
|
||||
|
||||
// ListConversations 列出所有对话
|
||||
func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Conversation, error) {
|
||||
func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID string) ([]*Conversation, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
@@ -403,20 +431,30 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Co
|
||||
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
||||
searchPattern := "%" + search + "%"
|
||||
orderClause := conversationOrderClause(sortBy, "c")
|
||||
where := ` WHERE (c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?))`
|
||||
args := []interface{}{searchPattern, searchPattern}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err = db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
||||
FROM conversations c
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
||||
FROM conversations c`+where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
searchPattern, searchPattern, limit, offset,
|
||||
args...,
|
||||
)
|
||||
} else {
|
||||
orderClause := conversationOrderClause(sortBy, "")
|
||||
where := ""
|
||||
args := []interface{}{}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "")
|
||||
if where != "" {
|
||||
where = " WHERE" + strings.TrimPrefix(where, " AND")
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
rows, err = db.Query(
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?",
|
||||
limit, offset,
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||
args...,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,23 +510,30 @@ const ungroupedConversationsSQL = `
|
||||
)`
|
||||
|
||||
// CountUngroupedConversations 统计不在任何分组中的对话数量。
|
||||
func (db *DB) CountUngroupedConversations() (int, error) {
|
||||
func (db *DB) CountUngroupedConversations(projectID string) (int, error) {
|
||||
where := ungroupedConversationsSQL
|
||||
args := []interface{}{}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) ` + ungroupedConversationsSQL).Scan(&count); err != nil {
|
||||
if err := db.QueryRow(`SELECT COUNT(*) `+where, args...).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计未分组对话失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
|
||||
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*Conversation, error) {
|
||||
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID string) ([]*Conversation, error) {
|
||||
orderClause := conversationOrderClause(sortBy, "c")
|
||||
where := ungroupedConversationsSQL
|
||||
args := []interface{}{}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||
ungroupedConversationsSQL+`
|
||||
where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
limit, offset,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
|
||||
@@ -533,6 +578,19 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*C
|
||||
return conversations, rows.Err()
|
||||
}
|
||||
|
||||
// GetConversationTitle 获取对话标题(轻量查询,不加载消息)
|
||||
func (db *DB) GetConversationTitle(id string) (string, error) {
|
||||
var title string
|
||||
err := db.QueryRow("SELECT title FROM conversations WHERE id = ?", id).Scan(&title)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("对话不存在")
|
||||
}
|
||||
return "", fmt.Errorf("查询对话标题失败: %w", err)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
// UpdateConversationTitle 更新对话标题
|
||||
func (db *DB) UpdateConversationTitle(id, title string) error {
|
||||
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
||||
@@ -1013,6 +1071,77 @@ type ProcessDetail struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// GetTurnUserMessage 返回锚点消息所在轮次中的用户原文(最近一条 user 消息,不含完整历史)。
|
||||
func (db *DB) GetTurnUserMessage(conversationID, anchorMessageID string) (string, error) {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
anchorMessageID = strings.TrimSpace(anchorMessageID)
|
||||
if conversationID == "" || anchorMessageID == "" {
|
||||
return "", nil
|
||||
}
|
||||
var content string
|
||||
err := db.QueryRow(`
|
||||
SELECT m.content FROM messages m
|
||||
WHERE m.conversation_id = ? AND m.role = 'user'
|
||||
AND m.created_at <= COALESCE((SELECT created_at FROM messages WHERE id = ? AND conversation_id = ?), m.created_at)
|
||||
ORDER BY m.created_at DESC, m.rowid DESC
|
||||
LIMIT 1`, conversationID, anchorMessageID, conversationID).Scan(&content)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("query turn user message: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// AssistantCognitionTexts 单条助手消息上的思考/推理/规划文本。
|
||||
type AssistantCognitionTexts struct {
|
||||
Thinking string
|
||||
ReasoningChain string
|
||||
Planning string
|
||||
}
|
||||
|
||||
// GetAssistantCognitionTexts 聚合助手消息在 process_details 中的 thinking / reasoning_chain / planning。
|
||||
func (db *DB) GetAssistantCognitionTexts(assistantMessageID string) (AssistantCognitionTexts, error) {
|
||||
assistantMessageID = strings.TrimSpace(assistantMessageID)
|
||||
if assistantMessageID == "" {
|
||||
return AssistantCognitionTexts{}, nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT event_type, message FROM process_details
|
||||
WHERE message_id = ? AND event_type IN ('thinking', 'reasoning_chain', 'planning')
|
||||
ORDER BY created_at ASC, rowid ASC`, assistantMessageID)
|
||||
if err != nil {
|
||||
return AssistantCognitionTexts{}, fmt.Errorf("query assistant cognition: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var thinkingParts, reasoningParts, planningParts []string
|
||||
for rows.Next() {
|
||||
var eventType, message string
|
||||
if err := rows.Scan(&eventType, &message); err != nil {
|
||||
continue
|
||||
}
|
||||
msg := strings.TrimSpace(message)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
switch eventType {
|
||||
case "thinking":
|
||||
thinkingParts = append(thinkingParts, msg)
|
||||
case "reasoning_chain":
|
||||
reasoningParts = append(reasoningParts, msg)
|
||||
case "planning":
|
||||
planningParts = append(planningParts, msg)
|
||||
}
|
||||
}
|
||||
return AssistantCognitionTexts{
|
||||
Thinking: strings.Join(thinkingParts, "\n\n"),
|
||||
ReasoningChain: strings.Join(reasoningParts, "\n\n"),
|
||||
Planning: strings.Join(planningParts, "\n\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddProcessDetail 添加过程详情事件
|
||||
func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message string, data interface{}) error {
|
||||
id := uuid.New().String()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestConversationProjectFilter(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "conversations.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&Project{Name: "target-a", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
|
||||
convNone, err := db.CreateConversation("unbound", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation unbound: %v", err)
|
||||
}
|
||||
convBound, err := db.CreateConversation("bound", ConversationCreateMeta{ProjectID: p.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation bound: %v", err)
|
||||
}
|
||||
|
||||
totalAll, err := db.CountConversations("", "")
|
||||
if err != nil || totalAll < 2 {
|
||||
t.Fatalf("CountConversations all: total=%d err=%v", totalAll, err)
|
||||
}
|
||||
|
||||
totalBound, err := db.CountConversations("", p.ID)
|
||||
if err != nil || totalBound != 1 {
|
||||
t.Fatalf("CountConversations project: total=%d err=%v", totalBound, err)
|
||||
}
|
||||
|
||||
totalUnbound, err := db.CountConversations("", ProjectFilterUnbound)
|
||||
if err != nil || totalUnbound != 1 {
|
||||
t.Fatalf("CountConversations unbound: total=%d err=%v", totalUnbound, err)
|
||||
}
|
||||
|
||||
listBound, err := db.ListConversations(10, 0, "", "", p.ID)
|
||||
if err != nil || len(listBound) != 1 || listBound[0].ID != convBound.ID {
|
||||
t.Fatalf("ListConversations project: %+v err=%v", listBound, err)
|
||||
}
|
||||
|
||||
listUnbound, err := db.ListConversations(10, 0, "", "", ProjectFilterUnbound)
|
||||
if err != nil || len(listUnbound) != 1 || listUnbound[0].ID != convNone.ID {
|
||||
t.Fatalf("ListConversations unbound: %+v err=%v", listUnbound, err)
|
||||
}
|
||||
|
||||
_ = convNone
|
||||
_ = convBound
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DeleteHitlInterruptLogsByIDs deletes decided HITL audit logs by id (pending rows are skipped).
|
||||
func (db *DB) DeleteHitlInterruptLogsByIDs(ids []string) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database is nil")
|
||||
}
|
||||
clean := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
clean = append(clean, id)
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(clean)), ",")
|
||||
q := fmt.Sprintf(`DELETE FROM hitl_interrupts WHERE status != 'pending' AND id IN (%s)`, placeholders)
|
||||
args := make([]interface{}, len(clean))
|
||||
for i, id := range clean {
|
||||
args[i] = id
|
||||
}
|
||||
res, err := db.Exec(q, args...)
|
||||
if err != nil {
|
||||
db.logger.Error("批量删除人机协同审计日志失败", zap.Error(err), zap.Int("count", len(clean)))
|
||||
return 0, fmt.Errorf("批量删除人机协同审计日志失败: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteHitlInterruptLogsMatching deletes decided logs matching whereSQL (e.g. "WHERE 1=1 AND status != 'pending' ...").
|
||||
func (db *DB) DeleteHitlInterruptLogsMatching(whereSQL string, args []interface{}) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database is nil")
|
||||
}
|
||||
whereSQL = strings.TrimSpace(whereSQL)
|
||||
if whereSQL == "" {
|
||||
return 0, fmt.Errorf("where clause is required")
|
||||
}
|
||||
q := `DELETE FROM hitl_interrupts ` + whereSQL
|
||||
res, err := db.Exec(q, args...)
|
||||
if err != nil {
|
||||
db.logger.Error("清空人机协同审计日志失败", zap.Error(err))
|
||||
return 0, fmt.Errorf("清空人机协同审计日志失败: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PurgeHitlInterruptLogsBefore deletes decided logs with decided/created time before cutoff.
|
||||
func (db *DB) PurgeHitlInterruptLogsBefore(cutoff time.Time) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database is nil")
|
||||
}
|
||||
res, err := db.Exec(
|
||||
`DELETE FROM hitl_interrupts WHERE status != 'pending' AND datetime(COALESCE(decided_at, created_at)) < datetime(?)`,
|
||||
cutoff.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
db.logger.Error("清理过期人机协同审计日志失败", zap.Error(err))
|
||||
return 0, fmt.Errorf("清理过期人机协同审计日志失败: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func ensureHitlInterruptsTable(t *testing.T, db *DB) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
mode TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_call_id TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL,
|
||||
decision TEXT,
|
||||
decision_comment TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
decided_at DATETIME
|
||||
);`); err != nil {
|
||||
t.Fatalf("create hitl_interrupts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteHitlInterruptLogsByIDs_skipsPending(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ensureHitlInterruptsTable(t, db)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, created_at)
|
||||
VALUES ('pending-1', 'c1', 'approval', 'exec', 'pending', ?)`, now); err != nil {
|
||||
t.Fatalf("insert pending: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||
VALUES ('done-1', 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, now, now); err != nil {
|
||||
t.Fatalf("insert decided: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := db.DeleteHitlInterruptLogsByIDs([]string{"pending-1", "done-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteHitlInterruptLogsByIDs: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := db.QueryRow(`SELECT status FROM hitl_interrupts WHERE id = 'pending-1'`).Scan(&status); err != nil {
|
||||
t.Fatalf("pending row missing: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'done-1'`).Scan(new(string)); err == nil {
|
||||
t.Fatal("decided row should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeHitlInterruptLogsBefore(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ensureHitlInterruptsTable(t, db)
|
||||
|
||||
old := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339)
|
||||
recent := time.Now().AddDate(0, 0, -1).UTC().Format(time.RFC3339)
|
||||
for _, row := range []struct{ id, decided string }{
|
||||
{"old-1", old},
|
||||
{"new-1", recent},
|
||||
} {
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||
VALUES (?, 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, row.id, row.decided, row.decided); err != nil {
|
||||
t.Fatalf("insert %s: %v", row.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -90)
|
||||
deleted, err := db.PurgeHitlInterruptLogsBefore(cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeHitlInterruptLogsBefore: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'old-1'`).Scan(new(string)); err == nil {
|
||||
t.Fatal("old row should be purged")
|
||||
}
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'new-1'`).Scan(new(string)); err != nil {
|
||||
t.Fatalf("new row should remain: %v", err)
|
||||
}
|
||||
}
|
||||
+288
-26
@@ -3,7 +3,6 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -227,6 +226,167 @@ func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolNa
|
||||
return executions, nil
|
||||
}
|
||||
|
||||
func toolExecutionsFilterSQL(status, toolName string) (string, []interface{}) {
|
||||
args := []interface{}{}
|
||||
conditions := []string{}
|
||||
if status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, status)
|
||||
}
|
||||
if toolName != "" {
|
||||
conditions = append(conditions, "LOWER(tool_name) LIKE ?")
|
||||
args = append(args, "%"+strings.ToLower(toolName)+"%")
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return ` WHERE ` + strings.Join(conditions, ` AND `), args
|
||||
}
|
||||
|
||||
// ToolStatsSummary 工具调用汇总(全量聚合,不含逐工具明细)
|
||||
type ToolStatsSummary struct {
|
||||
TotalCalls int
|
||||
SuccessCalls int
|
||||
FailedCalls int
|
||||
LastCallTime *time.Time
|
||||
ToolCount int
|
||||
}
|
||||
|
||||
// ToolStatsSummaryResult 汇总 + Top N 工具排行
|
||||
type ToolStatsSummaryResult struct {
|
||||
Summary ToolStatsSummary
|
||||
TopTools []*mcp.ToolStats
|
||||
}
|
||||
|
||||
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
|
||||
func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
if topN <= 0 {
|
||||
topN = 6
|
||||
}
|
||||
if topN > 100 {
|
||||
topN = 100
|
||||
}
|
||||
|
||||
result := &ToolStatsSummaryResult{
|
||||
TopTools: make([]*mcp.ToolStats, 0, topN),
|
||||
}
|
||||
|
||||
summaryQuery := `
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(total_calls), 0),
|
||||
COALESCE(SUM(success_calls), 0),
|
||||
COALESCE(SUM(failed_calls), 0),
|
||||
MAX(last_call_time)
|
||||
FROM tool_stats
|
||||
`
|
||||
var lastCallRaw sql.NullString
|
||||
err := db.QueryRow(summaryQuery).Scan(
|
||||
&result.Summary.ToolCount,
|
||||
&result.Summary.TotalCalls,
|
||||
&result.Summary.SuccessCalls,
|
||||
&result.Summary.FailedCalls,
|
||||
&lastCallRaw,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastCallRaw.Valid && strings.TrimSpace(lastCallRaw.String) != "" {
|
||||
if t, parseErr := time.Parse(time.RFC3339Nano, lastCallRaw.String); parseErr == nil {
|
||||
result.Summary.LastCallTime = &t
|
||||
} else if t, parseErr := time.Parse("2006-01-02 15:04:05.999999999-07:00", lastCallRaw.String); parseErr == nil {
|
||||
result.Summary.LastCallTime = &t
|
||||
} else if t, parseErr := time.Parse("2006-01-02 15:04:05", lastCallRaw.String); parseErr == nil {
|
||||
result.Summary.LastCallTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
topQuery := `
|
||||
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time
|
||||
FROM tool_stats
|
||||
WHERE total_calls > 0
|
||||
ORDER BY total_calls DESC, tool_name ASC
|
||||
LIMIT ?
|
||||
`
|
||||
rows, err := db.Query(topQuery, topN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var stat mcp.ToolStats
|
||||
var lastCallTime sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&stat.ToolName,
|
||||
&stat.TotalCalls,
|
||||
&stat.SuccessCalls,
|
||||
&stat.FailedCalls,
|
||||
&lastCallTime,
|
||||
); err != nil {
|
||||
db.logger.Warn("加载 Top 工具统计失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if lastCallTime.Valid {
|
||||
stat.LastCallTime = &lastCallTime.Time
|
||||
}
|
||||
result.TopTools = append(result.TopTools, &stat)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadToolExecutionListPage 分页加载执行记录列表(不含 arguments/result,供监控列表使用)
|
||||
func (db *DB) LoadToolExecutionListPage(offset, limit int, status, toolName string) ([]*mcp.ToolExecution, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, tool_name, status, start_time, end_time, duration_ms
|
||||
FROM tool_executions
|
||||
`
|
||||
whereSQL, args := toolExecutionsFilterSQL(status, toolName)
|
||||
query += whereSQL + ` ORDER BY start_time DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
executions := make([]*mcp.ToolExecution, 0, limit)
|
||||
for rows.Next() {
|
||||
var exec mcp.ToolExecution
|
||||
var endTime sql.NullTime
|
||||
var durationMs sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&exec.ID,
|
||||
&exec.ToolName,
|
||||
&exec.Status,
|
||||
&exec.StartTime,
|
||||
&endTime,
|
||||
&durationMs,
|
||||
); err != nil {
|
||||
db.logger.Warn("加载执行记录列表失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if endTime.Valid {
|
||||
exec.EndTime = &endTime.Time
|
||||
}
|
||||
if durationMs.Valid {
|
||||
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
|
||||
}
|
||||
executions = append(executions, &exec)
|
||||
}
|
||||
|
||||
return executions, nil
|
||||
}
|
||||
|
||||
// GetToolExecution 根据ID获取单条工具执行记录
|
||||
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
query := `
|
||||
@@ -288,6 +448,93 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
return &exec, nil
|
||||
}
|
||||
|
||||
// CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 cancelled(如进程重启后无对应执行协程)。
|
||||
func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg string) (int64, error) {
|
||||
errMsg = strings.TrimSpace(errMsg)
|
||||
if errMsg == "" {
|
||||
errMsg = "执行已中断(服务重启或会话结束)"
|
||||
}
|
||||
query := `
|
||||
UPDATE tool_executions
|
||||
SET status = 'cancelled',
|
||||
error = ?,
|
||||
end_time = ?,
|
||||
duration_ms = MAX(0, CAST((julianday(?) - julianday(start_time)) * 86400000 AS INTEGER))
|
||||
WHERE status = 'running'
|
||||
`
|
||||
res, err := db.Exec(query, errMsg, endTime, endTime)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// FinalizeStaleRunningToolExecutions 将「非活跃且超过 minAge」的 running 记录标记为 cancelled。
|
||||
// activeIDs 为当前进程内仍登记 cancel 的 executionId;不在集合内且已超时的视为孤儿记录。
|
||||
func (db *DB) FinalizeStaleRunningToolExecutions(endTime time.Time, minAge time.Duration, activeIDs map[string]struct{}, errMsg string) (int64, error) {
|
||||
errMsg = strings.TrimSpace(errMsg)
|
||||
if errMsg == "" {
|
||||
errMsg = "执行已中断(会话已结束)"
|
||||
}
|
||||
if minAge < 0 {
|
||||
minAge = 0
|
||||
}
|
||||
cutoff := endTime.Add(-minAge)
|
||||
rows, err := db.Query(`
|
||||
SELECT id, start_time FROM tool_executions
|
||||
WHERE status = 'running' AND start_time <= ?
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type staleRow struct {
|
||||
id string
|
||||
startTime time.Time
|
||||
}
|
||||
var stale []staleRow
|
||||
for rows.Next() {
|
||||
var row staleRow
|
||||
if err := rows.Scan(&row.id, &row.startTime); err != nil {
|
||||
db.logger.Warn("读取 stale running 执行记录失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if activeIDs != nil {
|
||||
if _, active := activeIDs[row.id]; active {
|
||||
continue
|
||||
}
|
||||
}
|
||||
stale = append(stale, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(stale) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var affected int64
|
||||
for _, row := range stale {
|
||||
durationMs := endTime.Sub(row.startTime).Milliseconds()
|
||||
if durationMs < 0 {
|
||||
durationMs = 0
|
||||
}
|
||||
res, err := db.Exec(`
|
||||
UPDATE tool_executions
|
||||
SET status = 'cancelled', error = ?, end_time = ?, duration_ms = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
`, errMsg, endTime, durationMs, row.id)
|
||||
if err != nil {
|
||||
db.logger.Warn("更新 stale running 执行记录失败", zap.Error(err), zap.String("executionId", row.id))
|
||||
continue
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
affected += n
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// DeleteToolExecution 删除工具执行记录
|
||||
func (db *DB) DeleteToolExecution(id string) error {
|
||||
query := `DELETE FROM tool_executions WHERE id = ?`
|
||||
@@ -600,13 +847,28 @@ func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
|
||||
|
||||
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
|
||||
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
|
||||
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
|
||||
query := `
|
||||
SELECT start_time,
|
||||
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
`
|
||||
var query string
|
||||
if dailyBuckets {
|
||||
query = `
|
||||
SELECT date(start_time, 'localtime') AS bucket,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`
|
||||
}
|
||||
|
||||
rows, err := db.Query(query, since)
|
||||
if err != nil {
|
||||
@@ -614,35 +876,35 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||
buckets := make([]CallsTimelineBucket, 0)
|
||||
for rows.Next() {
|
||||
var startTime time.Time
|
||||
var failed int
|
||||
if err := rows.Scan(&startTime, &failed); err != nil {
|
||||
var bucketStr string
|
||||
var total, failed int
|
||||
if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
|
||||
db.logger.Warn("加载调用趋势失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
key := truncateCallsTimelineBucket(startTime, dailyBuckets)
|
||||
entry := bucketMap[key]
|
||||
entry.total++
|
||||
entry.failed += failed
|
||||
bucketMap[key] = entry
|
||||
}
|
||||
|
||||
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
|
||||
for bucketTime, counts := range bucketMap {
|
||||
bucketTime, err := parseCallsTimelineBucket(bucketStr, dailyBuckets)
|
||||
if err != nil {
|
||||
db.logger.Warn("解析调用趋势时间桶失败", zap.Error(err), zap.String("bucket", bucketStr))
|
||||
continue
|
||||
}
|
||||
buckets = append(buckets, CallsTimelineBucket{
|
||||
BucketTime: bucketTime,
|
||||
Total: counts.total,
|
||||
Failed: counts.failed,
|
||||
Total: total,
|
||||
Failed: failed,
|
||||
})
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
|
||||
})
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
func parseCallsTimelineBucket(bucketStr string, dailyBuckets bool) (time.Time, error) {
|
||||
if dailyBuckets {
|
||||
return time.ParseInLocation("2006-01-02", bucketStr, time.Local)
|
||||
}
|
||||
return time.ParseInLocation("2006-01-02 15:04:05", bucketStr, time.Local)
|
||||
}
|
||||
|
||||
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
|
||||
// 如果统计信息变为0,则删除该统计记录
|
||||
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCancelOrphanedRunningToolExecutions(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
start := time.Now().Add(-2 * time.Hour)
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: "orphan-hydra",
|
||||
ToolName: "hydra",
|
||||
Arguments: map[string]interface{}{"target": "127.0.0.1"},
|
||||
Status: "running",
|
||||
StartTime: start,
|
||||
}
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution: %v", err)
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
n, err := db.CancelOrphanedRunningToolExecutions(end, "执行已中断(服务重启)")
|
||||
if err != nil {
|
||||
t.Fatalf("CancelOrphanedRunningToolExecutions: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 row updated, got %d", n)
|
||||
}
|
||||
|
||||
got, err := db.GetToolExecution("orphan-hydra")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution: %v", err)
|
||||
}
|
||||
if got.Status != "cancelled" {
|
||||
t.Fatalf("expected cancelled, got %s", got.Status)
|
||||
}
|
||||
if got.EndTime == nil {
|
||||
t.Fatal("expected end_time to be set")
|
||||
}
|
||||
if got.Duration <= 0 {
|
||||
t.Fatalf("expected positive duration, got %v", got.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeStaleRunningToolExecutions_skipsActive(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now()
|
||||
oldStart := now.Add(-5 * time.Minute)
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "stale", ToolName: "hydra", Status: "running", StartTime: oldStart,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveToolExecution stale: %v", err)
|
||||
}
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "active", ToolName: "hydra", Status: "running", StartTime: oldStart,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveToolExecution active: %v", err)
|
||||
}
|
||||
|
||||
active := map[string]struct{}{"active": {}}
|
||||
n, err := db.FinalizeStaleRunningToolExecutions(now, time.Minute, active, "执行已中断(会话已结束)")
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeStaleRunningToolExecutions: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 stale row updated, got %d", n)
|
||||
}
|
||||
|
||||
stale, err := db.GetToolExecution("stale")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution stale: %v", err)
|
||||
}
|
||||
if stale.Status != "cancelled" {
|
||||
t.Fatalf("stale expected cancelled, got %s", stale.Status)
|
||||
}
|
||||
|
||||
activeExec, err := db.GetToolExecution("active")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution active: %v", err)
|
||||
}
|
||||
if activeExec.Status != "running" {
|
||||
t.Fatalf("active expected running, got %s", activeExec.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor-summary.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now()
|
||||
tools := []struct {
|
||||
name string
|
||||
calls int
|
||||
ok int
|
||||
fail int
|
||||
result string
|
||||
}{
|
||||
{"alpha::run", 10, 9, 1, `{"content":[{"type":"text","text":"` + string(make([]byte, 64*1024)) + `"}]}`},
|
||||
{"beta::scan", 5, 5, 0, `{"content":[{"type":"text","text":"ok"}]}`},
|
||||
{"gamma::ping", 1, 1, 0, `{"content":[{"type":"text","text":"pong"}]}`},
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
if err := db.UpdateToolStats(tool.name, tool.calls, tool.ok, tool.fail, &now); err != nil {
|
||||
t.Fatalf("UpdateToolStats(%s): %v", tool.name, err)
|
||||
}
|
||||
for j := 0; j < tool.calls; j++ {
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: fmt.Sprintf("%s-exec-%d", tool.name, j),
|
||||
ToolName: tool.name,
|
||||
Arguments: map[string]interface{}{"n": j},
|
||||
Status: "completed",
|
||||
StartTime: now.Add(-time.Duration(j) * time.Minute),
|
||||
Result: &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: tool.result}}},
|
||||
}
|
||||
end := exec.StartTime.Add(time.Second)
|
||||
exec.EndTime = &end
|
||||
exec.Duration = time.Second
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := db.LoadToolStatsSummary(2)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolStatsSummary: %v", err)
|
||||
}
|
||||
if summary.Summary.ToolCount != 3 {
|
||||
t.Fatalf("toolCount = %d, want 3", summary.Summary.ToolCount)
|
||||
}
|
||||
if summary.Summary.TotalCalls != 16 {
|
||||
t.Fatalf("totalCalls = %d, want 16", summary.Summary.TotalCalls)
|
||||
}
|
||||
if len(summary.TopTools) != 2 {
|
||||
t.Fatalf("top tools = %d, want 2", len(summary.TopTools))
|
||||
}
|
||||
if summary.TopTools[0].ToolName != "alpha::run" {
|
||||
t.Fatalf("top tool = %q, want alpha::run", summary.TopTools[0].ToolName)
|
||||
}
|
||||
|
||||
list, err := db.LoadToolExecutionListPage(0, 5, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolExecutionListPage: %v", err)
|
||||
}
|
||||
if len(list) != 5 {
|
||||
t.Fatalf("list len = %d, want 5", len(list))
|
||||
}
|
||||
for _, exec := range list {
|
||||
if exec.Arguments != nil || exec.Result != nil || exec.Error != "" {
|
||||
t.Fatalf("expected lite execution row, got args/result/error on %s", exec.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package einomcp
|
||||
|
||||
import "sync"
|
||||
|
||||
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire,
|
||||
// 用于清除 pending tool_call(tool_result 由 ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)。
|
||||
// ToolInvokeNotifyHolder 由 Eino run loop 与 MCP/execute 桥共享;Fire 在工具原始返回时触发。
|
||||
// UI 的 tool_result 须等 ADK schema.Tool 事件(reduction 后正文),不在此 holder 的回调里推送。
|
||||
type ToolInvokeNotifyHolder struct {
|
||||
mu sync.RWMutex
|
||||
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
||||
|
||||
+99
-12
@@ -77,6 +77,13 @@ type responsePlanAgg struct {
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
|
||||
type thinkingBuf struct {
|
||||
b strings.Builder
|
||||
meta map[string]interface{}
|
||||
persistAs string // "thinking" | "reasoning_chain"
|
||||
}
|
||||
|
||||
func normalizeProcessDetailText(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
@@ -179,6 +186,8 @@ type AgentHandler struct {
|
||||
batchCronParser cron.Parser
|
||||
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
||||
hitlWhitelistSaver HitlToolWhitelistSaver
|
||||
hitlStrategySaver HitlAuditStrategySaver
|
||||
auditLLM *openai.Client
|
||||
audit *audit.Service
|
||||
}
|
||||
|
||||
@@ -200,9 +209,8 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
if h == nil || conversationID == "" || h.tasks == nil {
|
||||
return
|
||||
}
|
||||
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" {
|
||||
h.agent.CancelMCPToolExecutionWithNote(execID, "")
|
||||
}
|
||||
h.cancelActiveMCPToolForConversation(conversationID)
|
||||
h.tasks.AbortActiveEinoExecute(conversationID, "")
|
||||
if ok, err := h.tasks.CancelTask(conversationID, ErrTaskCancelled); ok {
|
||||
h.logger.Info("已取消会话运行中任务", zap.String("conversationId", conversationID))
|
||||
} else if err != nil {
|
||||
@@ -210,9 +218,19 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘
|
||||
func (h *AgentHandler) cancelActiveMCPToolForConversation(conversationID string) {
|
||||
if h == nil || h.tasks == nil || h.agent == nil {
|
||||
return
|
||||
}
|
||||
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" {
|
||||
h.agent.CancelMCPToolExecutionWithNote(execID, "")
|
||||
}
|
||||
}
|
||||
|
||||
// HitlToolWhitelistSaver 合并/设置 HITL 免审批工具到全局配置并落盘
|
||||
type HitlToolWhitelistSaver interface {
|
||||
MergeHitlToolWhitelistIntoConfig(add []string) error
|
||||
SetHitlToolWhitelist(tools []string) error
|
||||
}
|
||||
|
||||
// NewAgentHandler 创建新的Agent处理器
|
||||
@@ -228,6 +246,11 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
||||
bus := NewTaskEventBus()
|
||||
tm := NewAgentTaskManager()
|
||||
tm.SetTaskEventBus(bus)
|
||||
llmHTTP := &http.Client{Timeout: 2 * time.Minute}
|
||||
var llmCfg *config.OpenAIConfig
|
||||
if cfg != nil {
|
||||
llmCfg = &cfg.OpenAI
|
||||
}
|
||||
handler := &AgentHandler{
|
||||
agent: agent,
|
||||
db: db,
|
||||
@@ -238,7 +261,9 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
||||
config: cfg,
|
||||
hitlManager: NewHITLManager(db, logger),
|
||||
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
|
||||
auditLLM: openai.NewClient(llmCfg, llmHTTP, logger),
|
||||
}
|
||||
tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
|
||||
if err := handler.hitlManager.EnsureSchema(); err != nil {
|
||||
logger.Warn("初始化 HITL 表失败", zap.Error(err))
|
||||
}
|
||||
@@ -311,6 +336,7 @@ func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientInten
|
||||
type HITLRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Reviewer string `json:"reviewer,omitempty"` // human | audit_agent
|
||||
SensitiveTools []string `json:"sensitiveTools,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
}
|
||||
@@ -840,11 +866,6 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
|
||||
// thinking_stream_*(ReAct 等助手正文流)与 reasoning_chain_stream_*(Eino ReasoningContent):
|
||||
// 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。
|
||||
type thinkingBuf struct {
|
||||
b strings.Builder
|
||||
meta map[string]interface{}
|
||||
persistAs string // "thinking" | "reasoning_chain"
|
||||
}
|
||||
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
||||
flushedThinking := make(map[string]bool) // streamId -> flushed
|
||||
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
|
||||
@@ -857,6 +878,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
||||
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
||||
var respPlan responsePlanAgg
|
||||
if assistantMessageID != "" {
|
||||
h.tasks.SetHitlAssistantMessageID(conversationID, assistantMessageID)
|
||||
}
|
||||
syncHitlCognition := func() {
|
||||
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
|
||||
}
|
||||
flushResponsePlan := func() {
|
||||
if assistantMessageID == "" {
|
||||
return
|
||||
@@ -876,6 +903,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
||||
}
|
||||
syncHitlCognition()
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
}
|
||||
@@ -912,6 +940,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
flushedThinking[sid] = true
|
||||
}
|
||||
syncHitlCognition()
|
||||
}
|
||||
|
||||
return func(eventType, message string, data interface{}) {
|
||||
@@ -972,6 +1001,25 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
}
|
||||
|
||||
if eventType == "tool_result" {
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
toolName, _ := dataMap["toolName"].(string)
|
||||
toolCallID, _ := dataMap["toolCallId"].(string)
|
||||
success := true
|
||||
if v, ok := dataMap["success"].(bool); ok {
|
||||
success = v
|
||||
}
|
||||
resultText := ""
|
||||
if r, ok := dataMap["result"].(string); ok {
|
||||
resultText = r
|
||||
}
|
||||
if strings.TrimSpace(resultText) == "" {
|
||||
resultText = message
|
||||
}
|
||||
h.recordHitlToolExecutionResult(conversationID, toolCallID, toolName, success, resultText)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理知识检索日志记录
|
||||
if eventType == "tool_result" && h.knowledgeManager != nil {
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
@@ -1179,6 +1227,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
respPlan.meta[k] = v
|
||||
}
|
||||
}
|
||||
syncHitlCognition()
|
||||
return
|
||||
}
|
||||
if eventType == "response" {
|
||||
@@ -1248,6 +1297,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
}
|
||||
}
|
||||
syncHitlCognition()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1352,6 +1402,7 @@ func (h *AgentHandler) cancelToolContinueAfter(conversationID, preferredExecID,
|
||||
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
var req struct {
|
||||
ConversationID string `json:"conversationId" binding:"required"`
|
||||
ExecutionID string `json:"executionId,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ContinueAfter bool `json:"continueAfter,omitempty"`
|
||||
}
|
||||
@@ -1368,7 +1419,7 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
}
|
||||
note := strings.TrimSpace(req.Reason)
|
||||
activeExec := strings.TrimSpace(h.tasks.ActiveMCPExecutionID(req.ConversationID))
|
||||
if ok, payload := h.cancelToolContinueAfter(req.ConversationID, "", note); ok {
|
||||
if ok, payload := h.cancelToolContinueAfter(req.ConversationID, strings.TrimSpace(req.ExecutionID), note); ok {
|
||||
execID, _ := payload["executionId"].(string)
|
||||
h.logger.Info("对话页仅终止当前工具",
|
||||
zap.String("conversationId", req.ConversationID),
|
||||
@@ -1411,6 +1462,8 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
|
||||
var cause error = ErrTaskCancelled
|
||||
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
|
||||
h.cancelActiveMCPToolForConversation(req.ConversationID)
|
||||
h.tasks.AbortActiveEinoExecute(req.ConversationID, "")
|
||||
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
|
||||
if err != nil {
|
||||
h.logger.Error("取消任务失败", zap.Error(err))
|
||||
@@ -1477,17 +1530,51 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// enrichAgentTasksWithConversationTitles 为任务列表附加当前会话标题(供顶栏/任务页展示,重命名后自动同步)
|
||||
func (h *AgentHandler) enrichAgentTasksWithConversationTitles(tasks []*AgentTask) {
|
||||
if h == nil || h.db == nil {
|
||||
return
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task == nil || strings.TrimSpace(task.ConversationID) == "" {
|
||||
continue
|
||||
}
|
||||
if title, err := h.db.GetConversationTitle(task.ConversationID); err == nil {
|
||||
task.Title = strings.TrimSpace(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enrichCompletedTasksWithConversationTitles 为已完成任务附加当前会话标题
|
||||
func (h *AgentHandler) enrichCompletedTasksWithConversationTitles(tasks []*CompletedTask) {
|
||||
if h == nil || h.db == nil {
|
||||
return
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task == nil || strings.TrimSpace(task.ConversationID) == "" {
|
||||
continue
|
||||
}
|
||||
if title, err := h.db.GetConversationTitle(task.ConversationID); err == nil {
|
||||
task.Title = strings.TrimSpace(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListAgentTasks 列出所有运行中的任务
|
||||
func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
|
||||
tasks := h.tasks.GetActiveTasks()
|
||||
h.enrichAgentTasksWithConversationTitles(tasks)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": h.tasks.GetActiveTasks(),
|
||||
"tasks": tasks,
|
||||
})
|
||||
}
|
||||
|
||||
// ListCompletedTasks 列出最近完成的任务历史
|
||||
func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
|
||||
tasks := h.tasks.GetCompletedTasks()
|
||||
h.enrichCompletedTasksWithConversationTitles(tasks)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": h.tasks.GetCompletedTasks(),
|
||||
"tasks": tasks,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -798,6 +798,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// 更新机器人配置
|
||||
if req.Robots != nil {
|
||||
if err := config.ValidateWecomConfig(req.Robots.Wecom); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.config.Robots = *req.Robots
|
||||
h.logger.Info("更新机器人配置",
|
||||
zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled),
|
||||
@@ -1329,6 +1333,17 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
h.logger.Info("已更新嵌入模型配置记录")
|
||||
}
|
||||
|
||||
// 从 tools 目录重新加载工具配置(新增/修改/删除 yaml 后无需重启)
|
||||
if err := config.ReloadSecurityToolsFromDir(h.config, h.configPath); err != nil {
|
||||
h.logger.Error("重新加载工具配置失败", zap.Error(err))
|
||||
if h.audit != nil {
|
||||
h.audit.RecordFail(c, "config", "apply", "应用配置失败:重新加载工具", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重新加载工具配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Info("已从 tools 目录重新加载工具配置", zap.Int("tools_count", len(h.config.Security.Tools)))
|
||||
|
||||
// 重新注册工具(根据新的启用状态)
|
||||
h.logger.Info("重新注册工具")
|
||||
|
||||
@@ -1751,6 +1766,20 @@ func mergeHitlToolWhitelistSlice(existing, add []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// SetHitlToolWhitelist 将全局免审批工具白名单整表写入 config.yaml(替换,非合并)。
|
||||
func (h *ConfigHandler) SetHitlToolWhitelist(tools []string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, tools)
|
||||
if err := h.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("HITL 全局工具白名单已写入配置文件",
|
||||
zap.Int("count", len(h.config.Hitl.ToolWhitelist)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeHitlToolWhitelistIntoConfig 将会话侧栏提交的免审批工具名合并进内存配置并写入 config.yaml(与全局白名单去重规则一致:小写键、保留首次出现的原始大小写)。
|
||||
func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error {
|
||||
h.mu.Lock()
|
||||
@@ -1771,6 +1800,21 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
|
||||
hitlNode := ensureMap(root, "hitl")
|
||||
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
|
||||
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
|
||||
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
|
||||
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
|
||||
}
|
||||
|
||||
// UpdateHitlAuditAgentStrategy 更新审批/审查编辑两套审计 Agent 提示词并写入 config.yaml。
|
||||
func (h *ConfigHandler) UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(approvalPrompt)
|
||||
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(reviewEditPrompt)
|
||||
if err := h.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("HITL 审计 Agent 提示词已写入配置文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
|
||||
@@ -103,6 +103,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
search := c.Query("search") // 获取搜索参数
|
||||
projectID := strings.TrimSpace(c.Query("project_id"))
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
@@ -114,7 +115,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
excludeGrouped := strings.TrimSpace(search) == "" &&
|
||||
excludeGrouped := strings.TrimSpace(search) == "" && projectID == "" &&
|
||||
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
||||
sortBy := strings.TrimSpace(c.Query("sort_by"))
|
||||
|
||||
@@ -122,14 +123,14 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
var total int
|
||||
var err error
|
||||
if excludeGrouped {
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy)
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy, projectID)
|
||||
if err == nil {
|
||||
total, err = h.db.CountUngroupedConversations()
|
||||
total, err = h.db.CountUngroupedConversations(projectID)
|
||||
}
|
||||
} else {
|
||||
conversations, err = h.db.ListConversations(limit, offset, search, sortBy)
|
||||
conversations, err = h.db.ListConversations(limit, offset, search, sortBy, projectID)
|
||||
if err == nil {
|
||||
total, err = h.db.CountConversations(search)
|
||||
total, err = h.db.CountConversations(search, projectID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// rebindEinoRunningTask 中断并继续 / 空正文续跑:重建 cancel 链与超时 ctx,保持任务 running。
|
||||
func (h *AgentHandler) rebindEinoRunningTask(conversationID string, timeoutCancel context.CancelFunc) (context.Context, context.CancelCauseFunc, context.Context, context.CancelFunc) {
|
||||
if timeoutCancel != nil {
|
||||
timeoutCancel()
|
||||
}
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||
taskCtx, newTimeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||
return baseCtx, cancelWithCause, taskCtx, newTimeoutCancel
|
||||
}
|
||||
|
||||
// tryContinueOnEinoEmptyResponse Run 成功但 Response 为 emptyHint 时退避续跑;true 表示已准备下一段 Run。
|
||||
func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
||||
taskCtx context.Context,
|
||||
mw *config.MultiAgentEinoMiddlewareConfig,
|
||||
conversationID string,
|
||||
result *multiagent.RunResult,
|
||||
attempt *int,
|
||||
curHistory *[]agent.ChatMessage,
|
||||
curFinalMessage *string,
|
||||
progressCallback func(eventType, message string, data interface{}),
|
||||
) bool {
|
||||
if result == nil || !multiagent.IsEinoEmptyResponseResult(result) || !multiagent.HasEinoResumeTrace(result) {
|
||||
return false
|
||||
}
|
||||
maxAttempts := multiagent.EmptyResponseContinueMaxAttemptsFromConfig(mw)
|
||||
if *attempt >= maxAttempts {
|
||||
if h.logger != nil {
|
||||
h.logger.Warn("eino empty response continue exhausted",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.Int("maxAttempts", maxAttempts))
|
||||
}
|
||||
return false
|
||||
}
|
||||
*attempt++
|
||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||
|
||||
backoff := multiagent.EmptyResponseContinueBackoff(*attempt-1, mw)
|
||||
waitMsg := fmt.Sprintf("会话已结束但未捕获到助手正文,%d 秒后第 %d/%d 次自动续跑…",
|
||||
int(backoff.Seconds()), *attempt, maxAttempts)
|
||||
if progressCallback != nil {
|
||||
progressCallback("eino_empty_response_continue", waitMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": maxAttempts,
|
||||
"backoffSec": int(backoff.Seconds()),
|
||||
})
|
||||
}
|
||||
select {
|
||||
case <-taskCtx.Done():
|
||||
return false
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
||||
if progressCallback != nil {
|
||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": maxAttempts,
|
||||
"contextSource": "empty_response_continue",
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
var cumulativeMCPExecutionIDs []string
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
var emptyResponseContinueAttempt int
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -239,6 +240,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
|
||||
if runErr == nil {
|
||||
mw := &h.config.MultiAgent.EinoMiddleware
|
||||
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, conversationID, result, &emptyResponseContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(conversationID, timeoutCancel)
|
||||
continue
|
||||
}
|
||||
timeoutCancel()
|
||||
break
|
||||
}
|
||||
|
||||
+136
-89
@@ -23,6 +23,7 @@ import (
|
||||
type hitlRuntimeConfig struct {
|
||||
Enabled bool
|
||||
Mode string
|
||||
Reviewer string
|
||||
SensitiveTools map[string]struct{}
|
||||
Timeout time.Duration
|
||||
}
|
||||
@@ -49,6 +50,8 @@ type HITLManager struct {
|
||||
mu sync.RWMutex
|
||||
runtime map[string]hitlRuntimeConfig
|
||||
pending map[string]*pendingInterrupt
|
||||
// approvedExec 审批通过、待回写 tool_result 的队列(按会话 FIFO)
|
||||
approvedExec map[string][]hitlApprovedExecTrack
|
||||
}
|
||||
|
||||
func NewHITLManager(db *database.DB, logger *zap.Logger) *HITLManager {
|
||||
@@ -90,6 +93,7 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.migrateHitlSchemaColumns()
|
||||
|
||||
// On startup, cancel all orphaned pending interrupts from previous process.
|
||||
// Their in-memory channels are gone, so they can never be resolved.
|
||||
@@ -141,6 +145,7 @@ func (m *HITLManager) ActivateConversation(conversationID string, req *HITLReque
|
||||
m.runtime[conversationID] = hitlRuntimeConfig{
|
||||
Enabled: true,
|
||||
Mode: normalizeHitlMode(req.Mode),
|
||||
Reviewer: normalizeHitlReviewer(req.Reviewer),
|
||||
SensitiveTools: tools,
|
||||
Timeout: timeout,
|
||||
}
|
||||
@@ -153,17 +158,14 @@ func (m *HITLManager) DeactivateConversation(conversationID string) {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空)。
|
||||
// hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空),并合并内置元工具免审批项。
|
||||
func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
|
||||
if h == nil || h.config == nil {
|
||||
return nil
|
||||
return multiagent.MergeHitlExemptMetaTools(nil)
|
||||
}
|
||||
raw := h.config.Hitl.ToolWhitelist
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(raw))
|
||||
out := make([]string, 0, len(raw)+len(multiagent.HitlExemptMetaTools))
|
||||
for _, t := range raw {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
@@ -175,44 +177,35 @@ func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, strings.TrimSpace(t))
|
||||
}
|
||||
return out
|
||||
return multiagent.MergeHitlExemptMetaTools(out)
|
||||
}
|
||||
|
||||
// hitlRequestWithMergedConfigWhitelist 将会话/API 中的白名单与 config.yaml 全局白名单合并(并集),仅用于运行时 Activate;不写入数据库。
|
||||
// hitlRequestWithMergedConfigWhitelist 将会话/API 中的白名单与 config.yaml 全局白名单及内置元工具免审批项合并(并集),仅用于运行时 Activate;不写入数据库。
|
||||
func (h *AgentHandler) hitlRequestWithMergedConfigWhitelist(req *HITLRequest) *HITLRequest {
|
||||
gw := h.hitlConfigGlobalToolWhitelist()
|
||||
if len(gw) == 0 {
|
||||
return req
|
||||
}
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
union := make([]string, 0, len(gw)+len(req.SensitiveTools))
|
||||
for _, t := range gw {
|
||||
union := make([]string, 0, len(req.SensitiveTools)+16)
|
||||
add := func(t string) {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
continue
|
||||
return
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
return
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
union = append(union, strings.TrimSpace(t))
|
||||
}
|
||||
for _, t := range h.hitlConfigGlobalToolWhitelist() {
|
||||
add(t)
|
||||
}
|
||||
for _, t := range req.SensitiveTools {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
union = append(union, strings.TrimSpace(t))
|
||||
add(t)
|
||||
}
|
||||
out := *req
|
||||
out.SensitiveTools = union
|
||||
out.SensitiveTools = multiagent.MergeHitlExemptMetaTools(union)
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -362,22 +355,22 @@ func (m *HITLManager) SaveConversationConfig(conversationID string, req *HITLReq
|
||||
timeout = 0
|
||||
}
|
||||
_, err := m.db.Exec(`INSERT INTO hitl_conversation_configs
|
||||
(conversation_id, enabled, mode, sensitive_tools, timeout_seconds, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
(conversation_id, enabled, mode, reviewer, sensitive_tools, timeout_seconds, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
enabled=excluded.enabled, mode=excluded.mode, sensitive_tools=excluded.sensitive_tools, timeout_seconds=excluded.timeout_seconds, updated_at=excluded.updated_at`,
|
||||
conversationID, boolToInt(req.Enabled), mode, string(tools), timeout, time.Now())
|
||||
enabled=excluded.enabled, mode=excluded.mode, reviewer=excluded.reviewer, sensitive_tools=excluded.sensitive_tools, timeout_seconds=excluded.timeout_seconds, updated_at=excluded.updated_at`,
|
||||
conversationID, boolToInt(req.Enabled), mode, normalizeHitlReviewer(req.Reviewer), string(tools), timeout, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLRequest, error) {
|
||||
var enabledInt int
|
||||
var mode, toolsJSON string
|
||||
var mode, reviewer, toolsJSON string
|
||||
var timeout int
|
||||
err := m.db.QueryRow(`SELECT enabled, mode, sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID).
|
||||
Scan(&enabledInt, &mode, &toolsJSON, &timeout)
|
||||
err := m.db.QueryRow(`SELECT enabled, mode, COALESCE(reviewer,'human'), sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID).
|
||||
Scan(&enabledInt, &mode, &reviewer, &toolsJSON, &timeout)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return &HITLRequest{Enabled: false, Mode: "off", SensitiveTools: []string{}, TimeoutSeconds: 0}, nil
|
||||
return &HITLRequest{Enabled: false, Mode: "off", Reviewer: "human", SensitiveTools: []string{}, TimeoutSeconds: 0}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -390,6 +383,7 @@ func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLReques
|
||||
return &HITLRequest{
|
||||
Enabled: enabledInt == 1,
|
||||
Mode: mode,
|
||||
Reviewer: normalizeHitlReviewer(reviewer),
|
||||
SensitiveTools: tools,
|
||||
TimeoutSeconds: timeout,
|
||||
}, nil
|
||||
@@ -413,15 +407,16 @@ func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, tim
|
||||
if p.Mode != "review_edit" && len(d.EditedArguments) > 0 {
|
||||
d.EditedArguments = nil
|
||||
}
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=? WHERE id=?`,
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='human' WHERE id=?`,
|
||||
d.Decision, d.Comment, time.Now(), p.InterruptID)
|
||||
return d, nil
|
||||
case <-timeoutCh:
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='approve', decision_comment='timeout auto approve', decided_at=? WHERE id=?`,
|
||||
time.Now(), p.InterruptID)
|
||||
return hitlDecision{Decision: "approve", Comment: "timeout auto approve"}, nil
|
||||
comment := "HITL timeout auto-reject for safety"
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='reject', decision_comment=?, decided_at=?, decided_by='system' WHERE id=?`,
|
||||
comment, time.Now(), p.InterruptID)
|
||||
return hitlDecision{Decision: "reject", Comment: comment}, nil
|
||||
case <-ctx.Done():
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject', decision_comment='task cancelled', decided_at=? WHERE id=?`,
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject', decision_comment='task cancelled', decided_at=?, decided_by='system' WHERE id=?`,
|
||||
time.Now(), p.InterruptID)
|
||||
return hitlDecision{Decision: "reject", Comment: "task cancelled"}, ctx.Err()
|
||||
}
|
||||
@@ -445,12 +440,57 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
if !need {
|
||||
return nil, nil
|
||||
}
|
||||
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
|
||||
payloadRaw, _ := json.Marshal(payload)
|
||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
||||
if err != nil {
|
||||
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.Reviewer == "audit_agent" {
|
||||
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
|
||||
now := time.Now()
|
||||
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
|
||||
ad.Decision, ad.Comment, now, p.InterruptID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"mode": cfg.Mode,
|
||||
"decision": ad.Decision,
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
if ad.Decision == "reject" {
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": ad.Comment,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
return &ad, nil
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||
return &ad, nil
|
||||
}
|
||||
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
@@ -479,8 +519,12 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
return nil, waitErr
|
||||
}
|
||||
if d.Decision == "reject" {
|
||||
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
|
||||
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", "人工拒绝本次工具调用,模型将基于反馈继续迭代", map[string]interface{}{
|
||||
sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
@@ -498,6 +542,7 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
"editedArgs": d.EditedArguments,
|
||||
})
|
||||
}
|
||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
@@ -527,11 +572,6 @@ func (h *AgentHandler) handleHITLToolCall(runCtx context.Context, cancelRun cont
|
||||
}
|
||||
|
||||
func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
||||
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -539,15 +579,12 @@ func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
|
||||
offset := (page - 1) * pageSize
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, created_at, decided_at FROM hitl_interrupts WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if conversationID != "" {
|
||||
q += " AND conversation_id = ?"
|
||||
args = append(args, conversationID)
|
||||
}
|
||||
if status != "all" {
|
||||
q += " AND status = ?"
|
||||
args = append(args, status)
|
||||
q, args := h.buildHitlListQuery(false)
|
||||
q, args = h.appendHitlListFilters(q, args, c)
|
||||
total, err := h.countHitlQuery(q, args)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, pageSize, offset)
|
||||
@@ -557,41 +594,12 @@ func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &createdAt, &decidedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
msgID := ""
|
||||
if messageID.Valid {
|
||||
msgID = messageID.String
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": id,
|
||||
"conversationId": cid,
|
||||
"messageId": msgID,
|
||||
"mode": mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
"status": rowStatus,
|
||||
"decision": decision.String,
|
||||
"comment": comment.String,
|
||||
"createdAt": createdAt,
|
||||
"decidedAt": func() interface{} {
|
||||
if decidedAt.Valid {
|
||||
return decidedAt.Time
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
})
|
||||
items, err := h.scanHitlInterruptRows(rows)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize})
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total})
|
||||
}
|
||||
|
||||
type hitlDecisionReq struct {
|
||||
@@ -636,7 +644,7 @@ func (h *AgentHandler) DismissHITLInterrupt(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
res, err := h.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
||||
decision_comment='dismissed by user', decided_at=CURRENT_TIMESTAMP
|
||||
decision_comment='dismissed by user', decided_at=CURRENT_TIMESTAMP, decided_by='human'
|
||||
WHERE id=? AND status='pending'`, req.InterruptID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
@@ -732,6 +740,7 @@ func (h *AgentHandler) UpsertHITLConversationConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.Mode = normalizeHitlMode(req.Mode)
|
||||
req.Reviewer = normalizeHitlReviewer(req.Reviewer)
|
||||
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -753,6 +762,44 @@ type mergeHitlGlobalWhitelistReq struct {
|
||||
SensitiveTools []string `json:"sensitiveTools"`
|
||||
}
|
||||
|
||||
type setHitlGlobalWhitelistReq struct {
|
||||
ToolWhitelist []string `json:"toolWhitelist"`
|
||||
}
|
||||
|
||||
// GetHITLGlobalToolWhitelist 返回 config.yaml 中的全局免审批工具白名单。
|
||||
func (h *AgentHandler) GetHITLGlobalToolWhitelist(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
})
|
||||
}
|
||||
|
||||
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
|
||||
func (h *AgentHandler) SetHITLGlobalToolWhitelist(c *gin.Context) {
|
||||
if h.hitlWhitelistSaver == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
|
||||
return
|
||||
}
|
||||
var req setHitlGlobalWhitelistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.hitlWhitelistSaver.SetHitlToolWhitelist(req.ToolWhitelist); err != nil {
|
||||
h.logger.Warn("写入 HITL 工具白名单到 config.yaml 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "tool_whitelist_update", "HITL 全局白名单更新", "hitl_config", "tool_whitelist", nil)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
"hitlGlobalWhitelistMerged": false,
|
||||
})
|
||||
}
|
||||
|
||||
// MergeHITLGlobalToolWhitelist 无会话 ID 时将侧栏提交的免审批工具合并进 config.yaml(与 PUT /hitl/config 中白名单落盘规则一致)。
|
||||
func (h *AgentHandler) MergeHITLGlobalToolWhitelist(c *gin.Context) {
|
||||
if h.hitlWhitelistSaver == nil {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// auditAgentReview 在 reviewer=audit_agent 时由 LLM 代行审批。
|
||||
// 白名单工具在 shouldInterrupt 阶段已跳过,到达此处的一律需要裁决。
|
||||
func (h *AgentHandler) auditAgentReview(ctx context.Context, hitlMode, toolName string, payload map[string]interface{}) hitlDecision {
|
||||
if h == nil {
|
||||
return hitlDecision{Decision: "reject", Comment: "audit agent: handler unavailable"}
|
||||
}
|
||||
mode := normalizeHitlMode(hitlMode)
|
||||
prompt := config.DefaultHitlAuditAgentPrompt()
|
||||
if h.config != nil {
|
||||
prompt = h.config.Hitl.EffectiveAuditAgentPromptForMode(mode)
|
||||
}
|
||||
if h.auditLLM == nil {
|
||||
return hitlDecision{Decision: "reject", Comment: "audit agent: LLM 未配置"}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
userContent := buildAuditAgentReviewInput(mode, toolName, payload)
|
||||
requestBody := map[string]interface{}{
|
||||
"model": h.auditLLMModel(),
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": userContent},
|
||||
},
|
||||
"temperature": 0.1,
|
||||
"max_completion_tokens": 1024,
|
||||
// 审计裁决需要结构化 JSON;关闭 thinking 避免 Qwen 等把正文放进 reasoning_content 导致解析失败。
|
||||
"thinking": map[string]interface{}{"type": "disabled"},
|
||||
}
|
||||
|
||||
var apiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := h.auditLLM.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
|
||||
h.logger.Warn("审计 Agent LLM 调用失败", zap.Error(err), zap.String("tool", toolName))
|
||||
return hitlDecision{
|
||||
Decision: "reject",
|
||||
Comment: "audit agent: LLM 调用失败,保守拒绝",
|
||||
}
|
||||
}
|
||||
if len(apiResponse.Choices) == 0 {
|
||||
return hitlDecision{Decision: "reject", Comment: "audit agent: LLM 无有效响应,保守拒绝"}
|
||||
}
|
||||
msg := apiResponse.Choices[0].Message
|
||||
raw := strings.TrimSpace(msg.Content)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(msg.ReasoningContent)
|
||||
}
|
||||
dec, err := parseAuditAgentLLMContent(raw)
|
||||
if err != nil {
|
||||
snippet := raw
|
||||
if len(snippet) > 240 {
|
||||
snippet = snippet[:240] + "..."
|
||||
}
|
||||
h.logger.Warn("审计 Agent 响应解析失败",
|
||||
zap.Error(err),
|
||||
zap.String("tool", toolName),
|
||||
zap.String("mode", mode),
|
||||
zap.String("snippet", snippet),
|
||||
)
|
||||
return hitlDecision{Decision: "reject", Comment: "audit agent: 响应无法解析,保守拒绝"}
|
||||
}
|
||||
if mode != "review_edit" && len(dec.EditedArguments) > 0 {
|
||||
h.logger.Warn("审计 Agent 在审批模式下返回 editedArguments,已忽略",
|
||||
zap.String("tool", toolName),
|
||||
)
|
||||
dec.EditedArguments = nil
|
||||
}
|
||||
if dec.Comment == "" {
|
||||
dec.Comment = "audit agent: " + dec.Decision
|
||||
} else if !strings.HasPrefix(strings.ToLower(dec.Comment), "audit agent") {
|
||||
dec.Comment = "audit agent: " + dec.Comment
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
func (h *AgentHandler) auditLLMModel() string {
|
||||
if h.config != nil && strings.TrimSpace(h.config.OpenAI.Model) != "" {
|
||||
return strings.TrimSpace(h.config.OpenAI.Model)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildAuditAgentReviewInput(hitlMode, toolName string, payload map[string]interface{}) string {
|
||||
review := map[string]interface{}{
|
||||
"hitlMode": normalizeHitlMode(hitlMode),
|
||||
"toolName": strings.TrimSpace(toolName),
|
||||
}
|
||||
if payload != nil {
|
||||
for _, k := range []string{"arguments", "argumentsObj", "command", hitlPayloadUserMessage, hitlPayloadThinking, hitlPayloadReasoningChain, hitlPayloadPlanning} {
|
||||
if v, ok := payload[k]; ok && v != nil && fmt.Sprint(v) != "" {
|
||||
review[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := json.MarshalIndent(review, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"hitlMode":%q,"toolName":%q}`, normalizeHitlMode(hitlMode), toolName)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func parseAuditAgentLLMContent(content string) (hitlDecision, error) {
|
||||
s := strings.TrimSpace(content)
|
||||
if s == "" {
|
||||
return hitlDecision{}, errors.New("empty content")
|
||||
}
|
||||
for _, candidate := range auditAgentJSONCandidates(s) {
|
||||
dec, comment, editedArgs, err := parseAuditAgentDecisionObject(candidate)
|
||||
if err == nil {
|
||||
return hitlDecision{
|
||||
Decision: dec,
|
||||
Comment: comment,
|
||||
EditedArguments: editedArgs,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return hitlDecision{}, fmt.Errorf("no valid decision json in response")
|
||||
}
|
||||
|
||||
func auditAgentJSONCandidates(s string) []string {
|
||||
out := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{})
|
||||
add := func(c string) {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
return
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
add(s)
|
||||
add(stripMarkdownCodeFence(s))
|
||||
if obj := extractFirstJSONObject(s); obj != "" {
|
||||
add(obj)
|
||||
}
|
||||
if obj := extractFirstJSONObject(stripMarkdownCodeFence(s)); obj != "" {
|
||||
add(obj)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripMarkdownCodeFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, fence := range []string{"```json", "```JSON", "```"} {
|
||||
if strings.HasPrefix(s, fence) {
|
||||
s = strings.TrimPrefix(s, fence)
|
||||
}
|
||||
}
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func extractFirstJSONObject(s string) string {
|
||||
start := strings.Index(s, "{")
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
depth := 0
|
||||
inStr := false
|
||||
esc := false
|
||||
for i := start; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if inStr {
|
||||
if esc {
|
||||
esc = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
esc = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inStr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '"':
|
||||
inStr = true
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[start : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAuditAgentDecisionObject(jsonText string) (decision, comment string, editedArgs map[string]interface{}, err error) {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
rawDecision := auditAgentPickString(parsed, "decision", "Decision", "result", "action", "verdict", "决策", "决定")
|
||||
decision = normalizeAuditAgentDecision(rawDecision)
|
||||
if decision == "" {
|
||||
return "", "", nil, fmt.Errorf("missing decision")
|
||||
}
|
||||
comment = auditAgentPickString(parsed, "comment", "Comment", "reason", "message", "rationale", "备注", "理由", "说明")
|
||||
editedArgs = auditAgentPickObject(parsed, "editedArguments", "edited_arguments", "editedArgs")
|
||||
return decision, strings.TrimSpace(comment), editedArgs, nil
|
||||
}
|
||||
|
||||
func auditAgentPickString(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
s := strings.TrimSpace(fmt.Sprint(v))
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func auditAgentPickObject(m map[string]interface{}, keys ...string) map[string]interface{} {
|
||||
for _, k := range keys {
|
||||
v, ok := m[k]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
if len(t) > 0 {
|
||||
return t
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" || s == "{}" {
|
||||
continue
|
||||
}
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &obj); err == nil && len(obj) > 0 {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAuditAgentDecision(v string) string {
|
||||
d := strings.ToLower(strings.TrimSpace(v))
|
||||
switch d {
|
||||
case "approve", "approved", "pass", "passed", "allow", "allowed", "yes", "ok", "accept", "accepted":
|
||||
return "approve"
|
||||
case "reject", "rejected", "deny", "denied", "no", "block", "blocked", "refuse", "refused":
|
||||
return "reject"
|
||||
}
|
||||
switch strings.TrimSpace(v) {
|
||||
case "通过", "批准", "允许", "同意", "放行":
|
||||
return "approve"
|
||||
case "拒绝", "驳回", "禁止", "否决":
|
||||
return "reject"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type hitlAuditStrategyReq struct {
|
||||
AuditAgentPrompt string `json:"auditAgentPrompt"`
|
||||
AuditAgentPromptReviewEdit string `json:"auditAgentPromptReviewEdit"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) GetHITLAuditStrategy(c *gin.Context) {
|
||||
approvalPrompt := config.DefaultHitlAuditAgentPrompt()
|
||||
reviewEditPrompt := config.DefaultHitlAuditAgentPromptReviewEdit()
|
||||
approvalCustom := false
|
||||
reviewEditCustom := false
|
||||
if h.config != nil {
|
||||
approvalPrompt = h.config.Hitl.EffectiveAuditAgentPromptForMode("approval")
|
||||
reviewEditPrompt = h.config.Hitl.EffectiveAuditAgentPromptForMode("review_edit")
|
||||
approvalCustom = strings.TrimSpace(h.config.Hitl.AuditAgentPrompt) != ""
|
||||
reviewEditCustom = strings.TrimSpace(h.config.Hitl.AuditAgentPromptReviewEdit) != ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auditAgentPrompt": approvalPrompt,
|
||||
"auditAgentPromptCustom": approvalCustom,
|
||||
"auditAgentPromptReviewEdit": reviewEditPrompt,
|
||||
"auditAgentPromptReviewEditCustom": reviewEditCustom,
|
||||
"defaultAuditAgentPrompt": config.DefaultHitlAuditAgentPrompt(),
|
||||
"defaultAuditAgentPromptReviewEdit": config.DefaultHitlAuditAgentPromptReviewEdit(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) UpdateHITLAuditStrategy(c *gin.Context) {
|
||||
if h.hitlStrategySaver == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 策略持久化不可用"})
|
||||
return
|
||||
}
|
||||
var req hitlAuditStrategyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
approvalPrompt := strings.TrimSpace(req.AuditAgentPrompt)
|
||||
reviewEditPrompt := strings.TrimSpace(req.AuditAgentPromptReviewEdit)
|
||||
if err := h.hitlStrategySaver.UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt); err != nil {
|
||||
h.logger.Warn("保存审计 Agent 提示词失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "audit_strategy_update", "HITL 审计策略更新", "hitl_config", "audit_agent_prompt", nil)
|
||||
}
|
||||
if h.config != nil {
|
||||
h.config.Hitl.AuditAgentPrompt = approvalPrompt
|
||||
h.config.Hitl.AuditAgentPromptReviewEdit = reviewEditPrompt
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"auditAgentPrompt": config.HitlConfig{AuditAgentPrompt: approvalPrompt}.EffectiveAuditAgentPromptForMode("approval"),
|
||||
"auditAgentPromptCustom": approvalPrompt != "",
|
||||
"auditAgentPromptReviewEdit": config.HitlConfig{AuditAgentPromptReviewEdit: reviewEditPrompt}.EffectiveAuditAgentPromptForMode("review_edit"),
|
||||
"auditAgentPromptReviewEditCustom": reviewEditPrompt != "",
|
||||
})
|
||||
}
|
||||
|
||||
// HitlAuditStrategySaver 持久化审计 Agent 提示词到 config.yaml。
|
||||
type HitlAuditStrategySaver interface {
|
||||
UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt string) error
|
||||
}
|
||||
|
||||
// SetHitlAuditStrategySaver 设置审计策略落盘。
|
||||
func (h *AgentHandler) SetHitlAuditStrategySaver(s HitlAuditStrategySaver) {
|
||||
h.hitlStrategySaver = s
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAuditAgentLLMContentApprove(t *testing.T) {
|
||||
d, err := parseAuditAgentLLMContent(`{"decision":"approve","comment":"与任务一致"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Decision != "approve" || d.Comment != "与任务一致" {
|
||||
t.Fatalf("unexpected %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuditAgentLLMContentReject(t *testing.T) {
|
||||
d, err := parseAuditAgentLLMContent("```json\n{\"decision\":\"reject\",\"comment\":\"风险过高\"}\n```")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Decision != "reject" {
|
||||
t.Fatalf("expected reject, got %s", d.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuditAgentLLMContentInvalid(t *testing.T) {
|
||||
_, err := parseAuditAgentLLMContent(`{"decision":"maybe"}`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid decision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuditAgentLLMContentProseWrapped(t *testing.T) {
|
||||
d, err := parseAuditAgentLLMContent("好的,裁决如下:\n```json\n{\"decision\":\"approve\",\"comment\":\"只读 ls\"}\n```\n以上。")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Decision != "approve" {
|
||||
t.Fatalf("expected approve, got %s", d.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuditAgentLLMContentChineseDecision(t *testing.T) {
|
||||
d, err := parseAuditAgentLLMContent(`{"decision":"通过","comment":"风险低"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Decision != "approve" {
|
||||
t.Fatalf("expected approve, got %s", d.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuditAgentLLMContentWithEditedArguments(t *testing.T) {
|
||||
d, err := parseAuditAgentLLMContent(`{"decision":"approve","comment":"收窄路径","editedArguments":{"path":"/safe"}}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Decision != "approve" {
|
||||
t.Fatalf("expected approve, got %s", d.Decision)
|
||||
}
|
||||
if d.EditedArguments == nil || d.EditedArguments["path"] != "/safe" {
|
||||
t.Fatalf("unexpected edited args: %+v", d.EditedArguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditAgentReviewInputIncludesMode(t *testing.T) {
|
||||
s := buildAuditAgentReviewInput("review_edit", "execute", map[string]interface{}{
|
||||
"arguments": `{"command":"pwd"}`,
|
||||
})
|
||||
if !strings.Contains(s, "review_edit") || !strings.Contains(s, "execute") {
|
||||
t.Fatalf("unexpected input: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditAgentReviewInput(t *testing.T) {
|
||||
s := buildAuditAgentReviewInput("approval", "nmap", map[string]interface{}{
|
||||
"arguments": `{"target":"10.0.0.1"}`,
|
||||
"userMessage": "扫描内网",
|
||||
})
|
||||
if s == "" {
|
||||
t.Fatal("expected non-empty input")
|
||||
}
|
||||
if !strings.Contains(s, "nmap") || !strings.Contains(s, "10.0.0.1") || !strings.Contains(s, "扫描内网") {
|
||||
t.Fatalf("unexpected input: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type hitlCognitionState struct {
|
||||
AssistantMessageID string
|
||||
UserMessage string
|
||||
Thinking string
|
||||
ReasoningChain string
|
||||
Planning string
|
||||
}
|
||||
|
||||
// GetHitlCognition 返回当前运行任务上缓存的本轮 HITL 上下文(不含会话历史)。
|
||||
func (m *AgentTaskManager) GetHitlCognition(conversationID string) hitlCognitionFields {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if m == nil || conversationID == "" {
|
||||
return hitlCognitionFields{}
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.tasks[conversationID]
|
||||
if !ok || t == nil || t.hitlCognition == nil {
|
||||
return hitlCognitionFields{}
|
||||
}
|
||||
c := t.hitlCognition
|
||||
return hitlCognitionFields{
|
||||
UserMessage: c.UserMessage,
|
||||
Thinking: c.Thinking,
|
||||
ReasoningChain: c.ReasoningChain,
|
||||
Planning: c.Planning,
|
||||
}
|
||||
}
|
||||
|
||||
// ResetHitlCognition 新任务开始时重置本轮 HITL 上下文。
|
||||
func (m *AgentTaskManager) ResetHitlCognition(conversationID, userMessage string) {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if m == nil || conversationID == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[conversationID]
|
||||
if !ok || t == nil {
|
||||
return
|
||||
}
|
||||
t.hitlCognition = &hitlCognitionState{UserMessage: strings.TrimSpace(userMessage)}
|
||||
}
|
||||
|
||||
// SetHitlAssistantMessageID 记录当前助手消息 ID,供 HITL 与 DB 回退对齐。
|
||||
func (m *AgentTaskManager) SetHitlAssistantMessageID(conversationID, assistantMessageID string) {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
assistantMessageID = strings.TrimSpace(assistantMessageID)
|
||||
if m == nil || conversationID == "" || assistantMessageID == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[conversationID]
|
||||
if !ok || t == nil {
|
||||
return
|
||||
}
|
||||
if t.hitlCognition == nil {
|
||||
t.hitlCognition = &hitlCognitionState{}
|
||||
}
|
||||
t.hitlCognition.AssistantMessageID = assistantMessageID
|
||||
}
|
||||
|
||||
// UpdateHitlCognitionSnapshot 从进行中的进度流快照更新 thinking / reasoning / planning。
|
||||
func (m *AgentTaskManager) UpdateHitlCognitionSnapshot(conversationID, assistantMessageID, thinking, reasoningChain, planning string) {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if m == nil || conversationID == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[conversationID]
|
||||
if !ok || t == nil {
|
||||
return
|
||||
}
|
||||
if t.hitlCognition == nil {
|
||||
t.hitlCognition = &hitlCognitionState{}
|
||||
}
|
||||
if id := strings.TrimSpace(assistantMessageID); id != "" {
|
||||
t.hitlCognition.AssistantMessageID = id
|
||||
}
|
||||
if s := strings.TrimSpace(thinking); s != "" {
|
||||
t.hitlCognition.Thinking = s
|
||||
}
|
||||
if s := strings.TrimSpace(reasoningChain); s != "" {
|
||||
t.hitlCognition.ReasoningChain = s
|
||||
}
|
||||
if s := strings.TrimSpace(planning); s != "" {
|
||||
t.hitlCognition.Planning = s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
hitlPayloadUserMessage = "userMessage"
|
||||
hitlPayloadThinking = "thinking"
|
||||
hitlPayloadReasoningChain = "reasoningChain"
|
||||
hitlPayloadPlanning = "planning"
|
||||
)
|
||||
|
||||
type hitlCognitionFields struct {
|
||||
UserMessage string
|
||||
Thinking string
|
||||
ReasoningChain string
|
||||
Planning string
|
||||
}
|
||||
|
||||
func (h *AgentHandler) enrichHitlApprovalPayload(conversationID, assistantMessageID string, payload map[string]interface{}) {
|
||||
if h == nil || payload == nil {
|
||||
return
|
||||
}
|
||||
cog := h.collectHitlCognition(conversationID, assistantMessageID)
|
||||
if s := strings.TrimSpace(cog.UserMessage); s != "" {
|
||||
payload[hitlPayloadUserMessage] = s
|
||||
}
|
||||
if s := strings.TrimSpace(cog.Thinking); s != "" {
|
||||
payload[hitlPayloadThinking] = s
|
||||
}
|
||||
if s := strings.TrimSpace(cog.ReasoningChain); s != "" {
|
||||
payload[hitlPayloadReasoningChain] = s
|
||||
}
|
||||
if s := strings.TrimSpace(cog.Planning); s != "" {
|
||||
payload[hitlPayloadPlanning] = s
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AgentHandler) collectHitlCognition(conversationID, assistantMessageID string) hitlCognitionFields {
|
||||
var out hitlCognitionFields
|
||||
if h.tasks != nil {
|
||||
out = h.tasks.GetHitlCognition(conversationID)
|
||||
}
|
||||
if strings.TrimSpace(out.UserMessage) == "" && h.db != nil {
|
||||
if msg, err := h.db.GetTurnUserMessage(conversationID, assistantMessageID); err == nil {
|
||||
out.UserMessage = msg
|
||||
}
|
||||
}
|
||||
if h.db != nil && assistantMessageID != "" {
|
||||
dbCog, err := h.db.GetAssistantCognitionTexts(assistantMessageID)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(out.Thinking) == "" {
|
||||
out.Thinking = dbCog.Thinking
|
||||
}
|
||||
if strings.TrimSpace(out.ReasoningChain) == "" {
|
||||
out.ReasoningChain = dbCog.ReasoningChain
|
||||
}
|
||||
if strings.TrimSpace(out.Planning) == "" {
|
||||
out.Planning = dbCog.Planning
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func snapshotHitlCognitionFromStreams(thinkingStreams map[string]*thinkingBuf, respPlan *responsePlanAgg) (thinking, reasoningChain, planning string) {
|
||||
if len(thinkingStreams) > 0 {
|
||||
var thinkingParts, reasoningParts []string
|
||||
for _, tb := range thinkingStreams {
|
||||
if tb == nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(tb.b.String())
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if tb.persistAs == "reasoning_chain" {
|
||||
reasoningParts = append(reasoningParts, content)
|
||||
} else {
|
||||
thinkingParts = append(thinkingParts, content)
|
||||
}
|
||||
}
|
||||
thinking = strings.Join(thinkingParts, "\n\n")
|
||||
reasoningChain = strings.Join(reasoningParts, "\n\n")
|
||||
}
|
||||
if respPlan != nil {
|
||||
planning = strings.TrimSpace(respPlan.b.String())
|
||||
}
|
||||
return thinking, reasoningChain, planning
|
||||
}
|
||||
|
||||
func (h *AgentHandler) syncHitlCognitionFromProgress(conversationID, assistantMessageID string, thinkingStreams map[string]*thinkingBuf, respPlan *responsePlanAgg) {
|
||||
if h == nil || h.tasks == nil {
|
||||
return
|
||||
}
|
||||
thinking, reasoning, planning := snapshotHitlCognitionFromStreams(thinkingStreams, respPlan)
|
||||
if thinking == "" && reasoning == "" && planning == "" {
|
||||
return
|
||||
}
|
||||
h.tasks.UpdateHitlCognitionSnapshot(conversationID, assistantMessageID, thinking, reasoning, planning)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEnrichHitlApprovalPayload(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
conv, err := db.CreateConversation("hitl ctx", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("conv: %v", err)
|
||||
}
|
||||
if _, err := db.AddMessage(conv.ID, "user", "scan 10.0.0.1 please", nil); err != nil {
|
||||
t.Fatalf("user msg: %v", err)
|
||||
}
|
||||
asst, err := db.AddMessage(conv.ID, "assistant", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("asst msg: %v", err)
|
||||
}
|
||||
if err := db.AddProcessDetail(asst.ID, conv.ID, "thinking", "need port scan first", nil); err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
|
||||
h := &AgentHandler{db: db, tasks: NewAgentTaskManager()}
|
||||
payload := map[string]interface{}{"toolName": "nmap", "arguments": "{}"}
|
||||
h.enrichHitlApprovalPayload(conv.ID, asst.ID, payload)
|
||||
|
||||
if got := payload["userMessage"]; got != "scan 10.0.0.1 please" {
|
||||
t.Fatalf("userMessage=%v", got)
|
||||
}
|
||||
if got := payload["thinking"]; got != "need port scan first" {
|
||||
t.Fatalf("thinking=%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const hitlPayloadExecutionResult = "executionResult"
|
||||
|
||||
type hitlExecutionResult struct {
|
||||
Success bool `json:"success"`
|
||||
Result string `json:"result,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
RecordedAt time.Time `json:"recordedAt"`
|
||||
}
|
||||
|
||||
type hitlApprovedExecTrack struct {
|
||||
InterruptID string
|
||||
ConversationID string
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
}
|
||||
|
||||
// TrackApprovedHitlExecution 审批通过后登记,待 tool_result 回写执行结果。
|
||||
func (m *HITLManager) TrackApprovedHitlExecution(interruptID, conversationID, toolName, toolCallID string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
interruptID = strings.TrimSpace(interruptID)
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if interruptID == "" || conversationID == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.approvedExec == nil {
|
||||
m.approvedExec = make(map[string][]hitlApprovedExecTrack)
|
||||
}
|
||||
m.approvedExec[conversationID] = append(m.approvedExec[conversationID], hitlApprovedExecTrack{
|
||||
InterruptID: interruptID,
|
||||
ConversationID: conversationID,
|
||||
ToolName: strings.TrimSpace(toolName),
|
||||
ToolCallID: strings.TrimSpace(toolCallID),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *HITLManager) popApprovedInterruptForTool(conversationID, toolCallID, toolName string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
toolCallID = strings.TrimSpace(toolCallID)
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
queue := m.approvedExec[conversationID]
|
||||
if len(queue) == 0 {
|
||||
return ""
|
||||
}
|
||||
idx := -1
|
||||
if toolCallID != "" {
|
||||
for i, t := range queue {
|
||||
if t.ToolCallID == toolCallID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 && toolName != "" {
|
||||
for i, t := range queue {
|
||||
if strings.EqualFold(t.ToolName, toolName) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
id := queue[idx].InterruptID
|
||||
queue = append(queue[:idx], queue[idx+1:]...)
|
||||
if len(queue) == 0 {
|
||||
delete(m.approvedExec, conversationID)
|
||||
} else {
|
||||
m.approvedExec[conversationID] = queue
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mergeHitlPayloadExecutionResult(payloadJSON string, exec hitlExecutionResult) (string, error) {
|
||||
root := make(map[string]interface{})
|
||||
if strings.TrimSpace(payloadJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(payloadJSON), &root)
|
||||
}
|
||||
if root == nil {
|
||||
root = make(map[string]interface{})
|
||||
}
|
||||
root[hitlPayloadExecutionResult] = exec
|
||||
out, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
return payloadJSON, err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) recordHitlToolExecutionResult(conversationID, toolCallID, toolName string, success bool, result string) {
|
||||
if h == nil || h.hitlManager == nil || h.db == nil {
|
||||
return
|
||||
}
|
||||
interruptID := h.hitlManager.popApprovedInterruptForTool(conversationID, toolCallID, toolName)
|
||||
if interruptID == "" {
|
||||
return
|
||||
}
|
||||
var payloadJSON string
|
||||
err := h.db.QueryRow(`SELECT payload FROM hitl_interrupts WHERE id = ?`, interruptID).Scan(&payloadJSON)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
merged, err := mergeHitlPayloadExecutionResult(payloadJSON, hitlExecutionResult{
|
||||
Success: success,
|
||||
Result: strings.TrimSpace(result),
|
||||
ToolName: strings.TrimSpace(toolName),
|
||||
ToolCallID: strings.TrimSpace(toolCallID),
|
||||
RecordedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET payload = ? WHERE id = ?`, merged, interruptID)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeHitlPayloadExecutionResult(t *testing.T) {
|
||||
merged, err := mergeHitlPayloadExecutionResult(`{"userMessage":"hi","toolName":"nmap"}`, hitlExecutionResult{
|
||||
Success: true,
|
||||
Result: "open ports: 80",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(merged), &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root["userMessage"] != "hi" {
|
||||
t.Fatalf("userMessage lost: %v", root["userMessage"])
|
||||
}
|
||||
exec, ok := root["executionResult"].(map[string]interface{})
|
||||
if !ok || exec["success"] != true {
|
||||
t.Fatalf("executionResult missing: %v", root["executionResult"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopApprovedInterruptForTool(t *testing.T) {
|
||||
m := NewHITLManager(nil, nil)
|
||||
m.TrackApprovedHitlExecution("hitl_a", "conv1", "nmap", "tc1")
|
||||
m.TrackApprovedHitlExecution("hitl_b", "conv1", "exec", "")
|
||||
if id := m.popApprovedInterruptForTool("conv1", "tc1", "nmap"); id != "hitl_a" {
|
||||
t.Fatalf("tc1 match=%q", id)
|
||||
}
|
||||
if id := m.popApprovedInterruptForTool("conv1", "", "exec"); id != "hitl_b" {
|
||||
t.Fatalf("tool name match=%q", id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func normalizeHitlReviewer(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "audit_agent", "agent", "ai":
|
||||
return "audit_agent"
|
||||
default:
|
||||
return "human"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHitlDecidedBy(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "audit_agent", "agent", "ai":
|
||||
return "audit_agent"
|
||||
case "system", "timeout":
|
||||
return "system"
|
||||
case "manual":
|
||||
return "manual"
|
||||
default:
|
||||
return "human"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HITLManager) migrateHitlSchemaColumns() {
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||
}
|
||||
|
||||
func hitlInterruptRowToMap(
|
||||
id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string,
|
||||
messageID sql.NullString,
|
||||
decision, comment sql.NullString,
|
||||
createdAt time.Time,
|
||||
decidedAt sql.NullTime,
|
||||
) map[string]interface{} {
|
||||
msgID := ""
|
||||
if messageID.Valid {
|
||||
msgID = messageID.String
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"conversationId": cid,
|
||||
"messageId": msgID,
|
||||
"mode": mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
"status": rowStatus,
|
||||
"decision": decision.String,
|
||||
"comment": comment.String,
|
||||
"decidedBy": decidedBy,
|
||||
"createdAt": createdAt,
|
||||
"decidedAt": func() interface{} {
|
||||
if decidedAt.Valid {
|
||||
return decidedAt.Time
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
|
||||
where, args := h.buildHitlLogsWhere(logs)
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||
return q, args
|
||||
}
|
||||
|
||||
func (h *AgentHandler) buildHitlLogsWhere(logs bool) (string, []interface{}) {
|
||||
q := " WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
if logs {
|
||||
q += " AND status != 'pending'"
|
||||
} else {
|
||||
q += " AND status = 'pending'"
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
|
||||
func (h *AgentHandler) appendHitlListFilters(q string, args []interface{}, c *gin.Context) (string, []interface{}) {
|
||||
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
||||
toolName := strings.TrimSpace(c.Query("toolName"))
|
||||
decision := strings.TrimSpace(c.Query("decision"))
|
||||
decidedBy := strings.TrimSpace(c.Query("decidedBy"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
search := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
if conversationID != "" {
|
||||
q += " AND conversation_id = ?"
|
||||
args = append(args, conversationID)
|
||||
}
|
||||
if toolName != "" {
|
||||
q += " AND tool_name LIKE ?"
|
||||
args = append(args, "%"+toolName+"%")
|
||||
}
|
||||
if decision != "" && decision != "all" {
|
||||
q += " AND decision = ?"
|
||||
args = append(args, decision)
|
||||
}
|
||||
if decidedBy != "" && decidedBy != "all" {
|
||||
q += " AND COALESCE(decided_by,'human') = ?"
|
||||
args = append(args, normalizeHitlDecidedBy(decidedBy))
|
||||
}
|
||||
if status != "" && status != "all" {
|
||||
q += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
q += " AND (id LIKE ? OR conversation_id LIKE ? OR tool_name LIKE ? OR payload LIKE ? OR COALESCE(decision_comment,'') LIKE ?)"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
|
||||
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||
items := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) countHitlQuery(baseQ string, args []interface{}) (int, error) {
|
||||
countQ := "SELECT COUNT(*) FROM (" + baseQ + ") AS hitl_cnt"
|
||||
var total int
|
||||
if err := h.db.QueryRow(countQ, args...).Scan(&total); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) ListHITLLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
q, args := h.buildHitlListQuery(true)
|
||||
q, args = h.appendHitlListFilters(q, args, c)
|
||||
total, err := h.countHitlQuery(q, args)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
q += " ORDER BY COALESCE(decided_at, created_at) DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, pageSize, offset)
|
||||
rows, err := h.db.Query(q, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := h.scanHitlInterruptRows(rows)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize, "total": total, "retentionDays": h.hitlRetentionDays()})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hitlRetentionDays() int {
|
||||
if h.config != nil {
|
||||
return h.config.Hitl.RetentionDaysEffective()
|
||||
}
|
||||
return config.HitlConfig{}.RetentionDaysEffective()
|
||||
}
|
||||
|
||||
// DeleteHITLLogs 批量删除或按筛选清空已决策的人机协同审计日志(不删除 pending)。
|
||||
func (h *AgentHandler) DeleteHITLLogs(c *gin.Context) {
|
||||
var request struct {
|
||||
IDs []string `json:"ids"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var deleted int64
|
||||
var err error
|
||||
if request.All {
|
||||
where, args := h.buildHitlLogsWhere(true)
|
||||
where, args = h.appendHitlListFilters(where, args, c)
|
||||
deleted, err = h.db.DeleteHitlInterruptLogsMatching(where, args)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "logs_clear", "清空人机协同审计日志", "hitl_interrupt", "", map[string]interface{}{
|
||||
"deleted": deleted,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if len(request.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "审计日志 ID 列表不能为空"})
|
||||
return
|
||||
}
|
||||
deleted, err = h.db.DeleteHitlInterruptLogsByIDs(request.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "logs_delete_batch", "批量删除人机协同审计日志", "hitl_interrupt", "", map[string]interface{}{
|
||||
"count": len(request.IDs),
|
||||
"deleted": deleted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功", "deleted": deleted})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
}
|
||||
+221
-15
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -68,16 +69,34 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
||||
h.agentHandler = ah
|
||||
}
|
||||
|
||||
const monitorPageTopTools = 6
|
||||
|
||||
// MonitorStatsSummary 工具调用汇总
|
||||
type MonitorStatsSummary struct {
|
||||
TotalCalls int `json:"totalCalls"`
|
||||
SuccessCalls int `json:"successCalls"`
|
||||
FailedCalls int `json:"failedCalls"`
|
||||
LastCallTime *time.Time `json:"lastCallTime,omitempty"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
}
|
||||
|
||||
// MonitorResponse 监控响应
|
||||
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"`
|
||||
RetentionDays int `json:"retention_days,omitempty"`
|
||||
Executions []*mcp.ToolExecution `json:"executions"`
|
||||
Summary *MonitorStatsSummary `json:"summary"`
|
||||
TopTools []*mcp.ToolStats `json:"topTools"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
}
|
||||
|
||||
// StatsResponse 统计信息响应(Dashboard 等)
|
||||
type StatsResponse struct {
|
||||
Summary *MonitorStatsSummary `json:"summary"`
|
||||
TopTools []*mcp.ToolStats `json:"topTools"`
|
||||
}
|
||||
|
||||
// Monitor 获取监控信息
|
||||
@@ -101,9 +120,9 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
|
||||
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool)
|
||||
toolName := normalizeToolNameFilter(c.Query("tool"))
|
||||
|
||||
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
|
||||
executions, total := h.loadExecutionListWithPagination(page, pageSize, status, toolName)
|
||||
h.enrichExecutionsConversationID(executions)
|
||||
stats := h.loadStats()
|
||||
summary, topTools := h.loadStatsSummary(monitorPageTopTools)
|
||||
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages == 0 {
|
||||
@@ -112,7 +131,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, MonitorResponse{
|
||||
Executions: executions,
|
||||
Stats: stats,
|
||||
Summary: summary,
|
||||
TopTools: topTools,
|
||||
Timestamp: time.Now(),
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -134,6 +154,112 @@ func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
|
||||
return executions
|
||||
}
|
||||
|
||||
func (h *MonitorHandler) loadExecutionListWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
|
||||
if h.db == nil {
|
||||
allExecutions := h.mcpServer.GetAllExecutions()
|
||||
if status != "" || toolName != "" {
|
||||
filtered := make([]*mcp.ToolExecution, 0)
|
||||
for _, exec := range allExecutions {
|
||||
matchStatus := status == "" || exec.Status == status
|
||||
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
|
||||
if matchStatus && matchTool {
|
||||
filtered = append(filtered, exec)
|
||||
}
|
||||
}
|
||||
allExecutions = filtered
|
||||
}
|
||||
total := len(allExecutions)
|
||||
offset := (page - 1) * pageSize
|
||||
end := offset + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
if offset >= total {
|
||||
return []*mcp.ToolExecution{}, total
|
||||
}
|
||||
pageSlice := allExecutions[offset:end]
|
||||
out := make([]*mcp.ToolExecution, 0, len(pageSlice))
|
||||
for _, exec := range pageSlice {
|
||||
if exec == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, slimToolExecution(exec))
|
||||
}
|
||||
return out, total
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
executions, err := h.db.LoadToolExecutionListPage(offset, pageSize, status, toolName)
|
||||
if err != nil {
|
||||
h.logger.Warn("从数据库加载执行记录列表失败,回退到内存数据", zap.Error(err))
|
||||
return h.loadExecutionListWithPaginationFromMemory(page, pageSize, status, toolName)
|
||||
}
|
||||
|
||||
total, err := h.db.CountToolExecutions(status, toolName)
|
||||
if err != nil {
|
||||
h.logger.Warn("获取执行记录总数失败", zap.Error(err))
|
||||
total = offset + len(executions)
|
||||
if len(executions) == pageSize {
|
||||
total = offset + len(executions) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return executions, total
|
||||
}
|
||||
|
||||
func (h *MonitorHandler) loadExecutionListWithPaginationFromMemory(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
|
||||
allExecutions := h.mcpServer.GetAllExecutions()
|
||||
if status != "" || toolName != "" {
|
||||
filtered := make([]*mcp.ToolExecution, 0)
|
||||
for _, exec := range allExecutions {
|
||||
matchStatus := status == "" || exec.Status == status
|
||||
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
|
||||
if matchStatus && matchTool {
|
||||
filtered = append(filtered, exec)
|
||||
}
|
||||
}
|
||||
allExecutions = filtered
|
||||
}
|
||||
total := len(allExecutions)
|
||||
offset := (page - 1) * pageSize
|
||||
end := offset + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
if offset >= total {
|
||||
return []*mcp.ToolExecution{}, total
|
||||
}
|
||||
pageSlice := allExecutions[offset:end]
|
||||
out := make([]*mcp.ToolExecution, 0, len(pageSlice))
|
||||
for _, exec := range pageSlice {
|
||||
if exec == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, slimToolExecution(exec))
|
||||
}
|
||||
return out, total
|
||||
}
|
||||
|
||||
func slimToolExecution(exec *mcp.ToolExecution) *mcp.ToolExecution {
|
||||
if exec == nil {
|
||||
return nil
|
||||
}
|
||||
slim := &mcp.ToolExecution{
|
||||
ID: exec.ID,
|
||||
ToolName: exec.ToolName,
|
||||
Status: exec.Status,
|
||||
StartTime: exec.StartTime,
|
||||
}
|
||||
if exec.EndTime != nil {
|
||||
end := *exec.EndTime
|
||||
slim.EndTime = &end
|
||||
}
|
||||
if exec.Duration > 0 {
|
||||
slim.Duration = exec.Duration
|
||||
}
|
||||
return slim
|
||||
}
|
||||
|
||||
func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
|
||||
if h.db == nil {
|
||||
allExecutions := h.mcpServer.GetAllExecutions()
|
||||
@@ -206,7 +332,78 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
|
||||
return executions, total
|
||||
}
|
||||
|
||||
func (h *MonitorHandler) loadStats() map[string]*mcp.ToolStats {
|
||||
func (h *MonitorHandler) loadStatsSummary(topN int) (*MonitorStatsSummary, []*mcp.ToolStats) {
|
||||
if topN <= 0 {
|
||||
topN = monitorPageTopTools
|
||||
}
|
||||
|
||||
if h.db != nil {
|
||||
result, err := h.db.LoadToolStatsSummary(topN)
|
||||
if err == nil {
|
||||
return dbStatsSummaryToMonitor(result), result.TopTools
|
||||
}
|
||||
h.logger.Warn("从数据库加载统计汇总失败,回退到内存数据", zap.Error(err))
|
||||
}
|
||||
|
||||
stats := h.loadStatsMap()
|
||||
return summarizeToolStats(stats, topN)
|
||||
}
|
||||
|
||||
func dbStatsSummaryToMonitor(result *database.ToolStatsSummaryResult) *MonitorStatsSummary {
|
||||
if result == nil {
|
||||
return &MonitorStatsSummary{}
|
||||
}
|
||||
summary := &MonitorStatsSummary{
|
||||
TotalCalls: result.Summary.TotalCalls,
|
||||
SuccessCalls: result.Summary.SuccessCalls,
|
||||
FailedCalls: result.Summary.FailedCalls,
|
||||
ToolCount: result.Summary.ToolCount,
|
||||
}
|
||||
if result.Summary.LastCallTime != nil {
|
||||
t := *result.Summary.LastCallTime
|
||||
summary.LastCallTime = &t
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func summarizeToolStats(stats map[string]*mcp.ToolStats, topN int) (*MonitorStatsSummary, []*mcp.ToolStats) {
|
||||
summary := &MonitorStatsSummary{}
|
||||
if len(stats) == 0 {
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
all := make([]*mcp.ToolStats, 0, len(stats))
|
||||
for _, stat := range stats {
|
||||
if stat == nil {
|
||||
continue
|
||||
}
|
||||
summary.ToolCount++
|
||||
summary.TotalCalls += stat.TotalCalls
|
||||
summary.SuccessCalls += stat.SuccessCalls
|
||||
summary.FailedCalls += stat.FailedCalls
|
||||
if stat.LastCallTime != nil && (summary.LastCallTime == nil || stat.LastCallTime.After(*summary.LastCallTime)) {
|
||||
t := *stat.LastCallTime
|
||||
summary.LastCallTime = &t
|
||||
}
|
||||
if stat.TotalCalls > 0 {
|
||||
statCopy := *stat
|
||||
all = append(all, &statCopy)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].TotalCalls == all[j].TotalCalls {
|
||||
return all[i].ToolName < all[j].ToolName
|
||||
}
|
||||
return all[i].TotalCalls > all[j].TotalCalls
|
||||
})
|
||||
if len(all) > topN {
|
||||
all = all[:topN]
|
||||
}
|
||||
return summary, all
|
||||
}
|
||||
|
||||
func (h *MonitorHandler) loadStatsMap() map[string]*mcp.ToolStats {
|
||||
// 合并内部MCP服务器和外部MCP管理器的统计信息
|
||||
stats := make(map[string]*mcp.ToolStats)
|
||||
|
||||
@@ -334,7 +531,7 @@ func (h *MonitorHandler) CancelExecution(c *gin.Context) {
|
||||
|
||||
func (h *MonitorHandler) enrichExecutionsConversationID(executions []*mcp.ToolExecution) {
|
||||
for _, exec := range executions {
|
||||
if exec == nil {
|
||||
if exec == nil || exec.Status != "running" {
|
||||
continue
|
||||
}
|
||||
exec.ConversationID = h.conversationIDForRunningExecution(exec.ID)
|
||||
@@ -415,8 +612,17 @@ func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (h *MonitorHandler) GetStats(c *gin.Context) {
|
||||
stats := h.loadStats()
|
||||
c.JSON(http.StatusOK, stats)
|
||||
topN := 30
|
||||
if topStr := c.Query("top"); topStr != "" {
|
||||
if t, err := strconv.Atoi(topStr); err == nil && t > 0 && t <= 100 {
|
||||
topN = t
|
||||
}
|
||||
}
|
||||
summary, topTools := h.loadStatsSummary(topN)
|
||||
c.JSON(http.StatusOK, StatsResponse{
|
||||
Summary: summary,
|
||||
TopTools: topTools,
|
||||
})
|
||||
}
|
||||
|
||||
// CallsTimelinePoint 调用趋势数据点
|
||||
|
||||
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
var cumulativeMCPExecutionIDs []string
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
var emptyResponseContinueAttempt int
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -251,6 +252,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
|
||||
if runErr == nil {
|
||||
mw := &h.config.MultiAgent.EinoMiddleware
|
||||
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, conversationID, result, &emptyResponseContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(conversationID, timeoutCancel)
|
||||
continue
|
||||
}
|
||||
timeoutCancel()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -740,14 +740,21 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"properties": map[string]interface{}{
|
||||
"executions": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "执行记录列表",
|
||||
"description": "执行记录列表(轻量字段,不含 arguments/result)",
|
||||
"items": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/ToolExecution",
|
||||
},
|
||||
},
|
||||
"stats": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "统计信息",
|
||||
"description": "工具调用汇总",
|
||||
},
|
||||
"topTools": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "调用量 Top N 工具",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"timestamp": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -756,20 +763,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"total": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "总数",
|
||||
"description": "执行记录总数",
|
||||
},
|
||||
"page": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "当前页",
|
||||
},
|
||||
"page_size": map[string]interface{}{
|
||||
"pageSize": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "每页数量",
|
||||
},
|
||||
"total_pages": map[string]interface{}{
|
||||
"totalPages": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "总页数",
|
||||
},
|
||||
"retentionDays": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "执行记录保留天数",
|
||||
},
|
||||
},
|
||||
},
|
||||
"ConfigResponse": map[string]interface{}{
|
||||
@@ -1232,6 +1243,34 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "按项目筛选;传 __none__ 表示仅未绑定项目的对话",
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "exclude_grouped",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "为 true 时排除已加入分组的对话(默认在未搜索且未按项目筛选时启用)",
|
||||
"schema": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sort_by",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "排序字段:updated_at(默认)或 created_at",
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"updated_at", "created_at"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// agentSessionContextBlock 注入会话工作目录与项目黑板(用于 system prompt 追加块)。
|
||||
// agentSessionContextBlock 注入会话工作目录、项目黑板与用户原文锚点(用于 system prompt 追加块)。
|
||||
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
var parts []string
|
||||
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
||||
@@ -16,6 +16,9 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
||||
parts = append(parts, bb)
|
||||
}
|
||||
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
|
||||
parts = append(parts, uv)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
@@ -67,6 +70,29 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
||||
return strings.TrimSpace(block)
|
||||
}
|
||||
|
||||
// userVerbatimAnchorBlock 从 messages 表构建用户各轮原文锚点(压缩后仍由 summarization Finalize 刷新)。
|
||||
func (h *AgentHandler) userVerbatimAnchorBlock(conversationID string) string {
|
||||
if h == nil || h.db == nil || h.config == nil {
|
||||
return ""
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return ""
|
||||
}
|
||||
maxRunes := h.config.MultiAgent.UserVerbatimAnchorMaxRunesEffective()
|
||||
if maxRunes < 0 {
|
||||
return ""
|
||||
}
|
||||
msgs, err := h.db.GetMessages(conversationID)
|
||||
if err != nil {
|
||||
if h.logger != nil {
|
||||
h.logger.Warn("构建用户原文锚点失败", zap.String("conversationId", conversationID), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return project.BuildUserVerbatimAnchorBlockFromMessages(msgs, maxRunes)
|
||||
}
|
||||
|
||||
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||
if h == nil || h.db == nil {
|
||||
|
||||
+46
-23
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
|
||||
}
|
||||
|
||||
func (h *RobotHandler) cmdList() string {
|
||||
convs, err := h.db.ListConversations(50, 0, "", "")
|
||||
convs, err := h.db.ListConversations(50, 0, "", "", "")
|
||||
if err != nil {
|
||||
return "获取对话列表失败: " + err.Error()
|
||||
}
|
||||
@@ -711,12 +711,27 @@ type wecomReplyXML struct {
|
||||
Content string `xml:"Content"`
|
||||
}
|
||||
|
||||
// wecomRequireToken 企业微信回调必须配置 Token;未配置时拒绝请求,防止未授权触发 Agent。
|
||||
func (h *RobotHandler) wecomRequireToken(c *gin.Context) (string, bool) {
|
||||
token := strings.TrimSpace(h.config.Robots.Wecom.Token)
|
||||
if token == "" {
|
||||
h.logger.Warn("企业微信已启用但未配置 token,已拒绝回调(请在配置中设置 robots.wecom.token)")
|
||||
c.String(http.StatusForbidden, "")
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
// HandleWecomGET 企业微信 URL 校验(GET)
|
||||
func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
||||
if !h.config.Robots.Wecom.Enabled {
|
||||
c.String(http.StatusNotFound, "")
|
||||
return
|
||||
}
|
||||
token, ok := h.wecomRequireToken(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
|
||||
echostr := c.Query("echostr")
|
||||
msgSignature := c.Query("msg_signature")
|
||||
@@ -724,7 +739,7 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
||||
nonce := c.Query("nonce")
|
||||
|
||||
// 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1
|
||||
signature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, echostr)
|
||||
signature := h.signWecomRequest(token, timestamp, nonce, echostr)
|
||||
if signature != msgSignature {
|
||||
h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature))
|
||||
c.String(http.StatusBadRequest, "invalid signature")
|
||||
@@ -865,27 +880,28 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
||||
}
|
||||
h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
|
||||
|
||||
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段
|
||||
// 若配置了 Token 则必须校验签名,避免未授权请求触发 Agent(防止平台被接管)
|
||||
token := h.config.Robots.Wecom.Token
|
||||
if token != "" {
|
||||
if msgSignature == "" {
|
||||
h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需配置 token 并确保回调携带 msg_signature)")
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
var tmp wecomXML
|
||||
if err := xml.Unmarshal(bodyRaw, &tmp); err != nil {
|
||||
h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt)
|
||||
if expected != msgSignature {
|
||||
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段。
|
||||
// 启用企业微信时必须配置 token 并校验签名,避免未授权请求触发 Agent。
|
||||
token, ok := h.wecomRequireToken(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if msgSignature == "" {
|
||||
h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需确保回调携带 msg_signature)")
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
var tmp wecomXML
|
||||
if err := xml.Unmarshal(bodyRaw, &tmp); err != nil {
|
||||
h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt)
|
||||
if expected != msgSignature {
|
||||
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
|
||||
var body wecomXML
|
||||
@@ -899,6 +915,13 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
||||
// 保存企业 ID(用于明文模式回复)
|
||||
enterpriseID := body.ToUserName
|
||||
|
||||
// 配置了 EncodingAESKey 时必须走加密消息,拒绝明文 XML 绕过
|
||||
if strings.TrimSpace(h.config.Robots.Wecom.EncodingAESKey) != "" && strings.TrimSpace(body.Encrypt) == "" {
|
||||
h.logger.Warn("企业微信已配置加密模式但收到明文消息,已拒绝")
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 加密模式:先解密再解析内层 XML
|
||||
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
|
||||
h.logger.Debug("企业微信进入加密模式解密流程")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func newWecomTestHandler(token string, aesKey string) *RobotHandler {
|
||||
return &RobotHandler{
|
||||
config: &config.Config{
|
||||
Robots: config.RobotsConfig{
|
||||
Wecom: config.RobotWecomConfig{
|
||||
Enabled: true,
|
||||
Token: token,
|
||||
EncodingAESKey: aesKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWecomPOST_rejectsWhenTokenEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := newWecomTestHandler("", "")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `<?xml version="1.0"?><xml><FromUserName>attacker</FromUserName><MsgType>text</MsgType><Content>hi</Content></xml>`
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/robot/wecom", strings.NewReader(body))
|
||||
|
||||
h.HandleWecomPOST(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
if w.Body.String() == "success" {
|
||||
t.Fatal("expected rejection, got success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWecomPOST_rejectsPlaintextWhenEncryptionConfigured(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := newWecomTestHandler("secret-token", "abcdefghijklmnopqrstuvwxyz0123456789ABCD")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `<?xml version="1.0"?><xml><FromUserName>attacker</FromUserName><MsgType>text</MsgType><Content>hi</Content></xml>`
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/robot/wecom?timestamp=1&nonce=2&msg_signature=fake", strings.NewReader(body))
|
||||
|
||||
h.HandleWecomPOST(c)
|
||||
|
||||
if w.Body.String() == "success" {
|
||||
t.Fatal("expected rejection for plaintext in encryption mode, got success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWecomGET_rejectsWhenTokenEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := newWecomTestHandler("", "")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/robot/wecom?msg_signature=x×tamp=1&nonce=2&echostr=abc", nil)
|
||||
|
||||
h.HandleWecomGET(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func shouldPersistEinoAgentTraceAfterRunError(baseCtx context.Context) bool {
|
||||
// AgentTask 描述正在运行的Agent任务
|
||||
type AgentTask struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Status string `json:"status"`
|
||||
@@ -42,6 +43,9 @@ type AgentTask struct {
|
||||
// activeEinoExecuteAbortNote AbortActiveEinoExecute 写入的用户说明,由 execute 收尾时合并进工具结果
|
||||
activeEinoExecuteAbortNote string
|
||||
|
||||
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
|
||||
hitlCognition *hitlCognitionState
|
||||
|
||||
cancel func(error)
|
||||
}
|
||||
|
||||
@@ -233,6 +237,7 @@ func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
||||
// CompletedTask 已完成的任务(用于历史记录)
|
||||
type CompletedTask struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
@@ -247,6 +252,8 @@ type AgentTaskManager struct {
|
||||
maxHistorySize int // 最大历史记录数
|
||||
historyRetention time.Duration // 历史记录保留时间
|
||||
eventBus *TaskEventBus // 可选:任务结束时关闭镜像 SSE 订阅
|
||||
// toolCanceler 在用户整轮停止任务时终止当前 MCP 工具(非「中断并继续」)。
|
||||
toolCanceler func(conversationID string)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -277,6 +284,13 @@ func (m *AgentTaskManager) SetTaskEventBus(b *TaskEventBus) {
|
||||
m.eventBus = b
|
||||
}
|
||||
|
||||
// SetToolCanceler 设置整轮停止任务时终止当前 MCP 工具的回调(由 AgentHandler 注入)。
|
||||
func (m *AgentTaskManager) SetToolCanceler(fn func(conversationID string)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.toolCanceler = fn
|
||||
}
|
||||
|
||||
// GetTask 返回运行中任务(无则 nil)。
|
||||
func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask {
|
||||
m.mu.RLock()
|
||||
@@ -343,6 +357,7 @@ func (m *AgentTaskManager) StartTask(conversationID, message string, cancel cont
|
||||
}
|
||||
|
||||
m.tasks[conversationID] = task
|
||||
task.hitlCognition = &hitlCognitionState{UserMessage: strings.TrimSpace(message)}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
@@ -372,14 +387,21 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
||||
task.InterruptContinueNote = ""
|
||||
}
|
||||
cancel := task.cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
if cause == nil {
|
||||
cause = ErrTaskCancelled
|
||||
}
|
||||
var toolCanceler func(string)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
toolCanceler = m.toolCanceler
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel(cause)
|
||||
}
|
||||
if toolCanceler != nil {
|
||||
toolCanceler(conversationID)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
)
|
||||
|
||||
func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
called := false
|
||||
tm.SetToolCanceler(func(conversationID string) {
|
||||
if conversationID == "conv-1" {
|
||||
called = true
|
||||
}
|
||||
})
|
||||
|
||||
_, cancel := context.WithCancelCause(context.Background())
|
||||
_, err := tm.StartTask("conv-1", "hello", cancel)
|
||||
if err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
|
||||
ok, err := tm.CancelTask("conv-1", ErrTaskCancelled)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected tool canceler to be invoked on full task cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
called := false
|
||||
tm.SetToolCanceler(func(conversationID string) {
|
||||
called = true
|
||||
})
|
||||
|
||||
_, cancel := context.WithCancelCause(context.Background())
|
||||
_, err := tm.StartTask("conv-1", "hello", cancel)
|
||||
if err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
|
||||
ok, err := tm.CancelTask("conv-1", multiagent.ErrInterruptContinue)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("tool canceler must not run for interrupt-continue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var gotCause error
|
||||
tm.SetToolCanceler(func(conversationID string) {
|
||||
if conversationID == "conv-2" {
|
||||
gotCause = ErrTaskCancelled
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
if _, err := tm.StartTask("conv-2", "hello", cancel); err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tm.CancelTask("conv-2", nil); err != nil {
|
||||
t.Fatalf("CancelTask: %v", err)
|
||||
}
|
||||
if !errors.Is(context.Cause(ctx), ErrTaskCancelled) {
|
||||
t.Fatalf("expected ErrTaskCancelled cause, got %v", context.Cause(ctx))
|
||||
}
|
||||
if gotCause != ErrTaskCancelled {
|
||||
t.Fatalf("expected tool canceler path for default cancel cause")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RunCommandWS 交互式 PTY 终端依赖 Unix PTY(见 terminal_ws_unix.go);Windows 暂不支持。
|
||||
func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "Interactive WebSocket terminal is not supported on Windows; use POST /terminal/run or /terminal/run/stream instead.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package hitl
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const retentionPurgeInterval = time.Hour
|
||||
|
||||
// Service manages HITL audit log retention (decided hitl_interrupts rows).
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
cfg *config.Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService creates a HITL audit log retention service.
|
||||
func NewService(db *database.DB, cfg *config.Config, logger *zap.Logger) *Service {
|
||||
return &Service{db: db, cfg: cfg, logger: logger}
|
||||
}
|
||||
|
||||
// RetentionDays returns configured retention; 0 means keep forever.
|
||||
func (s *Service) RetentionDays() int {
|
||||
if s == nil || s.cfg == nil {
|
||||
return config.HitlConfig{}.RetentionDaysEffective()
|
||||
}
|
||||
return s.cfg.Hitl.RetentionDaysEffective()
|
||||
}
|
||||
|
||||
// PurgeExpired deletes decided HITL log rows older than retention_days when configured.
|
||||
func (s *Service) PurgeExpired() {
|
||||
if s == nil || s.db == nil || s.cfg == nil {
|
||||
return
|
||||
}
|
||||
days := s.cfg.Hitl.RetentionDaysEffective()
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
n, err := s.db.PurgeHitlInterruptLogsBefore(cutoff)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("清理过期人机协同审计日志失败", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if n > 0 && s.logger != nil {
|
||||
s.logger.Info("已清理过期人机协同审计日志", zap.Int64("deleted", n), zap.Int("retention_days", days))
|
||||
}
|
||||
}
|
||||
|
||||
// StartRetentionLoop periodically purges expired HITL audit log rows.
|
||||
func StartRetentionLoop(s *Service, logger *zap.Logger) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(retentionPurgeInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
s.PurgeExpired()
|
||||
if logger != nil {
|
||||
logger.Debug("hitl audit log retention tick completed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package hitl
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
appconfig "cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestServicePurgeExpired_respectsZeroRetention(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
decision TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
decided_at DATETIME
|
||||
)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
|
||||
old := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339)
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||
VALUES ('old-1', 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, old, old); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
zero := 0
|
||||
svc := NewService(db, &appconfig.Config{
|
||||
Hitl: appconfig.HitlConfig{RetentionDays: &zero},
|
||||
}, zap.NewNop())
|
||||
svc.PurgeExpired()
|
||||
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'old-1'`).Scan(new(string)); err != nil {
|
||||
t.Fatalf("record should remain when retention_days=0: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -814,6 +814,23 @@ func (m *ExternalMCPManager) CancelToolExecution(id string) bool {
|
||||
return m.CancelToolExecutionWithNote(id, "")
|
||||
}
|
||||
|
||||
// ActiveRunningExecutionIDs 返回当前进程内仍登记 cancel 的外部 MCP executionId 快照。
|
||||
func (m *ExternalMCPManager) ActiveRunningExecutionIDs() map[string]struct{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.runningCancels) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(m.runningCancels))
|
||||
for id := range m.runningCancels {
|
||||
out[id] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// updateStats 更新统计信息
|
||||
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -1170,6 +1170,23 @@ func (s *Server) CancelToolExecution(id string) bool {
|
||||
return s.CancelToolExecutionWithNote(id, "")
|
||||
}
|
||||
|
||||
// ActiveRunningExecutionIDs 返回当前进程内仍登记 cancel 的 executionId 快照。
|
||||
func (s *Server) ActiveRunningExecutionIDs() map[string]struct{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.runningCancelsMu.Lock()
|
||||
defer s.runningCancelsMu.Unlock()
|
||||
if len(s.runningCancels) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(s.runningCancels))
|
||||
for id := range s.runningCancels {
|
||||
out[id] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// initDefaultPrompts 初始化默认提示词模板
|
||||
func (s *Server) initDefaultPrompts() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
staleRunningMinAge = 45 * time.Second
|
||||
staleRunningReconcileGap = 2 * time.Minute
|
||||
)
|
||||
|
||||
// ExecutionReconciler 在启动或运行期将无对应协程的 running 执行记录收尾为 cancelled。
|
||||
type ExecutionReconciler struct {
|
||||
db *database.DB
|
||||
mcpServer *mcp.Server
|
||||
externalMgr *mcp.ExternalMCPManager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewExecutionReconciler creates a reconciler for orphaned MCP tool executions.
|
||||
func NewExecutionReconciler(db *database.DB, mcpServer *mcp.Server, externalMgr *mcp.ExternalMCPManager, logger *zap.Logger) *ExecutionReconciler {
|
||||
return &ExecutionReconciler{
|
||||
db: db,
|
||||
mcpServer: mcpServer,
|
||||
externalMgr: externalMgr,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileOnStartup marks every persisted running row as cancelled (safe right after process start).
|
||||
func (r *ExecutionReconciler) ReconcileOnStartup() {
|
||||
if r == nil || r.db == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
n, err := r.db.CancelOrphanedRunningToolExecutions(now, "执行已中断(服务重启)")
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("启动时清理孤儿 running 工具执行记录失败", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if n > 0 && r.logger != nil {
|
||||
r.logger.Info("启动时已收尾孤儿 running 工具执行记录", zap.Int64("count", n))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExecutionReconciler) activeExecutionIDs() map[string]struct{} {
|
||||
ids := make(map[string]struct{})
|
||||
if r.mcpServer != nil {
|
||||
for id := range r.mcpServer.ActiveRunningExecutionIDs() {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if r.externalMgr != nil {
|
||||
for id := range r.externalMgr.ActiveRunningExecutionIDs() {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ReconcileStaleRunning finalizes running rows that are not tracked in-memory and older than staleRunningMinAge.
|
||||
func (r *ExecutionReconciler) ReconcileStaleRunning() {
|
||||
if r == nil || r.db == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
n, err := r.db.FinalizeStaleRunningToolExecutions(now, staleRunningMinAge, r.activeExecutionIDs(), "执行已中断(会话已结束)")
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("定期收尾 stale running 工具执行记录失败", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if n > 0 && r.logger != nil {
|
||||
r.logger.Info("已收尾 stale running 工具执行记录", zap.Int64("count", n))
|
||||
}
|
||||
}
|
||||
|
||||
// StartStaleRunningReconcileLoop periodically reconciles orphaned running tool executions.
|
||||
func StartStaleRunningReconcileLoop(r *ExecutionReconciler, logger *zap.Logger) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(staleRunningReconcileGap)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
r.ReconcileStaleRunning()
|
||||
if logger != nil {
|
||||
logger.Debug("monitor stale running reconcile tick completed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestExecutionReconciler_ReconcileOnStartup(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "run-1", ToolName: "hydra", Status: "running", StartTime: time.Now().Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveToolExecution: %v", err)
|
||||
}
|
||||
|
||||
r := NewExecutionReconciler(db, mcp.NewServer(zap.NewNop()), nil, zap.NewNop())
|
||||
r.ReconcileOnStartup()
|
||||
|
||||
got, err := db.GetToolExecution("run-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution: %v", err)
|
||||
}
|
||||
if got.Status != "cancelled" {
|
||||
t.Fatalf("expected cancelled after startup reconcile, got %s", got.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
// InitADK configures global Eino ADK settings. Call once at process startup before
|
||||
// any ADK middleware or agents are created.
|
||||
func InitADK() error {
|
||||
if err := adk.SetLanguage(adk.LanguageChinese); err != nil {
|
||||
return fmt.Errorf("adk set language: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -299,6 +299,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
|
||||
var toolResultSent sync.Map // toolCallID -> struct{};ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
|
||||
tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) {
|
||||
// 仅由 ADK schema.Tool 事件调用;MCP/execute 桥在 reduction 前的 ToolInvokeNotify 不得推送 tool_result,
|
||||
// 否则全量输出会先占位并触发 toolResultSent 去重,导致 UI/监控展示与 agent 实际收到的截断正文不一致。
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
@@ -316,6 +318,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"isError": isErr,
|
||||
"result": content,
|
||||
"resultPreview": preview,
|
||||
"agentFacing": true, // 与 reduction 后送入 ChatModel 的正文一致,供前端展示
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": agentName,
|
||||
"einoRole": einoRoleTag(agentName),
|
||||
@@ -350,25 +353,6 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
}
|
||||
if args.ToolInvokeNotify != nil {
|
||||
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
// Eino execute / MCP 桥在工具返回时 Fire;若 ADK schema.Tool 事件迟迟不到,此处立即推送
|
||||
// tool_result 解除 UI「执行中」。tryEmitToolResultProgress 经 toolResultSent 去重,ADK 晚到不重复。
|
||||
isErr := !success || invokeErr != nil
|
||||
body := einoToolResultBody(content)
|
||||
if einoToolResultIsError(toolName, content) {
|
||||
isErr = true
|
||||
}
|
||||
if tail := friendlyEinoExecuteInvokeTail(invokeErr); tail != "" {
|
||||
if body == "" {
|
||||
body = tail
|
||||
} else if !strings.Contains(body, tail) {
|
||||
body = strings.TrimSpace(body) + "\n\n" + tail
|
||||
}
|
||||
}
|
||||
tryEmitToolResultProgress(toolName, body, toolCallID, isErr, einoAgent)
|
||||
})
|
||||
}
|
||||
|
||||
if args.EinoCallbacks != nil {
|
||||
ctx = einoobserve.AttachAgentRunCallbacks(ctx, args.EinoCallbacks, einoobserve.Params{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
const defaultEmptyResponseContinueMaxAttempts = 5
|
||||
|
||||
// IsEinoEmptyResponseResult 判断 Run 是否以「未捕获助手正文」占位结束(非真实用户可见回复)。
|
||||
func IsEinoEmptyResponseResult(result *RunResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return isEinoEmptyResponseText(result.Response)
|
||||
}
|
||||
|
||||
func isEinoEmptyResponseText(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(s, "no assistant text was captured") ||
|
||||
strings.Contains(s, "未捕获到助手文本输出")
|
||||
}
|
||||
|
||||
// HasEinoResumeTrace 轨迹非空,续跑才有上下文可恢复。
|
||||
func HasEinoResumeTrace(result *RunResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.TrimSpace(result.LastAgentTraceInput)
|
||||
return s != "" && s != "[]" && s != "null"
|
||||
}
|
||||
|
||||
// EmptyResponseContinueMaxAttemptsFromConfig 无助手正文时 Handler 层退避续跑上限;0=默认 5。
|
||||
func EmptyResponseContinueMaxAttemptsFromConfig(mw *config.MultiAgentEinoMiddlewareConfig) int {
|
||||
if mw != nil && mw.EmptyResponseContinueMaxAttempts > 0 {
|
||||
return mw.EmptyResponseContinueMaxAttempts
|
||||
}
|
||||
return defaultEmptyResponseContinueMaxAttempts
|
||||
}
|
||||
|
||||
// EmptyResponseContinueBackoff 与 run_retry 相同指数退避(2s, 4s, 8s… capped)。
|
||||
func EmptyResponseContinueBackoff(attempt int, mw *config.MultiAgentEinoMiddlewareConfig) time.Duration {
|
||||
maxBackoff := defaultEinoRunRetryMaxBackoff
|
||||
if mw != nil && mw.RunRetryMaxBackoffSec > 0 {
|
||||
maxBackoff = time.Duration(mw.RunRetryMaxBackoffSec) * time.Second
|
||||
}
|
||||
return einoTransientRetryBackoff(attempt, maxBackoff)
|
||||
}
|
||||
|
||||
// FormatEmptyResponseContinueUserMessage 系统自动续跑时注入的 user 轮次(不写入 messages 表气泡)。
|
||||
func FormatEmptyResponseContinueUserMessage() string {
|
||||
return strings.TrimSpace(`【系统自动续跑 / Auto resume】
|
||||
上一轮 Eino 会话未产出可见助手正文(可能流式中断或仅完成工具调用)。请基于已有轨迹与工具结果继续推进,并给出阶段性总结;勿重复已完成步骤。`)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsEinoEmptyResponseResult(t *testing.T) {
|
||||
empty := &RunResult{
|
||||
Response: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
||||
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||
}
|
||||
if !IsEinoEmptyResponseResult(empty) {
|
||||
t.Fatal("expected empty placeholder response")
|
||||
}
|
||||
ok := &RunResult{Response: "扫描完成,发现 2 个开放端口。"}
|
||||
if IsEinoEmptyResponseResult(ok) {
|
||||
t.Fatalf("expected real response, got placeholder match")
|
||||
}
|
||||
if IsEinoEmptyResponseResult(nil) {
|
||||
t.Fatal("nil result should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasEinoResumeTrace(t *testing.T) {
|
||||
if HasEinoResumeTrace(nil) {
|
||||
t.Fatal("nil")
|
||||
}
|
||||
if HasEinoResumeTrace(&RunResult{LastAgentTraceInput: "[]"}) {
|
||||
t.Fatal("enable resume on empty trace")
|
||||
}
|
||||
if !HasEinoResumeTrace(&RunResult{LastAgentTraceInput: `[{"role":"user","content":"hi"}]`}) {
|
||||
t.Fatal("expected resume trace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyResponseContinueMaxAttemptsFromConfig(t *testing.T) {
|
||||
if got := EmptyResponseContinueMaxAttemptsFromConfig(nil); got != defaultEmptyResponseContinueMaxAttempts {
|
||||
t.Fatalf("default: got %d want %d", got, defaultEmptyResponseContinueMaxAttempts)
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
|
||||
req.RunInBackendGround = true
|
||||
}
|
||||
req.Command = security.PrepareNonInteractiveShellCommand(prependPythonUnbufferedEnv(req.Command))
|
||||
req.Command = prependPythonUnbufferedEnv(req.Command)
|
||||
convID := mcp.MCPConversationIDFromContext(ctx)
|
||||
execReg := mcp.EinoExecuteRunRegistryFromContext(ctx)
|
||||
|
||||
|
||||
@@ -61,12 +61,6 @@ func TestEinoStreamingShellWrap_PreparesNonInteractiveCommand(t *testing.T) {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(inner.lastCommand, "exec </dev/null") {
|
||||
t.Fatalf("missing stdin redirect in inner command: %q", inner.lastCommand)
|
||||
}
|
||||
if !strings.Contains(inner.lastCommand, "GIT_PAGER=cat") {
|
||||
t.Fatalf("missing pager export in inner command: %q", inner.lastCommand)
|
||||
}
|
||||
if !strings.Contains(inner.lastCommand, "PYTHONUNBUFFERED=1") {
|
||||
t.Fatalf("missing python unbuffer in inner command: %q", inner.lastCommand)
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ func RunEinoSingleChatModelAgent(
|
||||
Name: einoSingleAgentName,
|
||||
Description: "Eino ADK ChatModelAgent with MCP tools for authorized security testing.",
|
||||
Instruction: ins,
|
||||
GenModelInput: literalInstructionGenModelInput,
|
||||
Model: mainModel,
|
||||
ToolsConfig: mainToolsCfg,
|
||||
MaxIterations: maxIter,
|
||||
|
||||
@@ -150,6 +150,7 @@ func newEinoSummarizationMiddleware(
|
||||
}
|
||||
if appCfg != nil {
|
||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
@@ -413,6 +414,36 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshUserVerbatimAnchorInMessages 压缩后从 messages 表刷新 system 中的用户原文锚点。
|
||||
func refreshUserVerbatimAnchorInMessages(msgs []adk.Message, db *database.DB, conversationID string, maxRunes int, logger *zap.Logger) []adk.Message {
|
||||
if maxRunes < 0 || db == nil {
|
||||
return msgs
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return msgs
|
||||
}
|
||||
rows, err := db.GetMessages(conversationID)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("summarization: 刷新用户原文锚点失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
block := project.BuildUserVerbatimAnchorBlockFromMessages(rows, maxRunes)
|
||||
if block == "" {
|
||||
return msgs
|
||||
}
|
||||
out := project.RefreshUserVerbatimAnchorInMessages(msgs, block)
|
||||
if logger != nil {
|
||||
logger.Info("summarization: 已刷新用户原文锚点", zap.String("conversationId", conversationID))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
||||
tc := agent.NewTikTokenCounter()
|
||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"github.com/bytedance/sonic"
|
||||
copenai "cyberstrike-ai/internal/openai"
|
||||
)
|
||||
|
||||
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
|
||||
// chat-completions JSON body. Applied only to summarization Generate calls via
|
||||
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
|
||||
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
changed := false
|
||||
for _, key := range []string{
|
||||
"thinking",
|
||||
"reasoning_effort",
|
||||
"output_config",
|
||||
"reasoning",
|
||||
} {
|
||||
if _, ok := payload[key]; ok {
|
||||
delete(payload, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return rawBody, nil
|
||||
}
|
||||
out, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return rawBody, err
|
||||
}
|
||||
return out, nil
|
||||
return copenai.StripReasoningFromChatCompletionBody(rawBody)
|
||||
}
|
||||
|
||||
@@ -409,9 +409,9 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
"需要写入请使用 upsert_project_fact。",
|
||||
project.FactIndexSectionEndMarker,
|
||||
"",
|
||||
"# Skills System",
|
||||
"**How to Use Skills**",
|
||||
"Remember: Skills make you more capable",
|
||||
transcriptSkillsSystemMarker,
|
||||
"**如何使用 Skill(技能)(渐进式展示):**",
|
||||
"记住:Skill 让你更加强大和稳定",
|
||||
}, "\n")
|
||||
|
||||
out := sanitizeSystemContentForTranscript(system)
|
||||
@@ -421,7 +421,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
if strings.Contains(out, "- nmap") || strings.Contains(out, "高强度扫描要求") {
|
||||
t.Fatalf("static persona should be stripped: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "# Skills System") || strings.Contains(out, "How to Use Skills") {
|
||||
if strings.Contains(out, transcriptSkillsSystemMarker) || strings.Contains(out, "如何使用 Skill") {
|
||||
t.Fatalf("skills boilerplate should be stripped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, transcriptStaticSystemOmitNote) {
|
||||
@@ -435,7 +435,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x)\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n# Skills System\nboiler"),
|
||||
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x)\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n" + transcriptSkillsSystemMarker + "\nboiler"),
|
||||
schema.UserMessage("hello"),
|
||||
schema.AssistantMessage("reply", nil),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ const (
|
||||
transcriptStaticSystemOmitNote = "[static system prompt omitted — unchanged in live context after compaction]"
|
||||
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
|
||||
transcriptPersonaStartMarker = "你是CyberStrikeAI"
|
||||
transcriptSkillsSystemMarker = "# Skills System"
|
||||
// ADK LanguageChinese injects skill middleware prompt with this header (see eino adk/middlewares/skill/prompt.go).
|
||||
transcriptSkillsSystemMarker = "# Skill 系统"
|
||||
transcriptSkillsSystemMarkerEnglish = "# Skills System"
|
||||
)
|
||||
|
||||
type transcriptToolCall struct {
|
||||
@@ -86,13 +88,23 @@ func stripToolNamesIndexFromSystem(s string) string {
|
||||
}
|
||||
|
||||
func stripSkillsSystemBoilerplate(s string) string {
|
||||
idx := strings.Index(s, transcriptSkillsSystemMarker)
|
||||
idx := indexFirstSubstring(s, transcriptSkillsSystemMarker, transcriptSkillsSystemMarkerEnglish)
|
||||
if idx < 0 {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return strings.TrimSpace(s[:idx])
|
||||
}
|
||||
|
||||
func indexFirstSubstring(s string, markers ...string) int {
|
||||
first := -1
|
||||
for _, m := range markers {
|
||||
if i := strings.Index(s, m); i >= 0 && (first < 0 || i < first) {
|
||||
first = i
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func extractProjectBlackboardSection(s string) string {
|
||||
start := strings.Index(s, project.FactIndexSectionStartMarker)
|
||||
if start < 0 {
|
||||
|
||||
@@ -190,29 +190,26 @@ func einoMessagesForRunRestart(args *einoADKRunLoopArgs, baseMsgs, accumulated [
|
||||
return append([]adk.Message(nil), baseMsgs...), einoRestartContextInitial
|
||||
}
|
||||
|
||||
// adkMessagesHasUserContent 从尾部向前查找,是否已有与 want 相同的 user 消息(避免重复 append)。
|
||||
// adkMessagesHasUserContent reports whether the conversation tail is already a user turn
|
||||
// with the given content. Only the last message counts: matching text in an earlier round
|
||||
// (e.g. user repeats the same prompt after an assistant reply) must not suppress appending
|
||||
// the new user turn — Claude 4.6+ rejects requests whose final message is assistant.
|
||||
func adkMessagesHasUserContent(msgs []adk.Message, want string) bool {
|
||||
want = strings.TrimSpace(want)
|
||||
if want == "" {
|
||||
return true
|
||||
}
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.Role == schema.User {
|
||||
return strings.TrimSpace(m.Content) == want
|
||||
}
|
||||
if m.Role == schema.Assistant || m.Role == schema.Tool {
|
||||
continue
|
||||
}
|
||||
break
|
||||
if len(msgs) == 0 {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
last := msgs[len(msgs)-1]
|
||||
if last == nil || last.Role != schema.User {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(last.Content) == want
|
||||
}
|
||||
|
||||
// appendUserMessageIfNeeded 在 history 轨迹之后追加本轮 user 消息(仅当轨迹中尚未包含该句)。
|
||||
// appendUserMessageIfNeeded 在 history 轨迹之后追加本轮 user 消息(仅当尾部已是相同 user 句)。
|
||||
func appendUserMessageIfNeeded(msgs []adk.Message, userMessage string) []adk.Message {
|
||||
if strings.TrimSpace(userMessage) == "" || adkMessagesHasUserContent(msgs, userMessage) {
|
||||
return msgs
|
||||
|
||||
@@ -143,3 +143,18 @@ func TestAppendUserMessageIfNeeded(t *testing.T) {
|
||||
t.Fatalf("should not duplicate user message: len=%d", len(dup))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendUserMessageIfNeeded_repeatPromptAfterAssistant(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []adk.Message{
|
||||
schema.UserMessage("扫描 example.com"),
|
||||
schema.AssistantMessage("开始扫描...", nil),
|
||||
}
|
||||
out := appendUserMessageIfNeeded(msgs, "扫描 example.com")
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("should append new user turn after assistant reply: len=%d", len(out))
|
||||
}
|
||||
if out[2].Role != schema.User || out[2].Content != "扫描 example.com" {
|
||||
t.Fatalf("tail should be repeated user prompt, got role=%s content=%q", out[2].Role, out[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// literalInstructionGenModelInput passes Instruction through as a system message without
|
||||
// FString template formatting. Eino defaultGenModelInput formats instruction whenever
|
||||
// SessionValues exist; prompts with literal curly braces (project blackboard "{关系边: ...}",
|
||||
// JSON examples, link syntax) then fail with "could not find key".
|
||||
//
|
||||
// Matches eino/adk/prebuilt/deep genModelInput — the supported fix per Eino docs.
|
||||
func literalInstructionGenModelInput(ctx context.Context, instruction string, input *adk.AgentInput) ([]adk.Message, error) {
|
||||
msgs := make([]adk.Message, 0, len(input.Messages)+1)
|
||||
if instruction != "" {
|
||||
msgs = append(msgs, schema.SystemMessage(instruction))
|
||||
}
|
||||
msgs = append(msgs, input.Messages...)
|
||||
return msgs, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestLiteralInstructionGenModelInput_PreservesLiteralCurlyBraces(t *testing.T) {
|
||||
t.Parallel()
|
||||
instruction := "- [finding/x] summary {关系边: discovered_on←target/dev}\n" +
|
||||
"如 finding 上 {from:target/*, type:discovered_on}"
|
||||
msgs, err := literalInstructionGenModelInput(context.Background(), instruction, &adk.AgentInput{
|
||||
Messages: []adk.Message{schema.UserMessage("继续")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != schema.System {
|
||||
t.Fatalf("first message must be system, got %s", msgs[0].Role)
|
||||
}
|
||||
for _, want := range []string{"{关系边:", "{from:target/*, type:discovered_on}"} {
|
||||
if !strings.Contains(msgs[0].Content, want) {
|
||||
t.Fatalf("system content missing %q: %q", want, msgs[0].Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -75,8 +74,8 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
if err != nil {
|
||||
if IsHumanRejectError(err) {
|
||||
// Human rejection should be a soft tool result so the model can continue iterating.
|
||||
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
||||
input.Name, strings.TrimSpace(err.Error()))
|
||||
// tool_search 须保持 JSON,否则 Eino toolsearch 中间件解析历史时会硬崩 ChatModel。
|
||||
msg := HitlRejectToolResult(input.Name, err.Error())
|
||||
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
|
||||
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
|
||||
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
|
||||
@@ -103,8 +102,7 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
|
||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||
if err != nil {
|
||||
if IsHumanRejectError(err) {
|
||||
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
||||
input.Name, strings.TrimSpace(err.Error()))
|
||||
msg := HitlRejectToolResult(input.Name, err.Error())
|
||||
hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
|
||||
return &compose.StreamToolOutput{
|
||||
Result: schema.StreamReaderFromArray([]string{msg}),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const toolSearchToolName = "tool_search"
|
||||
|
||||
// HitlExemptMetaTools 为编排/元工具:不直接执行攻击动作,但会阻塞 agent 控制流。
|
||||
// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel)。
|
||||
var HitlExemptMetaTools = []string{
|
||||
toolSearchToolName,
|
||||
"skill",
|
||||
"task",
|
||||
"write_todos",
|
||||
"transfer_to_agent",
|
||||
"exit",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskUpdate",
|
||||
"TaskList",
|
||||
}
|
||||
|
||||
// IsToolSearchTool reports whether name is the Eino dynamictool tool_search meta-tool.
|
||||
func IsToolSearchTool(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), toolSearchToolName)
|
||||
}
|
||||
|
||||
// MergeHitlExemptMetaTools unions configured whitelist with built-in meta-tool exemptions.
|
||||
func MergeHitlExemptMetaTools(configured []string) []string {
|
||||
merged := make([]string, 0, len(configured)+len(HitlExemptMetaTools))
|
||||
seen := make(map[string]struct{}, len(configured)+len(HitlExemptMetaTools))
|
||||
add := func(name string) {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if n == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
return
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
merged = append(merged, strings.TrimSpace(name))
|
||||
}
|
||||
for _, t := range configured {
|
||||
add(t)
|
||||
}
|
||||
for _, t := range HitlExemptMetaTools {
|
||||
add(t)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
type toolSearchHitlRejectPayload struct {
|
||||
SelectedTools []string `json:"selectedTools"`
|
||||
HitlRejected bool `json:"_hitlRejected"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// HitlRejectToolResult returns a tool result body safe for downstream consumers.
|
||||
// tool_search must stay JSON-shaped so toolsearch.extractSelectedTools does not terminate the graph.
|
||||
func HitlRejectToolResult(toolName, reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if !IsToolSearchTool(toolName) {
|
||||
if reason == "" {
|
||||
reason = "rejected by reviewer"
|
||||
}
|
||||
return fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
||||
strings.TrimSpace(toolName), reason)
|
||||
}
|
||||
payload := toolSearchHitlRejectPayload{
|
||||
SelectedTools: []string{},
|
||||
HitlRejected: true,
|
||||
Reason: reason,
|
||||
}
|
||||
if payload.Reason == "" {
|
||||
payload.Reason = "tool_search rejected by reviewer; no dynamic tools unlocked"
|
||||
}
|
||||
out, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"selectedTools":[],"_hitlRejected":true,"reason":"tool_search rejected by reviewer"}`
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHitlRejectToolResult_toolSearchIsJSON(t *testing.T) {
|
||||
raw := HitlRejectToolResult("tool_search", "rejected by user: timeout")
|
||||
var payload toolSearchHitlRejectPayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(payload.SelectedTools) != 0 {
|
||||
t.Fatalf("expected empty selectedTools, got %v", payload.SelectedTools)
|
||||
}
|
||||
if !payload.HitlRejected {
|
||||
t.Fatal("expected _hitlRejected true")
|
||||
}
|
||||
if !strings.Contains(payload.Reason, "timeout") {
|
||||
t.Fatalf("reason=%q", payload.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitlRejectToolResult_otherToolKeepsLegacyText(t *testing.T) {
|
||||
raw := HitlRejectToolResult("nmap", "too risky")
|
||||
if strings.HasPrefix(raw, "{") {
|
||||
t.Fatalf("expected legacy text, got %q", raw)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "[HITL Reject]") {
|
||||
t.Fatalf("expected [HITL Reject] prefix, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHitlExemptMetaTools_includesToolSearch(t *testing.T) {
|
||||
merged := MergeHitlExemptMetaTools([]string{"read_file"})
|
||||
found := false
|
||||
for _, name := range merged {
|
||||
if IsToolSearchTool(name) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool_search missing from %v", merged)
|
||||
}
|
||||
}
|
||||
@@ -254,10 +254,11 @@ func RunDeepAgent(
|
||||
)
|
||||
}
|
||||
sa, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
|
||||
Name: id,
|
||||
Description: desc,
|
||||
Instruction: subInstrFinal,
|
||||
Model: subModel,
|
||||
Name: id,
|
||||
Description: desc,
|
||||
Instruction: subInstrFinal,
|
||||
GenModelInput: literalInstructionGenModelInput,
|
||||
Model: subModel,
|
||||
ToolsConfig: adk.ToolsConfig{
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: subToolsForCfg,
|
||||
@@ -372,8 +373,15 @@ func RunDeepAgent(
|
||||
|
||||
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
|
||||
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
|
||||
taskEnrichExtra := systemPromptExtra
|
||||
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunes, taskEnrichExtra); mw != nil {
|
||||
var taskBlackboardSupplement string
|
||||
if appCfg.Project.Enabled && db != nil {
|
||||
if pid := strings.TrimSpace(projectID); pid != "" {
|
||||
if block, err := project.BuildFactIndexBlock(db, pid, appCfg.Project); err == nil {
|
||||
taskBlackboardSupplement = strings.TrimSpace(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunesEffective(), taskBlackboardSupplement); mw != nil {
|
||||
deepHandlers = append(deepHandlers, mw)
|
||||
}
|
||||
if len(mainOrchestratorPre) > 0 {
|
||||
@@ -424,6 +432,22 @@ func RunDeepAgent(
|
||||
var da adk.Agent
|
||||
switch orchMode {
|
||||
case "plan_execute":
|
||||
plannerModelCfg := &einoopenai.ChatModelConfig{
|
||||
APIKey: appCfg.OpenAI.APIKey,
|
||||
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
|
||||
Model: appCfg.OpenAI.Model,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
reasoning.ApplyPlanExecutePlannerModelConfig(plannerModelCfg, &appCfg.OpenAI)
|
||||
peMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("plan_execute 规划模型: %w", perr)
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("plan_execute: planner/replanner 使用无 reasoning 的独立 ChatModel(ToolChoiceForced 兼容)",
|
||||
zap.String("model", appCfg.OpenAI.Model),
|
||||
)
|
||||
}
|
||||
execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
||||
@@ -437,7 +461,7 @@ func RunDeepAgent(
|
||||
}
|
||||
}
|
||||
peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{
|
||||
MainToolCallingModel: mainModel,
|
||||
MainToolCallingModel: peMainModel,
|
||||
ExecModel: execModel,
|
||||
OrchInstruction: orchInstruction,
|
||||
ToolsCfg: mainToolsCfg,
|
||||
@@ -472,6 +496,7 @@ func RunDeepAgent(
|
||||
Name: orchestratorName,
|
||||
Description: orchDescription,
|
||||
Instruction: supInstr,
|
||||
GenModelInput: literalInstructionGenModelInput,
|
||||
Model: mainModel,
|
||||
ToolsConfig: mainToolsCfg,
|
||||
MaxIterations: deepMaxIter,
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
const defaultSubAgentUserContextMaxRunes = 2000
|
||||
const userContextSupplementHeader = "\n\n## 用户历史输入(原文,子代理必读)\n"
|
||||
|
||||
// taskContextEnrichMiddleware intercepts "task" tool calls on the orchestrator
|
||||
// and appends the user's original conversation messages to the task description.
|
||||
@@ -30,13 +31,14 @@ type taskContextEnrichMiddleware struct {
|
||||
// newTaskContextEnrichMiddleware returns a middleware that enriches task
|
||||
// descriptions with user conversation context. Returns nil if disabled
|
||||
// (maxRunes < 0) or no user messages exist.
|
||||
// projectBlackboard 仅传项目黑板索引块(BuildFactIndexBlock);勿传完整 systemPromptExtra。
|
||||
func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int, projectBlackboard string) adk.ChatModelAgentMiddleware {
|
||||
supplement := buildUserContextSupplement(userMessage, history, maxRunes)
|
||||
if bb := strings.TrimSpace(projectBlackboard); bb != "" {
|
||||
if supplement != "" {
|
||||
supplement += "\n\n## 项目黑板索引\n" + bb
|
||||
supplement += "\n\n" + bb
|
||||
} else {
|
||||
supplement = "\n\n## 项目黑板索引\n" + bb
|
||||
supplement = "\n\n" + bb
|
||||
}
|
||||
}
|
||||
if supplement == "" {
|
||||
@@ -86,9 +88,6 @@ func buildUserContextSupplement(userMessage string, history []agent.ChatMessage,
|
||||
if maxRunes < 0 {
|
||||
return ""
|
||||
}
|
||||
if maxRunes == 0 {
|
||||
maxRunes = defaultSubAgentUserContextMaxRunes
|
||||
}
|
||||
|
||||
var userMsgs []string
|
||||
for _, h := range history {
|
||||
@@ -107,12 +106,16 @@ func buildUserContextSupplement(userMessage string, history []agent.ChatMessage,
|
||||
return ""
|
||||
}
|
||||
|
||||
joined := strings.Join(userMsgs, "\n---\n")
|
||||
if len([]rune(joined)) > maxRunes {
|
||||
lines := make([]string, 0, len(userMsgs))
|
||||
for i, msg := range userMsgs {
|
||||
lines = append(lines, fmt.Sprintf("[第%d轮] %s", i+1, msg))
|
||||
}
|
||||
joined := strings.Join(lines, "\n")
|
||||
if maxRunes > 0 && len([]rune(joined)) > maxRunes {
|
||||
joined = truncateKeepFirstLast(userMsgs, maxRunes)
|
||||
}
|
||||
|
||||
return "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n" + joined
|
||||
return userContextSupplementHeader + joined
|
||||
}
|
||||
|
||||
// truncateKeepFirstLast keeps the first and last user messages, giving each
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestBuildUserContextSupplement_DisabledByNegative(t *testing.T) {
|
||||
func TestBuildUserContextSupplement_CustomMaxRunes(t *testing.T) {
|
||||
msg := strings.Repeat("A", 200)
|
||||
result := buildUserContextSupplement(msg, nil, 50)
|
||||
header := "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n"
|
||||
header := userContextSupplementHeader
|
||||
body := strings.TrimPrefix(result, header)
|
||||
if len([]rune(body)) > 50 {
|
||||
t.Errorf("body should be capped at 50 runes, got %d", len([]rune(body)))
|
||||
@@ -89,7 +89,7 @@ func TestBuildUserContextSupplement_TruncateKeepsFirstAndLast(t *testing.T) {
|
||||
history = append(history, agent.ChatMessage{Role: "user", Content: strings.Repeat("B", 500)})
|
||||
}
|
||||
last := "最后一条指令"
|
||||
result := buildUserContextSupplement(last, history, 0)
|
||||
result := buildUserContextSupplement(last, history, 800)
|
||||
if !strings.Contains(result, "http://target.com") {
|
||||
t.Error("first message (target URL) should survive truncation")
|
||||
}
|
||||
|
||||
@@ -806,10 +806,12 @@ func isClaudeProvider(cfg *config.OpenAIConfig) bool {
|
||||
// Eino HTTP Client Bridge
|
||||
// ============================================================
|
||||
|
||||
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含两层 transport 包装:
|
||||
// 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明
|
||||
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含多层 transport 包装:
|
||||
// 1. 当 cfg.Provider 为 claude 时,套 claudeRoundTripper,把 OpenAI /chat/completions 透明
|
||||
// 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。
|
||||
// 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
|
||||
// 2. reasoningToolChoiceCompatRoundTripper:tool_choice=required/object 时剥离 thinking 字段,避免
|
||||
// plan_execute replanner 等强制工具调用与推理模式冲突(部分网关返回 400)。
|
||||
// 3. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
|
||||
// (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai
|
||||
// SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。
|
||||
//
|
||||
@@ -825,6 +827,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport}
|
||||
if isClaudeProvider(cfg) {
|
||||
transport = &claudeRoundTripper{
|
||||
base: transport,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// reasoningPayloadKeys are OpenAI-compatible root fields that enable "thinking" /
|
||||
// extended-reasoning modes on gateways such as DashScope/Qwen and MiniMax.
|
||||
var reasoningPayloadKeys = []string{
|
||||
"thinking",
|
||||
"reasoning_effort",
|
||||
"output_config",
|
||||
"reasoning",
|
||||
}
|
||||
|
||||
// StripReasoningFromChatCompletionBody removes thinking / reasoning fields from a
|
||||
// chat-completions JSON body.
|
||||
func StripReasoningFromChatCompletionBody(rawBody []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
if !stripReasoningFields(payload) {
|
||||
return rawBody, nil
|
||||
}
|
||||
out, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return rawBody, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StripReasoningIfForcedToolChoice removes thinking / reasoning fields when the
|
||||
// request sets tool_choice to "required" or an object. Several providers reject
|
||||
// that combination (e.g. DashScope: "tool_choice does not support being set to
|
||||
// required or object in thinking mode").
|
||||
func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
if !forcedToolChoiceIncompatibleWithThinking(payload) {
|
||||
return rawBody, nil
|
||||
}
|
||||
if !stripReasoningFields(payload) {
|
||||
return rawBody, nil
|
||||
}
|
||||
out, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return rawBody, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stripReasoningFields(payload map[string]any) bool {
|
||||
changed := false
|
||||
for _, key := range reasoningPayloadKeys {
|
||||
if _, ok := payload[key]; ok {
|
||||
delete(payload, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool {
|
||||
tc, ok := payload["tool_choice"]
|
||||
if !ok || tc == nil {
|
||||
return false
|
||||
}
|
||||
switch v := tc.(type) {
|
||||
case string:
|
||||
return v == "required"
|
||||
case map[string]any:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripReasoningFromChatCompletionBody(t *testing.T) {
|
||||
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||
out, err := StripReasoningFromChatCompletionBody(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
|
||||
t.Fatalf("expected reasoning fields stripped, got %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"model":"deepseek-chat"`) {
|
||||
t.Fatalf("expected model preserved, got %s", s)
|
||||
}
|
||||
|
||||
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
|
||||
out2, err := StripReasoningFromChatCompletionBody(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(out2) != string(plain) {
|
||||
t.Fatalf("expected unchanged payload, got %s", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripReasoningIfForcedToolChoice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
strip bool
|
||||
contain string
|
||||
}{
|
||||
{
|
||||
name: "required strips thinking",
|
||||
in: `{"model":"minimax","messages":[],"thinking":{"type":"enabled"},"tool_choice":"required","tools":[]}`,
|
||||
strip: true,
|
||||
},
|
||||
{
|
||||
name: "object tool_choice strips thinking",
|
||||
in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"},"tool_choice":{"type":"function","function":{"name":"respond"}}}`,
|
||||
strip: true,
|
||||
},
|
||||
{
|
||||
name: "auto keeps thinking",
|
||||
in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"},"tool_choice":"auto"}`,
|
||||
strip: false,
|
||||
contain: "thinking",
|
||||
},
|
||||
{
|
||||
name: "no tool_choice keeps thinking",
|
||||
in: `{"model":"qwen","messages":[],"thinking":{"type":"enabled"}}`,
|
||||
strip: false,
|
||||
contain: "thinking",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := StripReasoningIfForcedToolChoice([]byte(tc.in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
hasThinking := strings.Contains(s, "thinking")
|
||||
if tc.strip && hasThinking {
|
||||
t.Fatalf("expected thinking stripped, got %s", s)
|
||||
}
|
||||
if !tc.strip && tc.contain != "" && !strings.Contains(s, tc.contain) {
|
||||
t.Fatalf("expected %q in %s", tc.contain, s)
|
||||
}
|
||||
if !tc.strip && string(out) != tc.in {
|
||||
t.Fatalf("expected unchanged payload, got %s", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
||||
var gotBody string
|
||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
gotBody = string(b)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", strings.NewReader(
|
||||
`{"model":"m","thinking":{"type":"enabled"},"tool_choice":"required","messages":[]}`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(gotBody, "thinking") {
|
||||
t.Fatalf("expected thinking stripped in transit, got %s", gotBody)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"tool_choice":"required"`) {
|
||||
t.Fatalf("expected tool_choice preserved, got %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from
|
||||
// chat/completions requests that force tool_choice, which some gateways reject
|
||||
// when thinking mode is enabled on the same request.
|
||||
type reasoningToolChoiceCompatRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if rt == nil || rt.base == nil || req == nil || req.Body == nil {
|
||||
if rt != nil && rt.base != nil {
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
if req.Method != http.MethodPost || !strings.HasSuffix(req.URL.Path, "/chat/completions") {
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
_ = req.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
patched, perr := StripReasoningIfForcedToolChoice(body)
|
||||
if perr != nil {
|
||||
patched = body
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(patched))
|
||||
req.ContentLength = int64(len(patched))
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
// UserVerbatimSectionHeading 用户原文锚点可读标题(块内保留,供 Agent 阅读)。
|
||||
UserVerbatimSectionHeading = "## 用户历史输入(原文保留,勿省略或改写)"
|
||||
|
||||
// UserVerbatimSectionStartMarker / EndMarker:HTML 注释边界,供程序化替换;对模型无指令语义。
|
||||
UserVerbatimSectionStartMarker = "<!-- user-verbatim-start -->"
|
||||
UserVerbatimSectionEndMarker = "<!-- user-verbatim-end -->"
|
||||
)
|
||||
|
||||
// ExtractUserContentsFromMessages 按时间顺序提取 user 角色消息的原文(跳过空白)。
|
||||
func ExtractUserContentsFromMessages(msgs []database.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
if !strings.EqualFold(strings.TrimSpace(msgs[i].Role), "user") {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(msgs[i].Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildUserVerbatimAnchorBlockFromMessages 从 messages 表行构建用户原文锚点块。
|
||||
// maxRunes: 0 = 不截断;>0 = 总 rune 上限(仍保留每一轮,仅对超长单条做尾部截断提示)。
|
||||
func BuildUserVerbatimAnchorBlockFromMessages(msgs []database.Message, maxRunes int) string {
|
||||
return BuildUserVerbatimAnchorBlock(ExtractUserContentsFromMessages(msgs), maxRunes)
|
||||
}
|
||||
|
||||
// BuildUserVerbatimAnchorBlock 将各轮用户原文格式化为 system prompt 锚点块。
|
||||
func BuildUserVerbatimAnchorBlock(userContents []string, maxRunes int) string {
|
||||
if len(userContents) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(userContents))
|
||||
for _, content := range userContents {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[第%d轮] %s", len(lines)+1, content))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
body := strings.Join(lines, "\n")
|
||||
if maxRunes > 0 {
|
||||
body = capUserVerbatimBody(body, maxRunes)
|
||||
}
|
||||
return wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n" + body)
|
||||
}
|
||||
|
||||
func capUserVerbatimBody(body string, maxRunes int) string {
|
||||
rs := []rune(body)
|
||||
if len(rs) <= maxRunes {
|
||||
return body
|
||||
}
|
||||
suffix := "\n\n...(用户原文锚点已达配置上限,更早轮次可能被截断;完整原文见 messages 表)..."
|
||||
suffixRunes := []rune(suffix)
|
||||
keep := maxRunes - len(suffixRunes)
|
||||
if keep <= 0 {
|
||||
return string(rs[:maxRunes])
|
||||
}
|
||||
return string(rs[:keep]) + suffix
|
||||
}
|
||||
|
||||
func wrapUserVerbatimBlock(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return UserVerbatimSectionStartMarker + "\n" + content + "\n" + UserVerbatimSectionEndMarker + "\n"
|
||||
}
|
||||
|
||||
// ReplaceUserVerbatimAnchorSection 用 freshBlock 替换 content 中已有的用户原文锚点段。
|
||||
func ReplaceUserVerbatimAnchorSection(content, freshBlock string) (string, bool) {
|
||||
content = strings.TrimSpace(content)
|
||||
freshBlock = strings.TrimSpace(freshBlock)
|
||||
if freshBlock == "" {
|
||||
return content, false
|
||||
}
|
||||
start, ok := userVerbatimSectionStart(content)
|
||||
if !ok {
|
||||
return content, false
|
||||
}
|
||||
end, ok := userVerbatimSectionEnd(content, start)
|
||||
if !ok {
|
||||
return content, false
|
||||
}
|
||||
return strings.TrimSpace(content[:start] + freshBlock + content[end:]), true
|
||||
}
|
||||
|
||||
func userVerbatimSectionStart(content string) (int, bool) {
|
||||
idx := strings.Index(content, UserVerbatimSectionStartMarker)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return idx, true
|
||||
}
|
||||
|
||||
func userVerbatimSectionEnd(content string, start int) (int, bool) {
|
||||
if start < 0 || start >= len(content) {
|
||||
return 0, false
|
||||
}
|
||||
tail := content[start:]
|
||||
idx := strings.LastIndex(tail, UserVerbatimSectionEndMarker)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return start + idx + len(UserVerbatimSectionEndMarker), true
|
||||
}
|
||||
|
||||
// RefreshUserVerbatimAnchorInMessages 在 summarization 等压缩后,用 freshBlock 刷新 system 中的用户原文锚点。
|
||||
// 若尚无锚点段,则追加到首条 system 消息;若无 system 消息则在开头插入一条。
|
||||
func RefreshUserVerbatimAnchorInMessages(msgs []adk.Message, freshBlock string) []adk.Message {
|
||||
freshBlock = strings.TrimSpace(freshBlock)
|
||||
if freshBlock == "" || len(msgs) == 0 {
|
||||
return msgs
|
||||
}
|
||||
|
||||
out := make([]adk.Message, len(msgs))
|
||||
changed := false
|
||||
for i, msg := range msgs {
|
||||
if msg == nil || msg.Role != schema.System {
|
||||
out[i] = msg
|
||||
continue
|
||||
}
|
||||
newContent, ok := ReplaceUserVerbatimAnchorSection(msg.Content, freshBlock)
|
||||
if !ok {
|
||||
out[i] = msg
|
||||
continue
|
||||
}
|
||||
cloned := *msg
|
||||
cloned.Content = newContent
|
||||
out[i] = &cloned
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
return out
|
||||
}
|
||||
|
||||
for i, msg := range msgs {
|
||||
if msg == nil || msg.Role != schema.System {
|
||||
continue
|
||||
}
|
||||
cloned := *msg
|
||||
cloned.Content = AppendSystemPromptBlock(cloned.Content, freshBlock)
|
||||
out[i] = &cloned
|
||||
return out
|
||||
}
|
||||
|
||||
prefix := make([]adk.Message, 0, len(msgs)+1)
|
||||
prefix = append(prefix, schema.SystemMessage(freshBlock))
|
||||
return append(prefix, msgs...)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestBuildUserVerbatimAnchorBlock_MultiTurn(t *testing.T) {
|
||||
msgs := []database.Message{
|
||||
{Role: "user", Content: "目标 https://a.com 仅测 /api"},
|
||||
{Role: "assistant", Content: "好的"},
|
||||
{Role: "user", Content: "用 admin:test 登录"},
|
||||
}
|
||||
block := BuildUserVerbatimAnchorBlockFromMessages(msgs, 0)
|
||||
if block == "" {
|
||||
t.Fatal("expected non-empty block")
|
||||
}
|
||||
if !strings.Contains(block, UserVerbatimSectionStartMarker) {
|
||||
t.Error("missing start marker")
|
||||
}
|
||||
if !strings.Contains(block, "[第1轮]") || !strings.Contains(block, "https://a.com") {
|
||||
t.Error("missing first user turn")
|
||||
}
|
||||
if !strings.Contains(block, "[第2轮]") || !strings.Contains(block, "admin:test") {
|
||||
t.Error("missing second user turn")
|
||||
}
|
||||
if strings.Contains(block, "好的") {
|
||||
t.Error("assistant content should not appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceUserVerbatimAnchorSection(t *testing.T) {
|
||||
old := "prefix\n\n" + wrapUserVerbatimBlock("## old\n\n[第1轮] a") + "\nsuffix"
|
||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] b\n[第2轮] c")
|
||||
out, ok := ReplaceUserVerbatimAnchorSection(old, newBlock)
|
||||
if !ok {
|
||||
t.Fatal("expected replace ok")
|
||||
}
|
||||
if !strings.Contains(out, "[第2轮] c") {
|
||||
t.Errorf("expected new block, got %q", out)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(out), "prefix") {
|
||||
t.Error("prefix should remain")
|
||||
}
|
||||
if !strings.Contains(out, "suffix") {
|
||||
t.Error("suffix should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshUserVerbatimAnchorInMessages_ReplaceExisting(t *testing.T) {
|
||||
oldBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] old")
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("instr\n\n" + oldBlock),
|
||||
schema.UserMessage("hi"),
|
||||
}
|
||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] new")
|
||||
out := RefreshUserVerbatimAnchorInMessages(msgs, newBlock)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("message count: got %d", len(out))
|
||||
}
|
||||
if !strings.Contains(out[0].Content, "[第1轮] new") {
|
||||
t.Errorf("system content: %q", out[0].Content)
|
||||
}
|
||||
if strings.Contains(out[0].Content, "[第1轮] old") {
|
||||
t.Error("old anchor should be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshUserVerbatimAnchorInMessages_InsertWhenMissing(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("base instruction"),
|
||||
schema.UserMessage("hi"),
|
||||
}
|
||||
block := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] anchor")
|
||||
out := RefreshUserVerbatimAnchorInMessages(msgs, block)
|
||||
if !strings.Contains(out[0].Content, "[第1轮] anchor") {
|
||||
t.Errorf("expected appended anchor, got %q", out[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserVerbatimAnchorBlock_MaxRunes(t *testing.T) {
|
||||
long := strings.Repeat("字", 200)
|
||||
block := BuildUserVerbatimAnchorBlock([]string{long}, 50)
|
||||
body := block
|
||||
if idx := strings.Index(body, UserVerbatimSectionStartMarker); idx >= 0 {
|
||||
body = strings.TrimPrefix(body[idx+len(UserVerbatimSectionStartMarker):], "\n")
|
||||
}
|
||||
if len([]rune(body)) > 120 {
|
||||
t.Errorf("expected capped body, got %d runes", len([]rune(body)))
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,35 @@ const (
|
||||
wireOutputConfig
|
||||
)
|
||||
|
||||
// ApplyPlanExecutePlannerModelConfig configures the plan_execute planner/replanner
|
||||
// ChatModel. Those Eino agents call WithToolChoice(Forced); several gateways reject
|
||||
// thinking / reasoning fields on the same request (tool_choice required/object).
|
||||
// Executor should keep the normal ApplyToEinoChatModelConfig path.
|
||||
func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig) {
|
||||
if cfg == nil || oa == nil {
|
||||
return
|
||||
}
|
||||
offOA := *oa
|
||||
offReasoning := oa.Reasoning
|
||||
offReasoning.Mode = "off"
|
||||
offOA.Reasoning = offReasoning
|
||||
ApplyToEinoChatModelConfig(cfg, &offOA, nil)
|
||||
clearReasoningFromChatModelConfig(cfg)
|
||||
}
|
||||
|
||||
func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.ReasoningEffort = ""
|
||||
if cfg.ExtraFields != nil {
|
||||
for _, key := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} {
|
||||
delete(cfg.ExtraFields, key)
|
||||
}
|
||||
}
|
||||
applyThinkingDisabled(cfg)
|
||||
}
|
||||
|
||||
// ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
|
||||
// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set.
|
||||
func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) {
|
||||
|
||||
@@ -49,6 +49,30 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://antchat.example.com/v1",
|
||||
Model: "minimax-m3",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Profile: "openai_compat",
|
||||
Mode: "on",
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
ApplyPlanExecutePlannerModelConfig(cfg, oa)
|
||||
if cfg.ReasoningEffort != "" {
|
||||
t.Fatalf("expected ReasoningEffort cleared, got %q", cfg.ReasoningEffort)
|
||||
}
|
||||
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||
if !ok || th["type"] != "disabled" {
|
||||
t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
|
||||
t.Fatalf("expected reasoning_effort stripped, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_disablesThinking(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
|
||||
@@ -162,9 +162,8 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
||||
output, err = runCommandWithPTY(ctx, cmd2, cb)
|
||||
}
|
||||
} else {
|
||||
outputBytes, err2 := cmd.CombinedOutput()
|
||||
output = string(outputBytes)
|
||||
err = err2
|
||||
// 非流式:内存缓冲 + ctx 取消杀进程组;行为对齐原 CombinedOutput,避免双流管道 fan-in 死锁。
|
||||
output, err = combinedOutputCancellable(ctx, cmd)
|
||||
if err != nil && shouldRetryWithPTY(output) {
|
||||
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
|
||||
zap.String("tool", toolName),
|
||||
@@ -692,83 +691,21 @@ func (e *Executor) formatParamValue(param config.ParameterConfig, value interfac
|
||||
// IsBackgroundShellCommand 检测命令是否为完全后台命令(末尾有独立 &,且不在引号内)。
|
||||
// command1 & command2 不算完全后台(command2 仍在前台执行)。
|
||||
func IsBackgroundShellCommand(command string) bool {
|
||||
// 移除首尾空格
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查命令中所有不在引号内的 & 符号
|
||||
// 找到最后一个 & 符号,检查它是否在命令末尾
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
escaped := false
|
||||
lastAmpersandPos := -1
|
||||
|
||||
for i, r := range command {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '\'' && !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
continue
|
||||
}
|
||||
if r == '"' && !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
continue
|
||||
}
|
||||
if r == '&' && !inSingleQuote && !inDoubleQuote {
|
||||
// 检查 & 前后是否有空格或换行(确保是独立的 &,而不是变量名的一部分)
|
||||
isStandalone := false
|
||||
|
||||
// 检查前面:空格、制表符、换行符,或者是命令开头
|
||||
if i == 0 {
|
||||
isStandalone = true
|
||||
} else {
|
||||
prev := command[i-1]
|
||||
if prev == ' ' || prev == '\t' || prev == '\n' || prev == '\r' {
|
||||
isStandalone = true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查后面:空格、制表符、换行符,或者是命令末尾
|
||||
if isStandalone {
|
||||
if i == len(command)-1 {
|
||||
// 在末尾,肯定是独立的 &
|
||||
lastAmpersandPos = i
|
||||
} else {
|
||||
next := command[i+1]
|
||||
if next == ' ' || next == '\t' || next == '\n' || next == '\r' {
|
||||
// 后面有空格,是独立的 &
|
||||
lastAmpersandPos = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到 & 符号,不是后台命令
|
||||
if lastAmpersandPos == -1 {
|
||||
positions := findStandaloneAmpersandPositions(command)
|
||||
if len(positions) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查最后一个 & 后面是否还有非空内容
|
||||
afterAmpersand := strings.TrimSpace(command[lastAmpersandPos+1:])
|
||||
if afterAmpersand == "" {
|
||||
// & 在末尾或后面只有空白字符,这是完全后台命令
|
||||
// 检查 & 前面是否有内容
|
||||
beforeAmpersand := strings.TrimSpace(command[:lastAmpersandPos])
|
||||
return beforeAmpersand != ""
|
||||
last := positions[len(positions)-1]
|
||||
afterAmpersand := strings.TrimSpace(command[last+1:])
|
||||
if afterAmpersand != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果 & 后面还有非空内容,说明是 command1 & command2 的情况
|
||||
// 这种情况下,command2会在前台执行,所以不算完全后台命令
|
||||
return false
|
||||
beforeAmpersand := strings.TrimSpace(command[:last])
|
||||
return beforeAmpersand != ""
|
||||
}
|
||||
|
||||
// executeSystemCommand 执行系统命令
|
||||
@@ -804,7 +741,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
zap.String("command", command),
|
||||
)
|
||||
|
||||
command = PrepareNonInteractiveShellCommand(command)
|
||||
command = PrepareShellCommandForExecute(command)
|
||||
|
||||
// 获取shell类型(可选,默认为sh)
|
||||
shell := "sh"
|
||||
@@ -845,10 +782,8 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
commandWithoutAmpersand := strings.TrimSuffix(strings.TrimSpace(command), "&")
|
||||
commandWithoutAmpersand = strings.TrimSpace(commandWithoutAmpersand)
|
||||
|
||||
// 构建新命令:将用户命令置于独立重定向的后台作业,再 echo $pid。
|
||||
// 若子进程与 echo 共享同一 stdout 管道,且长时间不向 stdout 写入换行,
|
||||
// bufio.ReadString('\n') 会永久阻塞(例如 beacon 持续写二进制/单行日志)。
|
||||
pidCommand := fmt.Sprintf("%s </dev/null >/dev/null 2>&1 & pid=$!; echo $pid", commandWithoutAmpersand)
|
||||
// 构建新命令:后台作业重定向标准流后 echo $pid(与 RedirectBackgroundJobStdio 一致)。
|
||||
pidCommand := RedirectBackgroundJobStdio(commandWithoutAmpersand+" &") + " pid=$!; echo $pid"
|
||||
|
||||
// 创建新命令来获取PID
|
||||
var pidCmd *exec.Cmd
|
||||
@@ -981,9 +916,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
output, err = runCommandWithPTY(ctx, cmd2, cb)
|
||||
}
|
||||
} else {
|
||||
outputBytes, err2 := cmd.CombinedOutput()
|
||||
output = string(outputBytes)
|
||||
err = err2
|
||||
output, err = combinedOutputCancellable(ctx, cmd)
|
||||
if err != nil && shouldRetryWithPTY(output) {
|
||||
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
|
||||
cmd2 := exec.CommandContext(ctx, shell, "-c", command)
|
||||
@@ -1027,12 +960,58 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
}, nil
|
||||
}
|
||||
|
||||
// combinedOutputCancellable 行为对齐 cmd.CombinedOutput(stdout/stderr 写入内存缓冲),
|
||||
// 但在 ctx 取消时 terminateCmdTree 终止整棵进程树。
|
||||
// 非流式路径不使用双流管道 fan-in,避免 stderr 撑满管道缓冲区时与 stdout 互相阻塞导致死锁。
|
||||
// 无输出空闲检测由上层 agent.tool_timeout_minutes 兜底,不改变原 CombinedOutput 语义。
|
||||
func combinedOutputCancellable(ctx context.Context, cmd *exec.Cmd) (string, error) {
|
||||
var stdoutBuf, stderrBuf strings.Builder
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
session, err := StartShellSession(cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Wait()
|
||||
}()
|
||||
|
||||
stopWatch := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
TerminateShellCmdSession(session)
|
||||
case <-stopWatch:
|
||||
}
|
||||
}()
|
||||
defer close(stopWatch)
|
||||
|
||||
var waitErr error
|
||||
select {
|
||||
case waitErr = <-done:
|
||||
case <-ctx.Done():
|
||||
waitErr = <-done
|
||||
return joinCommandOutput(stdoutBuf.String(), stderrBuf.String()), ctx.Err()
|
||||
}
|
||||
return joinCommandOutput(stdoutBuf.String(), stderrBuf.String()), waitErr
|
||||
}
|
||||
|
||||
func joinCommandOutput(stdout, stderr string) string {
|
||||
if stderr == "" {
|
||||
return stdout
|
||||
}
|
||||
if stdout == "" {
|
||||
return stderr
|
||||
}
|
||||
return stdout + stderr
|
||||
}
|
||||
|
||||
// streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。
|
||||
// 使用定长块读取,避免按行读取在无换行输出时永久阻塞;ctx 取消时终止进程树。
|
||||
func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback, noOutputSec int) (string, error) {
|
||||
if err := prepareShellCmdSession(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1042,7 +1021,8 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
|
||||
_ = stdoutPipe.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
session, err := StartShellSession(cmd)
|
||||
if err != nil {
|
||||
_ = stdoutPipe.Close()
|
||||
_ = stderrPipe.Close()
|
||||
return "", err
|
||||
@@ -1052,7 +1032,7 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
terminateCmdTree(cmd)
|
||||
TerminateShellCmdSession(session)
|
||||
case <-stopWatch:
|
||||
}
|
||||
}()
|
||||
@@ -1091,7 +1071,9 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
|
||||
if deltaBuilder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
cb(deltaBuilder.String())
|
||||
if cb != nil {
|
||||
cb(deltaBuilder.String())
|
||||
}
|
||||
deltaBuilder.Reset()
|
||||
lastFlush = time.Now()
|
||||
}
|
||||
@@ -1102,13 +1084,13 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
|
||||
}
|
||||
|
||||
fireInactivity := func() {
|
||||
terminateCmdTree(cmd)
|
||||
TerminateShellCmdSession(session)
|
||||
msg := ShellNoOutputTimeoutMessage(idleWatch.Sec)
|
||||
outBuilder.WriteString(msg)
|
||||
if cb != nil {
|
||||
cb(msg)
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
_ = session.Wait()
|
||||
}
|
||||
|
||||
chunksLoop:
|
||||
@@ -1118,6 +1100,11 @@ chunksLoop:
|
||||
idleCh = idleWatch.Expired
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
TerminateShellCmdSession(session)
|
||||
flush()
|
||||
_ = session.Wait()
|
||||
return outBuilder.String(), ctx.Err()
|
||||
case <-idleCh:
|
||||
fireInactivity()
|
||||
return outBuilder.String(), fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec)
|
||||
@@ -1138,7 +1125,7 @@ chunksLoop:
|
||||
flush()
|
||||
|
||||
// 等待命令结束,返回最终退出状态
|
||||
waitErr := cmd.Wait()
|
||||
waitErr := session.Wait()
|
||||
return outBuilder.String(), waitErr
|
||||
}
|
||||
|
||||
@@ -1210,13 +1197,18 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
|
||||
}
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
rootPID := 0
|
||||
if cmd.Process != nil {
|
||||
rootPID = cmd.Process.Pid
|
||||
}
|
||||
|
||||
// ctx 取消时尽快终止子进程
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = ptmx.Close() // 触发读退出
|
||||
terminateCmdTree(cmd)
|
||||
terminateProcessGroup(rootPID, cmd)
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -2,6 +2,8 @@ package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -147,3 +149,33 @@ func indexOf(slice []string, s string) int {
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestCombinedOutputCancellable_ContextCancelKillsTree 验证 ctx 取消时能在数秒内结束(杀进程组,非挂死)。
|
||||
func TestCombinedOutputCancellable_ContextCancelKillsTree(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix process group kill")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 300")
|
||||
ConfigureShellCmdForAgentExecute(cmd)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := combinedOutputCancellable(ctx, cmd)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected context cancel error")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("combinedOutputCancellable did not return within 5s after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,23 @@ func prepareShellCmdSession(cmd *exec.Cmd) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// terminateCmdTree 尽力终止 cmd 及其进程组(Unix 下 Setsid 后 PGID == 首进程 PID)。
|
||||
func terminateCmdTree(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
// terminateProcessGroup 对 rootPID 对应进程组发 SIGKILL;rootPID 为 0 时回退到 cmd.Process.Pid。
|
||||
func terminateProcessGroup(rootPID int, cmd *exec.Cmd) {
|
||||
pid := rootPID
|
||||
if pid <= 0 && cmd != nil && cmd.Process != nil {
|
||||
pid = cmd.Process.Pid
|
||||
}
|
||||
if pid <= 0 {
|
||||
return
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// terminateCmdTree 尽力终止 cmd 及其进程组(Unix 下 Setsid 后 PGID == 首进程 PID)。
|
||||
func terminateCmdTree(cmd *exec.Cmd) {
|
||||
terminateProcessGroup(0, cmd)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,42 @@
|
||||
|
||||
package security
|
||||
|
||||
import "os/exec"
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func prepareShellCmdSession(cmd *exec.Cmd) error {
|
||||
_ = cmd
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
// 独立进程组,便于 taskkill /T 终止整棵子进程树。
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP
|
||||
return nil
|
||||
}
|
||||
|
||||
func terminateCmdTree(cmd *exec.Cmd) {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
// terminateProcessGroup 使用 taskkill /F /T 终止进程及其子进程;rootPID 为 0 时回退到 cmd.Process.Pid。
|
||||
func terminateProcessGroup(rootPID int, cmd *exec.Cmd) {
|
||||
pid := rootPID
|
||||
if pid <= 0 && cmd != nil && cmd.Process != nil {
|
||||
pid = cmd.Process.Pid
|
||||
}
|
||||
if pid <= 0 {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
tk := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid))
|
||||
if err := tk.Run(); err != nil {
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// terminateCmdTree 使用 taskkill /F /T 终止进程及其子进程(Windows 上 Process.Kill 无法保证杀掉 python 等孙进程)。
|
||||
func terminateCmdTree(cmd *exec.Cmd) {
|
||||
terminateProcessGroup(0, cmd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package security
|
||||
|
||||
import "strings"
|
||||
|
||||
const backgroundJobStdioRedirect = " </dev/null >/dev/null 2>&1"
|
||||
|
||||
// findStandaloneAmpersandPositions 返回不在引号内的独立 & 下标(排除 &&)。
|
||||
func findStandaloneAmpersandPositions(command string) []int {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var positions []int
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
escaped := false
|
||||
|
||||
for i := 0; i < len(command); i++ {
|
||||
r := command[i]
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '\'' && !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
continue
|
||||
}
|
||||
if r == '"' && !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
continue
|
||||
}
|
||||
if r != '&' || inSingleQuote || inDoubleQuote {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(command) && command[i+1] == '&' {
|
||||
continue
|
||||
}
|
||||
if i > 0 && command[i-1] == '&' {
|
||||
continue
|
||||
}
|
||||
|
||||
isStandalone := i == 0
|
||||
if !isStandalone {
|
||||
prev := command[i-1]
|
||||
isStandalone = prev == ' ' || prev == '\t' || prev == '\n' || prev == '\r'
|
||||
}
|
||||
if !isStandalone {
|
||||
continue
|
||||
}
|
||||
if i == len(command)-1 {
|
||||
positions = append(positions, i)
|
||||
continue
|
||||
}
|
||||
next := command[i+1]
|
||||
if next == ' ' || next == '\t' || next == '\n' || next == '\r' {
|
||||
positions = append(positions, i)
|
||||
}
|
||||
}
|
||||
return positions
|
||||
}
|
||||
|
||||
func segmentHasStdioRedirect(segment string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(segment))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, ">/dev/null") || strings.Contains(lower, "2>/dev/null") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "&>") || strings.Contains(lower, "&>>") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "2>&1") && strings.Contains(lower, "/dev/null") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RedirectBackgroundJobStdio 为每个独立 & 前的后台段注入 </dev/null >/dev/null 2>&1,
|
||||
// 避免后台子进程占用 execute/exec 管道导致挂死。
|
||||
func RedirectBackgroundJobStdio(command string) string {
|
||||
positions := findStandaloneAmpersandPositions(command)
|
||||
if len(positions) == 0 {
|
||||
return command
|
||||
}
|
||||
|
||||
out := command
|
||||
for j := len(positions) - 1; j >= 0; j-- {
|
||||
i := positions[j]
|
||||
before := out[:i]
|
||||
after := out[i:]
|
||||
trimmed := strings.TrimRight(before, " \t\r\n")
|
||||
if segmentHasStdioRedirect(trimmed) {
|
||||
continue
|
||||
}
|
||||
trailing := before[len(trimmed):]
|
||||
out = trimmed + backgroundJobStdioRedirect + trailing + after
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PrepareShellCommandForExecute 组合 execute/exec 用的非交互包装与后台 IO 重定向。
|
||||
// 须先注入 exec </dev/null,再改写 & 后台段,否则段内 </dev/null 会使 stdin 重定向被误判为已存在。
|
||||
func PrepareShellCommandForExecute(shellCommand string) string {
|
||||
return RedirectBackgroundJobStdio(PrepareNonInteractiveShellCommand(shellCommand))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedirectBackgroundJobStdio_mixedCommand(t *testing.T) {
|
||||
in := "java -jar app.jar & JRMP_PID=$!; echo started"
|
||||
out := RedirectBackgroundJobStdio(in)
|
||||
if !strings.Contains(out, "java -jar app.jar </dev/null >/dev/null 2>&1 &") {
|
||||
t.Fatalf("expected redirect before &: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "echo started") {
|
||||
t.Fatalf("foreground tail preserved: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectBackgroundJobStdio_trailingOnly(t *testing.T) {
|
||||
in := "sleep 120 &"
|
||||
out := RedirectBackgroundJobStdio(in)
|
||||
want := "sleep 120 </dev/null >/dev/null 2>&1 &"
|
||||
if strings.TrimSpace(out) != want {
|
||||
t.Fatalf("got %q want %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectBackgroundJobStdio_skipsAlreadyRedirected(t *testing.T) {
|
||||
in := "sleep 1 >/dev/null 2>&1 & echo ok"
|
||||
out := RedirectBackgroundJobStdio(in)
|
||||
if out != in {
|
||||
t.Fatalf("should not double-redirect: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectBackgroundJobStdio_skipsAndAnd(t *testing.T) {
|
||||
in := "test -f /etc/passwd && echo ok"
|
||||
out := RedirectBackgroundJobStdio(in)
|
||||
if out != in {
|
||||
t.Fatalf("&& must not be treated as background &: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareShellCommandForExecute(t *testing.T) {
|
||||
out := PrepareShellCommandForExecute("java -jar x & echo hi")
|
||||
if !strings.Contains(out, "exec </dev/null") {
|
||||
t.Fatalf("missing stdin redirect: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "GIT_PAGER=cat") {
|
||||
t.Fatalf("missing pager export: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "java -jar x </dev/null >/dev/null 2>&1 &") {
|
||||
t.Fatalf("missing background redirect: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBackgroundShellCommand_usesSharedParser(t *testing.T) {
|
||||
if !IsBackgroundShellCommand("sleep 1 &") {
|
||||
t.Fatal("trailing & should be background")
|
||||
}
|
||||
if IsBackgroundShellCommand("sleep 1 & echo hi") {
|
||||
t.Fatal("mixed should not be fully background")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ func TerminateShellCmdTree(cmd *exec.Cmd) {
|
||||
terminateCmdTree(cmd)
|
||||
}
|
||||
|
||||
// TerminateShellCmdSession 使用 Start 时缓存的进程组 ID 终止(shell 已退出时仍有效)。
|
||||
func TerminateShellCmdSession(session *ShellSession) {
|
||||
TerminateShellSession(session)
|
||||
}
|
||||
|
||||
// EinoStreamingShell 为 Eino ADK execute 工具提供流式 shell,行为与 exec 对齐:
|
||||
// 并发读取 stdout/stderr(定长块,非按行),避免官方 local.ExecuteStreaming 先排空 stdout
|
||||
// 导致 stderr 错误(如 sudo 密码提示)长时间不可见、UI 一直显示「执行中」。
|
||||
@@ -55,8 +60,10 @@ func (s *EinoStreamingShell) ExecuteStreaming(ctx context.Context, input *filesy
|
||||
func runShellInBackground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
|
||||
defer w.Close()
|
||||
|
||||
command = PrepareShellCommandForExecute(command)
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
|
||||
ConfigureShellCmdForAgentExecute(cmd)
|
||||
applyDefaultTerminalEnv(cmd)
|
||||
attachNonInteractiveStdin(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = w.Send(nil, fmt.Errorf("failed to create stdout pipe: %w", err))
|
||||
@@ -68,7 +75,8 @@ func runShellInBackground(ctx context.Context, command string, w *schema.StreamW
|
||||
_ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err))
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
session, err := StartShellSession(cmd)
|
||||
if err != nil {
|
||||
_ = stdout.Close()
|
||||
_ = stderr.Close()
|
||||
_ = w.Send(nil, fmt.Errorf("failed to start command: %w", err))
|
||||
@@ -78,14 +86,14 @@ func runShellInBackground(ctx context.Context, command string, w *schema.StreamW
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
drainShellPipes(stdout, stderr)
|
||||
_ = cmd.Wait()
|
||||
_ = session.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
TerminateShellCmdTree(cmd)
|
||||
TerminateShellCmdSession(session)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
@@ -112,8 +120,10 @@ func drainShellPipes(stdout, stderr io.Reader) {
|
||||
func streamShellForeground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
|
||||
defer w.Close()
|
||||
|
||||
command = PrepareShellCommandForExecute(command)
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
|
||||
ConfigureShellCmdForAgentExecute(cmd)
|
||||
applyDefaultTerminalEnv(cmd)
|
||||
attachNonInteractiveStdin(cmd)
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
@@ -126,7 +136,8 @@ func streamShellForeground(ctx context.Context, command string, w *schema.Stream
|
||||
_ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err))
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
session, err := StartShellSession(cmd)
|
||||
if err != nil {
|
||||
_ = stdoutPipe.Close()
|
||||
_ = stderrPipe.Close()
|
||||
_ = w.Send(nil, fmt.Errorf("failed to start command: %w", err))
|
||||
@@ -137,7 +148,7 @@ func streamShellForeground(ctx context.Context, command string, w *schema.Stream
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
TerminateShellCmdTree(cmd)
|
||||
TerminateShellCmdSession(session)
|
||||
case <-stopWatch:
|
||||
}
|
||||
}()
|
||||
@@ -174,12 +185,12 @@ func streamShellForeground(ctx context.Context, command string, w *schema.Stream
|
||||
}
|
||||
hadOutput = true
|
||||
if w.Send(&filesystem.ExecuteResponse{Output: chunk}, nil) {
|
||||
TerminateShellCmdTree(cmd)
|
||||
TerminateShellCmdSession(session)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
waitErr := session.Wait()
|
||||
if waitErr == nil {
|
||||
exitCode := 0
|
||||
_ = w.Send(&filesystem.ExecuteResponse{ExitCode: &exitCode}, nil)
|
||||
|
||||
@@ -115,3 +115,38 @@ func TestEinoStreamingShell_StderrWhileStdoutBlocks(t *testing.T) {
|
||||
t.Fatalf("expected early stderr, got: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEinoStreamingShell_BackgroundJobDoesNotHoldPipe 模拟 cmd & 后继续前台逻辑:重定向后应快速结束。
|
||||
func TestEinoStreamingShell_BackgroundJobDoesNotHoldPipe(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping shell integration in -short")
|
||||
}
|
||||
shell := NewEinoStreamingShell()
|
||||
cmd := `(sh -c 'printf x; sleep 120') & echo started; sleep 0`
|
||||
sr, err := shell.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: cmd})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStreaming: %v", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
start := time.Now()
|
||||
var got strings.Builder
|
||||
for {
|
||||
resp, rerr := sr.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("recv: %v", rerr)
|
||||
}
|
||||
if resp != nil && resp.Output != "" {
|
||||
got.WriteString(resp.Output)
|
||||
}
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Fatalf("expected fast completion, took %v output=%q", time.Since(start), got.String())
|
||||
}
|
||||
if !strings.Contains(got.String(), "started") {
|
||||
t.Fatalf("expected foreground echo, got: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package security
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// ShellSession 在 Start 时记录根 shell 的进程组 ID,取消/超时时可杀整组(即使 cmd.Process 已失效)。
|
||||
type ShellSession struct {
|
||||
Cmd *exec.Cmd
|
||||
rootPID int
|
||||
}
|
||||
|
||||
// StartShellSession 配置独立进程组并启动 shell,缓存 rootPID(Unix 下即 PGID)。
|
||||
func StartShellSession(cmd *exec.Cmd) (*ShellSession, error) {
|
||||
if err := prepareShellCmdSession(cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pid := 0
|
||||
if cmd.Process != nil {
|
||||
pid = cmd.Process.Pid
|
||||
}
|
||||
return &ShellSession{Cmd: cmd, rootPID: pid}, nil
|
||||
}
|
||||
|
||||
// Wait 等待 shell 退出。
|
||||
func (s *ShellSession) Wait() error {
|
||||
if s == nil || s.Cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Cmd.Wait()
|
||||
}
|
||||
|
||||
// Terminate 终止 shell 及其进程组。
|
||||
func (s *ShellSession) Terminate() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
terminateProcessGroup(s.rootPID, s.Cmd)
|
||||
}
|
||||
|
||||
// TerminateShellSession 终止由 StartShellSession 启动的会话。
|
||||
func TerminateShellSession(session *ShellSession) {
|
||||
if session != nil {
|
||||
session.Terminate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShellSession_TerminateUsesCachedRootPID(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix process group kill")
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", "sleep 300")
|
||||
ConfigureShellCmdForAgentExecute(cmd)
|
||||
|
||||
session, err := StartShellSession(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("StartShellSession: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
session.Terminate()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- session.Wait() }()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("session did not finish within 5s after Terminate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellSession_TerminateAfterContextCancel(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix process group kill")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 300")
|
||||
ConfigureShellCmdForAgentExecute(cmd)
|
||||
|
||||
session, err := StartShellSession(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("StartShellSession: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
TerminateShellCmdSession(session)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- session.Wait() }()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("session did not finish within 5s after cancel+terminate")
|
||||
}
|
||||
}
|
||||
+63
-28
@@ -1,60 +1,95 @@
|
||||
name: "hydra"
|
||||
command: "hydra"
|
||||
args: ["-I"]
|
||||
enabled: true
|
||||
short_description: "密码暴力破解工具,支持多种协议和服务"
|
||||
description: |
|
||||
Hydra是一个快速的网络登录破解工具,支持多种协议和服务的密码暴力破解。
|
||||
Hydra 是网络登录口令爆破工具,支持 SSH、FTP、HTTP、SMB 等多种协议。
|
||||
|
||||
**主要功能:**
|
||||
- 支持多种协议(SSH, FTP, HTTP, SMB等)
|
||||
- 快速并行破解
|
||||
- 支持用户名和密码字典
|
||||
- 可恢复的会话
|
||||
**调用约定(必读):**
|
||||
- 必须提供 **用户名**:`username`(-l)或 `username_file`(-L)至少其一
|
||||
- 必须提供 **口令**:`password`(-p)、`password_file`(-P)或 `-C`(经 `additional_args`)至少其一
|
||||
- **先用小字典试跑**(几十~几百条),确认目标可达再扩大;禁止默认使用 rockyou 等超大字典
|
||||
- 默认已启用:找到即停(-f)、并行 4(-t)、忽略 restore(-I);长任务请设 `output_file`
|
||||
|
||||
**使用场景:**
|
||||
- 密码强度测试
|
||||
- 渗透测试
|
||||
- 安全评估
|
||||
- 弱密码检测
|
||||
**CLI 顺序:** `hydra [选项] <target> <service>`(本工具已按此顺序组参,勿把 target 写在选项前)
|
||||
|
||||
**使用场景:** 授权环境下的弱口令检测、密码强度评估
|
||||
|
||||
**注意:** 仅用于已授权目标;对无响应目标请减小 `wait_time` 或缩小字典,避免长时间挂起。
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标IP或主机名"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "service"
|
||||
type: "string"
|
||||
description: "服务类型(ssh, ftp, http等)"
|
||||
required: true
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "username"
|
||||
type: "string"
|
||||
description: "单个用户名"
|
||||
description: "单个用户名(-l);与 username_file 二选一至少填一个"
|
||||
required: false
|
||||
flag: "-l"
|
||||
format: "flag"
|
||||
- name: "username_file"
|
||||
type: "string"
|
||||
description: "用户名字典文件"
|
||||
description: "用户名字典文件(-L)"
|
||||
required: false
|
||||
flag: "-L"
|
||||
format: "flag"
|
||||
- name: "password"
|
||||
type: "string"
|
||||
description: "单个密码"
|
||||
description: "单个密码(-p)"
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
- name: "password_file"
|
||||
type: "string"
|
||||
description: "密码字典文件"
|
||||
description: "密码字典文件(-P);优先使用小字典试跑"
|
||||
required: false
|
||||
flag: "-P"
|
||||
format: "flag"
|
||||
- name: "stop_on_first"
|
||||
type: "bool"
|
||||
description: "找到一对有效账密后立即退出(-f,默认 true)"
|
||||
required: false
|
||||
flag: "-f"
|
||||
format: "flag"
|
||||
default: true
|
||||
- name: "tasks"
|
||||
type: "int"
|
||||
description: "每目标并行连接数(-t);SSH 等建议 4,默认 4"
|
||||
required: false
|
||||
flag: "-t"
|
||||
format: "flag"
|
||||
default: 4
|
||||
- name: "wait_time"
|
||||
type: "int"
|
||||
description: "单次连接等待响应秒数(-w),默认 16(低于 Hydra 默认 32,减少挂起感)"
|
||||
required: false
|
||||
flag: "-w"
|
||||
format: "flag"
|
||||
default: 16
|
||||
- name: "wait_between"
|
||||
type: "int"
|
||||
description: "每线程连接间隔秒数(-W),默认 1"
|
||||
required: false
|
||||
flag: "-W"
|
||||
format: "flag"
|
||||
default: 1
|
||||
- name: "output_file"
|
||||
type: "string"
|
||||
description: "将结果写入文件(-o),长任务建议指定"
|
||||
required: false
|
||||
flag: "-o"
|
||||
format: "flag"
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标 IP、主机名或 CIDR(须在选项之后)"
|
||||
required: true
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "service"
|
||||
type: "string"
|
||||
description: "服务类型(ssh、ftp、http-get、http-post-form、smb 等,见 hydra -h)"
|
||||
required: true
|
||||
position: 2
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "额外的Hydra参数"
|
||||
description: "额外参数(如 -s 端口、-S SSL、-m 模块选项、-C login:pass 文件),追加在命令末尾"
|
||||
required: false
|
||||
format: "positional"
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
name: "virustotal_search"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import os
|
||||
import time
|
||||
|
||||
# ==================== VirusTotal 配置 ====================
|
||||
# 请在此处配置您的 VirusTotal API 密钥
|
||||
# 您也可以在环境变量中设置:VT_API_KEY
|
||||
# enable 默认为 false,需开启才能调用该MCP
|
||||
VT_API_KEY = "" # 请填写您的 VirusTotal API 密钥
|
||||
# =======================================================
|
||||
|
||||
# VirusTotal API 基础 URL
|
||||
BASE_URL = "https://www.virustotal.com/api/v3"
|
||||
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
# 尝试从第一个参数读取 JSON 配置
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
arg1 = str(sys.argv[1])
|
||||
config = json.loads(arg1)
|
||||
if isinstance(config, dict):
|
||||
return config
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 传统位置参数方式
|
||||
config = {}
|
||||
if len(sys.argv) > 1:
|
||||
config['domain'] = str(sys.argv[1])
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
config['limit'] = int(sys.argv[2])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if len(sys.argv) > 3:
|
||||
config['include_ips'] = sys.argv[3].lower() in ('true', '1', 'yes')
|
||||
return config
|
||||
|
||||
def query_virustotal_subdomains(domain, api_key, limit=100, include_ips=False):
|
||||
"""
|
||||
查询 VirusTotal 的子域名信息
|
||||
|
||||
Args:
|
||||
domain: 要查询的域名
|
||||
api_key: VirusTotal API 密钥
|
||||
limit: 返回结果数量限制
|
||||
include_ips: 是否包含 IP 地址信息
|
||||
|
||||
Returns:
|
||||
dict: 包含查询结果的字典
|
||||
"""
|
||||
# 构建 API 请求 URL
|
||||
url = f"{BASE_URL}/domains/{domain}/subdomains"
|
||||
|
||||
headers = {
|
||||
"x-apikey": api_key,
|
||||
"accept": "application/json"
|
||||
}
|
||||
|
||||
params = {
|
||||
"limit": min(limit, 40) # API 限制最大 40
|
||||
}
|
||||
|
||||
all_results = []
|
||||
next_url = None
|
||||
|
||||
try:
|
||||
# 处理分页
|
||||
while True:
|
||||
if next_url:
|
||||
response = requests.get(next_url, headers=headers, timeout=30)
|
||||
else:
|
||||
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 提取子域名数据
|
||||
if 'data' in data and data['data']:
|
||||
for item in data['data']:
|
||||
if 'id' in item:
|
||||
subdomain_info = {
|
||||
'subdomain': item['id'],
|
||||
'type': item.get('type', 'domain'),
|
||||
}
|
||||
|
||||
# 如果 include_ips 为 True,尝试获取解析 IP
|
||||
if include_ips and 'attributes' in item:
|
||||
attributes = item.get('attributes', {})
|
||||
# 这里简化处理,实际可能需要额外的 API 调用
|
||||
subdomain_info['last_dns_records'] = attributes.get('last_dns_records', [])
|
||||
|
||||
all_results.append(subdomain_info)
|
||||
|
||||
# 检查是否有下一页
|
||||
if 'links' in data and 'next' in data['links'] and len(all_results) < limit:
|
||||
next_url = data['links']['next']
|
||||
# 避免请求过快
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
# 如果已达到限制,停止获取
|
||||
if len(all_results) >= limit:
|
||||
break
|
||||
|
||||
# 处理返回结果
|
||||
if all_results:
|
||||
return {
|
||||
"status": "success",
|
||||
"domain": domain,
|
||||
"total_found": len(all_results),
|
||||
"results": all_results[:limit],
|
||||
"message": f"成功获取 {len(all_results[:limit])} 个子域名"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "success",
|
||||
"domain": domain,
|
||||
"total_found": 0,
|
||||
"results": [],
|
||||
"message": f"未找到 {domain} 的子域名"
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = str(e)
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"API 请求失败: {error_msg}",
|
||||
"suggestion": "请检查网络连接、API 密钥是否正确,或 VirusTotal API 服务是否可用"
|
||||
}
|
||||
|
||||
# 处理特定 HTTP 状态码
|
||||
if hasattr(e, 'response') and e.response:
|
||||
status_code = e.response.status_code
|
||||
if status_code == 401:
|
||||
error_result["message"] = "API 密钥无效或未授权"
|
||||
error_result["suggestion"] = "请检查 VirusTotal API 密钥是否正确,或在 https://www.virustotal.com/ 获取有效密钥"
|
||||
elif status_code == 429:
|
||||
error_result["message"] = "API 请求频率超限"
|
||||
error_result["suggestion"] = "请稍后再试,VirusTotal API 有严格的速率限制(免费版每分钟4次)"
|
||||
elif status_code == 404:
|
||||
error_result["message"] = f"域名 '{domain}' 不存在或未找到"
|
||||
|
||||
return error_result
|
||||
|
||||
try:
|
||||
config = parse_args()
|
||||
|
||||
if not isinstance(config, dict):
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
|
||||
"type": "TypeError"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# 获取 API 密钥(从配置或环境变量)
|
||||
api_key = os.getenv('VT_API_KEY', VT_API_KEY).strip()
|
||||
|
||||
if not api_key:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": "缺少 VirusTotal API 密钥",
|
||||
"required_config": ["VT_API_KEY"],
|
||||
"note": "请在 YAML 文件的 VT_API_KEY 配置项中填写您的 VirusTotal API 密钥,或在环境变量 VT_API_KEY 中设置。API 密钥可在 https://www.virustotal.com/ 注册获取"
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# 获取必需参数
|
||||
domain = config.get('domain', '').strip()
|
||||
if not domain:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": "缺少必需参数: domain(要查询的域名)",
|
||||
"required_params": ["domain"],
|
||||
"examples": [
|
||||
"example.com",
|
||||
"google.com",
|
||||
"baidu.com"
|
||||
]
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# 获取可选参数
|
||||
limit = config.get('limit', 100)
|
||||
try:
|
||||
limit = int(limit)
|
||||
if limit < 1:
|
||||
limit = 100
|
||||
elif limit > 1000:
|
||||
limit = 1000 # 限制最大 1000
|
||||
except (ValueError, TypeError):
|
||||
limit = 100
|
||||
|
||||
include_ips = config.get('include_ips', False)
|
||||
if isinstance(include_ips, str):
|
||||
include_ips = include_ips.lower() in ('true', '1', 'yes')
|
||||
|
||||
# 执行查询
|
||||
result = query_virustotal_subdomains(domain, api_key, limit, include_ips)
|
||||
|
||||
# 输出结果
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
"status": "error",
|
||||
"message": f"执行出错: {str(e)}",
|
||||
"type": type(e).__name__
|
||||
}
|
||||
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
enabled: false
|
||||
|
||||
short_description: "VirusTotal 子域名查询工具,通过 VirusTotal API 被动收集域名子域名"
|
||||
|
||||
description: |
|
||||
VirusTotal 子域名查询工具,利用 VirusTotal 聚合的历史 DNS 数据来发现目标域名的子域名。
|
||||
|
||||
**主要功能:**
|
||||
- 被动子域名收集:从 VirusTotal 历史 DNS 数据中检索子域名
|
||||
- 分页查询:支持大量子域名的获取
|
||||
- IP 关联:可选包含 DNS 解析记录
|
||||
- 去重处理:自动去重返回结果
|
||||
|
||||
**使用场景:**
|
||||
- 安全测试前期信息收集
|
||||
- 企业网络资产发现
|
||||
- 攻击面分析
|
||||
- 威胁情报收集
|
||||
- 渗透测试信息收集
|
||||
|
||||
**数据来源:**
|
||||
VirusTotal 聚合了来自多个来源的 DNS 数据,包括:
|
||||
- 历史 DNS 解析记录
|
||||
- 被动 DNS 数据库
|
||||
- 证书透明度日志
|
||||
- 安全扫描数据
|
||||
|
||||
**注意事项:**
|
||||
- **API 密钥必需**:需要在 VirusTotal 注册账号并获取 API 密钥
|
||||
- **速率限制**:免费版 API 每分钟限制 4 次请求
|
||||
- **数据时效性**:数据基于历史扫描记录,可能不是实时的
|
||||
- **使用授权**:仅允许对您拥有合法授权的目标进行查询
|
||||
- **配额限制**:免费版每月有查询配额限制
|
||||
|
||||
parameters:
|
||||
- name: "domain"
|
||||
type: "string"
|
||||
description: |
|
||||
要查询的目标域名(必需)。
|
||||
|
||||
**格式要求:**
|
||||
- 仅输入主域名,不要包含协议头(http://)或路径
|
||||
- 支持二级域名查询
|
||||
|
||||
**示例值:**
|
||||
- "example.com"
|
||||
- "google.com"
|
||||
- "baidu.com"
|
||||
- "github.com"
|
||||
|
||||
**注意事项:**
|
||||
- 域名格式必须正确
|
||||
- 查询结果可能包含跨域子域名
|
||||
required: true
|
||||
position: 2
|
||||
format: "positional"
|
||||
|
||||
- name: "limit"
|
||||
type: "int"
|
||||
description: |
|
||||
返回结果数量限制(可选)。
|
||||
|
||||
**说明:**
|
||||
- 默认值:40
|
||||
- 最大值:1000(API 限制)
|
||||
- 建议值:100-500
|
||||
|
||||
**注意事项:**
|
||||
- 设置过大的值可能导致请求超时
|
||||
- API 单次返回限制为 40 条,超过会自动分页
|
||||
required: false
|
||||
position: 3
|
||||
format: "positional"
|
||||
default: 40
|
||||
|
||||
- name: "include_ips"
|
||||
type: "bool"
|
||||
description: |
|
||||
是否包含 IP 地址信息(可选)。
|
||||
|
||||
**说明:**
|
||||
- true:在结果中包含 DNS 解析记录
|
||||
- false:仅返回子域名列表
|
||||
|
||||
**注意事项:**
|
||||
- 包含 IP 信息会增加 API 调用次数
|
||||
- 可能包含历史解析 IP,不一定准确
|
||||
required: false
|
||||
position: 4
|
||||
format: "positional"
|
||||
default: false
|
||||
+1030
-7
File diff suppressed because it is too large
Load Diff
+107
-1
@@ -91,10 +91,12 @@
|
||||
"refresh": "Refresh",
|
||||
"refreshData": "Refresh data",
|
||||
"runningTasks": "Running tasks",
|
||||
"runningConversations": "Running conversations",
|
||||
"vulnTotal": "Total vulnerabilities",
|
||||
"toolCalls": "Tool invocations",
|
||||
"successRate": "Tool success rate",
|
||||
"clickToViewTasks": "Click to view tasks",
|
||||
"clickToViewChat": "Click to view conversations",
|
||||
"clickToViewVuln": "Click to view vulnerabilities",
|
||||
"clickToViewMCP": "Click to view MCP monitor",
|
||||
"accessOverviewTitle": "Access overview",
|
||||
@@ -499,6 +501,13 @@
|
||||
"conversationGroups": "Conversation groups",
|
||||
"addGroup": "New group",
|
||||
"recentConversations": "Recent conversations",
|
||||
"filterByProject": "Filter by project",
|
||||
"filterAllProjects": "All projects",
|
||||
"filterUnboundProjects": "Unbound",
|
||||
"projectConversationsTitle": "{{name}} · Conversations",
|
||||
"unboundConversationsTitle": "Unbound conversations",
|
||||
"noProjectConversations": "No conversations in this project",
|
||||
"noUnboundConversations": "No unbound conversations",
|
||||
"sortConversations": "Sort",
|
||||
"sortByCreatedAt": "Created time",
|
||||
"sortByUpdatedAt": "Updated time",
|
||||
@@ -636,7 +645,10 @@
|
||||
"agentModeOrchSupervisor": "Supervisor",
|
||||
"hitlTitle": "Human-in-the-loop",
|
||||
"hitlCardSubtitle": "Approvals & allowlist",
|
||||
"hitlReviewer": "Review",
|
||||
"hitlReviewerLabel": "Reviewer",
|
||||
"hitlReviewerHuman": "Human approval",
|
||||
"hitlReviewerAgent": "Audit Agent",
|
||||
"hitlReviewerHint": "Switch between human and Audit Agent anytime; rules and whitelist stay the same. You can pre-select even when HITL is off.",
|
||||
"hitlConfigTitle": "Collaboration mode config",
|
||||
"hitlModeLabel": "Mode",
|
||||
"hitlModeOff": "Off",
|
||||
@@ -655,7 +667,89 @@
|
||||
},
|
||||
"hitl": {
|
||||
"pageTitle": "HITL approvals",
|
||||
"pageReviewerLabel": "Current reviewer",
|
||||
"pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved locally for new chats. Takes effect immediately.",
|
||||
"pageReviewerSaved": "Reviewer saved.",
|
||||
"whitelistLabel": "Tool whitelist (no approval)",
|
||||
"whitelistHint": "One per line or comma-separated. Saved to config.yaml global whitelist and takes effect immediately (synced with chat sidebar).",
|
||||
"whitelistSaved": "Whitelist saved.",
|
||||
"whitelistSaveFailed": "Failed to save whitelist",
|
||||
"strategyLabel": "Audit strategy (prompt)",
|
||||
"strategyHint": "Whitelisted tools skip approval. Other tools are judged by the model using this prompt when Audit Agent is selected.",
|
||||
"strategyTabApproval": "Approval mode",
|
||||
"strategyTabReviewEdit": "Review & edit mode",
|
||||
"strategyHintApproval": "Whitelisted tools skip approval. In approval mode the Audit Agent only approves or rejects.",
|
||||
"strategyHintReviewEdit": "In review & edit mode the Audit Agent may narrow parameters via editedArguments before approve; reject if parameters cannot be safely adjusted.",
|
||||
"strategyReset": "Reset to default",
|
||||
"strategySaved": "Audit strategy saved.",
|
||||
"strategySaveFailed": "Failed to save audit strategy",
|
||||
"tabPending": "Pending",
|
||||
"tabLogs": "Audit logs",
|
||||
"tabStrategy": "Audit strategy",
|
||||
"tabWhitelist": "Tool whitelist",
|
||||
"pendingTitle": "Pending approvals",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Tool, conversation, payload, comment…",
|
||||
"searchApply": "Search",
|
||||
"filterDecision": "Decision",
|
||||
"filterDecidedBy": "Reviewer",
|
||||
"filterAll": "All",
|
||||
"decisionApprove": "Approve",
|
||||
"decisionReject": "Reject",
|
||||
"reviewerHuman": "Human",
|
||||
"reviewerAgent": "Audit Agent",
|
||||
"reviewerSystem": "System",
|
||||
"reviewerManual": "Manual entry",
|
||||
"logCreate": "New log",
|
||||
"logModalTitle": "Audit log",
|
||||
"logModalEdit": "Edit audit log",
|
||||
"fieldConversation": "Conversation ID",
|
||||
"fieldTool": "Tool name",
|
||||
"fieldComment": "Comment",
|
||||
"fieldPayload": "Payload (JSON)",
|
||||
"fieldUserMessage": "User message",
|
||||
"fieldThinking": "Thinking",
|
||||
"fieldReasoning": "Reasoning chain",
|
||||
"fieldPlanning": "Planning",
|
||||
"colId": "ID",
|
||||
"colTool": "Tool",
|
||||
"colConversation": "Conversation",
|
||||
"colDecision": "Decision",
|
||||
"colDecidedBy": "Reviewer",
|
||||
"colContext": "Context",
|
||||
"colTime": "Time",
|
||||
"colActions": "Actions",
|
||||
"viewDetail": "Detail",
|
||||
"logModalView": "Audit log detail",
|
||||
"fieldExecutionResult": "Execution result",
|
||||
"executionSuccess": "success",
|
||||
"executionFailed": "failed",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"logsEmpty": "No audit logs",
|
||||
"logsEmptyHint": "Records are created automatically when HITL approvals are approved or rejected.",
|
||||
"pageInfo": "{{total}} total",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"conversationRequired": "Conversation ID is required",
|
||||
"toolRequired": "Tool name is required",
|
||||
"saveFailed": "Save failed",
|
||||
"deleteConfirm": "Delete this audit log?",
|
||||
"deleteFailed": "Delete failed",
|
||||
"retentionHint": "Audit logs are kept for {{days}} days, then purged automatically.",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"batchDelete": "Batch delete",
|
||||
"batchDeleteConfirm": "Delete the selected {{count}} audit log(s)? This cannot be undone.",
|
||||
"batchDeleteSuccess": "Successfully deleted {{count}} audit log(s)",
|
||||
"batchDeleteFailed": "Batch delete failed",
|
||||
"clearAll": "Clear all",
|
||||
"clearAllConfirm": "Clear all {{count}} audit log(s) matching the current filters? This cannot be undone.",
|
||||
"clearAllConfirmNoFilter": "No filters are set. This will clear all {{count}} audit log(s). This cannot be undone. Continue?",
|
||||
"clearAllSuccess": "Cleared {{count}} audit log(s)",
|
||||
"clearAllFailed": "Clear failed",
|
||||
"selectLogsFirst": "Select audit logs to delete first",
|
||||
"loading": "Loading...",
|
||||
"emptyState": "No pending approvals",
|
||||
"dismiss": "Dismiss",
|
||||
@@ -1667,6 +1761,7 @@
|
||||
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
|
||||
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
|
||||
"timelineNoData": "No calls in this period",
|
||||
"timelineLoading": "Loading trend…",
|
||||
"timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks",
|
||||
"timelineLoadError": "Failed to load call trend",
|
||||
"timelineTotalLegend": "Total calls",
|
||||
@@ -1895,6 +1990,8 @@
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"statusChangeLabel": "Change status",
|
||||
"statusUpdateFailed": "Failed to update status",
|
||||
"searchVulnId": "Search vuln ID",
|
||||
"searchKeyword": "Search title, description, type, target…",
|
||||
"searchKeywordShort": "Keyword",
|
||||
@@ -1924,6 +2021,8 @@
|
||||
"detailTarget": "Target",
|
||||
"detailProject": "Project",
|
||||
"projectUnbound": "No project",
|
||||
"allProjects": "All projects",
|
||||
"filterByProject": "Filter by project",
|
||||
"projectBindHint": "Once bound, agents can list this finding under the project scope.",
|
||||
"projectBindFailed": "Failed to update project binding",
|
||||
"projectBindOk": "Project binding updated",
|
||||
@@ -2004,6 +2103,10 @@
|
||||
"settingsBasic": {
|
||||
"basicTitle": "Basic settings",
|
||||
"openaiConfig": "OpenAI config",
|
||||
"apiProvider": "API Provider",
|
||||
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
"visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)",
|
||||
"fofaConfig": "FOFA config",
|
||||
"agentConfig": "Agent config",
|
||||
"knowledgeConfig": "Knowledge base config",
|
||||
@@ -2522,6 +2625,9 @@
|
||||
"title": "Manage conversations · {{count}} total",
|
||||
"searchPlaceholder": "Search history",
|
||||
"conversationName": "Conversation name",
|
||||
"project": "Project",
|
||||
"noProject": "No project",
|
||||
"filterByProject": "Filter by project",
|
||||
"lastTime": "Last activity",
|
||||
"action": "Action",
|
||||
"selectAll": "Select all",
|
||||
|
||||
+108
-2
@@ -91,10 +91,12 @@
|
||||
"refresh": "刷新",
|
||||
"refreshData": "刷新数据",
|
||||
"runningTasks": "运行中任务",
|
||||
"runningConversations": "运行中对话",
|
||||
"vulnTotal": "漏洞总数",
|
||||
"toolCalls": "工具调用次数",
|
||||
"successRate": "工具执行成功率",
|
||||
"clickToViewTasks": "点击查看任务管理",
|
||||
"clickToViewChat": "点击查看对话",
|
||||
"clickToViewVuln": "点击查看漏洞管理",
|
||||
"clickToViewMCP": "点击查看 MCP 监控",
|
||||
"accessOverviewTitle": "接入概览",
|
||||
@@ -487,6 +489,13 @@
|
||||
"conversationGroups": "对话分组",
|
||||
"addGroup": "新建分组",
|
||||
"recentConversations": "最近对话",
|
||||
"filterByProject": "按项目筛选",
|
||||
"filterAllProjects": "全部项目",
|
||||
"filterUnboundProjects": "未绑定项目",
|
||||
"projectConversationsTitle": "{{name}} · 对话",
|
||||
"unboundConversationsTitle": "未绑定项目",
|
||||
"noProjectConversations": "该项目暂无对话",
|
||||
"noUnboundConversations": "暂无未绑定项目的对话",
|
||||
"sortConversations": "排序",
|
||||
"sortByCreatedAt": "创建时间",
|
||||
"sortByUpdatedAt": "更新时间",
|
||||
@@ -624,7 +633,10 @@
|
||||
"agentModeOrchSupervisor": "Supervisor",
|
||||
"hitlTitle": "人机协同",
|
||||
"hitlCardSubtitle": "审批与白名单",
|
||||
"hitlReviewer": "Review",
|
||||
"hitlReviewerLabel": "审批方",
|
||||
"hitlReviewerHuman": "人工审批",
|
||||
"hitlReviewerAgent": "审计 Agent",
|
||||
"hitlReviewerHint": "可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。",
|
||||
"hitlConfigTitle": "协同模式配置",
|
||||
"hitlModeLabel": "模式",
|
||||
"hitlModeOff": "关闭",
|
||||
@@ -633,7 +645,7 @@
|
||||
"hitlSensitiveTools": "敏感工具(逗号分隔)",
|
||||
"hitlWhitelistTools": "白名单工具(免审批,逗号分隔)",
|
||||
"hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)",
|
||||
"hitlWhitelistHint": "每行一个或逗号分隔;与 config 中全局白名单合并展示。",
|
||||
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
||||
"hitlApply": "应用",
|
||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
||||
@@ -643,7 +655,89 @@
|
||||
},
|
||||
"hitl": {
|
||||
"pageTitle": "人机协同审批",
|
||||
"pageReviewerLabel": "当前审批方",
|
||||
"pageReviewerHint": "作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。",
|
||||
"pageReviewerSaved": "审批方已保存。",
|
||||
"whitelistLabel": "免审批工具白名单",
|
||||
"whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。",
|
||||
"whitelistSaved": "白名单已保存。",
|
||||
"whitelistSaveFailed": "保存白名单失败",
|
||||
"strategyLabel": "审计策略(提示词)",
|
||||
"strategyHint": "白名单内工具免审批;其余工具在审批方为「审计 Agent」时,由模型按此提示词自主裁决。",
|
||||
"strategyTabApproval": "审批模式",
|
||||
"strategyTabReviewEdit": "审查编辑模式",
|
||||
"strategyHintApproval": "白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。",
|
||||
"strategyHintReviewEdit": "审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。",
|
||||
"strategyReset": "恢复默认",
|
||||
"strategySaved": "审计策略已保存。",
|
||||
"strategySaveFailed": "保存审计策略失败",
|
||||
"tabPending": "待审计",
|
||||
"tabLogs": "审计日志",
|
||||
"tabStrategy": "审计策略",
|
||||
"tabWhitelist": "工具白名单",
|
||||
"pendingTitle": "待处理审批",
|
||||
"searchLabel": "搜索",
|
||||
"searchPlaceholder": "工具名、会话 ID、载荷、备注…",
|
||||
"searchApply": "搜索",
|
||||
"filterDecision": "决策",
|
||||
"filterDecidedBy": "审批方",
|
||||
"filterAll": "全部",
|
||||
"decisionApprove": "通过",
|
||||
"decisionReject": "拒绝",
|
||||
"reviewerHuman": "人工",
|
||||
"reviewerAgent": "审计 Agent",
|
||||
"reviewerSystem": "系统",
|
||||
"reviewerManual": "手动录入",
|
||||
"logCreate": "新建日志",
|
||||
"logModalTitle": "审计日志",
|
||||
"logModalEdit": "编辑审计日志",
|
||||
"fieldConversation": "会话 ID",
|
||||
"fieldTool": "工具名",
|
||||
"fieldComment": "备注",
|
||||
"fieldPayload": "载荷 (JSON)",
|
||||
"fieldUserMessage": "用户原话",
|
||||
"fieldThinking": "本轮思考",
|
||||
"fieldReasoning": "推理链",
|
||||
"fieldPlanning": "规划",
|
||||
"colId": "ID",
|
||||
"colTool": "工具",
|
||||
"colConversation": "会话",
|
||||
"colDecision": "决策",
|
||||
"colDecidedBy": "审批方",
|
||||
"colContext": "上下文",
|
||||
"colTime": "时间",
|
||||
"colActions": "操作",
|
||||
"viewDetail": "详情",
|
||||
"logModalView": "审计日志详情",
|
||||
"fieldExecutionResult": "执行结果",
|
||||
"executionSuccess": "成功",
|
||||
"executionFailed": "失败",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"logsEmpty": "暂无审计日志",
|
||||
"logsEmptyHint": "人机协同审批通过或拒绝后会自动记录在此。",
|
||||
"pageInfo": "共 {{total}} 条",
|
||||
"prevPage": "上一页",
|
||||
"nextPage": "下一页",
|
||||
"conversationRequired": "请填写会话 ID",
|
||||
"toolRequired": "请填写工具名",
|
||||
"saveFailed": "保存失败",
|
||||
"deleteConfirm": "确定删除这条审计日志?",
|
||||
"deleteFailed": "删除失败",
|
||||
"retentionHint": "审计日志保留 {{days}} 天,超期自动清理",
|
||||
"selectedCount": "已选择 {{count}} 项",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"batchDelete": "批量删除",
|
||||
"batchDeleteConfirm": "确定删除选中的 {{count}} 条审计日志?此操作不可恢复。",
|
||||
"batchDeleteSuccess": "成功删除 {{count}} 条审计日志",
|
||||
"batchDeleteFailed": "批量删除失败",
|
||||
"clearAll": "清空",
|
||||
"clearAllConfirm": "确定清空当前筛选条件下的全部 {{count}} 条审计日志?此操作不可恢复。",
|
||||
"clearAllConfirmNoFilter": "未设置筛选条件,将清空全部 {{count}} 条审计日志。此操作不可恢复,是否继续?",
|
||||
"clearAllSuccess": "已清空 {{count}} 条审计日志",
|
||||
"clearAllFailed": "清空失败",
|
||||
"selectLogsFirst": "请先选择要删除的审计日志",
|
||||
"loading": "加载中...",
|
||||
"emptyState": "暂无待审批项",
|
||||
"dismiss": "忽略",
|
||||
@@ -1655,6 +1749,7 @@
|
||||
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
|
||||
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
|
||||
"timelineNoData": "该时段暂无调用",
|
||||
"timelineLoading": "趋势加载中…",
|
||||
"timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具",
|
||||
"timelineLoadError": "无法加载调用趋势",
|
||||
"timelineTotalLegend": "总调用",
|
||||
@@ -1883,6 +1978,8 @@
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"statusChangeLabel": "更改状态",
|
||||
"statusUpdateFailed": "更新状态失败",
|
||||
"searchVulnId": "搜索漏洞 ID",
|
||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||
"searchKeywordShort": "关键词",
|
||||
@@ -1912,6 +2009,8 @@
|
||||
"detailTarget": "目标",
|
||||
"detailProject": "所属项目",
|
||||
"projectUnbound": "未绑定项目",
|
||||
"allProjects": "全部项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞",
|
||||
"projectBindFailed": "绑定项目失败",
|
||||
"projectBindOk": "已更新项目绑定",
|
||||
@@ -1992,6 +2091,10 @@
|
||||
"settingsBasic": {
|
||||
"basicTitle": "基本设置",
|
||||
"openaiConfig": "OpenAI 配置",
|
||||
"apiProvider": "API 提供商",
|
||||
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
"visionProviderReuseOpenAI": "OpenAI 配置(留空复用)",
|
||||
"fofaConfig": "FOFA 配置",
|
||||
"agentConfig": "Agent 配置",
|
||||
"knowledgeConfig": "知识库配置",
|
||||
@@ -2510,6 +2613,9 @@
|
||||
"title": "管理对话记录·共{{count}}条",
|
||||
"searchPlaceholder": "搜索历史记录",
|
||||
"conversationName": "对话名称",
|
||||
"project": "项目",
|
||||
"noProject": "无项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"lastTime": "最近一次对话时间",
|
||||
"action": "操作",
|
||||
"selectAll": "全选",
|
||||
|
||||
@@ -22,7 +22,7 @@ const AUDIT_ACTIONS_BY_CATEGORY = {
|
||||
task: ['create_queue', 'start_queue', 'delete_queue', 'pause_queue', 'rerun_queue', 'delete_batch_task'],
|
||||
tool: ['execution_delete', 'execution_delete_batch'],
|
||||
file: ['upload', 'delete'],
|
||||
hitl: ['decision'],
|
||||
hitl: ['decision', 'audit_strategy_update'],
|
||||
role: ['create', 'update', 'delete'],
|
||||
skill: ['create', 'update', 'delete'],
|
||||
agent: ['markdown_create', 'markdown_update', 'markdown_delete']
|
||||
|
||||
+712
-91
File diff suppressed because it is too large
Load Diff
+29
-49
@@ -1,4 +1,4 @@
|
||||
// 仪表盘页面:拉取运行中任务、漏洞统计、批量任务、工具与 Skills 统计并渲染。
|
||||
// 仪表盘页面:拉取运行中对话、漏洞统计、批量任务、工具与 Skills 统计并渲染。
|
||||
//
|
||||
// 工程基础设施:
|
||||
// - dashboardState 集中保存运行时状态(in-flight controller / 自动轮询 timer / 上次更新时间 /
|
||||
@@ -118,7 +118,7 @@ async function refreshDashboard() {
|
||||
fetchJson('/api/agent-loop/tasks'),
|
||||
fetchJson('/api/vulnerabilities/stats'),
|
||||
fetchJson('/api/batch-tasks?limit=500&page=1'),
|
||||
fetchJson('/api/monitor/stats'),
|
||||
fetchJson('/api/monitor/stats?top=30'),
|
||||
fetchJson('/api/knowledge/stats'),
|
||||
fetchJson('/api/skills/stats'),
|
||||
fetchJson('/api/vulnerabilities?limit=10&page=1'),
|
||||
@@ -150,36 +150,24 @@ async function refreshDashboard() {
|
||||
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||
if (signal && signal.aborted) return;
|
||||
|
||||
// 运行中任务:Agent 循环任务 + 批量队列「执行中」数量统一统计,避免顶部 KPI 与运行概览不一致
|
||||
// 运行中对话:仅统计 Agent 循环任务;批量队列见右侧「批量任务队列」
|
||||
let agentRunningCount = null;
|
||||
if (tasksRes && Array.isArray(tasksRes.tasks)) {
|
||||
agentRunningCount = tasksRes.tasks.length;
|
||||
}
|
||||
let batchRunningCount = 0;
|
||||
let batchPendingCount = 0;
|
||||
if (batchRes && Array.isArray(batchRes.queues)) {
|
||||
batchRes.queues.forEach(q => {
|
||||
const s = (q.status || '').toLowerCase();
|
||||
if (s === 'running') batchRunningCount++;
|
||||
else if (s === 'pending' || s === 'paused') batchPendingCount++;
|
||||
});
|
||||
}
|
||||
const totalRunning = (agentRunningCount || 0) + batchRunningCount;
|
||||
const runningConversations = agentRunningCount !== null ? agentRunningCount : 0;
|
||||
if (runningEl) {
|
||||
if (agentRunningCount !== null) {
|
||||
runningEl.textContent = String(totalRunning);
|
||||
} else if (batchRes && Array.isArray(batchRes.queues)) {
|
||||
runningEl.textContent = String(batchRunningCount);
|
||||
} else {
|
||||
runningEl.textContent = '-';
|
||||
}
|
||||
runningEl.textContent = agentRunningCount !== null ? String(agentRunningCount) : '-';
|
||||
}
|
||||
// KPI 副标:N 待执行 / 全部空闲
|
||||
if (batchPendingCount > 0) {
|
||||
setKpiSubBadge('dashboard-kpi-tasks-sub-text',
|
||||
dt('dashboard.pendingCountLabel', { count: batchPendingCount }, batchPendingCount + ' 待执行'),
|
||||
'pending');
|
||||
} else if (totalRunning === 0) {
|
||||
// KPI 副标:全部空闲 / 正在执行
|
||||
if (runningConversations === 0) {
|
||||
setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.allIdle', null, '系统空闲'), 'idle');
|
||||
} else {
|
||||
setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.executingNow', null, '正在执行'), 'running');
|
||||
@@ -301,36 +289,27 @@ async function refreshDashboard() {
|
||||
updateProgressBar('dashboard-batch-progress-done', '0');
|
||||
}
|
||||
|
||||
// 工具调用:monitor/stats 为 { toolName: { totalCalls, successCalls, failedCalls, ... } }
|
||||
// 工具调用:monitor/stats 为 { summary, topTools }
|
||||
let toolsCount = 0, toolsTotalCalls = 0, toolsSuccessRate = -1, toolsFailedCount = 0;
|
||||
if (monitorRes && typeof monitorRes === 'object') {
|
||||
const names = Object.keys(monitorRes);
|
||||
let totalCalls = 0, totalSuccess = 0, totalFailed = 0;
|
||||
names.forEach(k => {
|
||||
const v = monitorRes[k];
|
||||
const n = v && (v.totalCalls ?? v.TotalCalls);
|
||||
if (typeof n === 'number') totalCalls += n;
|
||||
const s = v && (v.successCalls ?? v.SuccessCalls);
|
||||
if (typeof s === 'number') totalSuccess += s;
|
||||
const f = v && (v.failedCalls ?? v.FailedCalls);
|
||||
if (typeof f === 'number') totalFailed += f;
|
||||
});
|
||||
toolsCount = names.length;
|
||||
toolsTotalCalls = totalCalls;
|
||||
toolsFailedCount = totalFailed;
|
||||
setEl('dashboard-kpi-tools-calls', formatNumber(totalCalls));
|
||||
if (monitorRes && monitorRes.summary) {
|
||||
const s = monitorRes.summary;
|
||||
toolsCount = s.toolCount || 0;
|
||||
toolsTotalCalls = s.totalCalls || 0;
|
||||
toolsFailedCount = s.failedCalls || 0;
|
||||
const totalSuccess = s.successCalls || 0;
|
||||
setEl('dashboard-kpi-tools-calls', formatNumber(toolsTotalCalls));
|
||||
setKpiSubText('dashboard-kpi-tools-sub-text',
|
||||
dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具'));
|
||||
if (totalCalls > 0) {
|
||||
toolsSuccessRate = (totalSuccess / totalCalls) * 100;
|
||||
if (toolsTotalCalls > 0) {
|
||||
toolsSuccessRate = (totalSuccess / toolsTotalCalls) * 100;
|
||||
const rateStr = toolsSuccessRate.toFixed(1) + '%';
|
||||
setEl('dashboard-kpi-success-rate', rateStr);
|
||||
setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, totalFailed);
|
||||
setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, toolsFailedCount);
|
||||
} else {
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用'));
|
||||
}
|
||||
renderDashboardToolsBar(monitorRes);
|
||||
renderDashboardToolsBar(monitorRes.topTools);
|
||||
} else {
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
@@ -414,7 +393,7 @@ async function refreshDashboard() {
|
||||
var toolsConfiguredCount = (toolsConfigRes && typeof toolsConfigRes.total === 'number')
|
||||
? toolsConfigRes.total : 0;
|
||||
updateSmartCTA({
|
||||
totalRunning: totalRunning,
|
||||
totalRunning: runningConversations + batchRunningCount,
|
||||
totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0,
|
||||
totalCalls: toolsTotalCalls,
|
||||
toolsConfigured: toolsConfiguredCount,
|
||||
@@ -430,7 +409,7 @@ async function refreshDashboard() {
|
||||
failedTools: toolsFailedCount,
|
||||
toolsConfigured: toolsConfiguredCount,
|
||||
totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0,
|
||||
totalRunning: totalRunning
|
||||
totalRunning: runningConversations + batchRunningCount
|
||||
});
|
||||
|
||||
// 更新「上次更新」时间
|
||||
@@ -1615,12 +1594,12 @@ function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardToolsBar(monitorRes) {
|
||||
function renderDashboardToolsBar(topTools) {
|
||||
const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
const barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||
if (!placeholder || !barChartEl) return;
|
||||
|
||||
if (!monitorRes || typeof monitorRes !== 'object') {
|
||||
if (!Array.isArray(topTools) || topTools.length === 0) {
|
||||
placeholder.style.removeProperty('display');
|
||||
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
|
||||
barChartEl.style.display = 'none';
|
||||
@@ -1628,11 +1607,12 @@ function renderDashboardToolsBar(monitorRes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.keys(monitorRes).map(function (k) {
|
||||
const v = monitorRes[k];
|
||||
const totalCalls = v && (v.totalCalls ?? v.TotalCalls);
|
||||
return { name: k, totalCalls: typeof totalCalls === 'number' ? totalCalls : 0 };
|
||||
}).filter(function (e) { return e.totalCalls > 0; })
|
||||
const entries = topTools.map(function (t) {
|
||||
return {
|
||||
name: t.toolName || '',
|
||||
totalCalls: typeof t.totalCalls === 'number' ? t.totalCalls : 0,
|
||||
};
|
||||
}).filter(function (e) { return e.name && e.totalCalls > 0; })
|
||||
.sort(function (a, b) { return b.totalCalls - a.totalCalls; })
|
||||
.slice(0, 30);
|
||||
|
||||
|
||||
+1057
-17
File diff suppressed because it is too large
Load Diff
+260
-165
@@ -172,6 +172,24 @@ function einoMainStreamPlanningTitle(responseData) {
|
||||
return prefix + '📝 ' + plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eino 未捕获助手正文占位文案;终态 response 不应覆盖已有流式 buffer。
|
||||
*/
|
||||
function isEinoEmptyResponsePlaceholder(text) {
|
||||
if (text == null) return false;
|
||||
const s = String(text);
|
||||
return s.indexOf('no assistant text was captured') !== -1
|
||||
|| s.indexOf('未捕获到助手文本输出') !== -1;
|
||||
}
|
||||
|
||||
function resolveFinalAssistantResponseText(finalMessage, streamState) {
|
||||
const buf = streamState && streamState.buffer != null ? String(streamState.buffer).trim() : '';
|
||||
if (isEinoEmptyResponsePlaceholder(finalMessage) && buf) {
|
||||
return streamState.buffer;
|
||||
}
|
||||
return finalMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||
@@ -181,8 +199,9 @@ function finalizeMainResponseStreamItem(streamState, finalMessage, responseData)
|
||||
const item = document.getElementById(streamState.itemId);
|
||||
if (!item || !item.parentNode) return false;
|
||||
|
||||
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
|
||||
? String(finalMessage)
|
||||
const resolved = resolveFinalAssistantResponseText(finalMessage, streamState);
|
||||
const fullText = (resolved != null && String(resolved).trim() !== '')
|
||||
? String(resolved)
|
||||
: (streamState.buffer || '');
|
||||
if (!String(fullText).trim()) {
|
||||
item.parentNode.removeChild(item);
|
||||
@@ -970,17 +989,22 @@ async function requestCancel(conversationId) {
|
||||
}
|
||||
|
||||
/** 与 MCP 监控一致:仅终止当前进行中的工具调用,工具返回后本轮推理继续(可选 reason 合并进工具结果) */
|
||||
async function requestCancelWithContinue(conversationId, reason) {
|
||||
async function requestCancelWithContinue(conversationId, reason, options = {}) {
|
||||
const executionId = options && options.executionId ? String(options.executionId).trim() : '';
|
||||
const body = {
|
||||
conversationId,
|
||||
reason: reason || '',
|
||||
continueAfter: true,
|
||||
};
|
||||
if (executionId) {
|
||||
body.executionId = executionId;
|
||||
}
|
||||
const response = await apiFetch('/api/agent-loop/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
reason: reason || '',
|
||||
continueAfter: true,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
@@ -1021,7 +1045,9 @@ async function submitUserInterruptContinue() {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...';
|
||||
}
|
||||
await requestCancelWithContinue(conversationId, reason);
|
||||
await requestCancelWithContinue(conversationId, reason, {
|
||||
executionId: monitorCtx && monitorCtx.executionId ? monitorCtx.executionId : '',
|
||||
});
|
||||
if (monitorCtx && monitorCtx.executionId && typeof refreshMonitorPanel === 'function') {
|
||||
const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page)
|
||||
? monitorState.pagination.page
|
||||
@@ -2407,19 +2433,20 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||
let assistantIdFinal = existingAssistantId;
|
||||
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
|
||||
|
||||
if (!assistantIdFinal) {
|
||||
assistantIdFinal = addMessage('assistant', event.message, mcpIds, progressId);
|
||||
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
||||
setAssistantId(assistantIdFinal);
|
||||
} else {
|
||||
setAssistantId(assistantIdFinal);
|
||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
||||
}
|
||||
|
||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||
if (streamState && streamState.itemId) {
|
||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
||||
} else if (event.message && String(event.message).trim()) {
|
||||
} else if (bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||
addTimelineItem(timeline, 'planning', {
|
||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||
? einoMainStreamPlanningTitle(responseData)
|
||||
@@ -3118,6 +3145,12 @@ function attachToolResultToCall(progressId, toolCallId, data, options) {
|
||||
if (!item && mapping && mapping.timeline) {
|
||||
item = findToolCallItemById(mapping.timeline, toolCallId);
|
||||
}
|
||||
if (!item && progressId) {
|
||||
const progressRoot = document.getElementById(String(progressId));
|
||||
if (progressRoot) {
|
||||
item = findToolCallItemById(progressRoot, toolCallId);
|
||||
}
|
||||
}
|
||||
if (!item) return false;
|
||||
mergeToolResultIntoCallItem(item, data, options);
|
||||
return true;
|
||||
@@ -3154,7 +3187,7 @@ function coalesceProcessDetailsToolPairs(details) {
|
||||
if (id) callsById.set(id, copy);
|
||||
fifoCalls.push(copy);
|
||||
out.push(copy);
|
||||
} else if (et === 'tool_result') {
|
||||
} else if (et === 'tool_result') {
|
||||
let target = null;
|
||||
if (id && callsById.has(id)) {
|
||||
target = callsById.get(id);
|
||||
@@ -3168,6 +3201,12 @@ function coalesceProcessDetailsToolPairs(details) {
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
// agentFacing 或较新的 tool_result 覆盖旧合并(历史数据可能含 reduction 前全量正文)
|
||||
const prev = target.data._mergedResult;
|
||||
if (prev && data.agentFacing !== true && prev.agentFacing === true) {
|
||||
out.push(detail);
|
||||
continue;
|
||||
}
|
||||
absorbResult(target, detail);
|
||||
continue;
|
||||
}
|
||||
@@ -3421,6 +3460,28 @@ async function loadActiveTasks(showErrors = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveTaskDisplayName(task) {
|
||||
const _t = function (k) { return typeof window.t === 'function' ? window.t(k) : k; };
|
||||
const unnamedTaskText = _t('tasks.unnamedTask');
|
||||
if (!task) return unnamedTaskText;
|
||||
const title = (task.title || '').trim();
|
||||
if (title) return title;
|
||||
const message = (task.message || '').trim();
|
||||
return message || unnamedTaskText;
|
||||
}
|
||||
|
||||
function updateActiveTaskConversationTitle(conversationId, newTitle) {
|
||||
const bar = document.getElementById('active-tasks-bar');
|
||||
if (!bar || !conversationId) return;
|
||||
const title = (newTitle || '').trim();
|
||||
if (!title) return;
|
||||
bar.querySelectorAll('.active-task-item[data-conversation-id="' + conversationId + '"] .active-task-message')
|
||||
.forEach(function (el) {
|
||||
el.textContent = title;
|
||||
});
|
||||
}
|
||||
window.updateActiveTaskConversationTitle = updateActiveTaskConversationTitle;
|
||||
|
||||
function renderActiveTasks(tasks) {
|
||||
const bar = document.getElementById('active-tasks-bar');
|
||||
if (!bar) return;
|
||||
@@ -3481,13 +3542,17 @@ function renderActiveTasks(tasks) {
|
||||
};
|
||||
const statusText = statusMap[task.status] || _t('tasks.statusRunning');
|
||||
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
|
||||
const unnamedTaskText = _t('tasks.unnamedTask');
|
||||
const taskDisplayName = getActiveTaskDisplayName(task);
|
||||
const stopTaskBtnText = _t('tasks.stopTask');
|
||||
|
||||
if (task && task.conversationId) {
|
||||
item.dataset.conversationId = task.conversationId;
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="active-task-info">
|
||||
<span class="active-task-status">${statusText}</span>
|
||||
<span class="active-task-message">${escapeHtml(task.message || unnamedTaskText)}</span>
|
||||
<span class="active-task-message">${escapeHtml(taskDisplayName)}</span>
|
||||
</div>
|
||||
<div class="active-task-actions">
|
||||
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
|
||||
@@ -3527,12 +3592,15 @@ let monitorPanelFetchSeq = 0;
|
||||
// 监控面板状态
|
||||
const monitorState = {
|
||||
executions: [],
|
||||
stats: {},
|
||||
summary: null,
|
||||
topTools: [],
|
||||
timeline: null,
|
||||
timelineRange: null,
|
||||
timelineError: null,
|
||||
timelineLoading: false,
|
||||
lastFetchedAt: null,
|
||||
retentionDays: 0,
|
||||
selectedExecutions: new Set(),
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: (() => {
|
||||
@@ -3626,17 +3694,14 @@ async function refreshMonitorPanel(page = null) {
|
||||
|
||||
try {
|
||||
const mySeq = ++monitorPanelFetchSeq;
|
||||
// 如果指定了页码,使用指定页码,否则使用当前页码
|
||||
const currentPage = page !== null ? page : monitorState.pagination.page;
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
|
||||
// 获取当前的筛选条件
|
||||
|
||||
const statusFilter = document.getElementById('monitor-status-filter');
|
||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||
const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
|
||||
const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
||||
|
||||
// 构建请求 URL
|
||||
|
||||
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
|
||||
if (currentStatusFilter && currentStatusFilter !== 'all') {
|
||||
url += `&status=${encodeURIComponent(currentStatusFilter)}`;
|
||||
@@ -3644,37 +3709,34 @@ async function refreshMonitorPanel(page = null) {
|
||||
if (currentToolFilter && currentToolFilter !== 'all') {
|
||||
url += `&tool=${encodeURIComponent(currentToolFilter)}`;
|
||||
}
|
||||
|
||||
const { result, timeline, timelineError } = await fetchMonitorAndTimeline(url);
|
||||
|
||||
const range = getMcpMonitorTimelineRange();
|
||||
monitorState.timelineLoading = true;
|
||||
const timelinePromise = fetchMonitorTimeline(range);
|
||||
|
||||
const monitorResp = await apiFetch(url, { method: 'GET' });
|
||||
const result = await monitorResp.json().catch(() => ({}));
|
||||
if (!monitorResp.ok) {
|
||||
throw new Error(result.error || '获取监控数据失败');
|
||||
}
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.stats = result.stats || {};
|
||||
applyMonitorPayload(result, currentStatusFilter);
|
||||
|
||||
const { timeline, timelineError } = await timelinePromise;
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
monitorState.timeline = timeline;
|
||||
monitorState.timelineError = timelineError;
|
||||
monitorState.lastFetchedAt = new Date();
|
||||
monitorState.retentionDays = typeof result.retention_days === 'number' ? result.retention_days : 0;
|
||||
|
||||
// 更新分页信息
|
||||
if (result.total !== undefined) {
|
||||
monitorState.pagination = {
|
||||
page: result.page || currentPage,
|
||||
pageSize: result.page_size || pageSize,
|
||||
total: result.total || 0,
|
||||
totalPages: result.total_pages || 1
|
||||
};
|
||||
}
|
||||
|
||||
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
||||
renderMonitorExecutions(monitorState.executions, currentStatusFilter);
|
||||
renderMonitorPagination();
|
||||
|
||||
// 初始化每页显示数量选择器
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
initializeMonitorPageSize();
|
||||
} catch (error) {
|
||||
console.error('刷新监控面板失败:', error);
|
||||
monitorState.timelineLoading = false;
|
||||
if (statsContainer) {
|
||||
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
@@ -3717,10 +3779,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
|
||||
try {
|
||||
const mySeq = ++monitorPanelFetchSeq;
|
||||
const currentPage = 1; // 筛选时重置到第一页
|
||||
const currentPage = 1;
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
|
||||
// 构建请求 URL
|
||||
|
||||
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
url += `&status=${encodeURIComponent(statusFilter)}`;
|
||||
@@ -3728,37 +3789,34 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
if (toolFilter && toolFilter !== 'all') {
|
||||
url += `&tool=${encodeURIComponent(toolFilter)}`;
|
||||
}
|
||||
|
||||
const { result, timeline, timelineError } = await fetchMonitorAndTimeline(url);
|
||||
|
||||
const range = getMcpMonitorTimelineRange();
|
||||
monitorState.timelineLoading = true;
|
||||
const timelinePromise = fetchMonitorTimeline(range);
|
||||
|
||||
const monitorResp = await apiFetch(url, { method: 'GET' });
|
||||
const result = await monitorResp.json().catch(() => ({}));
|
||||
if (!monitorResp.ok) {
|
||||
throw new Error(result.error || '获取监控数据失败');
|
||||
}
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.stats = result.stats || {};
|
||||
applyMonitorPayload(result, statusFilter);
|
||||
|
||||
const { timeline, timelineError } = await timelinePromise;
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
monitorState.timeline = timeline;
|
||||
monitorState.timelineError = timelineError;
|
||||
monitorState.lastFetchedAt = new Date();
|
||||
monitorState.retentionDays = typeof result.retention_days === 'number' ? result.retention_days : 0;
|
||||
|
||||
// 更新分页信息
|
||||
if (result.total !== undefined) {
|
||||
monitorState.pagination = {
|
||||
page: result.page || currentPage,
|
||||
pageSize: result.page_size || pageSize,
|
||||
total: result.total || 0,
|
||||
totalPages: result.total_pages || 1
|
||||
};
|
||||
}
|
||||
|
||||
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
||||
renderMonitorExecutions(monitorState.executions, statusFilter);
|
||||
renderMonitorPagination();
|
||||
|
||||
// 初始化每页显示数量选择器
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
initializeMonitorPageSize();
|
||||
} catch (error) {
|
||||
console.error('刷新监控面板失败:', error);
|
||||
monitorState.timelineLoading = false;
|
||||
if (statsContainer) {
|
||||
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
@@ -3768,6 +3826,63 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
}
|
||||
}
|
||||
|
||||
function applyMonitorPayload(result, statusFilter) {
|
||||
const currentPage = monitorState.pagination.page;
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.summary = result.summary || null;
|
||||
monitorState.topTools = Array.isArray(result.topTools) ? result.topTools : [];
|
||||
monitorState.lastFetchedAt = new Date();
|
||||
monitorState.retentionDays = typeof result.retentionDays === 'number' ? result.retentionDays : 0;
|
||||
|
||||
if (result.total !== undefined) {
|
||||
monitorState.pagination = {
|
||||
page: result.page || currentPage,
|
||||
pageSize: result.pageSize || pageSize,
|
||||
total: result.total || 0,
|
||||
totalPages: result.totalPages || 1
|
||||
};
|
||||
}
|
||||
|
||||
renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
|
||||
renderMonitorExecutions(monitorState.executions, statusFilter);
|
||||
renderMonitorPagination();
|
||||
}
|
||||
|
||||
async function fetchMonitorTimeline(range) {
|
||||
try {
|
||||
const timelineResp = await apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' });
|
||||
const timelineJson = await timelineResp.json().catch(() => ({}));
|
||||
if (!timelineResp.ok) {
|
||||
return { timeline: null, timelineError: timelineJson.error || 'timeline failed' };
|
||||
}
|
||||
return { timeline: timelineJson, timelineError: null };
|
||||
} catch (err) {
|
||||
return { timeline: null, timelineError: err && err.message ? err.message : 'timeline failed' };
|
||||
}
|
||||
}
|
||||
|
||||
function updateMonitorTimelineSection() {
|
||||
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
||||
if (timelineInner) {
|
||||
const combined = timelineInner.closest('.mcp-stats-combined');
|
||||
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
|
||||
timelineInner.innerHTML = renderMcpStatsTimelineBody(
|
||||
monitorState.timeline,
|
||||
monitorState.timelineError,
|
||||
compactEmpty,
|
||||
monitorState.timelineLoading
|
||||
);
|
||||
bindMcpStatsTimelineEvents();
|
||||
syncMcpMonitorTimelineRangeUI();
|
||||
return;
|
||||
}
|
||||
if (monitorState.summary) {
|
||||
renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const MCP_STATS_TOP_N = 6;
|
||||
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
||||
@@ -3782,29 +3897,14 @@ function getMcpMonitorTimelineRange() {
|
||||
return range;
|
||||
}
|
||||
|
||||
async function fetchMonitorAndTimeline(monitorUrl) {
|
||||
const range = getMcpMonitorTimelineRange();
|
||||
const [monitorResp, timelineResp] = await Promise.all([
|
||||
apiFetch(monitorUrl, { method: 'GET' }),
|
||||
apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' })
|
||||
]);
|
||||
const result = await monitorResp.json().catch(() => ({}));
|
||||
if (!monitorResp.ok) {
|
||||
throw new Error(result.error || '获取监控数据失败');
|
||||
}
|
||||
let timeline = null;
|
||||
let timelineError = null;
|
||||
try {
|
||||
const timelineJson = await timelineResp.json().catch(() => ({}));
|
||||
if (timelineResp.ok) {
|
||||
timeline = timelineJson;
|
||||
} else {
|
||||
timelineError = timelineJson.error || 'timeline failed';
|
||||
}
|
||||
} catch (err) {
|
||||
timelineError = err && err.message ? err.message : 'timeline failed';
|
||||
}
|
||||
return { result, timeline, timelineError };
|
||||
function buildMonitorTotals(summary) {
|
||||
const s = summary && typeof summary === 'object' ? summary : {};
|
||||
return {
|
||||
total: s.totalCalls || 0,
|
||||
success: s.successCalls || 0,
|
||||
failed: s.failedCalls || 0,
|
||||
lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMcpTimelineLabel(isoOrDate, rangeKey, locale) {
|
||||
@@ -4028,34 +4128,19 @@ async function setMcpMonitorTimelineRange(range) {
|
||||
localStorage.setItem('mcpMonitorTimelineRange', range);
|
||||
monitorState.timelineRange = range;
|
||||
monitorState.timelineError = null;
|
||||
monitorState.timelineLoading = true;
|
||||
syncMcpMonitorTimelineRangeUI(range);
|
||||
updateMonitorTimelineSection();
|
||||
try {
|
||||
const timelineResp = await apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' });
|
||||
const timelineJson = await timelineResp.json().catch(() => ({}));
|
||||
if (!timelineResp.ok) {
|
||||
throw new Error(timelineJson.error || '加载趋势失败');
|
||||
}
|
||||
monitorState.timeline = timelineJson;
|
||||
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
||||
if (timelineInner) {
|
||||
const combined = timelineInner.closest('.mcp-stats-combined');
|
||||
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
|
||||
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
|
||||
bindMcpStatsTimelineEvents();
|
||||
syncMcpMonitorTimelineRangeUI(range);
|
||||
} else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
|
||||
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
||||
}
|
||||
const { timeline, timelineError } = await fetchMonitorTimeline(range);
|
||||
monitorState.timeline = timeline;
|
||||
monitorState.timelineError = timelineError;
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
} catch (err) {
|
||||
monitorState.timelineError = err.message || 'error';
|
||||
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
||||
if (timelineInner) {
|
||||
const combined = timelineInner.closest('.mcp-stats-combined');
|
||||
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
|
||||
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
|
||||
bindMcpStatsTimelineEvents();
|
||||
syncMcpMonitorTimelineRangeUI(range);
|
||||
}
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
}
|
||||
}
|
||||
window.setMcpMonitorTimelineRange = setMcpMonitorTimelineRange;
|
||||
@@ -4084,7 +4169,12 @@ function renderMcpStatsTimelineEmptyState(compact) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty) {
|
||||
function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loading) {
|
||||
if (loading) {
|
||||
const loadingText = mcpMonitorT('timelineLoading') || monitorFallback('趋势加载中…', 'Loading trend…');
|
||||
return `<div class="monitor-empty monitor-empty--inline">${escapeHtml(loadingText)}</div>`;
|
||||
}
|
||||
|
||||
const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined');
|
||||
|
||||
if (timelineError) {
|
||||
@@ -4152,7 +4242,7 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
|
||||
const timelineCol = showTimeline
|
||||
? `<div class="mcp-stats-combined__timeline">
|
||||
<p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p>
|
||||
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools)}</div>
|
||||
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools, monitorState.timelineLoading)}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
@@ -4207,20 +4297,11 @@ function refreshMonitorPanelFromState() {
|
||||
if (!monitorState.lastFetchedAt) return;
|
||||
const statusFilter = document.getElementById('monitor-status-filter');
|
||||
const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
|
||||
renderMonitorStats(monitorState.stats || {}, monitorState.lastFetchedAt);
|
||||
renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
|
||||
renderMonitorExecutions(monitorState.executions || [], currentStatusFilter);
|
||||
renderMonitorPagination();
|
||||
}
|
||||
|
||||
function normalizeMonitorStatsEntries(statsMap) {
|
||||
if (!statsMap || typeof statsMap !== 'object') return [];
|
||||
return Object.entries(statsMap).map(([key, item]) => {
|
||||
const stat = item && typeof item === 'object' ? { ...item } : {};
|
||||
if (!stat.toolName) stat.toolName = key;
|
||||
return stat;
|
||||
});
|
||||
}
|
||||
|
||||
const MCP_STATS_TOOL_CHEVRON = '<svg class="mcp-stats-tool-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||
|
||||
function getMcpStatsRateTone(rateNum) {
|
||||
@@ -4915,15 +4996,19 @@ function renderMcpStatsToolRanking(topTools, totals, activeToolFilter = '', opti
|
||||
return renderMcpStatsDetailSection(topTools, totals, activeToolFilter);
|
||||
}
|
||||
|
||||
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
function renderMonitorStats(summary = null, topTools = [], lastFetchedAt = null) {
|
||||
const container = document.getElementById('monitor-stats');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = normalizeMonitorStatsEntries(statsMap);
|
||||
const showTimeline = monitorState.timeline != null || !!monitorState.timelineError;
|
||||
if (entries.length === 0 && !showTimeline) {
|
||||
const tools = Array.isArray(topTools) ? topTools : [];
|
||||
const totals = buildMonitorTotals(summary);
|
||||
const toolCount = summary && typeof summary.toolCount === 'number' ? summary.toolCount : tools.length;
|
||||
const showTimeline = monitorState.timelineLoading || monitorState.timeline != null || !!monitorState.timelineError;
|
||||
const hasSummaryData = toolCount > 0 || totals.total > 0;
|
||||
|
||||
if (!hasSummaryData && !showTimeline) {
|
||||
const noStats = mcpMonitorT('noStatsData') || monitorFallback('暂无统计数据', 'No statistical data');
|
||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
||||
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||
@@ -4931,20 +5016,6 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totals = entries.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += item.totalCalls || 0;
|
||||
acc.success += item.successCalls || 0;
|
||||
acc.failed += item.failedCalls || 0;
|
||||
const lastCall = item.lastCallTime ? new Date(item.lastCallTime) : null;
|
||||
if (lastCall && (!acc.lastCallTime || lastCall > acc.lastCallTime)) {
|
||||
acc.lastCallTime = lastCall;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, success: 0, failed: 0, lastCallTime: null }
|
||||
);
|
||||
|
||||
const hasCalls = totals.total > 0;
|
||||
const successRateNum = hasCalls ? (totals.success / totals.total) * 100 : 0;
|
||||
const successRate = hasCalls ? successRateNum.toFixed(1) : '-';
|
||||
@@ -4965,19 +5036,13 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
const toolFilterEl = document.getElementById('monitor-tool-filter');
|
||||
const activeToolFilter = toolFilterEl ? toolFilterEl.value.trim() : '';
|
||||
|
||||
const topTools = entries
|
||||
.filter(tool => (tool.totalCalls || 0) > 0)
|
||||
.slice()
|
||||
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
||||
.slice(0, MCP_STATS_TOP_N);
|
||||
|
||||
const hasAnyCalls = totals.total > 0;
|
||||
const showCombined = hasAnyCalls && (topTools.length > 0 || showTimeline);
|
||||
const showCombined = hasAnyCalls && (tools.length > 0 || showTimeline);
|
||||
const html = `
|
||||
<div class="mcp-exec-stats">
|
||||
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
|
||||
${showCombined ? renderMcpStatsCombinedSection(
|
||||
topTools,
|
||||
tools,
|
||||
totals,
|
||||
activeToolFilter,
|
||||
monitorState.timeline,
|
||||
@@ -4995,7 +5060,7 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
} else if (toolFilterEl) {
|
||||
toolFilterEl.classList.remove('is-filter-active');
|
||||
}
|
||||
updateMonitorStatsSubtitle(lastFetchedAt, entries.length, monitorState.retentionDays);
|
||||
updateMonitorStatsSubtitle(lastFetchedAt, toolCount, monitorState.retentionDays);
|
||||
}
|
||||
|
||||
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
@@ -5052,10 +5117,12 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
const terminateBtn = status === 'running'
|
||||
? `<button type="button" class="btn-secondary btn-monitor-abort" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>`
|
||||
: '';
|
||||
const jsExecId = rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const isSelected = monitorState.selectedExecutions.has(rawExecId);
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" />
|
||||
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" />
|
||||
</td>
|
||||
<td>${toolName}</td>
|
||||
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
|
||||
@@ -5201,6 +5268,8 @@ async function deleteExecution(executionId) {
|
||||
throw new Error(error.error || deleteFailedMsg);
|
||||
}
|
||||
|
||||
monitorState.selectedExecutions.delete(executionId);
|
||||
|
||||
// 删除成功后刷新当前页面
|
||||
const currentPage = monitorState.pagination.page;
|
||||
await refreshMonitorPanel(currentPage);
|
||||
@@ -5214,10 +5283,22 @@ async function deleteExecution(executionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单条执行记录选中状态(持久化到 monitorState,避免轮询刷新后丢失)
|
||||
function toggleExecutionSelection(executionId, selected) {
|
||||
if (!executionId) {
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
monitorState.selectedExecutions.add(executionId);
|
||||
} else {
|
||||
monitorState.selectedExecutions.delete(executionId);
|
||||
}
|
||||
updateBatchActionsState();
|
||||
}
|
||||
|
||||
// 更新批量操作状态
|
||||
function updateBatchActionsState() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
|
||||
const selectedCount = checkboxes.length;
|
||||
const selectedCount = monitorState.selectedExecutions.size;
|
||||
const batchActions = document.getElementById('monitor-batch-actions');
|
||||
const selectedCountSpan = document.getElementById('monitor-selected-count');
|
||||
|
||||
@@ -5234,13 +5315,18 @@ function updateBatchActionsState() {
|
||||
selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项';
|
||||
}
|
||||
|
||||
// 更新全选复选框状态
|
||||
// 更新全选复选框状态(仅反映当前页)
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
if (selectAllCheckbox) {
|
||||
const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox');
|
||||
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
|
||||
selectAllCheckbox.checked = allChecked;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
|
||||
if (allCheckboxes.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
const checkedOnPage = Array.from(allCheckboxes).filter(cb => monitorState.selectedExecutions.has(cb.value)).length;
|
||||
selectAllCheckbox.checked = checkedOnPage === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkedOnPage > 0 && checkedOnPage < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5249,6 +5335,11 @@ function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
if (checkbox.checked) {
|
||||
monitorState.selectedExecutions.add(cb.value);
|
||||
} else {
|
||||
monitorState.selectedExecutions.delete(cb.value);
|
||||
}
|
||||
});
|
||||
updateBatchActionsState();
|
||||
}
|
||||
@@ -5258,6 +5349,7 @@ function selectAllExecutions() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = true;
|
||||
monitorState.selectedExecutions.add(cb.value);
|
||||
});
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
if (selectAllCheckbox) {
|
||||
@@ -5273,6 +5365,7 @@ function deselectAllExecutions() {
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
monitorState.selectedExecutions.clear();
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
@@ -5283,14 +5376,12 @@ function deselectAllExecutions() {
|
||||
|
||||
// 批量删除执行记录
|
||||
async function batchDeleteExecutions() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
const ids = Array.from(monitorState.selectedExecutions);
|
||||
if (ids.length === 0) {
|
||||
const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录';
|
||||
alert(selectFirstMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = Array.from(checkboxes).map(cb => cb.value);
|
||||
const count = ids.length;
|
||||
const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`;
|
||||
if (!confirm(batchConfirmMsg)) {
|
||||
@@ -5314,6 +5405,10 @@ async function batchDeleteExecutions() {
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
const deletedCount = result.deleted || count;
|
||||
|
||||
ids.forEach(function (id) {
|
||||
monitorState.selectedExecutions.delete(id);
|
||||
});
|
||||
|
||||
// 删除成功后刷新当前页面
|
||||
const currentPage = monitorState.pagination.page;
|
||||
|
||||
@@ -293,6 +293,9 @@ async function ensureProjectsLoaded(force) {
|
||||
projectsCacheAll = list;
|
||||
rebuildProjectNameMap(projectsCacheAll);
|
||||
_projectsListReady = true;
|
||||
if (typeof window.refreshConversationProjectFilter === 'function') {
|
||||
window.refreshConversationProjectFilter();
|
||||
}
|
||||
return projectsCacheAll;
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -371,6 +374,9 @@ async function loadProjectsList() {
|
||||
if (typeof refreshVulnerabilityProjectFilter === 'function') {
|
||||
refreshVulnerabilityProjectFilter();
|
||||
}
|
||||
if (typeof window.refreshAllProjectFilterSelects === 'function') {
|
||||
await window.refreshAllProjectFilterSelects();
|
||||
}
|
||||
}
|
||||
|
||||
function projectInitial(name) {
|
||||
@@ -2198,6 +2204,9 @@ async function applyChatProjectSelection(projectId) {
|
||||
setActiveProjectId(projectId);
|
||||
}
|
||||
updateChatProjectButtonLabel();
|
||||
if (typeof window.onConversationProjectBindingChanged === 'function') {
|
||||
window.onConversationProjectBindingChanged(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */
|
||||
@@ -2326,3 +2335,4 @@ window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||
window.projectNameById = projectNameById;
|
||||
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
||||
|
||||
@@ -335,7 +335,9 @@ async function initPage(pageId) {
|
||||
}
|
||||
break;
|
||||
case 'hitl':
|
||||
if (typeof refreshHitlPending === 'function') {
|
||||
if (typeof refreshHitlActivePanel === 'function') {
|
||||
refreshHitlActivePanel();
|
||||
} else if (typeof refreshHitlPending === 'function') {
|
||||
refreshHitlPending();
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -181,7 +181,7 @@ function updateCompletedTasksHistory(currentTasks) {
|
||||
|
||||
tasksState.completedTasksHistory.push({
|
||||
conversationId: task.conversationId,
|
||||
message: task.message || '未命名任务',
|
||||
message: task.title || task.message || '未命名任务',
|
||||
startedAt: task.startedAt,
|
||||
status: finalStatus,
|
||||
completedAt: new Date().toISOString()
|
||||
@@ -537,7 +537,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
` : '<div class="task-checkbox-placeholder"></div>'}
|
||||
<span class="task-status ${status.class}">${status.text}</span>
|
||||
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
|
||||
<span class="task-message" title="${escapeHtml(task.message || _t('tasks.unnamedTask'))}">${escapeHtml(task.message || _t('tasks.unnamedTask'))}</span>
|
||||
<span class="task-message" title="${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}">${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}</span>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
|
||||
|
||||
+434
-17
@@ -39,6 +39,220 @@ function vulnStatusLabel(code) {
|
||||
return m[code] ? vulnT(m[code]) : code;
|
||||
}
|
||||
|
||||
const VULN_STATUS_CODES = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'];
|
||||
const VULNERABILITY_REMOVE_ANIM_MS = 200;
|
||||
|
||||
function getVulnerabilityScrollContainer() {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
return page ? page.querySelector('.page-content') : null;
|
||||
}
|
||||
|
||||
function getExpandedVulnerabilityIds() {
|
||||
const ids = [];
|
||||
document.querySelectorAll('#vulnerabilities-list .vulnerability-content').forEach(function (el) {
|
||||
if (el.style.display !== 'none') {
|
||||
const id = (el.id || '').replace(/^content-/, '');
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function restoreExpandedVulnerabilityDetails(expandedIds) {
|
||||
if (!expandedIds || !expandedIds.length) return;
|
||||
expandedIds.forEach(function (id) {
|
||||
const content = document.getElementById('content-' + id);
|
||||
const icon = document.getElementById('expand-icon-' + id);
|
||||
if (!content || content.style.display !== 'none') return;
|
||||
content.style.display = 'block';
|
||||
if (icon) icon.style.transform = 'rotate(90deg)';
|
||||
loadVulnerabilityRelatedFacts(id).catch(function (e) { console.warn(e); });
|
||||
});
|
||||
}
|
||||
|
||||
function buildVulnerabilityStatusPicker(vuln) {
|
||||
const current = vuln.status || 'open';
|
||||
const id = escapeHtml(vuln.id);
|
||||
const label = escapeHtml(vulnT('vulnerabilityPage.statusChangeLabel'));
|
||||
const caretSvg = '<svg class="vuln-status-picker-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const options = VULN_STATUS_CODES.map(function (code) {
|
||||
const selected = code === current;
|
||||
const selCls = selected ? ' is-selected' : '';
|
||||
const ariaSel = selected ? ' aria-selected="true"' : ' aria-selected="false"';
|
||||
return '<button type="button" class="vuln-status-picker-option' + selCls + '" role="option" data-value="' + code + '"' + ariaSel + '>' +
|
||||
'<span class="vuln-status-picker-check" aria-hidden="true">✓</span>' +
|
||||
'<span class="vuln-status-picker-label">' + escapeHtml(vulnStatusLabel(code)) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
return '<div class="vuln-status-picker status-' + escapeHtml(current) + '" data-vuln-id="' + id + '" data-prev-status="' + escapeHtml(current) + '">' +
|
||||
'<button type="button" class="vuln-status-picker-trigger" aria-label="' + label + '" aria-haspopup="listbox" aria-expanded="false">' +
|
||||
'<span class="vuln-status-picker-value">' + escapeHtml(vulnStatusLabel(current)) + '</span>' +
|
||||
caretSvg +
|
||||
'</button>' +
|
||||
'<div class="vuln-status-picker-menu" role="listbox" hidden>' + options + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const VULN_STATUS_PICKER_STATUS_CLASSES = VULN_STATUS_CODES.map(function (code) {
|
||||
return 'status-' + code;
|
||||
});
|
||||
|
||||
function setVulnerabilityStatusPickerDisabled(pickerEl, disabled) {
|
||||
if (!pickerEl) return;
|
||||
pickerEl.classList.toggle('is-disabled', !!disabled);
|
||||
const trigger = pickerEl.querySelector('.vuln-status-picker-trigger');
|
||||
if (trigger) trigger.disabled = !!disabled;
|
||||
}
|
||||
|
||||
function updateVulnerabilityStatusPicker(pickerEl, status) {
|
||||
if (!pickerEl) return;
|
||||
const code = status || 'open';
|
||||
VULN_STATUS_PICKER_STATUS_CLASSES.forEach(function (cls) {
|
||||
pickerEl.classList.remove(cls);
|
||||
});
|
||||
pickerEl.classList.add('status-' + code);
|
||||
pickerEl.dataset.prevStatus = code;
|
||||
const valueEl = pickerEl.querySelector('.vuln-status-picker-value');
|
||||
if (valueEl) valueEl.textContent = vulnStatusLabel(code);
|
||||
pickerEl.querySelectorAll('.vuln-status-picker-option').forEach(function (opt) {
|
||||
const isSel = opt.getAttribute('data-value') === code;
|
||||
opt.classList.toggle('is-selected', isSel);
|
||||
opt.setAttribute('aria-selected', isSel ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
let vulnerabilityStatusPickerDocBound = false;
|
||||
|
||||
function closeAllVulnerabilityStatusPickers() {
|
||||
document.querySelectorAll('.vuln-status-picker.open').forEach(function (picker) {
|
||||
picker.classList.remove('open');
|
||||
const menu = picker.querySelector('.vuln-status-picker-menu');
|
||||
const trigger = picker.querySelector('.vuln-status-picker-trigger');
|
||||
if (menu) menu.hidden = true;
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function initVulnerabilityStatusPickers(root) {
|
||||
if (!vulnerabilityStatusPickerDocBound) {
|
||||
document.addEventListener('click', closeAllVulnerabilityStatusPickers);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllVulnerabilityStatusPickers();
|
||||
});
|
||||
vulnerabilityStatusPickerDocBound = true;
|
||||
}
|
||||
|
||||
const scope = root || document.getElementById('vulnerabilities-list');
|
||||
if (!scope) return;
|
||||
|
||||
scope.querySelectorAll('.vuln-status-picker').forEach(function (picker) {
|
||||
if (picker.dataset.bound === '1') return;
|
||||
picker.dataset.bound = '1';
|
||||
|
||||
picker.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
picker.addEventListener('keydown', function (e) { e.stopPropagation(); });
|
||||
|
||||
const trigger = picker.querySelector('.vuln-status-picker-trigger');
|
||||
const menu = picker.querySelector('.vuln-status-picker-menu');
|
||||
if (!trigger || !menu) return;
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (picker.classList.contains('is-disabled')) return;
|
||||
const wasOpen = picker.classList.contains('open');
|
||||
closeAllVulnerabilityStatusPickers();
|
||||
if (!wasOpen) {
|
||||
picker.classList.add('open');
|
||||
menu.hidden = false;
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const opt = e.target.closest('.vuln-status-picker-option');
|
||||
if (!opt || picker.classList.contains('is-disabled')) return;
|
||||
const newStatus = opt.getAttribute('data-value');
|
||||
const vulnId = picker.dataset.vulnId;
|
||||
closeAllVulnerabilityStatusPickers();
|
||||
changeVulnerabilityStatus(vulnId, newStatus, picker);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function vulnerabilityStatusMatchesFilter(status) {
|
||||
const filterStatus = (vulnerabilityFilters.status || '').trim();
|
||||
return !filterStatus || filterStatus === status;
|
||||
}
|
||||
|
||||
function removeVulnerabilityCard(vulnId, options) {
|
||||
const opts = options || {};
|
||||
const card = document.getElementById('vulnerability-card-' + vulnId) ||
|
||||
document.querySelector('.vulnerability-card[data-vuln-id="' + vulnId + '"]');
|
||||
if (!card) return;
|
||||
|
||||
const nextCard = card.nextElementSibling;
|
||||
card.classList.add('vulnerability-card--removing');
|
||||
|
||||
setTimeout(function () {
|
||||
card.remove();
|
||||
if (opts.decrementTotal !== false) {
|
||||
vulnerabilityPagination.total = Math.max(0, (vulnerabilityPagination.total || 0) - 1);
|
||||
vulnerabilityPagination.totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(vulnerabilityPagination.total / vulnerabilityPagination.pageSize)
|
||||
);
|
||||
renderVulnerabilityPagination();
|
||||
}
|
||||
|
||||
const list = document.getElementById('vulnerabilities-list');
|
||||
const remaining = list ? list.querySelectorAll('.vulnerability-card').length : 0;
|
||||
if (remaining === 0) {
|
||||
if (vulnerabilityPagination.currentPage > 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
loadVulnerabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.focusNext !== false && nextCard && nextCard.classList.contains('vulnerability-card')) {
|
||||
nextCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, VULNERABILITY_REMOVE_ANIM_MS);
|
||||
}
|
||||
|
||||
async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
|
||||
if (!vulnId || !pickerEl) return;
|
||||
const prevStatus = pickerEl.dataset.prevStatus || newStatus;
|
||||
if (newStatus === prevStatus) return;
|
||||
|
||||
setVulnerabilityStatusPickerDisabled(pickerEl, true);
|
||||
try {
|
||||
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(vulnId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(function () { return {}; });
|
||||
throw new Error(err.error || vulnT('vulnerabilityPage.statusUpdateFailed'));
|
||||
}
|
||||
|
||||
updateVulnerabilityStatusPicker(pickerEl, newStatus);
|
||||
loadVulnerabilityStats();
|
||||
|
||||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||||
removeVulnerabilityCard(vulnId, { decrementTotal: true, focusNext: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新漏洞状态失败:', error);
|
||||
updateVulnerabilityStatusPicker(pickerEl, prevStatus);
|
||||
alert(vulnT('vulnerabilityPage.statusUpdateFailed') + ': ' + error.message);
|
||||
} finally {
|
||||
setVulnerabilityStatusPickerDisabled(pickerEl, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 从localStorage读取每页显示数量,默认为20
|
||||
const getVulnerabilityPageSize = () => {
|
||||
const saved = localStorage.getItem('vulnerabilityPageSize');
|
||||
@@ -175,6 +389,7 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
syncVulnerabilityStatCardActiveState();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
|
||||
// 初始化漏洞管理页面
|
||||
@@ -387,6 +602,7 @@ function initVulnerabilityFilterPanel() {
|
||||
|
||||
if (vulnerabilityFilterPanelBound) {
|
||||
updateVulnerabilityFilterPanelState();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
return;
|
||||
}
|
||||
vulnerabilityFilterPanelBound = true;
|
||||
@@ -448,6 +664,146 @@ function initVulnerabilityFilterPanel() {
|
||||
});
|
||||
|
||||
bindVulnerabilityFilterTypeaheads();
|
||||
initVulnerabilityFilterSelects();
|
||||
}
|
||||
|
||||
const VULN_FILTER_CUSTOM_SELECT_IDS = ['vulnerability-project-filter', 'vulnerability-status-filter'];
|
||||
const vulnFilterCustomSelectMap = {};
|
||||
let vulnFilterCustomSelectDocBound = false;
|
||||
|
||||
const VULN_FILTER_SELECT_CARET = '<svg class="vuln-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
|
||||
function closeAllVulnFilterCustomSelects() {
|
||||
Object.keys(vulnFilterCustomSelectMap).forEach(function (id) {
|
||||
const reg = vulnFilterCustomSelectMap[id];
|
||||
if (!reg || !reg.wrapper) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function syncVulnFilterCustomSelect(selectId) {
|
||||
const reg = vulnFilterCustomSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
const select = reg.select;
|
||||
const dropdown = reg.dropdown;
|
||||
const trigger = reg.trigger;
|
||||
const valueSpan = trigger.querySelector('.vuln-filter-select-value');
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'vuln-filter-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'vuln-filter-select-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'vuln-filter-select-label';
|
||||
label.textContent = opt.textContent;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
const selectedOpt = select.options[select.selectedIndex];
|
||||
if (valueSpan) {
|
||||
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||
}
|
||||
trigger.disabled = !!select.disabled;
|
||||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
}
|
||||
|
||||
function syncAllVulnFilterCustomSelects() {
|
||||
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(syncVulnFilterCustomSelect);
|
||||
}
|
||||
|
||||
function enhanceVulnFilterCustomSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
if (select.dataset.vulnCustomSelect === '1') {
|
||||
syncVulnFilterCustomSelect(selectId);
|
||||
return;
|
||||
}
|
||||
select.dataset.vulnCustomSelect = '1';
|
||||
select.classList.add('vuln-filter-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'vuln-filter-select';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'vuln-filter-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'vuln-filter-select-value';
|
||||
trigger.appendChild(valueSpan);
|
||||
trigger.insertAdjacentHTML('beforeend', VULN_FILTER_SELECT_CARET);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'vuln-filter-select-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
const parent = select.parentNode;
|
||||
parent.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(dropdown);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
vulnFilterCustomSelectMap[selectId] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
if (typeof closeAllVulnerabilityStatusPickers === 'function') {
|
||||
closeAllVulnerabilityStatusPickers();
|
||||
}
|
||||
const open = wrapper.classList.contains('open');
|
||||
closeAllVulnFilterCustomSelects();
|
||||
if (!open) {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
const opt = e.target.closest('.vuln-filter-select-option');
|
||||
if (!opt) return;
|
||||
e.stopPropagation();
|
||||
const val = opt.getAttribute('data-value');
|
||||
if (val === null) return;
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
syncVulnFilterCustomSelect(selectId);
|
||||
});
|
||||
}
|
||||
|
||||
function initVulnerabilityFilterSelects() {
|
||||
if (!vulnFilterCustomSelectDocBound) {
|
||||
document.addEventListener('click', closeAllVulnFilterCustomSelects);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllVulnFilterCustomSelects();
|
||||
});
|
||||
vulnFilterCustomSelectDocBound = true;
|
||||
}
|
||||
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(enhanceVulnFilterCustomSelect);
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
|
||||
function countVulnerabilityAdvancedFiltersActive() {
|
||||
@@ -559,6 +915,9 @@ function removeVulnerabilityFilterByKey(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
|
||||
vulnerabilityFilters[key] = '';
|
||||
}
|
||||
if (key === 'project_id' || key === 'status') {
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
@@ -779,9 +1138,22 @@ function updateVulnerabilityStats(stats) {
|
||||
}
|
||||
|
||||
// 加载漏洞列表
|
||||
async function loadVulnerabilities(page = null) {
|
||||
async function loadVulnerabilities(page = null, options = {}) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const preserveScroll = !!opts.preserveScroll;
|
||||
const silent = !!opts.silent;
|
||||
let expandedIds = opts.expandedIds;
|
||||
|
||||
const scrollEl = preserveScroll ? getVulnerabilityScrollContainer() : null;
|
||||
const scrollTop = scrollEl ? scrollEl.scrollTop : 0;
|
||||
if (expandedIds === undefined && preserveScroll) {
|
||||
expandedIds = getExpandedVulnerabilityIds();
|
||||
}
|
||||
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
if (!silent) {
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查apiFetch是否可用
|
||||
@@ -830,8 +1202,14 @@ async function loadVulnerabilities(page = null) {
|
||||
console.error('未知的响应格式:', data);
|
||||
}
|
||||
|
||||
renderVulnerabilities(vulnerabilities);
|
||||
renderVulnerabilities(vulnerabilities, { expandedIds: expandedIds || [] });
|
||||
renderVulnerabilityPagination();
|
||||
|
||||
if (preserveScroll && scrollEl) {
|
||||
requestAnimationFrame(function () {
|
||||
scrollEl.scrollTop = scrollTop;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载漏洞列表失败:', error);
|
||||
listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`;
|
||||
@@ -839,7 +1217,8 @@ async function loadVulnerabilities(page = null) {
|
||||
}
|
||||
|
||||
// 渲染漏洞列表
|
||||
function renderVulnerabilities(vulnerabilities) {
|
||||
function renderVulnerabilities(vulnerabilities, renderOptions) {
|
||||
const opts = renderOptions && typeof renderOptions === 'object' ? renderOptions : {};
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
|
||||
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
|
||||
@@ -862,7 +1241,6 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
const html = vulnerabilities.map(vuln => {
|
||||
const severityClass = `severity-${vuln.severity}`;
|
||||
const severityText = vulnSeverityLabel(vuln.severity);
|
||||
const statusText = vulnStatusLabel(vuln.status);
|
||||
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
|
||||
const projectLabel = vuln.project_id
|
||||
? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id)
|
||||
@@ -875,7 +1253,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
const deleteTitle = escapeHtml(vulnT('common.delete'));
|
||||
|
||||
return `
|
||||
<div class="vulnerability-card ${severityClass}">
|
||||
<div class="vulnerability-card ${severityClass}" id="vulnerability-card-${vuln.id}" data-vuln-id="${escapeHtml(vuln.id)}">
|
||||
<div class="vulnerability-header" onclick="toggleVulnerabilityDetails('${vuln.id}')" style="cursor: pointer;">
|
||||
<div class="vulnerability-title-section">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
@@ -886,7 +1264,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
</div>
|
||||
<div class="vulnerability-meta">
|
||||
<span class="severity-badge ${severityClass}">${severityText}</span>
|
||||
<span class="status-badge status-${vuln.status}">${statusText}</span>
|
||||
${buildVulnerabilityStatusPicker(vuln)}
|
||||
${projectBadge}
|
||||
<span class="vulnerability-date">${createdDate}</span>
|
||||
</div>
|
||||
@@ -935,10 +1313,13 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
}).join('');
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
initVulnerabilityStatusPickers(listContainer);
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
|
||||
restoreExpandedVulnerabilityDetails(opts.expandedIds);
|
||||
|
||||
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
|
||||
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
|
||||
setTimeout(() => {
|
||||
@@ -1191,11 +1572,27 @@ async function saveVulnerability() {
|
||||
throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed'));
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
const editedId = currentVulnerabilityId;
|
||||
const isEdit = !!editedId;
|
||||
const expandedIds = isEdit ? getExpandedVulnerabilityIds() : [];
|
||||
|
||||
closeVulnerabilityModal();
|
||||
loadVulnerabilityStats();
|
||||
// 保存/更新后,重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
loadVulnerabilities();
|
||||
|
||||
if (!isEdit) {
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
loadVulnerabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
const newStatus = (updated && updated.status) || data.status;
|
||||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||||
removeVulnerabilityCard(editedId, { decrementTotal: true, focusNext: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
} catch (error) {
|
||||
console.error('保存漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
|
||||
@@ -1216,14 +1613,20 @@ async function deleteVulnerability(id) {
|
||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||||
|
||||
loadVulnerabilityStats();
|
||||
// 删除后,如果当前页没有数据了,回到上一页
|
||||
const card = document.getElementById('vulnerability-card-' + id) ||
|
||||
document.querySelector('.vulnerability-card[data-vuln-id="' + id + '"]');
|
||||
if (card) {
|
||||
removeVulnerabilityCard(id, { decrementTotal: true, focusNext: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (vulnerabilityPagination.currentPage > 1 && vulnerabilityPagination.total > 0) {
|
||||
const itemsOnCurrentPage = vulnerabilityPagination.total - (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
|
||||
if (itemsOnCurrentPage <= 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
}
|
||||
loadVulnerabilities();
|
||||
await loadVulnerabilities(null, { preserveScroll: true });
|
||||
} catch (error) {
|
||||
console.error('删除漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
|
||||
@@ -1263,6 +1666,7 @@ function clearVulnerabilityFilters() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
syncAllVulnFilterCustomSelects();
|
||||
|
||||
vulnerabilityFilters = {
|
||||
q: '',
|
||||
@@ -1685,10 +2089,16 @@ window.onclick = function(event) {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
document.addEventListener('languagechange', async function () {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
if (page && page.classList.contains('active')) {
|
||||
const panel = document.getElementById('vulnerability-filter-panel');
|
||||
if (panel && typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(panel);
|
||||
}
|
||||
renderVulnerabilityFilterChips();
|
||||
await refreshVulnerabilityProjectFilter();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
loadVulnerabilities();
|
||||
}
|
||||
});
|
||||
@@ -1709,11 +2119,15 @@ async function bindVulnerabilityProject(vulnId, projectId, silent) {
|
||||
alert(vulnT('vulnerabilityPage.projectBindOk'));
|
||||
}
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
const expandedIds = getExpandedVulnerabilityIds();
|
||||
if (!expandedIds.includes(vulnId)) {
|
||||
expandedIds.push(vulnId);
|
||||
}
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
} catch (error) {
|
||||
console.error('绑定项目失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message);
|
||||
loadVulnerabilities();
|
||||
await loadVulnerabilities(null, { preserveScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1738,15 +2152,16 @@ async function refreshVulnerabilityProjectFilter() {
|
||||
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||||
}
|
||||
const cur = vulnerabilityFilters.project_id || sel.value || '';
|
||||
let html = '<option value="">全部项目</option>';
|
||||
let html = '<option value="">' + escapeHtml(vulnT('vulnerabilityPage.allProjects')) + '</option>';
|
||||
(list || []).forEach((p) => {
|
||||
if (!p.id) return;
|
||||
const selected = p.id === cur ? ' selected' : '';
|
||||
const arch = p.status === 'archived' ? ' [归档]' : '';
|
||||
const arch = p.status === 'archived' ? ' [' + vulnT('projects.archived') + ']' : '';
|
||||
html += `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
|
||||
});
|
||||
sel.innerHTML = html;
|
||||
if (cur) sel.value = cur;
|
||||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||||
const modalSel = document.getElementById('vulnerability-project-id');
|
||||
if (modalSel && isAppModalOpen('vulnerability-modal')) {
|
||||
const modalCur = modalSel.value || '';
|
||||
@@ -1762,6 +2177,7 @@ function setVulnerabilityProjectFilter(projectId) {
|
||||
vulnerabilityFilters.project_id = projectId || '';
|
||||
const sel = document.getElementById('vulnerability-project-filter');
|
||||
if (sel) sel.value = projectId || '';
|
||||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
@@ -1777,4 +2193,5 @@ window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter;
|
||||
window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
|
||||
window.bindVulnerabilityProject = bindVulnerabilityProject;
|
||||
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
|
||||
window.changeVulnerabilityStatus = changeVulnerabilityStatus;
|
||||
|
||||
|
||||
+207
-16
@@ -377,9 +377,9 @@
|
||||
</div>
|
||||
<!-- 第一行:核心 KPI(关键指标置顶 + 副标徽章承载次级信息) -->
|
||||
<div class="dashboard-kpi-row" id="dashboard-cards">
|
||||
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }" data-i18n="dashboard.clickToViewTasks" data-i18n-attr="title" title="点击查看任务管理">
|
||||
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('chat')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('chat'); }" data-i18n="dashboard.clickToViewChat" data-i18n-attr="title" title="点击查看对话">
|
||||
<div class="dashboard-kpi-head">
|
||||
<div class="dashboard-kpi-label" data-i18n="dashboard.runningTasks">运行中任务</div>
|
||||
<div class="dashboard-kpi-label" data-i18n="dashboard.runningConversations">运行中对话</div>
|
||||
<span class="dashboard-kpi-icon dashboard-kpi-icon-tasks" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></span>
|
||||
</div>
|
||||
<div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div>
|
||||
@@ -778,7 +778,7 @@
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<!-- 全局搜索 -->
|
||||
<div class="conversation-search-box" style="margin-bottom: 16px; margin-top: 16px;">
|
||||
<div class="conversation-search-box">
|
||||
<input type="text" id="conversation-search-input" data-i18n="chat.searchHistory" data-i18n-attr="placeholder" placeholder="搜索历史记录..."
|
||||
oninput="handleConversationSearch(this.value)"
|
||||
onkeypress="if(event.key === 'Enter') handleConversationSearch(this.value)" />
|
||||
@@ -790,6 +790,15 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 按项目筛选对话 -->
|
||||
<div class="conversation-project-filter">
|
||||
<label class="conversation-project-filter-label" for="conversation-project-filter" data-i18n="chat.filterByProject">按项目筛选</label>
|
||||
<select id="conversation-project-filter" class="conversation-project-filter-native" onchange="onConversationProjectFilterChange(this.value)" data-i18n="chat.filterByProject" data-i18n-attr="title" title="按项目筛选">
|
||||
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
|
||||
<option value="__none__" data-i18n="chat.filterUnboundProjects">未绑定项目</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 对话分组 -->
|
||||
<div class="conversation-groups-section">
|
||||
@@ -933,6 +942,19 @@
|
||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hitl-config-field" id="hitl-reviewer-field">
|
||||
<label class="hitl-config-label" data-i18n="chat.hitlReviewerLabel">审批方</label>
|
||||
<div class="hitl-reviewer-toggle" role="group" aria-label="Reviewer">
|
||||
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="hitl-reviewer-select" value="human" />
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlReviewerHint">可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。</p>
|
||||
</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>
|
||||
@@ -1151,13 +1173,177 @@
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary" onclick="refreshHitlPending()" data-i18n="common.refresh">刷新</button>
|
||||
<button type="button" class="btn-secondary" id="hitl-refresh-btn" onclick="refreshHitlActivePanel()" data-i18n="common.refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="settings-section">
|
||||
<h3 data-i18n="hitl.pendingTitle">待处理审批</h3>
|
||||
<div class="hitl-page-reviewer-bar" id="hitl-page-reviewer-bar">
|
||||
<div class="hitl-page-reviewer-main">
|
||||
<span class="hitl-page-reviewer-label" data-i18n="hitl.pageReviewerLabel">当前审批方</span>
|
||||
<div class="hitl-reviewer-toggle hitl-reviewer-toggle--page" role="group" aria-label="Reviewer">
|
||||
<button type="button" class="hitl-reviewer-toggle-btn is-active" data-reviewer="human" aria-pressed="true">
|
||||
<span data-i18n="chat.hitlReviewerHuman">人工审批</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-reviewer-toggle-btn" data-reviewer="audit_agent" aria-pressed="false">
|
||||
<span data-i18n="chat.hitlReviewerAgent">审计 Agent</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hitl-page-reviewer-hint" data-i18n="hitl.pageReviewerHint">作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。</p>
|
||||
</div>
|
||||
<div class="hitl-page-tabs" role="tablist">
|
||||
<button type="button" class="hitl-page-tab hitl-page-tab--active" id="hitl-tab-pending" role="tab" aria-selected="true" onclick="switchHitlPageTab('pending')">
|
||||
<span data-i18n="hitl.tabPending">待审计</span>
|
||||
<span class="hitl-tab-badge" id="hitl-pending-count" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-page-tab" id="hitl-tab-logs" role="tab" aria-selected="false" onclick="switchHitlPageTab('logs')">
|
||||
<span data-i18n="hitl.tabLogs">审计日志</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-page-tab" id="hitl-tab-strategy" role="tab" aria-selected="false" onclick="switchHitlPageTab('strategy')">
|
||||
<span data-i18n="hitl.tabStrategy">审计策略</span>
|
||||
</button>
|
||||
<button type="button" class="hitl-page-tab" id="hitl-tab-whitelist" role="tab" aria-selected="false" onclick="switchHitlPageTab('whitelist')">
|
||||
<span data-i18n="hitl.tabWhitelist">工具白名单</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="hitl-panel-pending" class="hitl-page-panel">
|
||||
<div class="hitl-filters">
|
||||
<label>
|
||||
<span data-i18n="hitl.searchLabel">搜索</span>
|
||||
<input type="search" id="hitl-pending-search" class="hitl-filter-input" data-i18n="hitl.searchPlaceholder" data-i18n-attr="placeholder" placeholder="" onkeydown="if(event.key==='Enter')filterHitlPending()" />
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" onclick="filterHitlPending()" data-i18n="hitl.searchApply">搜索</button>
|
||||
</div>
|
||||
<div id="hitl-pending-list" class="hitl-pending-list"></div>
|
||||
<div id="hitl-pending-pagination" class="hitl-pending-pagination"></div>
|
||||
</div>
|
||||
|
||||
<div id="hitl-panel-logs" class="hitl-page-panel" hidden>
|
||||
<div class="hitl-filters hitl-filters--logs">
|
||||
<label>
|
||||
<span data-i18n="hitl.searchLabel">搜索</span>
|
||||
<input type="search" id="hitl-logs-search" class="hitl-filter-input" data-i18n="hitl.searchPlaceholder" data-i18n-attr="placeholder" placeholder="" onkeydown="if(event.key==='Enter')filterHitlLogs()" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="hitl.filterDecision">决策</span>
|
||||
<select id="hitl-logs-decision-filter" class="hitl-filter-select" onchange="filterHitlLogs()">
|
||||
<option value="all" data-i18n="hitl.filterAll">全部</option>
|
||||
<option value="approve" data-i18n="hitl.decisionApprove">通过</option>
|
||||
<option value="reject" data-i18n="hitl.decisionReject">拒绝</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="hitl.filterDecidedBy">审批方</span>
|
||||
<select id="hitl-logs-decidedby-filter" class="hitl-filter-select" onchange="filterHitlLogs()">
|
||||
<option value="all" data-i18n="hitl.filterAll">全部</option>
|
||||
<option value="human" data-i18n="hitl.reviewerHuman">人工</option>
|
||||
<option value="audit_agent" data-i18n="hitl.reviewerAgent">审计 Agent</option>
|
||||
<option value="system" data-i18n="hitl.reviewerSystem">系统</option>
|
||||
<option value="manual" data-i18n="hitl.reviewerManual">手动录入</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" onclick="filterHitlLogs()" data-i18n="hitl.searchApply">搜索</button>
|
||||
<button type="button" class="btn-secondary btn-delete" onclick="clearHitlLogs()" data-i18n="hitl.clearAll">清空</button>
|
||||
</div>
|
||||
<p id="hitl-logs-retention-hint" class="hitl-logs-retention-hint" hidden></p>
|
||||
<div id="hitl-logs-batch-actions" class="monitor-batch-actions" style="display: none;">
|
||||
<div class="batch-actions-info">
|
||||
<span id="hitl-logs-selected-count" data-i18n="hitl.selectedCount" data-i18n-params='{"count":0}'>已选择 0 项</span>
|
||||
</div>
|
||||
<div class="batch-actions-buttons">
|
||||
<button type="button" class="btn-secondary" onclick="selectAllHitlLogs()" data-i18n="hitl.selectAll">全选</button>
|
||||
<button type="button" class="btn-secondary" onclick="deselectAllHitlLogs()" data-i18n="hitl.deselectAll">取消全选</button>
|
||||
<button type="button" class="btn-secondary btn-delete" onclick="batchDeleteHitlLogs()" data-i18n="hitl.batchDelete">批量删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hitl-logs-table-wrap" class="hitl-logs-table-wrap">
|
||||
<div class="loading-spinner" data-i18n="hitl.loading">加载中...</div>
|
||||
</div>
|
||||
<div id="hitl-logs-pagination" class="hitl-logs-pagination"></div>
|
||||
</div>
|
||||
|
||||
<div id="hitl-panel-strategy" class="hitl-page-panel" hidden>
|
||||
<div class="hitl-page-strategy-bar" id="hitl-page-strategy-bar">
|
||||
<div class="hitl-page-strategy-header">
|
||||
<span class="hitl-page-strategy-label" data-i18n="hitl.strategyLabel">审计策略</span>
|
||||
<div class="hitl-page-strategy-actions">
|
||||
<button type="button" class="btn-link" id="hitl-strategy-reset-btn" onclick="resetHitlAuditStrategy()" data-i18n="hitl.strategyReset">恢复默认</button>
|
||||
<button type="button" class="btn-secondary" id="hitl-strategy-save-btn" onclick="saveHitlAuditStrategy()" data-i18n="common.save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-strategy-subtabs" role="tablist" aria-label="Audit strategy mode">
|
||||
<button type="button" class="hitl-strategy-subtab hitl-strategy-subtab--active" id="hitl-strategy-tab-approval" role="tab" aria-selected="true" data-strategy-mode="approval" onclick="switchHitlStrategyMode('approval')" data-i18n="hitl.strategyTabApproval">审批模式</button>
|
||||
<button type="button" class="hitl-strategy-subtab" id="hitl-strategy-tab-review-edit" role="tab" aria-selected="false" data-strategy-mode="review_edit" onclick="switchHitlStrategyMode('review_edit')" data-i18n="hitl.strategyTabReviewEdit">审查编辑模式</button>
|
||||
</div>
|
||||
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-approval" data-i18n="hitl.strategyHintApproval">白名单内工具免审批;审批模式下审计 Agent 仅裁决通过/拒绝。</p>
|
||||
<p class="hitl-page-strategy-hint" id="hitl-strategy-hint-review-edit" hidden data-i18n="hitl.strategyHintReviewEdit">审查编辑模式下审计 Agent 可通过 editedArguments 收窄参数后放行;无法安全改参时应拒绝。</p>
|
||||
<textarea id="hitl-audit-agent-prompt" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off"></textarea>
|
||||
<textarea id="hitl-audit-agent-prompt-review-edit" class="hitl-strategy-textarea" rows="14" spellcheck="false" autocomplete="off" hidden></textarea>
|
||||
<div id="hitl-strategy-feedback" class="hitl-apply-feedback" role="status" aria-live="polite" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hitl-panel-whitelist" class="hitl-page-panel" hidden>
|
||||
<div class="hitl-page-whitelist-bar" id="hitl-page-whitelist-bar">
|
||||
<div class="hitl-page-whitelist-header">
|
||||
<span class="hitl-page-whitelist-label" data-i18n="hitl.whitelistLabel">免审批工具白名单</span>
|
||||
<button type="button" class="btn-secondary" id="hitl-page-whitelist-save-btn" onclick="saveHitlPageWhitelist()" data-i18n="common.save">保存</button>
|
||||
</div>
|
||||
<p class="hitl-page-whitelist-hint" data-i18n="hitl.whitelistHint">每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。</p>
|
||||
<textarea id="hitl-page-sensitive-tools" class="hitl-page-whitelist-textarea" rows="6" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<div id="hitl-page-whitelist-feedback" class="hitl-apply-feedback" role="status" aria-live="polite" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hitl-log-modal" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="hitl-log-modal-title">
|
||||
<div class="modal-content hitl-log-modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="hitl-log-modal-title" data-i18n="hitl.logModalView">审计日志详情</h3>
|
||||
<button type="button" class="modal-close" onclick="closeHitlLogModal()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="hitl-log-context-readonly" class="hitl-log-readonly-section" hidden></div>
|
||||
<div id="hitl-log-execution-readonly" class="hitl-log-readonly-section" hidden></div>
|
||||
<dl class="hitl-log-detail-meta">
|
||||
<div class="hitl-log-detail-row">
|
||||
<dt data-i18n="hitl.colId">ID</dt>
|
||||
<dd id="hitl-log-detail-id" class="hitl-log-detail-mono">—</dd>
|
||||
</div>
|
||||
<div class="hitl-log-detail-row">
|
||||
<dt data-i18n="hitl.colTool">工具</dt>
|
||||
<dd id="hitl-log-detail-tool">—</dd>
|
||||
</div>
|
||||
<div class="hitl-log-detail-row">
|
||||
<dt data-i18n="hitl.colConversation">会话</dt>
|
||||
<dd id="hitl-log-detail-conversation" class="hitl-log-detail-mono">—</dd>
|
||||
</div>
|
||||
<div class="hitl-log-detail-row">
|
||||
<dt data-i18n="hitl.colDecision">决策</dt>
|
||||
<dd id="hitl-log-detail-decision">—</dd>
|
||||
</div>
|
||||
<div class="hitl-log-detail-row">
|
||||
<dt data-i18n="hitl.colDecidedBy">审批方</dt>
|
||||
<dd id="hitl-log-detail-decided-by">—</dd>
|
||||
</div>
|
||||
<div class="hitl-log-detail-row">
|
||||
<dt data-i18n="hitl.colTime">时间</dt>
|
||||
<dd id="hitl-log-detail-time">—</dd>
|
||||
</div>
|
||||
<div class="hitl-log-detail-row hitl-log-detail-row--full" id="hitl-log-detail-comment-row" hidden>
|
||||
<dt data-i18n="hitl.fieldComment">备注</dt>
|
||||
<dd id="hitl-log-detail-comment">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="hitl-log-detail-payload" id="hitl-log-detail-payload-wrap" hidden>
|
||||
<div class="hitl-context-label" data-i18n="hitl.fieldPayload">载荷</div>
|
||||
<pre id="hitl-log-detail-payload" class="hitl-context-text"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeHitlLogModal()" data-i18n="common.close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1929,14 +2115,14 @@
|
||||
data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--project">
|
||||
<span class="sr-only">项目</span>
|
||||
<select id="vulnerability-project-filter" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
|
||||
<option value="">全部项目</option>
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.detailProject">项目</span>
|
||||
<select id="vulnerability-project-filter" data-i18n="vulnerabilityPage.filterByProject" data-i18n-attr="title" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
|
||||
<option value="" data-i18n="vulnerabilityPage.allProjects">全部项目</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
||||
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
|
||||
<select id="vulnerability-status-filter" data-i18n="vulnerabilityPage.status" data-i18n-attr="title" title="状态">
|
||||
<option value="" data-i18n="knowledgePage.all">全部状态</option>
|
||||
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
|
||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||
@@ -2523,10 +2709,10 @@
|
||||
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="openai-provider">API 提供商</label>
|
||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="openai">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude">Claude (Anthropic Messages API)</option>
|
||||
<option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -2610,9 +2796,9 @@
|
||||
<div class="form-group">
|
||||
<label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label>
|
||||
<select id="vision-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="">OpenAI 配置(留空复用)</option>
|
||||
<option value="openai">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude">Claude (Anthropic Messages API)</option>
|
||||
<option value="" data-i18n="settingsBasic.visionProviderReuseOpenAI">OpenAI 配置(留空复用)</option>
|
||||
<option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -3764,6 +3950,10 @@
|
||||
<div class="modal-header">
|
||||
<h2 id="batch-manage-title">管理对话记录·共<span id="batch-manage-count">0</span>条</h2>
|
||||
<div class="batch-manage-header-actions">
|
||||
<select id="batch-project-filter" class="conversation-project-filter-native" onchange="applyBatchConversationFilters()" data-i18n="batchManageModal.filterByProject" data-i18n-attr="title" title="按项目筛选">
|
||||
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
|
||||
<option value="__none__" data-i18n="chat.filterUnboundProjects">未绑定项目</option>
|
||||
</select>
|
||||
<div class="batch-search-box">
|
||||
<input type="text" id="batch-search-input" data-i18n="batchManageModal.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索历史记录" oninput="filterBatchConversations(this.value)" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -3781,6 +3971,7 @@
|
||||
<input type="checkbox" id="batch-select-all" onchange="toggleSelectAllBatch()" data-i18n="batchManageModal.selectAll" data-i18n-attr="title" title="全选" />
|
||||
</div>
|
||||
<div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div>
|
||||
<div class="batch-table-col-project" data-i18n="batchManageModal.project">项目</div>
|
||||
<div class="batch-table-col-time" data-i18n="batchManageModal.lastTime">最近一次对话时间</div>
|
||||
<div class="batch-table-col-action" data-i18n="batchManageModal.action">操作</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user