Compare commits

...

39 Commits

Author SHA1 Message Date
公明 fd4bbe8d76 Update config.yaml 2026-06-30 20:22:19 +08:00
公明 d80651e4d8 Add files via upload 2026-06-30 20:16:43 +08:00
公明 f920ff0a5d Update config.yaml 2026-06-30 20:15:26 +08:00
公明 ce8b57501d Add files via upload 2026-06-30 20:14:28 +08:00
公明 ecb38a3959 Add files via upload 2026-06-30 20:13:31 +08:00
公明 e69fdb71ca Add files via upload 2026-06-30 20:11:54 +08:00
公明 6aa1631748 Add files via upload 2026-06-30 20:10:36 +08:00
公明 52de3b0f41 Add files via upload 2026-06-30 20:09:18 +08:00
公明 e537e55198 Add files via upload 2026-06-30 20:07:28 +08:00
公明 dc20b4804e Update config.yaml 2026-06-30 19:55:00 +08:00
公明 6245d69364 Add files via upload 2026-06-30 19:53:44 +08:00
公明 ede32951bf Add files via upload 2026-06-30 19:52:30 +08:00
公明 866a8ebccf Add files via upload 2026-06-30 19:10:46 +08:00
公明 276b3f7ef5 Add files via upload 2026-06-30 18:39:26 +08:00
公明 81e461db54 Update config.yaml 2026-06-30 18:38:27 +08:00
公明 02cd488a3d Add files via upload 2026-06-30 18:06:15 +08:00
公明 b4b2f55665 Add files via upload 2026-06-30 18:04:16 +08:00
公明 7aa0ebea6d Add files via upload 2026-06-30 18:02:08 +08:00
公明 63ef4399f8 Add files via upload 2026-06-30 18:00:00 +08:00
公明 553d0ed6bf Add files via upload 2026-06-30 17:59:02 +08:00
公明 d92bbbea07 Add files via upload 2026-06-30 17:56:40 +08:00
公明 f89ad1b42d Add files via upload 2026-06-30 16:00:00 +08:00
公明 bbe14c1861 Add files via upload 2026-06-30 15:00:50 +08:00
公明 2fc37fefd1 Add files via upload 2026-06-30 14:38:49 +08:00
公明 ded8ac5a3f Add files via upload 2026-06-30 13:03:40 +08:00
公明 bf44cf58d3 Add files via upload 2026-06-30 11:55:32 +08:00
公明 6d390e80d5 Add files via upload 2026-06-30 11:34:38 +08:00
公明 cfc49ba16f Add files via upload 2026-06-30 11:06:29 +08:00
公明 d03f2fcf2b Add files via upload 2026-06-30 10:50:29 +08:00
公明 6e67684bba Add files via upload 2026-06-30 00:16:31 +08:00
公明 8f9d2f381a Add files via upload 2026-06-29 16:57:32 +08:00
公明 89c275269f Update config.yaml 2026-06-29 16:52:45 +08:00
公明 cb4900c61d Add files via upload 2026-06-29 16:51:54 +08:00
公明 5c192cd308 Add files via upload 2026-06-29 16:46:26 +08:00
公明 8571e41138 Add files via upload 2026-06-29 16:24:43 +08:00
公明 e1a74b29b1 Add files via upload 2026-06-29 16:16:59 +08:00
公明 39f1c72755 Add files via upload 2026-06-29 14:35:52 +08:00
公明 dd3621e89d Add files via upload 2026-06-29 14:18:08 +08:00
公明 0bcb16e021 Add files via upload 2026-06-29 10:41:42 +08:00
52 changed files with 5508 additions and 272 deletions
+63 -2
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.47" version: "v1.6.48"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -102,9 +102,69 @@ agent:
system_prompt_path: "" system_prompt_path: ""
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。 # 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
# 非白名单工具在审批方=审计 Agent 时,按会话 HITL 模式选用提示词:
# approval → audit_agent_prompt
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
hitl: 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* # 多代理与 Eino 单代理(CloudWeGo Eino ADK;单代理入口 /api/eino-agent*,多代理 /api/multi-agent*
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct # 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
# Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体 orchestration 中指定;机器人按 robot_default_agent_mode # Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体 orchestration 中指定;机器人按 robot_default_agent_mode
@@ -150,6 +210,7 @@ multi_agent:
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制 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_attempts: 0 # 429/5xx/网络抖动时可退避重试次数(run loop + summarization 共用 isEinoTransientRunError);0=默认 10
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30 run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
deep_output_key: final_answer # P0Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single deep_output_key: final_answer # P0Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置 deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑 task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 265 KiB

+13
View File
@@ -21,6 +21,7 @@ import (
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/einoobserve" "cyberstrike-ai/internal/einoobserve"
"cyberstrike-ai/internal/handler" "cyberstrike-ai/internal/handler"
"cyberstrike-ai/internal/hitl"
"cyberstrike-ai/internal/knowledge" "cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/logger" "cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
@@ -109,6 +110,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
monitorRetention.PurgeExpired() monitorRetention.PurgeExpired()
monitor.StartRetentionLoop(monitorRetention, log.Logger) monitor.StartRetentionLoop(monitorRetention, log.Logger)
hitlRetention := hitl.NewService(db, cfg, log.Logger)
hitlRetention.PurgeExpired()
hitl.StartRetentionLoop(hitlRetention, log.Logger)
// 创建MCP服务器(带数据库持久化) // 创建MCP服务器(带数据库持久化)
mcpServer := mcp.NewServerWithStorage(log.Logger, db) mcpServer := mcp.NewServerWithStorage(log.Logger, db)
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes) mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
@@ -363,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 := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
configHandler.SetAudit(auditSvc) configHandler.SetAudit(auditSvc)
agentHandler.SetHitlToolWhitelistSaver(configHandler) agentHandler.SetHitlToolWhitelistSaver(configHandler)
agentHandler.SetHitlAuditStrategySaver(configHandler)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger) externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
externalMCPHandler.SetAudit(auditSvc) externalMCPHandler.SetAudit(auditSvc)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger) roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
@@ -812,11 +818,18 @@ func setupRoutes(
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop) protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream) protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
protected.GET("/hitl/pending", agentHandler.ListHITLPending) 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/decision", agentHandler.DecideHITLInterrupt)
protected.POST("/hitl/dismiss", agentHandler.DismissHITLInterrupt) protected.POST("/hitl/dismiss", agentHandler.DismissHITLInterrupt)
protected.GET("/hitl/config/:conversationId", agentHandler.GetHITLConversationConfig) protected.GET("/hitl/config/:conversationId", agentHandler.GetHITLConversationConfig)
protected.PUT("/hitl/config", agentHandler.UpsertHITLConversationConfig) 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.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
protected.PUT("/hitl/audit-strategy", agentHandler.UpdateHITLAuditStrategy)
// Agent Loop 取消与任务列表 // Agent Loop 取消与任务列表
protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop) protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop)
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks) protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
+182 -26
View File
@@ -283,6 +283,8 @@ type MultiAgentEinoMiddlewareConfig struct {
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"` RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。 // RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"` 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 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"` TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
} }
@@ -503,6 +505,17 @@ type RobotWecomConfig struct {
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId 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 钉钉机器人配置 // RobotDingtalkConfig 钉钉机器人配置
type RobotDingtalkConfig struct { type RobotDingtalkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
@@ -627,10 +640,100 @@ type AgentConfig struct {
} }
// HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。 // HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效;其他字段若仅改文件仍需重启 // tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效。
// audit_agent_prompt / audit_agent_prompt_review_edit 可在人机协同页编辑并立即生效;空则使用内置默认。
type HitlConfig struct { type HitlConfig struct {
// ToolWhitelist 全局免审批工具名(与每条会话配置的 sensitiveTools 语义相同:白名单内工具不触发 HITL)。 // ToolWhitelist 全局免审批工具名(与白名单内工具不触发 HITL 审批)。
ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"` 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 { type AuthConfig struct {
@@ -817,33 +920,13 @@ func Load(path string) (*Config, error) {
// 如果配置了工具目录,从目录加载工具配置 // 如果配置了工具目录,从目录加载工具配置
if cfg.Security.ToolsDir != "" { if cfg.Security.ToolsDir != "" {
configDir := filepath.Dir(path) inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...)
toolsDir := cfg.Security.ToolsDir toolsDir := ResolveToolsDir(cfg.Security.ToolsDir, path)
merged, err := MergeToolsFromDir(toolsDir, inlineTools)
// 如果是相对路径,相对于配置文件所在目录
if !filepath.IsAbs(toolsDir) {
toolsDir = filepath.Join(configDir, toolsDir)
}
tools, err := LoadToolsFromDir(toolsDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("从工具目录加载工具配置失败: %w", err) return nil, fmt.Errorf("从工具目录加载工具配置失败: %w", err)
} }
cfg.Security.Tools = merged
// 合并工具配置:目录中的工具优先,主配置中的工具作为补充
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
} }
// 外部 MCP:迁移 + 环境变量展开 // 外部 MCP:迁移 + 环境变量展开
@@ -887,6 +970,10 @@ func Load(path string) (*Config, error) {
} }
} }
if err := ValidateWecomConfig(cfg.Robots.Wecom); err != nil {
return nil, err
}
return &cfg, nil return &cfg, nil
} }
@@ -1111,6 +1198,75 @@ func PrintMCPConfigJSON(mcp MCPConfig) {
fmt.Println("----------------------------------------------------------------") 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.ToolsApplyConfig 热重载用)。
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 从目录加载所有工具配置文件 // LoadToolsFromDir 从目录加载所有工具配置文件
func LoadToolsFromDir(dir string) ([]ToolConfig, error) { func LoadToolsFromDir(dir string) ([]ToolConfig, error) {
var tools []ToolConfig var tools []ToolConfig
+45
View File
@@ -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)
}
})
}
}
+111
View File
@@ -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)
}
}
+85
View File
@@ -3,6 +3,7 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -577,6 +578,19 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
return conversations, rows.Err() 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 更新对话标题 // UpdateConversationTitle 更新对话标题
func (db *DB) UpdateConversationTitle(id, title string) error { func (db *DB) UpdateConversationTitle(id, title string) error {
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间 // 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
@@ -1057,6 +1071,77 @@ type ProcessDetail struct {
CreatedAt time.Time `json:"createdAt"` 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 添加过程详情事件 // AddProcessDetail 添加过程详情事件
func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message string, data interface{}) error { func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message string, data interface{}) error {
id := uuid.New().String() id := uuid.New().String()
+75
View File
@@ -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
}
+106
View File
@@ -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)
}
}
+83 -8
View File
@@ -77,6 +77,13 @@ type responsePlanAgg struct {
b strings.Builder 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 { func normalizeProcessDetailText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n") s = strings.ReplaceAll(s, "\r", "\n")
@@ -179,6 +186,8 @@ type AgentHandler struct {
batchCronParser cron.Parser batchCronParser cron.Parser
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选) // hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
hitlWhitelistSaver HitlToolWhitelistSaver hitlWhitelistSaver HitlToolWhitelistSaver
hitlStrategySaver HitlAuditStrategySaver
auditLLM *openai.Client
audit *audit.Service audit *audit.Service
} }
@@ -218,9 +227,10 @@ func (h *AgentHandler) cancelActiveMCPToolForConversation(conversationID string)
} }
} }
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘 // HitlToolWhitelistSaver 合并/设置 HITL 免审批工具到全局配置并落盘
type HitlToolWhitelistSaver interface { type HitlToolWhitelistSaver interface {
MergeHitlToolWhitelistIntoConfig(add []string) error MergeHitlToolWhitelistIntoConfig(add []string) error
SetHitlToolWhitelist(tools []string) error
} }
// NewAgentHandler 创建新的Agent处理器 // NewAgentHandler 创建新的Agent处理器
@@ -236,6 +246,11 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
bus := NewTaskEventBus() bus := NewTaskEventBus()
tm := NewAgentTaskManager() tm := NewAgentTaskManager()
tm.SetTaskEventBus(bus) tm.SetTaskEventBus(bus)
llmHTTP := &http.Client{Timeout: 2 * time.Minute}
var llmCfg *config.OpenAIConfig
if cfg != nil {
llmCfg = &cfg.OpenAI
}
handler := &AgentHandler{ handler := &AgentHandler{
agent: agent, agent: agent,
db: db, db: db,
@@ -246,6 +261,7 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
config: cfg, config: cfg,
hitlManager: NewHITLManager(db, logger), hitlManager: NewHITLManager(db, logger),
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor), 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) tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
if err := handler.hitlManager.EnsureSchema(); err != nil { if err := handler.hitlManager.EnsureSchema(); err != nil {
@@ -320,6 +336,7 @@ func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientInten
type HITLRequest struct { type HITLRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
Reviewer string `json:"reviewer,omitempty"` // human | audit_agent
SensitiveTools []string `json:"sensitiveTools,omitempty"` SensitiveTools []string `json:"sensitiveTools,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"` TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
} }
@@ -849,11 +866,6 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
// thinking_stream_*ReAct 等助手正文流)与 reasoning_chain_stream_*Eino ReasoningContent): // thinking_stream_*ReAct 等助手正文流)与 reasoning_chain_stream_*Eino ReasoningContent):
// 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。 // 不逐条落库,按 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 thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
flushedThinking := make(map[string]bool) // streamId -> flushed flushedThinking := make(map[string]bool) // streamId -> flushed
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
@@ -866,6 +878,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta // response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。 // 聚合为一条 planning 写入 process_details,刷新后与线上一致。
var respPlan responsePlanAgg var respPlan responsePlanAgg
if assistantMessageID != "" {
h.tasks.SetHitlAssistantMessageID(conversationID, assistantMessageID)
}
syncHitlCognition := func() {
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
}
flushResponsePlan := func() { flushResponsePlan := func() {
if assistantMessageID == "" { if assistantMessageID == "" {
return return
@@ -885,6 +903,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil { if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning")) h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
} }
syncHitlCognition()
respPlan.meta = nil respPlan.meta = nil
respPlan.b.Reset() respPlan.b.Reset()
} }
@@ -921,6 +940,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} }
flushedThinking[sid] = true flushedThinking[sid] = true
} }
syncHitlCognition()
} }
return func(eventType, message string, data interface{}) { return func(eventType, message string, data interface{}) {
@@ -981,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 eventType == "tool_result" && h.knowledgeManager != nil {
if dataMap, ok := data.(map[string]interface{}); ok { if dataMap, ok := data.(map[string]interface{}); ok {
@@ -1188,6 +1227,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
respPlan.meta[k] = v respPlan.meta[k] = v
} }
} }
syncHitlCognition()
return return
} }
if eventType == "response" { if eventType == "response" {
@@ -1257,6 +1297,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} }
} }
} }
syncHitlCognition()
return return
} }
@@ -1489,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 列出所有运行中的任务 // ListAgentTasks 列出所有运行中的任务
func (h *AgentHandler) ListAgentTasks(c *gin.Context) { func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
tasks := h.tasks.GetActiveTasks()
h.enrichAgentTasksWithConversationTitles(tasks)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"tasks": h.tasks.GetActiveTasks(), "tasks": tasks,
}) })
} }
// ListCompletedTasks 列出最近完成的任务历史 // ListCompletedTasks 列出最近完成的任务历史
func (h *AgentHandler) ListCompletedTasks(c *gin.Context) { func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
tasks := h.tasks.GetCompletedTasks()
h.enrichCompletedTasksWithConversationTitles(tasks)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"tasks": h.tasks.GetCompletedTasks(), "tasks": tasks,
}) })
} }
+44
View File
@@ -798,6 +798,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
// 更新机器人配置 // 更新机器人配置
if req.Robots != nil { 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.config.Robots = *req.Robots
h.logger.Info("更新机器人配置", h.logger.Info("更新机器人配置",
zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled), zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled),
@@ -1329,6 +1333,17 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
h.logger.Info("已更新嵌入模型配置记录") 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("重新注册工具") h.logger.Info("重新注册工具")
@@ -1751,6 +1766,20 @@ func mergeHitlToolWhitelistSlice(existing, add []string) []string {
return out 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(与全局白名单去重规则一致:小写键、保留首次出现的原始大小写)。 // MergeHitlToolWhitelistIntoConfig 将会话侧栏提交的免审批工具名合并进内存配置并写入 config.yaml(与全局白名单去重规则一致:小写键、保留首次出现的原始大小写)。
func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error { func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error {
h.mu.Lock() h.mu.Lock()
@@ -1771,6 +1800,21 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
hitlNode := ensureMap(root, "hitl") hitlNode := ensureMap(root, "hitl")
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数 // flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist) 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) { func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
@@ -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
}
+8
View File
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var cumulativeMCPExecutionIDs []string var cumulativeMCPExecutionIDs []string
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。 // 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int var mainIterationOffset int
var emptyResponseContinueAttempt int
for { for {
segmentMainIterationMax := 0 segmentMainIterationMax := 0
@@ -239,6 +240,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
} }
if runErr == nil { 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() timeoutCancel()
break break
} }
+136 -89
View File
@@ -23,6 +23,7 @@ import (
type hitlRuntimeConfig struct { type hitlRuntimeConfig struct {
Enabled bool Enabled bool
Mode string Mode string
Reviewer string
SensitiveTools map[string]struct{} SensitiveTools map[string]struct{}
Timeout time.Duration Timeout time.Duration
} }
@@ -49,6 +50,8 @@ type HITLManager struct {
mu sync.RWMutex mu sync.RWMutex
runtime map[string]hitlRuntimeConfig runtime map[string]hitlRuntimeConfig
pending map[string]*pendingInterrupt pending map[string]*pendingInterrupt
// approvedExec 审批通过、待回写 tool_result 的队列(按会话 FIFO
approvedExec map[string][]hitlApprovedExecTrack
} }
func NewHITLManager(db *database.DB, logger *zap.Logger) *HITLManager { func NewHITLManager(db *database.DB, logger *zap.Logger) *HITLManager {
@@ -90,6 +93,7 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
if err != nil { if err != nil {
return err return err
} }
m.migrateHitlSchemaColumns()
// On startup, cancel all orphaned pending interrupts from previous process. // On startup, cancel all orphaned pending interrupts from previous process.
// Their in-memory channels are gone, so they can never be resolved. // 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{ m.runtime[conversationID] = hitlRuntimeConfig{
Enabled: true, Enabled: true,
Mode: normalizeHitlMode(req.Mode), Mode: normalizeHitlMode(req.Mode),
Reviewer: normalizeHitlReviewer(req.Reviewer),
SensitiveTools: tools, SensitiveTools: tools,
Timeout: timeout, Timeout: timeout,
} }
@@ -153,17 +158,14 @@ func (m *HITLManager) DeactivateConversation(conversationID string) {
m.mu.Unlock() m.mu.Unlock()
} }
// hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空)。 // hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空),并合并内置元工具免审批项
func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string { func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
if h == nil || h.config == nil { if h == nil || h.config == nil {
return nil return multiagent.MergeHitlExemptMetaTools(nil)
} }
raw := h.config.Hitl.ToolWhitelist raw := h.config.Hitl.ToolWhitelist
if len(raw) == 0 {
return nil
}
seen := make(map[string]struct{}) seen := make(map[string]struct{})
out := make([]string, 0, len(raw)) out := make([]string, 0, len(raw)+len(multiagent.HitlExemptMetaTools))
for _, t := range raw { for _, t := range raw {
n := strings.ToLower(strings.TrimSpace(t)) n := strings.ToLower(strings.TrimSpace(t))
if n == "" { if n == "" {
@@ -175,44 +177,35 @@ func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
seen[n] = struct{}{} seen[n] = struct{}{}
out = append(out, strings.TrimSpace(t)) 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 { func (h *AgentHandler) hitlRequestWithMergedConfigWhitelist(req *HITLRequest) *HITLRequest {
gw := h.hitlConfigGlobalToolWhitelist()
if len(gw) == 0 {
return req
}
if req == nil { if req == nil {
return nil return nil
} }
seen := make(map[string]struct{}) seen := make(map[string]struct{})
union := make([]string, 0, len(gw)+len(req.SensitiveTools)) union := make([]string, 0, len(req.SensitiveTools)+16)
for _, t := range gw { add := func(t string) {
n := strings.ToLower(strings.TrimSpace(t)) n := strings.ToLower(strings.TrimSpace(t))
if n == "" { if n == "" {
continue return
} }
if _, ok := seen[n]; ok { if _, ok := seen[n]; ok {
continue return
} }
seen[n] = struct{}{} seen[n] = struct{}{}
union = append(union, strings.TrimSpace(t)) union = append(union, strings.TrimSpace(t))
} }
for _, t := range h.hitlConfigGlobalToolWhitelist() {
add(t)
}
for _, t := range req.SensitiveTools { for _, t := range req.SensitiveTools {
n := strings.ToLower(strings.TrimSpace(t)) add(t)
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
union = append(union, strings.TrimSpace(t))
} }
out := *req out := *req
out.SensitiveTools = union out.SensitiveTools = multiagent.MergeHitlExemptMetaTools(union)
return &out return &out
} }
@@ -362,22 +355,22 @@ func (m *HITLManager) SaveConversationConfig(conversationID string, req *HITLReq
timeout = 0 timeout = 0
} }
_, err := m.db.Exec(`INSERT INTO hitl_conversation_configs _, err := m.db.Exec(`INSERT INTO hitl_conversation_configs
(conversation_id, enabled, mode, sensitive_tools, timeout_seconds, updated_at) (conversation_id, enabled, mode, reviewer, sensitive_tools, timeout_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET 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`, 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, string(tools), timeout, time.Now()) conversationID, boolToInt(req.Enabled), mode, normalizeHitlReviewer(req.Reviewer), string(tools), timeout, time.Now())
return err return err
} }
func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLRequest, error) { func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLRequest, error) {
var enabledInt int var enabledInt int
var mode, toolsJSON string var mode, reviewer, toolsJSON string
var timeout int var timeout int
err := m.db.QueryRow(`SELECT enabled, mode, sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID). 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, &toolsJSON, &timeout) Scan(&enabledInt, &mode, &reviewer, &toolsJSON, &timeout)
if errors.Is(err, sql.ErrNoRows) { 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 { if err != nil {
return nil, err return nil, err
@@ -390,6 +383,7 @@ func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLReques
return &HITLRequest{ return &HITLRequest{
Enabled: enabledInt == 1, Enabled: enabledInt == 1,
Mode: mode, Mode: mode,
Reviewer: normalizeHitlReviewer(reviewer),
SensitiveTools: tools, SensitiveTools: tools,
TimeoutSeconds: timeout, TimeoutSeconds: timeout,
}, nil }, nil
@@ -413,15 +407,16 @@ func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, tim
if p.Mode != "review_edit" && len(d.EditedArguments) > 0 { if p.Mode != "review_edit" && len(d.EditedArguments) > 0 {
d.EditedArguments = nil 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) d.Decision, d.Comment, time.Now(), p.InterruptID)
return d, nil return d, nil
case <-timeoutCh: case <-timeoutCh:
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='approve', decision_comment='timeout auto approve', decided_at=? WHERE id=?`, comment := "HITL timeout auto-reject for safety"
time.Now(), p.InterruptID) _, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='reject', decision_comment=?, decided_at=?, decided_by='system' WHERE id=?`,
return hitlDecision{Decision: "approve", Comment: "timeout auto approve"}, nil comment, time.Now(), p.InterruptID)
return hitlDecision{Decision: "reject", Comment: comment}, nil
case <-ctx.Done(): 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) time.Now(), p.InterruptID)
return hitlDecision{Decision: "reject", Comment: "task cancelled"}, ctx.Err() return hitlDecision{Decision: "reject", Comment: "task cancelled"}, ctx.Err()
} }
@@ -445,12 +440,57 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
if !need { if !need {
return nil, nil return nil, nil
} }
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
payloadRaw, _ := json.Marshal(payload) payloadRaw, _ := json.Marshal(payload)
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw)) p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
if err != nil { if err != nil {
h.logger.Warn("创建 HITL 中断失败", zap.Error(err)) h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
return nil, 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 { if sendEventFunc != nil {
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{ sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -479,8 +519,12 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
return nil, waitErr return nil, waitErr
} }
if d.Decision == "reject" { if d.Decision == "reject" {
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
}
if sendEventFunc != nil { if sendEventFunc != nil {
sendEventFunc("hitl_rejected", "人工拒绝本次工具调用,模型将基于反馈继续迭代", map[string]interface{}{ sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"interruptId": p.InterruptID, "interruptId": p.InterruptID,
"toolName": toolName, "toolName": toolName,
@@ -498,6 +542,7 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
"editedArgs": d.EditedArguments, "editedArgs": d.EditedArguments,
}) })
} }
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
return &d, nil return &d, nil
} }
@@ -527,11 +572,6 @@ func (h *AgentHandler) handleHITLToolCall(runCtx context.Context, cancelRun cont
} }
func (h *AgentHandler) ListHITLPending(c *gin.Context) { 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")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -539,15 +579,12 @@ func (h *AgentHandler) ListHITLPending(c *gin.Context) {
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200))) pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
offset := (page - 1) * pageSize 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` q, args := h.buildHitlListQuery(false)
args := []interface{}{} q, args = h.appendHitlListFilters(q, args, c)
if conversationID != "" { total, err := h.countHitlQuery(q, args)
q += " AND conversation_id = ?" if err != nil {
args = append(args, conversationID) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} return
if status != "all" {
q += " AND status = ?"
args = append(args, status)
} }
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?" q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, pageSize, offset) args = append(args, pageSize, offset)
@@ -557,41 +594,12 @@ func (h *AgentHandler) ListHITLPending(c *gin.Context) {
return return
} }
defer rows.Close() defer rows.Close()
items := make([]map[string]interface{}, 0) items, err := h.scanHitlInterruptRows(rows)
for rows.Next() { if err != nil {
var id, cid, mode, toolName, toolCallID, payload, rowStatus string c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
var messageID sql.NullString return
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
}(),
})
} }
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 { type hitlDecisionReq struct {
@@ -636,7 +644,7 @@ func (h *AgentHandler) DismissHITLInterrupt(c *gin.Context) {
return return
} }
res, err := h.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject', 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) WHERE id=? AND status='pending'`, req.InterruptID)
if err != nil { if err != nil {
c.JSON(500, gin.H{"error": err.Error()}) c.JSON(500, gin.H{"error": err.Error()})
@@ -732,6 +740,7 @@ func (h *AgentHandler) UpsertHITLConversationConfig(c *gin.Context) {
return return
} }
req.Mode = normalizeHitlMode(req.Mode) req.Mode = normalizeHitlMode(req.Mode)
req.Reviewer = normalizeHitlReviewer(req.Reviewer)
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil { if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -753,6 +762,44 @@ type mergeHitlGlobalWhitelistReq struct {
SensitiveTools []string `json:"sensitiveTools"` 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 中白名单落盘规则一致)。 // MergeHITLGlobalToolWhitelist 无会话 ID 时将侧栏提交的免审批工具合并进 config.yaml(与 PUT /hitl/config 中白名单落盘规则一致)。
func (h *AgentHandler) MergeHITLGlobalToolWhitelist(c *gin.Context) { func (h *AgentHandler) MergeHITLGlobalToolWhitelist(c *gin.Context) {
if h.hitlWhitelistSaver == nil { if h.hitlWhitelistSaver == nil {
+357
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+97
View File
@@ -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
}
}
+102
View File
@@ -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)
}
+46
View File
@@ -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)
}
}
+132
View File
@@ -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)
}
+39
View File
@@ -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)
}
}
+263
View File
@@ -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))
}
+8
View File
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
var cumulativeMCPExecutionIDs []string var cumulativeMCPExecutionIDs []string
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。 // 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int var mainIterationOffset int
var emptyResponseContinueAttempt int
for { for {
segmentMainIterationMax := 0 segmentMainIterationMax := 0
@@ -251,6 +252,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
} }
if runErr == nil { 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() timeoutCancel()
break break
} }
+45 -22
View File
@@ -711,12 +711,27 @@ type wecomReplyXML struct {
Content string `xml:"Content"` 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 // HandleWecomGET 企业微信 URL 校验(GET
func (h *RobotHandler) HandleWecomGET(c *gin.Context) { func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
if !h.config.Robots.Wecom.Enabled { if !h.config.Robots.Wecom.Enabled {
c.String(http.StatusNotFound, "") c.String(http.StatusNotFound, "")
return return
} }
token, ok := h.wecomRequireToken(c)
if !ok {
return
}
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串 // Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
echostr := c.Query("echostr") echostr := c.Query("echostr")
msgSignature := c.Query("msg_signature") msgSignature := c.Query("msg_signature")
@@ -724,7 +739,7 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
nonce := c.Query("nonce") nonce := c.Query("nonce")
// 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1 // 验证签名:将 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 { if signature != msgSignature {
h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature)) h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature))
c.String(http.StatusBadRequest, "invalid 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))) h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段 // 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段
// 若配置了 Token 则必须校验签名,避免未授权请求触发 Agent(防止平台被接管) // 启用企业微信时必须配置 token 校验签名,避免未授权请求触发 Agent
token := h.config.Robots.Wecom.Token token, ok := h.wecomRequireToken(c)
if token != "" { if !ok {
if msgSignature == "" { return
h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需配置 token 并确保回调携带 msg_signature") }
c.String(http.StatusOK, "") if msgSignature == "" {
return h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需确保回调携带 msg_signature")
} c.String(http.StatusOK, "")
var tmp wecomXML return
if err := xml.Unmarshal(bodyRaw, &tmp); err != nil { }
h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err)) var tmp wecomXML
c.String(http.StatusOK, "") if err := xml.Unmarshal(bodyRaw, &tmp); err != nil {
return h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err))
} c.String(http.StatusOK, "")
expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt) return
if expected != msgSignature { }
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature)) expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt)
c.String(http.StatusOK, "") if expected != msgSignature {
return h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
} c.String(http.StatusOK, "")
return
} }
var body wecomXML var body wecomXML
@@ -899,6 +915,13 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
// 保存企业 ID(用于明文模式回复) // 保存企业 ID(用于明文模式回复)
enterpriseID := body.ToUserName 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 // 加密模式:先解密再解析内层 XML
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" { if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
h.logger.Debug("企业微信进入加密模式解密流程") h.logger.Debug("企业微信进入加密模式解密流程")
+78
View File
@@ -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&timestamp=1&nonce=2&echostr=abc", nil)
h.HandleWecomGET(c)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
+6
View File
@@ -26,6 +26,7 @@ func shouldPersistEinoAgentTraceAfterRunError(baseCtx context.Context) bool {
// AgentTask 描述正在运行的Agent任务 // AgentTask 描述正在运行的Agent任务
type AgentTask struct { type AgentTask struct {
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
Status string `json:"status"` Status string `json:"status"`
@@ -42,6 +43,9 @@ type AgentTask struct {
// activeEinoExecuteAbortNote AbortActiveEinoExecute 写入的用户说明,由 execute 收尾时合并进工具结果 // activeEinoExecuteAbortNote AbortActiveEinoExecute 写入的用户说明,由 execute 收尾时合并进工具结果
activeEinoExecuteAbortNote string activeEinoExecuteAbortNote string
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
hitlCognition *hitlCognitionState
cancel func(error) cancel func(error)
} }
@@ -233,6 +237,7 @@ func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
// CompletedTask 已完成的任务(用于历史记录) // CompletedTask 已完成的任务(用于历史记录)
type CompletedTask struct { type CompletedTask struct {
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"` CompletedAt time.Time `json:"completedAt"`
@@ -352,6 +357,7 @@ func (m *AgentTaskManager) StartTask(conversationID, message string, cancel cont
} }
m.tasks[conversationID] = task m.tasks[conversationID] = task
task.hitlCognition = &hitlCognitionState{UserMessage: strings.TrimSpace(message)}
return task, nil return task, nil
} }
+71
View File
@@ -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")
}
}
}()
}
+50
View File
@@ -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)
}
}
@@ -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)
}
}
+2 -25
View File
@@ -1,35 +1,12 @@
package multiagent package multiagent
import ( import (
"github.com/bytedance/sonic" copenai "cyberstrike-ai/internal/openai"
) )
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a // stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
// chat-completions JSON body. Applied only to summarization Generate calls via // chat-completions JSON body. Applied only to summarization Generate calls via
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged. // model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) { func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
var payload map[string]any return copenai.StripReasoningFromChatCompletionBody(rawBody)
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
} }
+3 -5
View File
@@ -3,7 +3,6 @@ package multiagent
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
@@ -75,8 +74,8 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
if err != nil { if err != nil {
if IsHumanRejectError(err) { if IsHumanRejectError(err) {
// Human rejection should be a soft tool result so the model can continue iterating. // 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.", // tool_search 须保持 JSON,否则 Eino toolsearch 中间件解析历史时会硬崩 ChatModel。
input.Name, strings.TrimSpace(err.Error())) msg := HitlRejectToolResult(input.Name, err.Error())
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END, // transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具, // 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。 // 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
@@ -103,8 +102,7 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
edited, err := fn(ctx, input.Name, input.Arguments) edited, err := fn(ctx, input.Name, input.Arguments)
if err != nil { if err != nil {
if IsHumanRejectError(err) { 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.", msg := HitlRejectToolResult(input.Name, err.Error())
input.Name, strings.TrimSpace(err.Error()))
hitlClearReturnDirectlyIfTransfer(ctx, input.Name) hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
return &compose.StreamToolOutput{ return &compose.StreamToolOutput{
Result: schema.StreamReaderFromArray([]string{msg}), 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)
}
}
+17 -1
View File
@@ -432,6 +432,22 @@ func RunDeepAgent(
var da adk.Agent var da adk.Agent
switch orchMode { switch orchMode {
case "plan_execute": 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 的独立 ChatModelToolChoiceForced 兼容)",
zap.String("model", appCfg.OpenAI.Model),
)
}
execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg) execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
if perr != nil { if perr != nil {
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr) return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
@@ -445,7 +461,7 @@ func RunDeepAgent(
} }
} }
peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{ peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{
MainToolCallingModel: mainModel, MainToolCallingModel: peMainModel,
ExecModel: execModel, ExecModel: execModel,
OrchInstruction: orchInstruction, OrchInstruction: orchInstruction,
ToolsCfg: mainToolsCfg, ToolsCfg: mainToolsCfg,
+6 -3
View File
@@ -806,10 +806,12 @@ func isClaudeProvider(cfg *config.OpenAIConfig) bool {
// Eino HTTP Client Bridge // Eino HTTP Client Bridge
// ============================================================ // ============================================================
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含层 transport 包装: // NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含层 transport 包装:
// 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明 // 1. 当 cfg.Provider 为 claude 时,套 claudeRoundTripper,把 OpenAI /chat/completions 透明
// 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。 // 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。
// 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行 // 2. reasoningToolChoiceCompatRoundTrippertool_choice=required/object 时剥离 thinking 字段,避免
// plan_execute replanner 等强制工具调用与推理模式冲突(部分网关返回 400)。
// 3. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
// (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai // (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai
// SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。 // 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 { if transport == nil {
transport = http.DefaultTransport transport = http.DefaultTransport
} }
transport = &reasoningToolChoiceCompatRoundTripper{base: transport}
if isClaudeProvider(cfg) { if isClaudeProvider(cfg) {
transport = &claudeRoundTripper{ transport = &claudeRoundTripper{
base: transport, base: transport,
+79
View File
@@ -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
}
}
+120
View File
@@ -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)
}
+29
View File
@@ -26,6 +26,35 @@ const (
wireOutputConfig 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. // ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set. // Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set.
func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) { func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) {
+24
View File
@@ -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) { func TestApplyReasoningOff_disablesThinking(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{} cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{ oa := &config.OpenAIConfig{
+318
View File
@@ -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
- 最大值:1000API 限制)
- 建议值: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
+474 -1
View File
@@ -1513,7 +1513,7 @@ header {
.hitl-pending-payload { .hitl-pending-payload {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
max-height: 160px; max-height: min(20vh, 180px);
overflow: auto; overflow: auto;
margin: 0 0 4px 0; margin: 0 0 4px 0;
padding: 10px 12px; padding: 10px 12px;
@@ -1595,6 +1595,479 @@ header {
opacity: 0.8; opacity: 0.8;
} }
.hitl-context-block {
margin-top: 10px;
}
.hitl-context-label {
font-size: 12px;
font-weight: 600;
color: #475569;
margin-bottom: 4px;
}
.hitl-context-text {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
max-height: min(24vh, 210px);
overflow: auto;
}
.hitl-log-modal-content .hitl-context-text,
.hitl-log-detail-payload .hitl-context-text {
max-height: min(28vh, 260px);
}
.hitl-log-readonly-section {
margin-bottom: 12px;
}
.hitl-context-block--execution .hitl-context-text {
background: #f0fdf4;
border-color: #bbf7d0;
max-height: min(20vh, 180px);
}
.hitl-logs-summary {
font-size: 12px;
color: #64748b;
max-width: 140px;
}
.hitl-reviewer-toggle {
display: inline-flex;
gap: 0;
padding: 3px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid rgba(15, 23, 42, 0.08);
}
.hitl-reviewer-toggle-btn {
appearance: none;
border: none;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
}
.hitl-reviewer-toggle-btn:hover {
color: #0f172a;
}
.hitl-reviewer-toggle-btn.is-active {
background: #fff;
color: var(--accent-color, #0066ff);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
}
.hitl-reviewer-toggle-btn:focus-visible {
outline: 2px solid var(--accent-color, #0066ff);
outline-offset: 2px;
}
.hitl-page-reviewer-bar {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.hitl-page-reviewer-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
}
.hitl-page-reviewer-label {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.hitl-page-reviewer-hint {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: #64748b;
}
.hitl-page-whitelist-bar {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
box-sizing: border-box;
}
.hitl-page-whitelist-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
margin-bottom: 8px;
}
.hitl-page-whitelist-label {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.hitl-page-whitelist-hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.5;
color: #64748b;
}
.hitl-page-whitelist-textarea {
width: 100%;
min-height: 140px;
box-sizing: border-box;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
color: #0f172a;
background: #f8fafc;
resize: vertical;
}
.hitl-page-whitelist-textarea:focus {
outline: none;
border-color: var(--accent-color, #0066ff);
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.12);
background: #fff;
}
.hitl-page-strategy-bar {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
box-sizing: border-box;
}
.hitl-page-strategy-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
margin-bottom: 8px;
}
.hitl-page-strategy-label {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.hitl-page-strategy-actions {
display: flex;
align-items: center;
gap: 8px;
}
.hitl-strategy-subtabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0 0 8px;
}
.hitl-strategy-subtab {
border: 1px solid rgba(15, 23, 42, 0.12);
background: #f8fafc;
color: #475569;
border-radius: 8px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.hitl-strategy-subtab:hover {
background: #f1f5f9;
color: #0f172a;
}
.hitl-strategy-subtab--active {
background: #e0e7ff;
border-color: #a5b4fc;
color: #3730a3;
}
.hitl-page-strategy-hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.5;
color: #64748b;
}
.hitl-strategy-textarea {
width: 100%;
min-height: 320px;
box-sizing: border-box;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
color: #0f172a;
background: #f8fafc;
resize: vertical;
}
.hitl-strategy-textarea:focus {
outline: none;
border-color: var(--accent-color, #0066ff);
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.12);
background: #fff;
}
.hitl-page-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0;
}
.hitl-page-tab {
appearance: none;
border: none;
background: transparent;
color: var(--text-secondary, #64748b);
font-size: 14px;
font-weight: 500;
padding: 10px 14px;
border-bottom: 2px solid transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
.hitl-page-tab--active {
color: var(--accent-color, #0066ff);
border-bottom-color: var(--accent-color, #0066ff);
}
.hitl-tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 600;
}
.hitl-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
margin-bottom: 16px;
}
.hitl-filters label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--text-secondary, #64748b);
}
.hitl-filter-input,
.hitl-filter-select {
min-width: 220px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
background: #fff;
}
.hitl-logs-table-wrap {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.hitl-logs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.hitl-logs-table th,
.hitl-logs-table td {
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
text-align: left;
vertical-align: top;
}
.hitl-logs-table th {
background: #f8fafc;
color: #475569;
font-weight: 600;
white-space: nowrap;
}
.hitl-logs-cell-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
max-width: 180px;
word-break: break-all;
}
.hitl-logs-actions {
white-space: nowrap;
}
.hitl-decision-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
background: #f1f5f9;
color: #334155;
}
.hitl-decision-tag.hitl-decision--approve {
background: #dcfce7;
color: #166534;
}
.hitl-decision-tag.hitl-decision--reject {
background: #fee2e2;
color: #991b1b;
}
.hitl-logs-pagination,
.hitl-pending-pagination {
margin-top: 12px;
padding: 0;
}
.hitl-logs-pagination .monitor-pagination,
.hitl-pending-pagination .monitor-pagination {
margin-top: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.hitl-log-modal-content {
max-width: 640px;
width: calc(100vw - 32px);
}
.hitl-log-detail-meta {
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.hitl-log-detail-row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 12px;
align-items: start;
margin: 0;
}
.hitl-log-detail-row--full {
grid-template-columns: 1fr;
}
.hitl-log-detail-row--full dt {
margin-bottom: 4px;
}
.hitl-log-detail-row dt {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.hitl-log-detail-row dd {
margin: 0;
font-size: 14px;
color: #0f172a;
word-break: break-word;
}
.hitl-log-detail-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.hitl-log-detail-payload {
margin-top: 14px;
}
.hitl-logs-retention-hint {
margin: 0 0 12px;
font-size: 13px;
color: #64748b;
}
.hitl-logs-empty-hint {
margin: 8px 0 0;
font-size: 13px;
color: #64748b;
}
.btn-link {
appearance: none;
border: none;
background: none;
color: var(--accent-color, #0066ff);
cursor: pointer;
font-size: 13px;
padding: 0 6px 0 0;
}
.btn-link--danger {
color: #dc2626;
}
.hitl-config-select:focus, .hitl-config-select:focus,
.hitl-config-textarea:focus { .hitl-config-textarea:focus {
outline: none; outline: none;
+86 -1
View File
@@ -645,7 +645,10 @@
"agentModeOrchSupervisor": "Supervisor", "agentModeOrchSupervisor": "Supervisor",
"hitlTitle": "Human-in-the-loop", "hitlTitle": "Human-in-the-loop",
"hitlCardSubtitle": "Approvals & allowlist", "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", "hitlConfigTitle": "Collaboration mode config",
"hitlModeLabel": "Mode", "hitlModeLabel": "Mode",
"hitlModeOff": "Off", "hitlModeOff": "Off",
@@ -664,7 +667,89 @@
}, },
"hitl": { "hitl": {
"pageTitle": "HITL approvals", "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", "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...", "loading": "Loading...",
"emptyState": "No pending approvals", "emptyState": "No pending approvals",
"dismiss": "Dismiss", "dismiss": "Dismiss",
+87 -2
View File
@@ -633,7 +633,10 @@
"agentModeOrchSupervisor": "Supervisor", "agentModeOrchSupervisor": "Supervisor",
"hitlTitle": "人机协同", "hitlTitle": "人机协同",
"hitlCardSubtitle": "审批与白名单", "hitlCardSubtitle": "审批与白名单",
"hitlReviewer": "Review", "hitlReviewerLabel": "审批方",
"hitlReviewerHuman": "人工审批",
"hitlReviewerAgent": "审计 Agent",
"hitlReviewerHint": "可在人工与审计 Agent 之间随时切换;规则与白名单不变。人机协同为「关闭」时也可预先选择。",
"hitlConfigTitle": "协同模式配置", "hitlConfigTitle": "协同模式配置",
"hitlModeLabel": "模式", "hitlModeLabel": "模式",
"hitlModeOff": "关闭", "hitlModeOff": "关闭",
@@ -642,7 +645,7 @@
"hitlSensitiveTools": "敏感工具(逗号分隔)", "hitlSensitiveTools": "敏感工具(逗号分隔)",
"hitlWhitelistTools": "白名单工具(免审批,逗号分隔)", "hitlWhitelistTools": "白名单工具(免审批,逗号分隔)",
"hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)", "hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)",
"hitlWhitelistHint": "每行一个或逗号分隔与 config 全局白名单合并展示。", "hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔与 config 全局白名单合并。",
"hitlApply": "应用", "hitlApply": "应用",
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。", "hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。", "hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
@@ -652,7 +655,89 @@
}, },
"hitl": { "hitl": {
"pageTitle": "人机协同审批", "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": "待处理审批", "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": "加载中...", "loading": "加载中...",
"emptyState": "暂无待审批项", "emptyState": "暂无待审批项",
"dismiss": "忽略", "dismiss": "忽略",
+1 -1
View File
@@ -22,7 +22,7 @@ const AUDIT_ACTIONS_BY_CATEGORY = {
task: ['create_queue', 'start_queue', 'delete_queue', 'pause_queue', 'rerun_queue', 'delete_batch_task'], task: ['create_queue', 'start_queue', 'delete_queue', 'pause_queue', 'rerun_queue', 'delete_batch_task'],
tool: ['execution_delete', 'execution_delete_batch'], tool: ['execution_delete', 'execution_delete_batch'],
file: ['upload', 'delete'], file: ['upload', 'delete'],
hitl: ['decision'], hitl: ['decision', 'audit_strategy_update'],
role: ['create', 'update', 'delete'], role: ['create', 'update', 'delete'],
skill: ['create', 'update', 'delete'], skill: ['create', 'update', 'delete'],
agent: ['markdown_create', 'markdown_update', 'markdown_delete'] agent: ['markdown_create', 'markdown_update', 'markdown_delete']
+278 -56
View File
@@ -139,11 +139,18 @@ function normalizeHitlMode(mode) {
function defaultHitlConfig() { function defaultHitlConfig() {
return { return {
mode: HITL_MODE_OFF, mode: HITL_MODE_OFF,
reviewer: 'human',
sensitiveTools: '', sensitiveTools: '',
updatedAt: '' updatedAt: ''
}; };
} }
function normalizeHitlReviewer(v) {
const x = String(v || '').trim().toLowerCase();
if (x === 'audit_agent' || x === 'agent' || x === 'ai') return 'audit_agent';
return 'human';
}
/** 白名单字符串拆成数组(逗号或换行分隔,与 textarea 一致) */ /** 白名单字符串拆成数组(逗号或换行分隔,与 textarea 一致) */
function hitlToolsSplitToArray(s) { function hitlToolsSplitToArray(s) {
return String(s || '') return String(s || '')
@@ -218,6 +225,7 @@ function getHitlLastGlobalConfig() {
if (!parsed || typeof parsed !== 'object') return null; if (!parsed || typeof parsed !== 'object') return null;
return { return {
mode: normalizeHitlMode(parsed.mode), mode: normalizeHitlMode(parsed.mode),
reviewer: normalizeHitlReviewer(parsed.reviewer),
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
}; };
@@ -248,6 +256,7 @@ function getHitlConfigForConversation(conversationId) {
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
draftCfg = { draftCfg = {
mode: normalizeHitlMode(parsed.mode), mode: normalizeHitlMode(parsed.mode),
reviewer: normalizeHitlReviewer(parsed.reviewer),
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
}; };
@@ -258,6 +267,7 @@ function getHitlConfigForConversation(conversationId) {
} }
const g = globalLast ? { const g = globalLast ? {
mode: normalizeHitlMode(globalLast.mode), mode: normalizeHitlMode(globalLast.mode),
reviewer: normalizeHitlReviewer(globalLast.reviewer),
sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools, sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools,
updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : '' updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : ''
} : null; } : null;
@@ -280,6 +290,7 @@ function getHitlConfigForConversation(conversationId) {
} }
return { return {
mode: normalizeHitlMode(parsed.mode), mode: normalizeHitlMode(parsed.mode),
reviewer: normalizeHitlReviewer(parsed.reviewer),
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools, sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '' updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
}; };
@@ -288,10 +299,52 @@ function getHitlConfigForConversation(conversationId) {
} }
} }
function setHitlReviewerUI(reviewer) {
const v = normalizeHitlReviewer(reviewer);
const hidden = document.getElementById('hitl-reviewer-select');
if (hidden) hidden.value = v;
document.querySelectorAll('.hitl-reviewer-toggle-btn').forEach(function (btn) {
const active = btn.getAttribute('data-reviewer') === v;
btn.classList.toggle('is-active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
async function onHitlReviewerChanged(reviewer) {
setHitlReviewerUI(reviewer);
const cfg = readHitlConfigFromForm();
const cid = typeof currentConversationId === 'string' ? currentConversationId.trim() : '';
saveHitlConfigForConversation(cid, cfg, { syncGlobalLast: true });
if (cid && typeof window.saveHitlConversationConfig === 'function') {
try {
await window.saveHitlConversationConfig(cid, cfg);
const ok = typeof window.t === 'function' ? window.t('hitl.pageReviewerSaved') : '审批方已保存。';
showChatToast(ok, 'success');
} catch (e) {
console.warn('onHitlReviewerChanged', e);
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
showChatToast(prefix, 'error');
}
}
}
function bindHitlReviewerToggleListeners() {
document.querySelectorAll('.hitl-reviewer-toggle-btn').forEach(function (btn) {
if (btn.dataset.hitlReviewerBound === '1') return;
btn.dataset.hitlReviewerBound = '1';
btn.addEventListener('click', function () {
const v = btn.getAttribute('data-reviewer');
if (!v) return;
onHitlReviewerChanged(v);
});
});
}
function saveHitlConfigForConversation(conversationId, cfg, opts) { function saveHitlConfigForConversation(conversationId, cfg, opts) {
const syncGlobalLast = !!(opts && opts.syncGlobalLast); const syncGlobalLast = !!(opts && opts.syncGlobalLast);
const payload = { const payload = {
mode: normalizeHitlMode(cfg && cfg.mode), mode: normalizeHitlMode(cfg && cfg.mode),
reviewer: normalizeHitlReviewer(cfg && cfg.reviewer),
sensitiveTools: typeof (cfg && cfg.sensitiveTools) === 'string' ? cfg.sensitiveTools : '', sensitiveTools: typeof (cfg && cfg.sensitiveTools) === 'string' ? cfg.sensitiveTools : '',
updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : '' updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : ''
}; };
@@ -308,8 +361,10 @@ function saveHitlConfigForConversation(conversationId, cfg, opts) {
function readHitlConfigFromForm() { function readHitlConfigFromForm() {
const modeEl = document.getElementById('hitl-mode-select'); const modeEl = document.getElementById('hitl-mode-select');
const reviewerEl = document.getElementById('hitl-reviewer-select');
const toolsEl = document.getElementById('hitl-sensitive-tools'); const toolsEl = document.getElementById('hitl-sensitive-tools');
const mode = normalizeHitlMode(modeEl ? modeEl.value : HITL_MODE_OFF); const mode = normalizeHitlMode(modeEl ? modeEl.value : HITL_MODE_OFF);
const reviewer = normalizeHitlReviewer(reviewerEl ? reviewerEl.value : 'human');
let sensitiveTools = toolsEl ? String(toolsEl.value || '').trim() : ''; let sensitiveTools = toolsEl ? String(toolsEl.value || '').trim() : '';
const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null; const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null;
if (Array.isArray(g) && g.length > 0) { if (Array.isArray(g) && g.length > 0) {
@@ -317,6 +372,7 @@ function readHitlConfigFromForm() {
} }
return { return {
mode, mode,
reviewer,
sensitiveTools, sensitiveTools,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -330,7 +386,9 @@ function applyHitlConfigToUI(cfg) {
const conf = cfg || defaultHitlConfig(); const conf = cfg || defaultHitlConfig();
const modeEl = document.getElementById('hitl-mode-select'); const modeEl = document.getElementById('hitl-mode-select');
const toolsEl = document.getElementById('hitl-sensitive-tools'); const toolsEl = document.getElementById('hitl-sensitive-tools');
if (modeEl) modeEl.value = normalizeHitlMode(conf.mode); const uiMode = normalizeHitlMode(conf.mode);
if (modeEl) modeEl.value = uiMode;
setHitlReviewerUI(conf.reviewer);
let toolsVal = conf.sensitiveTools || ''; let toolsVal = conf.sensitiveTools || '';
const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null; const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null;
if (Array.isArray(g) && g.length > 0) { if (Array.isArray(g) && g.length > 0) {
@@ -341,6 +399,15 @@ function applyHitlConfigToUI(cfg) {
updateHitlStatusUI(conf); updateHitlStatusUI(conf);
} }
function bindHitlSidebarModeListener() {
const modeEl = document.getElementById('hitl-mode-select');
if (!modeEl || modeEl.dataset.hitlModeBound === '1') return;
modeEl.dataset.hitlModeBound = '1';
modeEl.addEventListener('change', function () {
applyHitlConfigToUI(readHitlConfigFromForm());
});
}
function refreshHitlConfigByCurrentConversation() { function refreshHitlConfigByCurrentConversation() {
const cfg = getHitlConfigForConversation(currentConversationId || ''); const cfg = getHitlConfigForConversation(currentConversationId || '');
applyHitlConfigToUI(cfg); applyHitlConfigToUI(cfg);
@@ -413,6 +480,9 @@ async function applyHitlSidebarConfig() {
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。'; const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
showHitlApplyFeedback(localOnly, false); showHitlApplyFeedback(localOnly, false);
} }
if (typeof window.refreshHitlPageWhitelist === 'function') {
window.refreshHitlPageWhitelist();
}
} catch (e) { } catch (e) {
console.warn('applyHitlSidebarConfig', e); console.warn('applyHitlSidebarConfig', e);
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败'; const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
@@ -449,6 +519,12 @@ if (typeof window !== 'undefined') {
window.readHitlConfigFromForm = readHitlConfigFromForm; window.readHitlConfigFromForm = readHitlConfigFromForm;
window.applyHitlConfigToUI = applyHitlConfigToUI; window.applyHitlConfigToUI = applyHitlConfigToUI;
window.saveHitlConfigForConversation = saveHitlConfigForConversation; window.saveHitlConfigForConversation = saveHitlConfigForConversation;
window.getHitlConfigForConversation = getHitlConfigForConversation;
bindHitlSidebarModeListener();
bindHitlReviewerToggleListeners();
window.setHitlReviewerUI = setHitlReviewerUI;
window.onHitlReviewerChanged = onHitlReviewerChanged;
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
window.getHitlLastGlobalConfig = getHitlLastGlobalConfig; window.getHitlLastGlobalConfig = getHitlLastGlobalConfig;
window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay; window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay;
window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString; window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString;
@@ -743,6 +819,9 @@ async function initChatAgentModeFromConfig() {
window.csaiHitlGlobalToolWhitelist = tw.slice(); window.csaiHitlGlobalToolWhitelist = tw.slice();
} }
} }
if (typeof window.refreshHitlPageWhitelist === 'function') {
window.refreshHitlPageWhitelist();
}
document.querySelectorAll('.agent-mode-option').forEach(function (el) { document.querySelectorAll('.agent-mode-option').forEach(function (el) {
const v = el.getAttribute('data-value'); const v = el.getAttribute('data-value');
if (v === 'deep' || v === 'plan_execute' || v === 'supervisor') { if (v === 'deep' || v === 'plan_execute' || v === 'supervisor') {
@@ -959,6 +1038,7 @@ async function sendMessage() {
body.hitl = { body.hitl = {
enabled: true, enabled: true,
mode: normalizeHitlMode(hitlCfg.mode), mode: normalizeHitlMode(hitlCfg.mode),
reviewer: normalizeHitlReviewer(hitlCfg.reviewer),
sensitiveTools: sensitiveTools sensitiveTools: sensitiveTools
}; };
} }
@@ -3367,7 +3447,7 @@ function createConversationListItem(conversation) {
// 处理历史记录搜索 // 处理历史记录搜索
let conversationSearchTimer = null; let conversationSearchTimer = null;
function handleConversationSearch(query) { function handleConversationSearch(query) {
conversationsPagination.page = 1; commitConversationsPage(1, { bumpNavigateGen: true });
conversationsSearchQuery = query || ''; conversationsSearchQuery = query || '';
// 防抖处理,避免频繁请求 // 防抖处理,避免频繁请求
if (conversationSearchTimer) { if (conversationSearchTimer) {
@@ -3402,7 +3482,7 @@ function clearConversationSearch() {
clearBtn.style.display = 'none'; clearBtn.style.display = 'none';
} }
conversationsPagination.page = 1; commitConversationsPage(1, { bumpNavigateGen: true });
conversationsSearchQuery = ''; conversationsSearchQuery = '';
loadConversations(''); loadConversations('');
} }
@@ -6078,6 +6158,7 @@ let groupsCache = [];
let conversationGroupMappingCache = {}; let conversationGroupMappingCache = {};
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况) let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染 let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
let conversationsListNavigateGen = 0; // 用户主动翻页代数,防止后台刷新覆盖翻页结果
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size'; const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by'; const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by';
const CONVERSATIONS_PROJECT_FILTER_KEY = 'cyberstrike.conversations_project_filter'; const CONVERSATIONS_PROJECT_FILTER_KEY = 'cyberstrike.conversations_project_filter';
@@ -6302,7 +6383,7 @@ async function refreshConversationProjectFilter() {
function onConversationProjectFilterChange(projectId) { function onConversationProjectFilterChange(projectId) {
setConversationProjectFilter(projectId || ''); setConversationProjectFilter(projectId || '');
conversationsPagination.page = 1; commitConversationsPage(1, { bumpNavigateGen: true });
loadConversationsWithGroups(conversationsSearchQuery); loadConversationsWithGroups(conversationsSearchQuery);
} }
@@ -6409,7 +6490,7 @@ function setConversationSortBy(sortBy) {
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
updateConversationSortMenuUI(); updateConversationSortMenuUI();
closeConversationSortMenu(); closeConversationSortMenu();
conversationsPagination.page = 1; commitConversationsPage(1, { bumpNavigateGen: true });
loadConversationsWithGroups(conversationsSearchQuery); loadConversationsWithGroups(conversationsSearchQuery);
} }
@@ -6437,8 +6518,106 @@ function getConversationsPageSize() {
return 50; return 50;
} }
let conversationsPagination = { page: 1, pageSize: getConversationsPageSize(), total: 0 }; let conversationsPagination = {
page: 1,
pageSize: getConversationsPageSize(),
total: 0,
visibleCount: 0,
};
let conversationsSearchQuery = ''; let conversationsSearchQuery = '';
let conversationsPaginationEventsBound = false;
function getConversationsTotalPages() {
const { total, pageSize } = conversationsPagination;
return Math.max(1, Math.ceil((total || 0) / pageSize) || 1);
}
/**
* 分页状态约定
* - conversationsPagination.page 仅在此处用户操作 / reconcile 钳制 / clamp写入
* - loadConversationsWithGroups 只读页码 intentPage 或当前 page 计算 offset
* - isStaleConversationListLoad 丢弃页码或 navigateGen 已变的在途请求
*/
function commitConversationsPage(page, { bumpNavigateGen = false } = {}) {
const next = Math.max(1, parseInt(page, 10) || 1);
if (bumpNavigateGen) {
conversationsListNavigateGen += 1;
}
conversationsPagination.page = next;
return next;
}
function isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage) {
if (loadSeq !== conversationsListLoadSeq) return true;
// 后台刷新期间用户已翻页(含 2→1、1→2),丢弃过期结果
if (intentPage == null && navigateGenAtStart !== conversationsListNavigateGen) return true;
// 用户主动翻页后,丢弃目标页已变化的请求
if (intentPage != null && intentPage !== conversationsPagination.page) return true;
// 后台刷新完成时页码已变(如 reconcile 钳制),丢弃过期结果
if (intentPage == null && activePage != null && activePage !== conversationsPagination.page) return true;
return false;
}
function reconcileConversationsPageAfterTotal(activePage, intentPage, parsed, pageSize, offset, resolvedTotal) {
let total = resolvedTotal;
const totalPages = () => Math.max(1, Math.ceil((total || 0) / pageSize) || 1);
if (activePage <= totalPages()) {
return { ok: true, total };
}
const serverTotal = parseListTotalValue(parsed.total, parsed.items.length);
const hasPageData = parsed.items.length > 0;
const knownTotal = conversationsPagination.total || 0;
// 用户主动翻页且服务端确有该页数据时,不信过期/偏低的 total(避免 2>1 被钳回第 1 页)
if (intentPage != null && (hasPageData || serverTotal > offset || total > offset || knownTotal > offset)) {
total = Math.max(total, serverTotal, knownTotal, offset + parsed.items.length);
if (activePage <= totalPages()) {
return { ok: true, total };
}
}
const clampedPage = totalPages();
commitConversationsPage(clampedPage);
return { ok: false, total, clampedPage };
}
function clampConversationsPageToTotal() {
const totalPages = getConversationsTotalPages();
if (conversationsPagination.page > totalPages) {
commitConversationsPage(totalPages);
return true;
}
if (conversationsPagination.page < 1) {
commitConversationsPage(1);
return true;
}
return false;
}
let conversationsPaginationRenderLock = false;
function initConversationsPaginationEvents() {
if (conversationsPaginationEventsBound) return;
const el = document.getElementById('conversations-pagination');
if (!el) return;
conversationsPaginationEventsBound = true;
el.addEventListener('click', (e) => {
const btn = e.target.closest('[data-conv-page]');
if (!btn || btn.disabled) return;
e.preventDefault();
const page = parseInt(btn.getAttribute('data-conv-page'), 10);
if (Number.isFinite(page)) {
goConversationsPage(page);
}
});
el.addEventListener('change', (e) => {
if (conversationsPaginationRenderLock) return;
if (e.target && e.target.id === 'conversations-page-size-pagination') {
changeConversationsPageSize();
}
});
}
function parseListTotalValue(raw, itemsLength) { function parseListTotalValue(raw, itemsLength) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
@@ -6475,6 +6654,9 @@ function parseConversationsListResponse(data) {
async function resolveConversationsListTotal(params, parsed, pageSize, offset) { async function resolveConversationsListTotal(params, parsed, pageSize, offset) {
const serverTotal = parsed.total; const serverTotal = parsed.total;
if (!parsed.isLegacyArray && typeof serverTotal === 'number' && Number.isFinite(serverTotal) && serverTotal >= 0) {
return serverTotal;
}
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) { if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
return serverTotal; return serverTotal;
} }
@@ -6531,19 +6713,20 @@ function renderConversationsPagination(visibleCount) {
const el = document.getElementById('conversations-pagination'); const el = document.getElementById('conversations-pagination');
if (!el) return; if (!el) return;
const { page, pageSize, total } = conversationsPagination; const { page, pageSize, total } = conversationsPagination;
const count = typeof visibleCount === 'number' ? visibleCount : (conversationsPagination.visibleCount || 0); if (typeof visibleCount === 'number') {
conversationsPagination.visibleCount = count; conversationsPagination.visibleCount = visibleCount;
}
if (count === 0 || total === 0) { if (!total) {
el.innerHTML = ''; el.innerHTML = '';
el.hidden = true; el.hidden = true;
return; return;
} }
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1); const totalPages = getConversationsTotalPages();
const navDisabled = totalPages <= 1; const navDisabled = totalPages <= 1;
el.hidden = false; el.hidden = false;
const start = (page - 1) * pageSize + 1; const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total); const end = Math.min(page * pageSize, total);
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
const infoText = tFn const infoText = tFn
@@ -6555,42 +6738,54 @@ function renderConversationsPagination(visibleCount) {
const perPageLabel = tFn ? tFn('chat.paginationPerPage') : 'Per page'; const perPageLabel = tFn ? tFn('chat.paginationPerPage') : 'Per page';
const prevLabel = tFn ? tFn('chat.paginationPrev') : 'Prev'; const prevLabel = tFn ? tFn('chat.paginationPrev') : 'Prev';
const nextLabel = tFn ? tFn('chat.paginationNext') : 'Next'; const nextLabel = tFn ? tFn('chat.paginationNext') : 'Next';
el.innerHTML = ` const prevPage = page - 1;
const nextPage = page + 1;
conversationsPaginationRenderLock = true;
try {
el.innerHTML = `
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact"> <div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
<span class="pagination-info">${escapeHtml(infoText)}</span> <span class="pagination-info">${escapeHtml(infoText)}</span>
<div class="pagination-controls"> <div class="pagination-controls">
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(prevLabel)}" aria-label="${escapeHtml(prevLabel)}"></button> <button type="button" class="btn-icon-pagination" data-conv-page="${prevPage}" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(prevLabel)}" aria-label="${escapeHtml(prevLabel)}"></button>
<span class="pagination-page">${escapeHtml(pageText)}</span> <span class="pagination-page">${escapeHtml(pageText)}</span>
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(nextLabel)}" aria-label="${escapeHtml(nextLabel)}"></button> <button type="button" class="btn-icon-pagination" data-conv-page="${nextPage}" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(nextLabel)}" aria-label="${escapeHtml(nextLabel)}"></button>
</div> </div>
<label class="pagination-page-size"> <label class="pagination-page-size">
${escapeHtml(perPageLabel)} ${escapeHtml(perPageLabel)}
<select id="conversations-page-size-pagination" onchange="changeConversationsPageSize()"> <select id="conversations-page-size-pagination">
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option> <option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option> <option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option> <option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
</select> </select>
</label> </label>
</div>`; </div>`;
} finally {
conversationsPaginationRenderLock = false;
}
} }
function goConversationsPage(page) { function goConversationsPage(page) {
const totalPages = Math.max(1, Math.ceil((conversationsPagination.total || 0) / conversationsPagination.pageSize) || 1); const requestedPage = Math.max(1, parseInt(page, 10) || 1);
const next = Math.min(Math.max(1, page), totalPages); const scrollToTop = requestedPage !== conversationsPagination.page;
if (next === conversationsPagination.page) return; commitConversationsPage(requestedPage, { bumpNavigateGen: true });
conversationsPagination.page = next; loadConversationsWithGroups(conversationsSearchQuery, {
loadConversationsWithGroups(conversationsSearchQuery); refreshMeta: false,
scrollToTop,
intentPage: requestedPage,
});
} }
function changeConversationsPageSize() { function changeConversationsPageSize() {
const sel = document.getElementById('conversations-page-size-pagination'); const sel = document.getElementById('conversations-page-size-pagination');
const newSize = sel ? parseInt(sel.value, 10) : 50; const newSize = sel ? parseInt(sel.value, 10) : 50;
if (![20, 50, 100].includes(newSize)) return; if (![20, 50, 100].includes(newSize)) return;
// 重建 DOM 后浏览器可能异步触发 change,值未变时不应重置页码
if (newSize === conversationsPagination.pageSize) return;
try { try {
localStorage.setItem(CONVERSATIONS_PAGE_SIZE_KEY, String(newSize)); localStorage.setItem(CONVERSATIONS_PAGE_SIZE_KEY, String(newSize));
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
conversationsPagination.pageSize = newSize; conversationsPagination.pageSize = newSize;
conversationsPagination.page = 1; commitConversationsPage(1, { bumpNavigateGen: true });
loadConversationsWithGroups(conversationsSearchQuery); loadConversationsWithGroups(conversationsSearchQuery);
} }
@@ -6689,13 +6884,18 @@ async function loadGroups() {
} }
// 加载对话列表(修改为支持分组和置顶) // 加载对话列表(修改为支持分组和置顶)
async function loadConversationsWithGroups(searchQuery = '') { async function loadConversationsWithGroups(searchQuery = '', options = {}) {
const refreshMeta = options.refreshMeta !== false;
const scrollToTop = options.scrollToTop === true;
const intentPage = Number.isFinite(options.intentPage) ? options.intentPage : null;
const navigateGenAtStart = conversationsListNavigateGen;
const loadSeq = ++conversationsListLoadSeq; const loadSeq = ++conversationsListLoadSeq;
try { try {
conversationsSearchQuery = searchQuery || ''; conversationsSearchQuery = searchQuery || '';
conversationsPagination.pageSize = getConversationsPageSize(); const pageSize = getConversationsPageSize();
const pageSize = conversationsPagination.pageSize; conversationsPagination.pageSize = pageSize;
const offset = (conversationsPagination.page - 1) * pageSize; const activePage = intentPage != null ? intentPage : conversationsPagination.page;
const offset = (activePage - 1) * pageSize;
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (conversationSortBy === 'created_at') { if (conversationSortBy === 'created_at') {
convParams.set('sort_by', 'created_at'); convParams.set('sort_by', 'created_at');
@@ -6711,12 +6911,13 @@ async function loadConversationsWithGroups(searchQuery = '') {
} }
updateConversationSidebarFilterUI(); updateConversationSidebarFilterUI();
const url = `/api/conversations?${convParams}`; const url = `/api/conversations?${convParams}`;
const [,, response] = await Promise.all([ const fetchTasks = [apiFetch(url)];
loadGroups(), if (refreshMeta) {
loadConversationGroupMapping(), fetchTasks.unshift(loadGroups(), loadConversationGroupMapping());
apiFetch(url), }
]); const results = await Promise.all(fetchTasks);
if (loadSeq !== conversationsListLoadSeq) return; const response = results[results.length - 1];
if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
const listContainer = document.getElementById('conversations-list'); const listContainer = document.getElementById('conversations-list');
if (!listContainer) { if (!listContainer) {
@@ -6739,9 +6940,34 @@ async function loadConversationsWithGroups(searchQuery = '') {
} }
const data = await response.json(); const data = await response.json();
if (loadSeq !== conversationsListLoadSeq) return; if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
const parsed = parseConversationsListResponse(data); const parsed = parseConversationsListResponse(data);
conversationsPagination.total = await resolveConversationsListTotal(convParams, parsed, pageSize, offset); const resolvedTotal = await resolveConversationsListTotal(convParams, parsed, pageSize, offset);
if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
conversationsPagination.total = resolvedTotal;
const pageCheck = reconcileConversationsPageAfterTotal(
activePage, intentPage, parsed, pageSize, offset, resolvedTotal
);
conversationsPagination.total = pageCheck.total;
if (!pageCheck.ok) {
if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
// 用户主动翻页被钳制时仍保留 intent,并 bump navigateGen 使在途后台刷新失效
if (intentPage != null) {
commitConversationsPage(pageCheck.clampedPage, { bumpNavigateGen: true });
}
loadConversationsWithGroups(searchQuery, {
...options,
intentPage: pageCheck.clampedPage,
scrollToTop: options.scrollToTop === true || activePage !== pageCheck.clampedPage,
});
return;
}
if (intentPage == null && clampConversationsPageToTotal()) {
if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
loadConversationsWithGroups(searchQuery, options);
return;
}
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重 // 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
const uniqueConversations = []; const uniqueConversations = [];
@@ -6754,18 +6980,21 @@ async function loadConversationsWithGroups(searchQuery = '') {
uniqueConversations.push(conv); uniqueConversations.push(conv);
}); });
const hasSearchQuery = searchQuery && searchQuery.trim();
const hasProjectFilter = !!getConversationProjectFilter();
// 与请求参数 exclude_grouped 一致:后端已排除分组内对话,勿再用 mapping 缓存二次过滤(易导致 2→1 等页被滤空)
const listUsesUngroupedApi = !hasSearchQuery && !hasProjectFilter;
if (uniqueConversations.length === 0) { if (uniqueConversations.length === 0) {
listContainer.innerHTML = emptyStateHtml; listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
renderConversationsPagination(0); renderConversationsPagination(0);
return; return;
} }
// 分离置顶和普通对话 // 分离置顶和普通对话
const pinnedConvs = []; const pinnedConvs = [];
const normalConvs = []; const normalConvs = [];
const hasSearchQuery = searchQuery && searchQuery.trim();
const hasProjectFilter = !!getConversationProjectFilter();
uniqueConversations.forEach(conv => { uniqueConversations.forEach(conv => {
// 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的) // 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的)
@@ -6789,11 +7018,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
return; return;
} }
// 如果没有搜索关键词,使用原有逻辑 // 未走 exclude_grouped 接口时,才用 mapping 缓存过滤分组内对话
// "最近对话"列表应该只显示不在任何分组中的对话 if (!listUsesUngroupedApi && conversationGroupMappingCache[conv.id]) {
// 无论是否在分组详情页,都不应该在"最近对话"中显示分组中的对话
if (conversationGroupMappingCache[conv.id]) {
// 对话在某个分组中,不应该显示在"最近对话"列表中
return; return;
} }
@@ -6878,15 +7104,6 @@ async function loadConversationsWithGroups(searchQuery = '') {
const visibleCount = pinnedConvs.length + Object.values(groups).reduce((n, arr) => n + (arr ? arr.length : 0), 0); const visibleCount = pinnedConvs.length + Object.values(groups).reduce((n, arr) => n + (arr ? arr.length : 0), 0);
conversationsPagination.visibleCount = visibleCount; conversationsPagination.visibleCount = visibleCount;
if (!hasSearchQuery && visibleCount === 0 && parsed.items.length > 0) {
const totalPages = Math.max(1, Math.ceil(parsed.total / pageSize));
if (conversationsPagination.page < totalPages) {
conversationsPagination.page += 1;
loadConversationsWithGroups(searchQuery);
return;
}
}
if (fragment.children.length === 0) { if (fragment.children.length === 0) {
listContainer.innerHTML = emptyStateHtml; listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
@@ -6894,22 +7111,21 @@ async function loadConversationsWithGroups(searchQuery = '') {
return; return;
} }
if (loadSeq !== conversationsListLoadSeq) return; if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
listContainer.appendChild(fragment); listContainer.appendChild(fragment);
updateActiveConversation(); updateActiveConversation();
renderConversationsPagination(visibleCount); renderConversationsPagination(visibleCount);
// 恢复滚动位置 // 翻页时回到列表顶部;后台刷新保留滚动位置
if (sidebarContent) { if (sidebarContent) {
// 使用 requestAnimationFrame 确保 DOM 已经更新
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (loadSeq === conversationsListLoadSeq) { if (!isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) {
sidebarContent.scrollTop = savedScrollTop; sidebarContent.scrollTop = scrollToTop ? 0 : savedScrollTop;
} }
}); });
} }
} catch (error) { } catch (error) {
if (loadSeq !== conversationsListLoadSeq) return; if (isStaleConversationListLoad(loadSeq, intentPage, navigateGenAtStart, activePage)) return;
console.error('加载对话列表失败:', error); console.error('加载对话列表失败:', error);
// 错误时显示空状态,而不是错误提示(更友好的用户体验) // 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list'); const listContainer = document.getElementById('conversations-list');
@@ -7330,6 +7546,11 @@ async function renameConversation() {
} }
} }
// 同步更新顶栏正在运行的任务名称
if (typeof updateActiveTaskConversationTitle === 'function') {
updateActiveTaskConversationTitle(convId, newTitle.trim());
}
// 重新加载对话列表 // 重新加载对话列表
loadConversationsWithGroups(); loadConversationsWithGroups();
} catch (error) { } catch (error) {
@@ -9352,6 +9573,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (window.i18nReady) await window.i18nReady; if (window.i18nReady) await window.i18nReady;
updateConversationSortMenuUI(); updateConversationSortMenuUI();
initConversationProjectCustomSelect(); initConversationProjectCustomSelect();
initConversationsPaginationEvents();
await refreshConversationProjectFilter(); await refreshConversationProjectFilter();
await loadGroups(); await loadGroups();
await loadConversationsWithGroups(); await loadConversationsWithGroups();
+1057 -17
View File
File diff suppressed because it is too large Load Diff
+53 -7
View File
@@ -172,6 +172,24 @@ function einoMainStreamPlanningTitle(responseData) {
return prefix + '📝 ' + plan; 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 落库类型一致 * 主通道 response 结束时将流式占位条目固化为 planning与后端 flushResponsePlan 落库类型一致
* 避免 integrateProgressToMCPSection 快照前删除占位导致助手输出仅刷新后才出现 * 避免 integrateProgressToMCPSection 快照前删除占位导致助手输出仅刷新后才出现
@@ -181,8 +199,9 @@ function finalizeMainResponseStreamItem(streamState, finalMessage, responseData)
const item = document.getElementById(streamState.itemId); const item = document.getElementById(streamState.itemId);
if (!item || !item.parentNode) return false; if (!item || !item.parentNode) return false;
const fullText = (finalMessage != null && String(finalMessage).trim() !== '') const resolved = resolveFinalAssistantResponseText(finalMessage, streamState);
? String(finalMessage) const fullText = (resolved != null && String(resolved).trim() !== '')
? String(resolved)
: (streamState.buffer || ''); : (streamState.buffer || '');
if (!String(fullText).trim()) { if (!String(fullText).trim()) {
item.parentNode.removeChild(item); item.parentNode.removeChild(item);
@@ -2414,19 +2433,20 @@ function handleStreamEvent(event, progressElement, progressId,
const streamState = responseStreamStateByProgressId.get(progressId); const streamState = responseStreamStateByProgressId.get(progressId);
const existingAssistantId = streamState?.assistantId || getAssistantId(); const existingAssistantId = streamState?.assistantId || getAssistantId();
let assistantIdFinal = existingAssistantId; let assistantIdFinal = existingAssistantId;
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
if (!assistantIdFinal) { if (!assistantIdFinal) {
assistantIdFinal = addMessage('assistant', event.message, mcpIds, progressId); assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
setAssistantId(assistantIdFinal); setAssistantId(assistantIdFinal);
} else { } else {
setAssistantId(assistantIdFinal); setAssistantId(assistantIdFinal);
updateAssistantBubbleContent(assistantIdFinal, event.message, true); updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
} }
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情 // 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
if (streamState && streamState.itemId) { if (streamState && streamState.itemId) {
finalizeMainResponseStreamItem(streamState, event.message, responseData); 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', { addTimelineItem(timeline, 'planning', {
title: typeof einoMainStreamPlanningTitle === 'function' title: typeof einoMainStreamPlanningTitle === 'function'
? einoMainStreamPlanningTitle(responseData) ? einoMainStreamPlanningTitle(responseData)
@@ -3440,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) { function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar'); const bar = document.getElementById('active-tasks-bar');
if (!bar) return; if (!bar) return;
@@ -3500,13 +3542,17 @@ function renderActiveTasks(tasks) {
}; };
const statusText = statusMap[task.status] || _t('tasks.statusRunning'); const statusText = statusMap[task.status] || _t('tasks.statusRunning');
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status); const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
const unnamedTaskText = _t('tasks.unnamedTask'); const taskDisplayName = getActiveTaskDisplayName(task);
const stopTaskBtnText = _t('tasks.stopTask'); const stopTaskBtnText = _t('tasks.stopTask');
if (task && task.conversationId) {
item.dataset.conversationId = task.conversationId;
}
item.innerHTML = ` item.innerHTML = `
<div class="active-task-info"> <div class="active-task-info">
<span class="active-task-status">${statusText}</span> <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>
<div class="active-task-actions"> <div class="active-task-actions">
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''} ${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
+3 -1
View File
@@ -335,7 +335,9 @@ async function initPage(pageId) {
} }
break; break;
case 'hitl': case 'hitl':
if (typeof refreshHitlPending === 'function') { if (typeof refreshHitlActivePanel === 'function') {
refreshHitlActivePanel();
} else if (typeof refreshHitlPending === 'function') {
refreshHitlPending(); refreshHitlPending();
} }
break; break;
+2 -2
View File
@@ -181,7 +181,7 @@ function updateCompletedTasksHistory(currentTasks) {
tasksState.completedTasksHistory.push({ tasksState.completedTasksHistory.push({
conversationId: task.conversationId, conversationId: task.conversationId,
message: task.message || '未命名任务', message: task.title || task.message || '未命名任务',
startedAt: task.startedAt, startedAt: task.startedAt,
status: finalStatus, status: finalStatus,
completedAt: new Date().toISOString() completedAt: new Date().toISOString()
@@ -537,7 +537,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
` : '<div class="task-checkbox-placeholder"></div>'} ` : '<div class="task-checkbox-placeholder"></div>'}
<span class="task-status ${status.class}">${status.text}</span> <span class="task-status ${status.class}">${status.text}</span>
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</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>
<div class="task-actions"> <div class="task-actions">
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''} ${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
+180 -3
View File
@@ -942,6 +942,19 @@
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option> <option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
</select> </select>
</div> </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"> <div class="hitl-config-field hitl-config-field--tools">
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label> <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> <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>
@@ -1160,13 +1173,177 @@
<div class="page-header"> <div class="page-header">
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2> <h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
<div class="page-header-actions"> <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> </div>
<div class="page-content"> <div class="page-content">
<div class="settings-section"> <div class="hitl-page-reviewer-bar" id="hitl-page-reviewer-bar">
<h3 data-i18n="hitl.pendingTitle">待处理审批</h3> <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-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">&times;</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> </div>
</div> </div>