mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-06 06:13:58 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43eb3e546b | |||
| 2d52c9b6ac | |||
| d5401b8b4c | |||
| 5fd4393a2e | |||
| a049f6b5c2 | |||
| acba8e5a39 | |||
| f826b91362 | |||
| 98c2de2a60 | |||
| 1c4d4b305b | |||
| f210ac9a03 | |||
| 6685076dfb | |||
| 7f322653f6 | |||
| 66ac2f1357 | |||
| c446e22d0c | |||
| 0358d3a67d | |||
| 9b82f265fd | |||
| 3d9cae58e4 | |||
| 1f1eadee5e | |||
| 0569255189 | |||
| 8ccf90d067 | |||
| b3be89f47d | |||
| b9bf8f62d4 | |||
| 05ca0c1480 | |||
| 47a4f3fc5b | |||
| a3b378ae9e | |||
| a904d26e78 | |||
| 7ba7476c4f | |||
| ae25a243ac | |||
| 23bd6288ff | |||
| fef21d3a24 | |||
| 933bba4517 | |||
| e1d65437cc | |||
| 9325aed1eb | |||
| dee2b3ab42 | |||
| a69bc93fa1 | |||
| b1a620bfce | |||
| 61b164eec2 | |||
| ba77e1837e | |||
| eacad60fd6 |
@@ -285,7 +285,7 @@ Requirements / tips:
|
|||||||
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent.
|
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent.
|
||||||
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
|
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
|
||||||
- **Management** – Web UI: **Agents → Agent management**; API `/api/multi-agent/markdown-agents`.
|
- **Management** – Web UI: **Agents → Agent management**; API `/api/multi-agent/markdown-agents`.
|
||||||
- **Config** – `multi_agent` in `config.yaml`: `enabled`, `default_mode`, `robot_use_multi_agent`, `batch_use_multi_agent`, `max_iteration`, `plan_execute_loop_max_iterations`, per-mode orchestrator instruction fields, optional YAML `sub_agents` merged with disk (`id` clash → Markdown wins), **`eino_skills`**, **`eino_middleware`** (optional ADK middleware and Deep/Supervisor tuning).
|
- **Config** – `multi_agent` in `config.yaml`: `enabled`, `robot_default_agent_mode`, `batch_use_multi_agent`, `max_iteration`, `plan_execute_loop_max_iterations`, per-mode orchestrator instruction fields, optional YAML `sub_agents` merged with disk (`id` clash → Markdown wins), **`eino_skills`**, **`eino_middleware`** (optional ADK middleware and Deep/Supervisor tuning).
|
||||||
- **Details** – **[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)** (streaming, robots, batch, middleware caveats).
|
- **Details** – **[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)** (streaming, robots, batch, middleware caveats).
|
||||||
|
|
||||||
### Skills System (Agent Skills + Eino)
|
### Skills System (Agent Skills + Eino)
|
||||||
@@ -536,7 +536,7 @@ agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-age
|
|||||||
multi_agent:
|
multi_agent:
|
||||||
enabled: false
|
enabled: false
|
||||||
default_mode: "single" # single | multi (UI default when multi-agent is enabled)
|
default_mode: "single" # single | multi (UI default when multi-agent is enabled)
|
||||||
robot_use_multi_agent: false
|
robot_default_agent_mode: react
|
||||||
batch_use_multi_agent: false
|
batch_use_multi_agent: false
|
||||||
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
|
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
|
||||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
||||||
|
|||||||
+2
-2
@@ -283,7 +283,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理。
|
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理。
|
||||||
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
||||||
- **界面管理**:**Agents → Agent 管理**;API `/api/multi-agent/markdown-agents`。
|
- **界面管理**:**Agents → Agent 管理**;API `/api/multi-agent/markdown-agents`。
|
||||||
- **配置项**:`multi_agent`:`enabled`、`default_mode`、`robot_use_multi_agent`、`batch_use_multi_agent`、`max_iteration`、`plan_execute_loop_max_iterations`、各模式 orchestrator 指令字段、可选 YAML `sub_agents` 与目录合并(同 `id` → Markdown 优先)、**`eino_skills`**、**`eino_middleware`**。
|
- **配置项**:`multi_agent`:`enabled`、`robot_default_agent_mode`、`batch_use_multi_agent`、`max_iteration`、`plan_execute_loop_max_iterations`、各模式 orchestrator 指令字段、可选 YAML `sub_agents` 与目录合并(同 `id` → Markdown 优先)、**`eino_skills`**、**`eino_middleware`**。
|
||||||
- **更多细节**:[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)(流式、机器人、批量、中间件差异)。
|
- **更多细节**:[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)(流式、机器人、批量、中间件差异)。
|
||||||
|
|
||||||
### Skills 技能系统(Agent Skills + Eino)
|
### Skills 技能系统(Agent Skills + Eino)
|
||||||
@@ -534,7 +534,7 @@ agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代
|
|||||||
multi_agent:
|
multi_agent:
|
||||||
enabled: false
|
enabled: false
|
||||||
default_mode: "single" # single | multi(开启多代理时的界面默认模式)
|
default_mode: "single" # single | multi(开启多代理时的界面默认模式)
|
||||||
robot_use_multi_agent: false
|
robot_default_agent_mode: react
|
||||||
batch_use_multi_agent: false
|
batch_use_multi_agent: false
|
||||||
orchestrator_instruction: "" # Deep;orchestrator.md 正文为空时使用
|
orchestrator_instruction: "" # Deep;orchestrator.md 正文为空时使用
|
||||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
||||||
|
|||||||
+16
-4
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.17"
|
version: "v1.6.21"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -34,6 +34,12 @@ auth:
|
|||||||
log:
|
log:
|
||||||
level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误)
|
level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误)
|
||||||
output: stdout # 日志输出位置: stdout(标准输出), stderr(标准错误), 或文件路径
|
output: stdout # 日志输出位置: stdout(标准输出), stderr(标准错误), 或文件路径
|
||||||
|
# 平台操作审计(系统设置 -> 日志审计;不记录对话正文与每次工具调用)
|
||||||
|
audit:
|
||||||
|
enabled: true
|
||||||
|
retention_days: 15 # 0 表示不自动清理
|
||||||
|
max_detail_bytes: 8192
|
||||||
|
auth_failure_cooldown_seconds: 60 # 同一 IP 登录/改密失败审计最短间隔(秒);未配置时默认 60;-1 关闭节流
|
||||||
# ============================================
|
# ============================================
|
||||||
# 对话相关配置
|
# 对话相关配置
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -55,7 +61,7 @@ openai:
|
|||||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||||
reasoning:
|
reasoning:
|
||||||
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||||
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
|
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||||
@@ -76,16 +82,18 @@ agent:
|
|||||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||||
# system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
# system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||||
|
|
||||||
|
system_prompt_path: ""
|
||||||
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
||||||
hitl:
|
hitl:
|
||||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||||
tool_whitelist: [read_file, list_dir, glob, grep]
|
tool_whitelist: [read_file, list_dir, glob, grep]
|
||||||
# 多代理(CloudWeGo Eino DeepAgent,与上方单 Agent /api/agent-loop 并存)
|
# 多代理(CloudWeGo Eino DeepAgent,与上方单 Agent /api/agent-loop 并存)
|
||||||
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
||||||
# 启用后需重启服务才会注册 /api/multi-agent 与 /api/multi-agent/stream;Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体中传入;机器人/批量无请求体时固定按 deep
|
# 启用后需重启服务才会注册 /api/multi-agent 与 /api/multi-agent/stream;Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体中传入;机器人按 robot_default_agent_mode
|
||||||
multi_agent:
|
multi_agent:
|
||||||
enabled: true
|
enabled: true
|
||||||
robot_use_multi_agent: true # true 时企业微信/钉钉/飞书机器人也走 Eino 多代理(成本更高)
|
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认对话模式:react | eino_single | deep | plan_execute | supervisor
|
||||||
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
||||||
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
|
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
|
||||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
|
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
|
||||||
@@ -125,6 +133,8 @@ multi_agent:
|
|||||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||||
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
||||||
checkpoint_dir: "" # 非空:为 adk.NewRunner 启用按会话子目录的文件型 CheckPointStore,便于中断恢复持久化;Resume 的 HTTP/前端流程需另行对接
|
checkpoint_dir: "" # 非空:为 adk.NewRunner 启用按会话子目录的文件型 CheckPointStore,便于中断恢复持久化;Resume 的 HTTP/前端流程需另行对接
|
||||||
|
run_retry_max_attempts: 0 # >0:429/5xx/网络抖动时 ADK 运行循环指数退避续跑次数;0=默认 10
|
||||||
|
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
||||||
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入
|
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入
|
||||||
deep_model_retry_max_retries: 0 # >0:ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
|
deep_model_retry_max_retries: 0 # >0:ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
|
||||||
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
||||||
@@ -254,11 +264,13 @@ robots:
|
|||||||
enabled: false
|
enabled: false
|
||||||
client_id: ""
|
client_id: ""
|
||||||
client_secret: ""
|
client_secret: ""
|
||||||
|
allow_conversation_id_fallback: false
|
||||||
lark: # 飞书
|
lark: # 飞书
|
||||||
enabled: false
|
enabled: false
|
||||||
app_id: ""
|
app_id: ""
|
||||||
app_secret: ""
|
app_secret: ""
|
||||||
verify_token: ""
|
verify_token: ""
|
||||||
|
allow_chat_id_fallback: false
|
||||||
# ============================================
|
# ============================================
|
||||||
# Skills 相关配置
|
# Skills 相关配置
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtIS
|
|||||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
||||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
||||||
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
|
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/c2"
|
"cyberstrike-ai/internal/c2"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
@@ -61,6 +62,7 @@ type App struct {
|
|||||||
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
|
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
|
||||||
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
|
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
|
||||||
c2Handler *handler.C2Handler // C2 REST(与 Manager 生命周期同步)
|
c2Handler *handler.C2Handler // C2 REST(与 Manager 生命周期同步)
|
||||||
|
auditSvc *audit.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建新应用
|
// New 创建新应用
|
||||||
@@ -93,6 +95,11 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
return nil, fmt.Errorf("初始化数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditSvc := audit.NewService(db, cfg, log.Logger)
|
||||||
|
audit.RegisterConversationCreateHook(auditSvc)
|
||||||
|
auditSvc.PurgeExpired()
|
||||||
|
audit.StartRetentionLoop(auditSvc, 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)
|
||||||
@@ -222,6 +229,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
|
|
||||||
// 创建知识库API处理器
|
// 创建知识库API处理器
|
||||||
knowledgeHandler = handler.NewKnowledgeHandler(knowledgeManager, knowledgeRetriever, knowledgeIndexer, db, log.Logger)
|
knowledgeHandler = handler.NewKnowledgeHandler(knowledgeManager, knowledgeRetriever, knowledgeIndexer, db, log.Logger)
|
||||||
|
knowledgeHandler.SetAudit(auditSvc)
|
||||||
log.Logger.Info("知识库模块初始化完成", zap.Bool("handler_created", knowledgeHandler != nil))
|
log.Logger.Info("知识库模块初始化完成", zap.Bool("handler_created", knowledgeHandler != nil))
|
||||||
|
|
||||||
// 扫描知识库并建立索引(异步)
|
// 扫描知识库并建立索引(异步)
|
||||||
@@ -318,31 +326,42 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
log.Logger.Warn("创建 agents 目录失败", zap.String("path", agentsDir), zap.Error(err))
|
log.Logger.Warn("创建 agents 目录失败", zap.String("path", agentsDir), zap.Error(err))
|
||||||
}
|
}
|
||||||
markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir)
|
markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir)
|
||||||
|
markdownAgentsHandler.SetAudit(auditSvc)
|
||||||
log.Logger.Info("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir))
|
log.Logger.Info("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir))
|
||||||
|
|
||||||
// 创建处理器
|
// 创建处理器
|
||||||
agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger)
|
agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger)
|
||||||
|
agentHandler.SetAudit(auditSvc)
|
||||||
agentHandler.SetAgentsMarkdownDir(agentsDir)
|
agentHandler.SetAgentsMarkdownDir(agentsDir)
|
||||||
// 如果知识库已启用,设置知识库管理器到AgentHandler以便记录检索日志
|
// 如果知识库已启用,设置知识库管理器到AgentHandler以便记录检索日志
|
||||||
if knowledgeManager != nil {
|
if knowledgeManager != nil {
|
||||||
agentHandler.SetKnowledgeManager(knowledgeManager)
|
agentHandler.SetKnowledgeManager(knowledgeManager)
|
||||||
}
|
}
|
||||||
monitorHandler := handler.NewMonitorHandler(mcpServer, executor, db, log.Logger)
|
monitorHandler := handler.NewMonitorHandler(mcpServer, executor, db, log.Logger)
|
||||||
|
monitorHandler.SetAudit(auditSvc)
|
||||||
monitorHandler.SetExternalMCPManager(externalMCPMgr) // 设置外部MCP管理器,以便获取外部MCP执行记录
|
monitorHandler.SetExternalMCPManager(externalMCPMgr) // 设置外部MCP管理器,以便获取外部MCP执行记录
|
||||||
notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger)
|
notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger)
|
||||||
groupHandler := handler.NewGroupHandler(db, log.Logger)
|
groupHandler := handler.NewGroupHandler(db, log.Logger)
|
||||||
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
|
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
|
||||||
|
authHandler.SetAudit(auditSvc)
|
||||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||||
|
vulnerabilityHandler.SetAudit(auditSvc)
|
||||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||||
|
webshellHandler.SetAudit(auditSvc)
|
||||||
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
||||||
|
chatUploadsHandler.SetAudit(auditSvc)
|
||||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
|
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
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)
|
||||||
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
||||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||||
|
externalMCPHandler.SetAudit(auditSvc)
|
||||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||||
|
roleHandler.SetAudit(auditSvc)
|
||||||
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
|
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
|
||||||
|
skillsHandler.SetAudit(auditSvc)
|
||||||
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
||||||
terminalHandler := handler.NewTerminalHandler(log.Logger)
|
terminalHandler := handler.NewTerminalHandler(log.Logger)
|
||||||
if db != nil {
|
if db != nil {
|
||||||
@@ -357,9 +376,12 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
registerC2Tools(mcpServer, c2Manager, log.Logger, cfg.Server.Port)
|
registerC2Tools(mcpServer, c2Manager, log.Logger, cfg.Server.Port)
|
||||||
}
|
}
|
||||||
c2Handler := handler.NewC2Handler(c2Manager, log.Logger)
|
c2Handler := handler.NewC2Handler(c2Manager, log.Logger)
|
||||||
|
c2Handler.SetAudit(auditSvc)
|
||||||
|
|
||||||
// 创建OpenAPI处理器
|
// 创建OpenAPI处理器
|
||||||
conversationHandler := handler.NewConversationHandler(db, log.Logger)
|
conversationHandler := handler.NewConversationHandler(db, log.Logger)
|
||||||
|
conversationHandler.SetAudit(auditSvc)
|
||||||
|
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||||
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler)
|
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler)
|
||||||
|
|
||||||
@@ -385,6 +407,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
c2Watchdog: c2Watchdog,
|
c2Watchdog: c2Watchdog,
|
||||||
c2WatchdogCancel: watchdogCancel,
|
c2WatchdogCancel: watchdogCancel,
|
||||||
c2Handler: c2Handler,
|
c2Handler: c2Handler,
|
||||||
|
auditSvc: auditSvc,
|
||||||
}
|
}
|
||||||
// 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启
|
// 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启
|
||||||
app.startRobotConnections()
|
app.startRobotConnections()
|
||||||
@@ -487,6 +510,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
fofaHandler,
|
fofaHandler,
|
||||||
terminalHandler,
|
terminalHandler,
|
||||||
app.c2Handler,
|
app.c2Handler,
|
||||||
|
auditHandler,
|
||||||
mcpServer,
|
mcpServer,
|
||||||
authManager,
|
authManager,
|
||||||
openAPIHandler,
|
openAPIHandler,
|
||||||
@@ -731,6 +755,7 @@ func setupRoutes(
|
|||||||
fofaHandler *handler.FofaHandler,
|
fofaHandler *handler.FofaHandler,
|
||||||
terminalHandler *handler.TerminalHandler,
|
terminalHandler *handler.TerminalHandler,
|
||||||
c2Handler *handler.C2Handler,
|
c2Handler *handler.C2Handler,
|
||||||
|
auditHandler *handler.AuditHandler,
|
||||||
mcpServer *mcp.Server,
|
mcpServer *mcp.Server,
|
||||||
authManager *security.AuthManager,
|
authManager *security.AuthManager,
|
||||||
openAPIHandler *handler.OpenAPIHandler,
|
openAPIHandler *handler.OpenAPIHandler,
|
||||||
@@ -867,6 +892,13 @@ func setupRoutes(
|
|||||||
protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream)
|
protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream)
|
||||||
protected.GET("/terminal/ws", terminalHandler.RunCommandWS)
|
protected.GET("/terminal/ws", terminalHandler.RunCommandWS)
|
||||||
|
|
||||||
|
// 平台审计日志
|
||||||
|
protected.GET("/audit/meta", auditHandler.Meta)
|
||||||
|
protected.GET("/audit/summary", auditHandler.Summary)
|
||||||
|
protected.GET("/audit/logs", auditHandler.ListLogs)
|
||||||
|
protected.GET("/audit/logs/export", auditHandler.ExportLogs)
|
||||||
|
protected.GET("/audit/logs/:id", auditHandler.GetLog)
|
||||||
|
|
||||||
// 外部MCP管理
|
// 外部MCP管理
|
||||||
protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs)
|
protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs)
|
||||||
protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats)
|
protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats)
|
||||||
@@ -1928,6 +1960,9 @@ func initializeKnowledge(
|
|||||||
|
|
||||||
// 创建知识库API处理器
|
// 创建知识库API处理器
|
||||||
knowledgeHandler := handler.NewKnowledgeHandler(knowledgeManager, knowledgeRetriever, knowledgeIndexer, db, logger)
|
knowledgeHandler := handler.NewKnowledgeHandler(knowledgeManager, knowledgeRetriever, knowledgeIndexer, db, logger)
|
||||||
|
if app != nil && app.auditSvc != nil {
|
||||||
|
knowledgeHandler.SetAudit(app.auditSvc)
|
||||||
|
}
|
||||||
logger.Info("知识库模块初始化完成", zap.Bool("handler_created", knowledgeHandler != nil))
|
logger.Info("知识库模块初始化完成", zap.Bool("handler_created", knowledgeHandler != nil))
|
||||||
|
|
||||||
// 设置知识库管理器到AgentHandler以便记录检索日志
|
// 设置知识库管理器到AgentHandler以便记录检索日志
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterConversationCreateHook records platform audit rows for every new conversation.
|
||||||
|
func RegisterConversationCreateHook(s *Service) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.SetConversationCreateHook(func(conv *database.Conversation, meta database.ConversationCreateMeta) {
|
||||||
|
detail := map[string]interface{}{
|
||||||
|
"title": conv.Title,
|
||||||
|
"source": meta.Source,
|
||||||
|
}
|
||||||
|
if meta.WebShellConnectionID != "" {
|
||||||
|
detail["webshell_connection_id"] = meta.WebShellConnectionID
|
||||||
|
}
|
||||||
|
s.Record(nil, Entry{
|
||||||
|
Category: "conversation",
|
||||||
|
Action: "create",
|
||||||
|
Result: "success",
|
||||||
|
Message: "创建对话",
|
||||||
|
ResourceType: "conversation",
|
||||||
|
ResourceID: conv.ID,
|
||||||
|
Detail: detail,
|
||||||
|
ClientIP: meta.ClientIP,
|
||||||
|
SessionHint: meta.SessionHint,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationCreateMeta builds audit metadata for conversation creation.
|
||||||
|
func ConversationCreateMeta(source string) database.ConversationCreateMeta {
|
||||||
|
return database.ConversationCreateMeta{Source: strings.TrimSpace(source)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationCreateMetaFromGin includes client IP and session hint when available.
|
||||||
|
func ConversationCreateMetaFromGin(c *gin.Context, source string) database.ConversationCreateMeta {
|
||||||
|
m := ConversationCreateMeta(source)
|
||||||
|
if c == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.ClientIP = c.ClientIP()
|
||||||
|
if token := c.GetString(security.ContextAuthTokenKey); token != "" {
|
||||||
|
m.SessionHint = sessionHint(token)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
// RetentionDays returns configured retention; 0 means keep forever.
|
||||||
|
func (s *Service) RetentionDays() int {
|
||||||
|
if s == nil || s.cfg == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.cfg.Audit.RetentionDaysEffective()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// RecordAction writes a platform audit row with common defaults.
|
||||||
|
func (s *Service) RecordAction(c *gin.Context, category, action, result, message, resourceType, resourceID string, detail map[string]interface{}) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Record(c, Entry{
|
||||||
|
Category: category,
|
||||||
|
Action: action,
|
||||||
|
Result: result,
|
||||||
|
Message: message,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOK is a shorthand for successful operations.
|
||||||
|
func (s *Service) RecordOK(c *gin.Context, category, action, message, resourceType, resourceID string, detail map[string]interface{}) {
|
||||||
|
s.RecordAction(c, category, action, "success", message, resourceType, resourceID, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFail is a shorthand for failed operations.
|
||||||
|
func (s *Service) RecordFail(c *gin.Context, category, action, message string, detail map[string]interface{}) {
|
||||||
|
s.RecordAction(c, category, action, "failure", message, "", "", detail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var auditActionsResourceRemoved = map[string]bool{
|
||||||
|
"delete": true,
|
||||||
|
"item_delete": true,
|
||||||
|
"connection_delete": true,
|
||||||
|
"listener_delete": true,
|
||||||
|
"session_delete": true,
|
||||||
|
"task_delete": true,
|
||||||
|
"execution_delete": true,
|
||||||
|
"execution_delete_batch": true,
|
||||||
|
"delete_queue": true,
|
||||||
|
"delete_batch_task": true,
|
||||||
|
"markdown_delete": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyResourceAvailability sets log.ResourceAvailable when the linked resource can be checked.
|
||||||
|
func ApplyResourceAvailability(db *database.DB, log *database.AuditLog) {
|
||||||
|
if log == nil || strings.TrimSpace(log.ResourceID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if auditActionsResourceRemoved[log.Action] {
|
||||||
|
f := false
|
||||||
|
log.ResourceAvailable = &f
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
available, known := resourceStillExists(db, log.ResourceType, log.ResourceID)
|
||||||
|
if known {
|
||||||
|
log.ResourceAvailable = &available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceStillExists(db *database.DB, resourceType, resourceID string) (bool, bool) {
|
||||||
|
resourceID = strings.TrimSpace(resourceID)
|
||||||
|
if resourceID == "" {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(resourceType)
|
||||||
|
if t == "" {
|
||||||
|
if len(resourceID) > 8 && !strings.HasPrefix(resourceID, "c2_") {
|
||||||
|
t = "conversation"
|
||||||
|
} else {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch t {
|
||||||
|
case "conversation":
|
||||||
|
ok, err := db.ConversationExists(resourceID)
|
||||||
|
return ok, err == nil
|
||||||
|
case "vulnerability":
|
||||||
|
_, err := db.GetVulnerability(resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return false, strings.Contains(err.Error(), "不存在")
|
||||||
|
}
|
||||||
|
return true, true
|
||||||
|
case "batch_queue":
|
||||||
|
_, err := db.GetBatchQueue(resourceID)
|
||||||
|
return err == nil, true
|
||||||
|
case "c2_listener":
|
||||||
|
_, err := db.GetC2Listener(resourceID)
|
||||||
|
return err == nil, true
|
||||||
|
case "c2_session":
|
||||||
|
_, err := db.GetC2Session(resourceID)
|
||||||
|
return err == nil, true
|
||||||
|
case "c2_task":
|
||||||
|
_, err := db.GetC2Task(resourceID)
|
||||||
|
return err == nil, true
|
||||||
|
case "webshell_connection":
|
||||||
|
c, err := db.GetWebshellConnection(resourceID)
|
||||||
|
return err == nil && c != nil, true
|
||||||
|
case "tool_execution":
|
||||||
|
_, err := db.GetToolExecution(resourceID)
|
||||||
|
return err == nil, true
|
||||||
|
default:
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditRetentionPurgeInterval is how often PurgeExpired runs while the process is up (startup also purges once).
|
||||||
|
const auditRetentionPurgeInterval = time.Hour
|
||||||
|
|
||||||
|
// StartRetentionLoop periodically purges expired audit rows.
|
||||||
|
func StartRetentionLoop(s *Service, logger *zap.Logger) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(auditRetentionPurgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
s.PurgeExpired()
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("audit retention tick completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sensitiveKeySubstrings = []string{
|
||||||
|
"password", "api_key", "apikey", "secret", "token", "authorization",
|
||||||
|
"credential", "private_key", "access_key",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeDetail redacts sensitive keys and truncates serialized size.
|
||||||
|
func SanitizeDetail(detail map[string]interface{}, maxBytes int) map[string]interface{} {
|
||||||
|
if detail == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = 8192
|
||||||
|
}
|
||||||
|
out := sanitizeValue("", detail)
|
||||||
|
if m, ok := out.(map[string]interface{}); ok {
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
if len(b) > maxBytes {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"_truncated": true,
|
||||||
|
"_preview": string(b[:maxBytes]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"value": out}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeValue(key string, v interface{}) interface{} {
|
||||||
|
kl := strings.ToLower(key)
|
||||||
|
for _, sub := range sensitiveKeySubstrings {
|
||||||
|
if strings.Contains(kl, sub) {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
m := make(map[string]interface{}, len(t))
|
||||||
|
for k, val := range t {
|
||||||
|
m[k] = sanitizeValue(k, val)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
case []interface{}:
|
||||||
|
arr := make([]interface{}, len(t))
|
||||||
|
for i, val := range t {
|
||||||
|
arr[i] = sanitizeValue(key, val)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service persists platform audit logs.
|
||||||
|
type Service struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
logger *zap.Logger
|
||||||
|
failThrottle *failureThrottle
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates an audit service.
|
||||||
|
func NewService(db *database.DB, cfg *config.Config, logger *zap.Logger) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
cfg: cfg,
|
||||||
|
logger: logger,
|
||||||
|
failThrottle: newFailureThrottle(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether audit persistence is on.
|
||||||
|
func (s *Service) Enabled() bool {
|
||||||
|
if s == nil || s.cfg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.cfg.Audit.EnabledEffective()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record writes one audit row from a Gin request context.
|
||||||
|
func (s *Service) Record(c *gin.Context, e Entry) {
|
||||||
|
if s == nil || !s.Enabled() || s.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Category) == "" || strings.TrimSpace(e.Action) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.Result == "failure" && !s.allowFailureAudit(c, e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Result) == "" {
|
||||||
|
e.Result = "success"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Level) == "" {
|
||||||
|
if e.Result == "failure" {
|
||||||
|
e.Level = "warn"
|
||||||
|
} else {
|
||||||
|
e.Level = "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Actor) == "" {
|
||||||
|
e.Actor = "admin"
|
||||||
|
}
|
||||||
|
maxDetail := s.cfg.Audit.MaxDetailBytesEffective()
|
||||||
|
detail := SanitizeDetail(e.Detail, maxDetail)
|
||||||
|
|
||||||
|
sessionHintVal := e.SessionHint
|
||||||
|
if sessionHintVal == "" && c != nil {
|
||||||
|
if token := c.GetString(security.ContextAuthTokenKey); token != "" {
|
||||||
|
sessionHintVal = sessionHint(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientIPVal := e.ClientIP
|
||||||
|
if clientIPVal == "" {
|
||||||
|
clientIPVal = clientIP(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &database.AuditLog{
|
||||||
|
ID: "audit_" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Level: e.Level,
|
||||||
|
Category: e.Category,
|
||||||
|
Action: e.Action,
|
||||||
|
Result: e.Result,
|
||||||
|
Actor: e.Actor,
|
||||||
|
SessionHint: sessionHintVal,
|
||||||
|
ClientIP: clientIPVal,
|
||||||
|
UserAgent: userAgent(c),
|
||||||
|
ResourceType: e.ResourceType,
|
||||||
|
ResourceID: e.ResourceID,
|
||||||
|
Message: e.Message,
|
||||||
|
Detail: detail,
|
||||||
|
}
|
||||||
|
if err := s.db.AppendAuditLog(row); err != nil && s.logger != nil {
|
||||||
|
s.logger.Warn("写入审计日志失败",
|
||||||
|
zap.String("action", e.Action),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSystem writes an audit row without HTTP context (e.g. retention cleanup).
|
||||||
|
func (s *Service) RecordSystem(e Entry) {
|
||||||
|
s.Record(nil, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpired deletes rows older than retention_days when configured.
|
||||||
|
func (s *Service) PurgeExpired() {
|
||||||
|
if s == nil || s.db == nil || s.cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := s.cfg.Audit.RetentionDaysEffective()
|
||||||
|
if days <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -days)
|
||||||
|
n, err := s.db.DeleteAuditLogsBefore(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HintFromToken returns a short stable hash prefix for a session token.
|
||||||
|
func HintFromToken(token string) string {
|
||||||
|
return sessionHint(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionHint(token string) string {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) allowFailureAudit(c *gin.Context, e Entry) bool {
|
||||||
|
if !isAuthFailureThrottled(e.Category, e.Action) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cooldown := time.Duration(s.cfg.Audit.AuthFailureCooldownEffective()) * time.Second
|
||||||
|
key := authFailureThrottleKey(e.Category, e.Action, clientIP(c))
|
||||||
|
return s.failThrottle.allow(key, cooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(c *gin.Context) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.ClientIP()
|
||||||
|
}
|
||||||
|
|
||||||
|
func userAgent(c *gin.Context) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ua := c.GetHeader("User-Agent")
|
||||||
|
if len(ua) > 512 {
|
||||||
|
return ua[:512]
|
||||||
|
}
|
||||||
|
return ua
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// failureThrottle deduplicates high-frequency failure audit rows (e.g. wrong password).
|
||||||
|
type failureThrottle struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
last map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFailureThrottle() *failureThrottle {
|
||||||
|
return &failureThrottle{last: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow reports whether a row with the given key may be written now.
|
||||||
|
func (t *failureThrottle) allow(key string, cooldown time.Duration) bool {
|
||||||
|
if t == nil || cooldown <= 0 || key == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if prev, ok := t.last[key]; ok && now.Sub(prev) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.last[key] = now
|
||||||
|
if len(t.last) > 4096 {
|
||||||
|
for k, ts := range t.last {
|
||||||
|
if now.Sub(ts) > cooldown*2 {
|
||||||
|
delete(t.last, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// authFailureThrottleKey builds a per-IP key for auth failure deduplication.
|
||||||
|
func authFailureThrottleKey(category, action, clientIP string) string {
|
||||||
|
return category + ":" + action + ":" + clientIP
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuthFailureThrottled(category, action string) bool {
|
||||||
|
if category != "auth" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "login", "change_password":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
// Entry describes one platform audit record (not chat/tool execution bodies).
|
||||||
|
type Entry struct {
|
||||||
|
Level string
|
||||||
|
Category string
|
||||||
|
Action string
|
||||||
|
Result string // success | failure
|
||||||
|
Actor string
|
||||||
|
SessionHint string
|
||||||
|
ResourceType string
|
||||||
|
ResourceID string
|
||||||
|
Message string
|
||||||
|
Detail map[string]interface{}
|
||||||
|
ClientIP string // optional when c is nil (robot, batch, DB hook)
|
||||||
|
}
|
||||||
+83
-10
@@ -26,6 +26,7 @@ type Config struct {
|
|||||||
Security SecurityConfig `yaml:"security"`
|
Security SecurityConfig `yaml:"security"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
|
Audit AuditConfig `yaml:"audit,omitempty" json:"audit,omitempty"`
|
||||||
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
|
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
|
||||||
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
|
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
|
||||||
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
|
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
|
||||||
@@ -39,9 +40,9 @@ type Config struct {
|
|||||||
|
|
||||||
// MultiAgentConfig 基于 CloudWeGo Eino adk/prebuilt 的多代理编排(deep | plan_execute | supervisor,与单 Agent /agent-loop 并存)。
|
// MultiAgentConfig 基于 CloudWeGo Eino adk/prebuilt 的多代理编排(deep | plan_execute | supervisor,与单 Agent /agent-loop 并存)。
|
||||||
type MultiAgentConfig struct {
|
type MultiAgentConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
RobotUseMultiAgent bool `yaml:"robot_use_multi_agent" json:"robot_use_multi_agent"` // 为 true 时钉钉/飞书/企微机器人走 Eino 多代理
|
RobotDefaultAgentMode string `yaml:"robot_default_agent_mode,omitempty" json:"robot_default_agent_mode,omitempty"` // react | eino_single | deep | plan_execute | supervisor
|
||||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||||
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
||||||
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
||||||
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // 主代理 / 执行器最大推理轮次(Deep、Supervisor、plan_execute 的 Executor)
|
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // 主代理 / 执行器最大推理轮次(Deep、Supervisor、plan_execute 的 Executor)
|
||||||
@@ -227,6 +228,10 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
||||||
// DeepModelRetryMaxRetries > 0 enables deep.Config ModelRetryConfig (framework-level chat model retries).
|
// DeepModelRetryMaxRetries > 0 enables deep.Config ModelRetryConfig (framework-level chat model retries).
|
||||||
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
|
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
|
||||||
|
// RunRetryMaxAttempts > 0:429/5xx/网络抖动时 handler 分段续跑次数;0=默认 10。
|
||||||
|
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
||||||
|
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
|
||||||
|
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
||||||
// 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"`
|
||||||
}
|
}
|
||||||
@@ -362,9 +367,9 @@ type MultiAgentSubConfig struct {
|
|||||||
|
|
||||||
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
|
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
|
||||||
type MultiAgentPublic struct {
|
type MultiAgentPublic struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||||
SubAgentCount int `json:"sub_agent_count"`
|
SubAgentCount int `json:"sub_agent_count"`
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
|
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
|
||||||
@@ -372,6 +377,18 @@ type MultiAgentPublic struct {
|
|||||||
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeRobotAgentMode 解析机器人默认对话模式(react | eino_single | deep | plan_execute | supervisor);空值视为 react。
|
||||||
|
func NormalizeRobotAgentMode(ma MultiAgentConfig) string {
|
||||||
|
s := strings.TrimSpace(strings.ToLower(ma.RobotDefaultAgentMode))
|
||||||
|
if s == "" || s == "single" || s == "react" {
|
||||||
|
return "react"
|
||||||
|
}
|
||||||
|
if s == "eino_single" {
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
return NormalizeMultiAgentOrchestration(s)
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeMultiAgentOrchestration 返回 deep、plan_execute 或 supervisor。
|
// NormalizeMultiAgentOrchestration 返回 deep、plan_execute 或 supervisor。
|
||||||
func NormalizeMultiAgentOrchestration(s string) string {
|
func NormalizeMultiAgentOrchestration(s string) string {
|
||||||
v := strings.TrimSpace(strings.ToLower(s))
|
v := strings.TrimSpace(strings.ToLower(s))
|
||||||
@@ -387,9 +404,9 @@ func NormalizeMultiAgentOrchestration(s string) string {
|
|||||||
|
|
||||||
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
||||||
type MultiAgentAPIUpdate struct {
|
type MultiAgentAPIUpdate struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||||
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
||||||
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||||
@@ -497,7 +514,7 @@ type OpenAIConfig struct {
|
|||||||
type OpenAIReasoningConfig struct {
|
type OpenAIReasoningConfig struct {
|
||||||
// Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。
|
// Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。
|
||||||
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
|
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
|
||||||
// Effort: low | medium | high | max;空表示不单独指定强度(各 profile 行为见 internal/reasoning)。
|
// Effort: low | medium | high | max | xhigh;max/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度。
|
||||||
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
|
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
|
||||||
// AllowClientReasoning 为 false 时忽略请求体 reasoning;nil 或未设置等同于 true。
|
// AllowClientReasoning 为 false 时忽略请求体 reasoning;nil 或未设置等同于 true。
|
||||||
AllowClientReasoning *bool `yaml:"allow_client_reasoning,omitempty" json:"allow_client_reasoning,omitempty"`
|
AllowClientReasoning *bool `yaml:"allow_client_reasoning,omitempty" json:"allow_client_reasoning,omitempty"`
|
||||||
@@ -575,6 +592,51 @@ type AuthConfig struct {
|
|||||||
GeneratedPasswordPersistErr string `yaml:"-" json:"-"`
|
GeneratedPasswordPersistErr string `yaml:"-" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuditConfig platform operation audit log settings (not chat/tool execution bodies).
|
||||||
|
type AuditConfig struct {
|
||||||
|
// Enabled nil or true enables persistence; explicit false disables.
|
||||||
|
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||||
|
RetentionDays int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||||
|
MaxDetailBytes int `yaml:"max_detail_bytes,omitempty" json:"max_detail_bytes,omitempty"`
|
||||||
|
// AuthFailureCooldownSeconds: per-IP cooldown for auth login/change_password failure audit rows; -1 disables; 0 uses default 60.
|
||||||
|
AuthFailureCooldownSeconds int `yaml:"auth_failure_cooldown_seconds,omitempty" json:"auth_failure_cooldown_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnabledEffective returns true unless audit.enabled is explicitly false.
|
||||||
|
func (a AuditConfig) EnabledEffective() bool {
|
||||||
|
if a.Enabled == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *a.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionDaysEffective returns retention; 0 means keep forever.
|
||||||
|
func (a AuditConfig) RetentionDaysEffective() int {
|
||||||
|
if a.RetentionDays < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return a.RetentionDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxDetailBytesEffective caps serialized detail JSON size.
|
||||||
|
func (a AuditConfig) MaxDetailBytesEffective() int {
|
||||||
|
if a.MaxDetailBytes <= 0 {
|
||||||
|
return 8192
|
||||||
|
}
|
||||||
|
return a.MaxDetailBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthFailureCooldownEffective returns seconds between duplicate auth-failure audit rows per IP (default 60; -1 disables).
|
||||||
|
func (a AuditConfig) AuthFailureCooldownEffective() int {
|
||||||
|
if a.AuthFailureCooldownSeconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if a.AuthFailureCooldownSeconds == 0 {
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
return a.AuthFailureCooldownSeconds
|
||||||
|
}
|
||||||
|
|
||||||
// ExternalMCPConfig 外部MCP配置
|
// ExternalMCPConfig 外部MCP配置
|
||||||
type ExternalMCPConfig struct {
|
type ExternalMCPConfig struct {
|
||||||
Servers map[string]ExternalMCPServerConfig `yaml:"servers,omitempty" json:"servers,omitempty"`
|
Servers map[string]ExternalMCPServerConfig `yaml:"servers,omitempty" json:"servers,omitempty"`
|
||||||
@@ -667,6 +729,9 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Auth.SessionDurationHours <= 0 {
|
if cfg.Auth.SessionDurationHours <= 0 {
|
||||||
cfg.Auth.SessionDurationHours = 12
|
cfg.Auth.SessionDurationHours = 12
|
||||||
}
|
}
|
||||||
|
if cfg.Audit.MaxDetailBytes <= 0 {
|
||||||
|
cfg.Audit.MaxDetailBytes = 8192
|
||||||
|
}
|
||||||
if strings.TrimSpace(cfg.Auth.Password) == "" {
|
if strings.TrimSpace(cfg.Auth.Password) == "" {
|
||||||
password, err := generateStrongPassword(24)
|
password, err := generateStrongPassword(24)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1170,6 +1235,14 @@ func Default() *Config {
|
|||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SessionDurationHours: 12,
|
SessionDurationHours: 12,
|
||||||
},
|
},
|
||||||
|
Audit: func() AuditConfig {
|
||||||
|
on := true
|
||||||
|
return AuditConfig{
|
||||||
|
RetentionDays: 90,
|
||||||
|
MaxDetailBytes: 8192,
|
||||||
|
Enabled: &on,
|
||||||
|
}
|
||||||
|
}(),
|
||||||
Robots: RobotsConfig{
|
Robots: RobotsConfig{
|
||||||
Session: RobotSessionConfig{
|
Session: RobotSessionConfig{
|
||||||
StrictUserIdentity: &strictRobotIdentity,
|
StrictUserIdentity: &strictRobotIdentity,
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditLog platform operation audit record.
|
||||||
|
type AuditLog struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
SessionHint string `json:"sessionHint,omitempty"`
|
||||||
|
ClientIP string `json:"clientIp,omitempty"`
|
||||||
|
UserAgent string `json:"userAgent,omitempty"`
|
||||||
|
ResourceType string `json:"resourceType,omitempty"`
|
||||||
|
ResourceID string `json:"resourceId,omitempty"`
|
||||||
|
ResourceAvailable *bool `json:"resourceAvailable,omitempty"` // API-only: whether linked resource still exists
|
||||||
|
Message string `json:"message"`
|
||||||
|
Detail map[string]interface{} `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogsFilter query parameters.
|
||||||
|
type ListAuditLogsFilter struct {
|
||||||
|
Level string
|
||||||
|
Category string
|
||||||
|
Action string
|
||||||
|
Result string
|
||||||
|
Query string
|
||||||
|
ResourceType string
|
||||||
|
ResourceID string
|
||||||
|
Since *time.Time
|
||||||
|
Until *time.Time
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
||||||
|
conditions := []string{"1=1"}
|
||||||
|
args := []interface{}{}
|
||||||
|
if filter.Level != "" {
|
||||||
|
conditions = append(conditions, "level = ?")
|
||||||
|
args = append(args, filter.Level)
|
||||||
|
}
|
||||||
|
if filter.Category != "" {
|
||||||
|
conditions = append(conditions, "category = ?")
|
||||||
|
args = append(args, filter.Category)
|
||||||
|
}
|
||||||
|
if filter.Action != "" {
|
||||||
|
conditions = append(conditions, "action = ?")
|
||||||
|
args = append(args, filter.Action)
|
||||||
|
}
|
||||||
|
if filter.Result != "" {
|
||||||
|
conditions = append(conditions, "result = ?")
|
||||||
|
args = append(args, filter.Result)
|
||||||
|
}
|
||||||
|
if filter.ResourceType != "" {
|
||||||
|
conditions = append(conditions, "resource_type = ?")
|
||||||
|
args = append(args, filter.ResourceType)
|
||||||
|
}
|
||||||
|
if filter.ResourceID != "" {
|
||||||
|
conditions = append(conditions, "resource_id = ?")
|
||||||
|
args = append(args, filter.ResourceID)
|
||||||
|
}
|
||||||
|
if filter.Since != nil {
|
||||||
|
conditions = append(conditions, "created_at >= ?")
|
||||||
|
args = append(args, *filter.Since)
|
||||||
|
}
|
||||||
|
if filter.Until != nil {
|
||||||
|
conditions = append(conditions, "created_at <= ?")
|
||||||
|
args = append(args, *filter.Until)
|
||||||
|
}
|
||||||
|
if q := strings.TrimSpace(filter.Query); q != "" {
|
||||||
|
like := "%" + q + "%"
|
||||||
|
conditions = append(conditions, "(message LIKE ? OR resource_id LIKE ? OR action LIKE ? OR category LIKE ?)")
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
return strings.Join(conditions, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendAuditLog inserts one audit row.
|
||||||
|
func (db *DB) AppendAuditLog(row *AuditLog) error {
|
||||||
|
if row == nil {
|
||||||
|
return errors.New("audit log is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.ID) == "" {
|
||||||
|
return errors.New("audit id is required")
|
||||||
|
}
|
||||||
|
if row.CreatedAt.IsZero() {
|
||||||
|
row.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.Level) == "" {
|
||||||
|
row.Level = "info"
|
||||||
|
}
|
||||||
|
detailJSON := ""
|
||||||
|
if len(row.Detail) > 0 {
|
||||||
|
if b, err := json.Marshal(row.Detail); err == nil {
|
||||||
|
detailJSON = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
id, created_at, level, category, action, result, actor, session_hint,
|
||||||
|
client_ip, user_agent, resource_type, resource_id, message, detail_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
_, err := db.Exec(query,
|
||||||
|
row.ID, row.CreatedAt, row.Level, row.Category, row.Action, row.Result,
|
||||||
|
row.Actor, row.SessionHint, row.ClientIP, row.UserAgent,
|
||||||
|
row.ResourceType, row.ResourceID, row.Message, detailJSON,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogByID returns one row.
|
||||||
|
func (db *DB) GetAuditLogByID(id string) (*AuditLog, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return nil, errors.New("id is required")
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, created_at, level, category, action, result, actor,
|
||||||
|
COALESCE(session_hint, ''), COALESCE(client_ip, ''), COALESCE(user_agent, ''),
|
||||||
|
COALESCE(resource_type, ''), COALESCE(resource_id, ''), message, COALESCE(detail_json, '')
|
||||||
|
FROM audit_logs WHERE id = ?
|
||||||
|
`
|
||||||
|
var row AuditLog
|
||||||
|
var detailJSON string
|
||||||
|
err := db.QueryRow(query, id).Scan(
|
||||||
|
&row.ID, &row.CreatedAt, &row.Level, &row.Category, &row.Action, &row.Result, &row.Actor,
|
||||||
|
&row.SessionHint, &row.ClientIP, &row.UserAgent,
|
||||||
|
&row.ResourceType, &row.ResourceID, &row.Message, &detailJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if detailJSON != "" {
|
||||||
|
_ = json.Unmarshal([]byte(detailJSON), &row.Detail)
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAuditLogs counts rows matching filter.
|
||||||
|
func (db *DB) CountAuditLogs(filter ListAuditLogsFilter) (int64, error) {
|
||||||
|
where, args := buildAuditLogsWhere(filter)
|
||||||
|
query := `SELECT COUNT(*) FROM audit_logs WHERE ` + where
|
||||||
|
var n int64
|
||||||
|
err := db.QueryRow(query, args...).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogs lists audit rows newest first.
|
||||||
|
func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) {
|
||||||
|
where, args := buildAuditLogsWhere(filter)
|
||||||
|
limit := filter.Limit
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
offset := filter.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, created_at, level, category, action, result, actor,
|
||||||
|
COALESCE(session_hint, ''), COALESCE(client_ip, ''), COALESCE(user_agent, ''),
|
||||||
|
COALESCE(resource_type, ''), COALESCE(resource_id, ''), message, COALESCE(detail_json, '')
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE ` + where + `
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var list []*AuditLog
|
||||||
|
for rows.Next() {
|
||||||
|
var row AuditLog
|
||||||
|
var detailJSON string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.ID, &row.CreatedAt, &row.Level, &row.Category, &row.Action, &row.Result, &row.Actor,
|
||||||
|
&row.SessionHint, &row.ClientIP, &row.UserAgent,
|
||||||
|
&row.ResourceType, &row.ResourceID, &row.Message, &detailJSON,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if detailJSON != "" {
|
||||||
|
_ = json.Unmarshal([]byte(detailJSON), &row.Detail)
|
||||||
|
}
|
||||||
|
list = append(list, &row)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAuditLogsBefore removes rows older than cutoff.
|
||||||
|
func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) {
|
||||||
|
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < ?`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
@@ -37,12 +37,12 @@ type Message struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateConversation 创建新对话
|
// CreateConversation 创建新对话
|
||||||
func (db *DB) CreateConversation(title string) (*Conversation, error) {
|
func (db *DB) CreateConversation(title string, meta ConversationCreateMeta) (*Conversation, error) {
|
||||||
return db.CreateConversationWithWebshell("", title)
|
return db.CreateConversationWithWebshell("", title, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateConversationWithWebshell 创建新对话,可选绑定 WebShell 连接 ID(为空则普通对话)
|
// CreateConversationWithWebshell 创建新对话,可选绑定 WebShell 连接 ID(为空则普通对话)
|
||||||
func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string) (*Conversation, error) {
|
func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string, meta ConversationCreateMeta) (*Conversation, error) {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -62,12 +62,17 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string)
|
|||||||
return nil, fmt.Errorf("创建对话失败: %w", err)
|
return nil, fmt.Errorf("创建对话失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Conversation{
|
conv := &Conversation{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}, nil
|
}
|
||||||
|
if webshellConnectionID != "" {
|
||||||
|
meta.WebShellConnectionID = webshellConnectionID
|
||||||
|
}
|
||||||
|
notifyConversationCreated(conv, meta)
|
||||||
|
return conv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConversationByWebshellConnectionID 根据 WebShell 连接 ID 获取该连接下最近一条对话(用于 AI 助手持久化)
|
// GetConversationByWebshellConnectionID 根据 WebShell 连接 ID 获取该连接下最近一条对话(用于 AI 助手持久化)
|
||||||
@@ -182,6 +187,23 @@ func (db *DB) ListConversationsByWebshellConnectionID(connectionID string) ([]We
|
|||||||
return list, rows.Err()
|
return list, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConversationExists reports whether a conversation row exists (lightweight check for audit links).
|
||||||
|
func (db *DB) ConversationExists(id string) (bool, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var one int
|
||||||
|
err := db.QueryRow("SELECT 1 FROM conversations WHERE id = ? LIMIT 1", id).Scan(&one)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetConversation 获取对话
|
// GetConversation 获取对话
|
||||||
func (db *DB) GetConversation(id string) (*Conversation, error) {
|
func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||||
var conv Conversation
|
var conv Conversation
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
// ConversationCreateMeta describes how a conversation was created (for audit hooks).
|
||||||
|
type ConversationCreateMeta struct {
|
||||||
|
Source string
|
||||||
|
WebShellConnectionID string
|
||||||
|
ClientIP string
|
||||||
|
SessionHint string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationCreateHook is invoked after a conversation row is inserted.
|
||||||
|
type ConversationCreateHook func(conv *Conversation, meta ConversationCreateMeta)
|
||||||
|
|
||||||
|
var conversationCreateHook ConversationCreateHook
|
||||||
|
|
||||||
|
// SetConversationCreateHook registers a global hook (e.g. platform audit).
|
||||||
|
func SetConversationCreateHook(h ConversationCreateHook) {
|
||||||
|
conversationCreateHook = h
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyConversationCreated(conv *Conversation, meta ConversationCreateMeta) {
|
||||||
|
if conversationCreateHook == nil || conv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if meta.Source == "" {
|
||||||
|
meta.Source = "unknown"
|
||||||
|
}
|
||||||
|
conversationCreateHook(conv, meta)
|
||||||
|
}
|
||||||
@@ -387,6 +387,24 @@ func (db *DB) initTables() error {
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);`
|
);`
|
||||||
|
|
||||||
|
createAuditLogsTable := `
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
level TEXT NOT NULL DEFAULT 'info',
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
session_hint TEXT,
|
||||||
|
client_ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
detail_json TEXT
|
||||||
|
);`
|
||||||
|
|
||||||
createC2ProfilesTable := `
|
createC2ProfilesTable := `
|
||||||
CREATE TABLE IF NOT EXISTS c2_profiles (
|
CREATE TABLE IF NOT EXISTS c2_profiles (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -445,6 +463,10 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_c2_events_created_at ON c2_events(created_at);
|
CREATE INDEX IF NOT EXISTS idx_c2_events_created_at ON c2_events(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_c2_events_category ON c2_events(category);
|
CREATE INDEX IF NOT EXISTS idx_c2_events_category ON c2_events(category);
|
||||||
CREATE INDEX IF NOT EXISTS idx_c2_events_session ON c2_events(session_id);
|
CREATE INDEX IF NOT EXISTS idx_c2_events_session ON c2_events(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit_logs(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_result ON audit_logs(result);
|
||||||
`
|
`
|
||||||
|
|
||||||
if _, err := db.Exec(createConversationsTable); err != nil {
|
if _, err := db.Exec(createConversationsTable); err != nil {
|
||||||
@@ -514,6 +536,10 @@ func (db *DB) initTables() error {
|
|||||||
return fmt.Errorf("创建webshell_connection_states表失败: %w", err)
|
return fmt.Errorf("创建webshell_connection_states表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(createAuditLogsTable); err != nil {
|
||||||
|
return fmt.Errorf("创建audit_logs表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
for tableName, ddl := range map[string]string{
|
for tableName, ddl := range map[string]string{
|
||||||
"c2_listeners": createC2ListenersTable,
|
"c2_listeners": createC2ListenersTable,
|
||||||
"c2_sessions": createC2SessionsTable,
|
"c2_sessions": createC2SessionsTable,
|
||||||
|
|||||||
@@ -3,12 +3,84 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VulnerabilityListFilter 列表/统计/导出共用的筛选条件
|
||||||
|
type VulnerabilityListFilter struct {
|
||||||
|
ID string
|
||||||
|
Search string // 关键词模糊匹配(标题、描述、类型、目标等)
|
||||||
|
ConversationID string
|
||||||
|
Severity string
|
||||||
|
Status string
|
||||||
|
TaskID string
|
||||||
|
ConversationTag string
|
||||||
|
TaskTag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeVulnerabilityLikePattern(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||||
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||||
|
return "%" + s + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (string, []interface{}) {
|
||||||
|
if f.ID != "" {
|
||||||
|
query += " AND id = ?"
|
||||||
|
args = append(args, f.ID)
|
||||||
|
}
|
||||||
|
if f.ConversationID != "" {
|
||||||
|
query += " AND conversation_id = ?"
|
||||||
|
args = append(args, f.ConversationID)
|
||||||
|
}
|
||||||
|
if f.TaskID != "" {
|
||||||
|
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
|
||||||
|
args = append(args, f.TaskID, f.TaskID)
|
||||||
|
}
|
||||||
|
if f.ConversationTag != "" {
|
||||||
|
query += " AND conversation_tag = ?"
|
||||||
|
args = append(args, f.ConversationTag)
|
||||||
|
}
|
||||||
|
if f.TaskTag != "" {
|
||||||
|
query += " AND task_tag = ?"
|
||||||
|
args = append(args, f.TaskTag)
|
||||||
|
}
|
||||||
|
if f.Severity != "" {
|
||||||
|
query += " AND severity = ?"
|
||||||
|
args = append(args, f.Severity)
|
||||||
|
}
|
||||||
|
if f.Status != "" {
|
||||||
|
query += " AND status = ?"
|
||||||
|
args = append(args, f.Status)
|
||||||
|
}
|
||||||
|
search := strings.TrimSpace(f.Search)
|
||||||
|
if search != "" {
|
||||||
|
pattern := escapeVulnerabilityLikePattern(search)
|
||||||
|
query += ` AND (
|
||||||
|
LOWER(id) LIKE LOWER(?) OR
|
||||||
|
LOWER(title) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(description, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(vulnerability_type, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(target, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(proof, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(impact, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(recommendation, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(conversation_id, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(conversation_tag, '')) LIKE LOWER(?) OR
|
||||||
|
LOWER(COALESCE(task_tag, '')) LIKE LOWER(?)
|
||||||
|
)`
|
||||||
|
for i := 0; i < 11; i++ {
|
||||||
|
args = append(args, pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|
||||||
// Vulnerability 漏洞
|
// Vulnerability 漏洞
|
||||||
type Vulnerability struct {
|
type Vulnerability struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -97,7 +169,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListVulnerabilities 列出漏洞
|
// ListVulnerabilities 列出漏洞
|
||||||
func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severity, status, taskID, conversationTag, taskTag string) ([]*Vulnerability, error) {
|
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, conversation_id, title, description, severity, status, conversation_tag, task_tag,
|
SELECT id, conversation_id, title, description, severity, status, conversation_tag, task_tag,
|
||||||
vulnerability_type, target, proof, impact, recommendation,
|
vulnerability_type, target, proof, impact, recommendation,
|
||||||
@@ -108,35 +180,7 @@ func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severit
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`
|
`
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
|
query, args = filter.appendWhere(query, args)
|
||||||
if id != "" {
|
|
||||||
query += " AND id = ?"
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
if conversationID != "" {
|
|
||||||
query += " AND conversation_id = ?"
|
|
||||||
args = append(args, conversationID)
|
|
||||||
}
|
|
||||||
if taskID != "" {
|
|
||||||
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
|
|
||||||
args = append(args, taskID, taskID)
|
|
||||||
}
|
|
||||||
if conversationTag != "" {
|
|
||||||
query += " AND conversation_tag = ?"
|
|
||||||
args = append(args, conversationTag)
|
|
||||||
}
|
|
||||||
if taskTag != "" {
|
|
||||||
query += " AND task_tag = ?"
|
|
||||||
args = append(args, taskTag)
|
|
||||||
}
|
|
||||||
if severity != "" {
|
|
||||||
query += " AND severity = ?"
|
|
||||||
args = append(args, severity)
|
|
||||||
}
|
|
||||||
if status != "" {
|
|
||||||
query += " AND status = ?"
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -168,38 +212,10 @@ func (db *DB) ListVulnerabilities(limit, offset int, id, conversationID, severit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CountVulnerabilities 统计漏洞总数(支持筛选条件)
|
// CountVulnerabilities 统计漏洞总数(支持筛选条件)
|
||||||
func (db *DB) CountVulnerabilities(id, conversationID, severity, status, taskID, conversationTag, taskTag string) (int, error) {
|
func (db *DB) CountVulnerabilities(filter VulnerabilityListFilter) (int, error) {
|
||||||
query := "SELECT COUNT(*) FROM vulnerabilities WHERE 1=1"
|
query := "SELECT COUNT(*) FROM vulnerabilities WHERE 1=1"
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
|
query, args = filter.appendWhere(query, args)
|
||||||
if id != "" {
|
|
||||||
query += " AND id = ?"
|
|
||||||
args = append(args, id)
|
|
||||||
}
|
|
||||||
if conversationID != "" {
|
|
||||||
query += " AND conversation_id = ?"
|
|
||||||
args = append(args, conversationID)
|
|
||||||
}
|
|
||||||
if taskID != "" {
|
|
||||||
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
|
|
||||||
args = append(args, taskID, taskID)
|
|
||||||
}
|
|
||||||
if conversationTag != "" {
|
|
||||||
query += " AND conversation_tag = ?"
|
|
||||||
args = append(args, conversationTag)
|
|
||||||
}
|
|
||||||
if taskTag != "" {
|
|
||||||
query += " AND task_tag = ?"
|
|
||||||
args = append(args, taskTag)
|
|
||||||
}
|
|
||||||
if severity != "" {
|
|
||||||
query += " AND severity = ?"
|
|
||||||
args = append(args, severity)
|
|
||||||
}
|
|
||||||
if status != "" {
|
|
||||||
query += " AND status = ?"
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
err := db.QueryRow(query, args...).Scan(&count)
|
err := db.QueryRow(query, args...).Scan(&count)
|
||||||
@@ -245,19 +261,12 @@ func (db *DB) DeleteVulnerability(id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
||||||
func (db *DB) GetVulnerabilityStats(conversationID, taskID string) (map[string]interface{}, error) {
|
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
||||||
stats := make(map[string]interface{})
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
where := "WHERE 1=1"
|
where := "WHERE 1=1"
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
if conversationID != "" {
|
where, args = filter.appendWhere(where, args)
|
||||||
where += " AND conversation_id = ?"
|
|
||||||
args = append(args, conversationID)
|
|
||||||
}
|
|
||||||
if taskID != "" {
|
|
||||||
where += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
|
|
||||||
args = append(args, taskID, taskID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 总漏洞数
|
// 总漏洞数
|
||||||
var totalCount int
|
var totalCount int
|
||||||
|
|||||||
+170
-46
@@ -17,6 +17,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/reasoning"
|
"cyberstrike-ai/internal/reasoning"
|
||||||
@@ -131,6 +132,12 @@ type AgentHandler struct {
|
|||||||
batchRunning map[string]struct{}
|
batchRunning map[string]struct{}
|
||||||
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
||||||
hitlWhitelistSaver HitlToolWhitelistSaver
|
hitlWhitelistSaver HitlToolWhitelistSaver
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *AgentHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘
|
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘
|
||||||
@@ -207,7 +214,7 @@ type ChatAttachment struct {
|
|||||||
type ChatReasoningRequest struct {
|
type ChatReasoningRequest struct {
|
||||||
// Mode: default(跟随系统)| off | on | auto
|
// Mode: default(跟随系统)| off | on | auto
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
// Effort: low | medium | high | max;空表示不指定(由系统默认与各 profile 决定)。
|
// Effort: low | medium | high | max | xhigh(原样下发;不同网关最高档命名不同)。空表示不指定。
|
||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +560,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
conversationID := req.ConversationID
|
conversationID := req.ConversationID
|
||||||
if conversationID == "" {
|
if conversationID == "" {
|
||||||
title := safeTruncateString(req.Message, 50)
|
title := safeTruncateString(req.Message, 50)
|
||||||
conv, err := h.db.CreateConversation(title)
|
conv, err := h.db.CreateConversation(title, audit.ConversationCreateMetaFromGin(c, "agent_loop"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("创建对话失败", zap.Error(err))
|
h.logger.Error("创建对话失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -717,11 +724,43 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
||||||
|
}
|
||||||
|
errMsg := "执行失败: " + errMA.Error()
|
||||||
|
if assistantMessageID != "" {
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||||
|
}
|
||||||
|
return "", conversationID, errMA
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||||
|
if assistantMessageID != "" {
|
||||||
|
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
||||||
|
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
||||||
|
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
||||||
|
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
||||||
|
}
|
||||||
|
return resultMA.Response, conversationID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:与 /api/agent-loop/stream 相同执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:与 /api/agent-loop/stream 相同执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
||||||
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationID, message, role string) (response string, convID string, err error) {
|
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, conversationID, message, role string) (response string, convID string, err error) {
|
||||||
if conversationID == "" {
|
if conversationID == "" {
|
||||||
title := safeTruncateString(message, 50)
|
title := safeTruncateString(message, 50)
|
||||||
conv, createErr := h.db.CreateConversation(title)
|
src := "robot"
|
||||||
|
if strings.TrimSpace(platform) != "" {
|
||||||
|
src = "robot:" + strings.TrimSpace(platform)
|
||||||
|
}
|
||||||
|
conv, createErr := h.db.CreateConversation(title, audit.ConversationCreateMeta(src))
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
return "", "", fmt.Errorf("创建对话失败: %w", createErr)
|
return "", "", fmt.Errorf("创建对话失败: %w", createErr)
|
||||||
}
|
}
|
||||||
@@ -769,53 +808,88 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
if assistantMsg != nil {
|
if assistantMsg != nil {
|
||||||
assistantMessageID = assistantMsg.ID
|
assistantMessageID = assistantMsg.ID
|
||||||
}
|
}
|
||||||
progressCallback := h.createProgressCallback(ctx, nil, conversationID, assistantMessageID, nil)
|
|
||||||
|
|
||||||
useRobotMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.RobotUseMultiAgent
|
// 注册运行中任务并向 taskEventBus 镜像进度事件,供 Web 端 task-events 补流(与 agent-loop/stream 一致)。
|
||||||
if useRobotMulti {
|
taskCtx, cancelWithCause := context.WithCancelCause(ctx)
|
||||||
resultMA, errMA := multiagent.RunDeepAgent(
|
defer cancelWithCause(nil)
|
||||||
ctx,
|
taskStatus := "completed"
|
||||||
h.config,
|
defer func() {
|
||||||
&h.config.MultiAgent,
|
h.tasks.FinishTask(conversationID, taskStatus)
|
||||||
h.agent,
|
}()
|
||||||
h.logger,
|
if _, err := h.tasks.StartTask(conversationID, message, cancelWithCause); err != nil {
|
||||||
conversationID,
|
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||||
finalMessage,
|
return "", conversationID, fmt.Errorf("当前会话已有任务正在执行中,请稍后再试")
|
||||||
agentHistoryMessages,
|
|
||||||
roleTools,
|
|
||||||
progressCallback,
|
|
||||||
h.agentsMarkdownDir,
|
|
||||||
"deep",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if errMA != nil {
|
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
|
|
||||||
h.persistEinoAgentTraceForResume(conversationID, resultMA)
|
|
||||||
}
|
|
||||||
errMsg := "执行失败: " + errMA.Error()
|
|
||||||
if assistantMessageID != "" {
|
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
|
||||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
|
||||||
}
|
|
||||||
return "", conversationID, errMA
|
|
||||||
}
|
}
|
||||||
if assistantMessageID != "" {
|
return "", conversationID, fmt.Errorf("无法启动任务: %w", err)
|
||||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
}
|
||||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||||
|
|
||||||
|
robotMode := "react"
|
||||||
|
if h.config != nil {
|
||||||
|
robotMode = config.NormalizeRobotAgentMode(h.config.MultiAgent)
|
||||||
|
}
|
||||||
|
switch robotMode {
|
||||||
|
case "eino_single":
|
||||||
|
curHist := agentHistoryMessages
|
||||||
|
curMsg := finalMessage
|
||||||
|
segmentUserMessage := finalMessage
|
||||||
|
var resultMA *multiagent.RunResult
|
||||||
|
var errMA error
|
||||||
|
var transientRunAttempts int
|
||||||
|
for {
|
||||||
|
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
|
||||||
|
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||||
|
conversationID, curMsg, curHist, roleTools, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if errMA == nil {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
} else {
|
if handled, _ := h.handleEinoTransientRetryContinue(
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
taskCtx, conversationID, resultMA, errMA, &transientRunAttempts,
|
||||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
|
||||||
|
); handled {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
taskStatus = "failed"
|
||||||
|
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||||
}
|
}
|
||||||
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
||||||
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
case "deep", "plan_execute", "supervisor":
|
||||||
|
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||||
|
h.logger.Warn("机器人配置为多代理模式但未启用 multi_agent,回退原生 ReAct",
|
||||||
|
zap.String("robot_mode", robotMode))
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return resultMA.Response, conversationID, nil
|
curHist := agentHistoryMessages
|
||||||
|
curMsg := finalMessage
|
||||||
|
segmentUserMessage := finalMessage
|
||||||
|
var resultMA *multiagent.RunResult
|
||||||
|
var errMA error
|
||||||
|
var transientRunAttempts int
|
||||||
|
for {
|
||||||
|
resultMA, errMA = multiagent.RunDeepAgent(
|
||||||
|
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||||
|
conversationID, curMsg, curHist, roleTools, progressCallback,
|
||||||
|
h.agentsMarkdownDir, robotMode, nil,
|
||||||
|
)
|
||||||
|
if errMA == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handled, _ := h.handleEinoTransientRetryContinue(
|
||||||
|
taskCtx, conversationID, resultMA, errMA, &transientRunAttempts,
|
||||||
|
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
|
||||||
|
); handled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
taskStatus = "failed"
|
||||||
|
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||||
|
}
|
||||||
|
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
|
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
taskStatus = "failed"
|
||||||
errMsg := "执行失败: " + err.Error()
|
errMsg := "执行失败: " + err.Error()
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||||
@@ -847,6 +921,23 @@ type StreamEvent struct {
|
|||||||
Data interface{} `json:"data,omitempty"`
|
Data interface{} `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishProgressToTaskEventBus 将进度事件镜像到 taskEventBus(机器人/无 HTTP SSE 客户端时供 Web task-events 订阅)。
|
||||||
|
func (h *AgentHandler) publishProgressToTaskEventBus(conversationID, eventType, message string, data interface{}) {
|
||||||
|
if h == nil || h.taskEventBus == nil || strings.TrimSpace(conversationID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||||
|
eventJSON, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseLine := make([]byte, 0, len(eventJSON)+8)
|
||||||
|
sseLine = append(sseLine, []byte("data: ")...)
|
||||||
|
sseLine = append(sseLine, eventJSON...)
|
||||||
|
sseLine = append(sseLine, '\n', '\n')
|
||||||
|
h.taskEventBus.Publish(conversationID, sseLine)
|
||||||
|
}
|
||||||
|
|
||||||
// createProgressCallback 创建进度回调函数,用于保存processDetails
|
// createProgressCallback 创建进度回调函数,用于保存processDetails
|
||||||
// sendEventFunc: 可选的流式事件发送函数,如果为nil则不发送流式事件
|
// sendEventFunc: 可选的流式事件发送函数,如果为nil则不发送流式事件
|
||||||
func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) agent.ProgressCallback {
|
func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) agent.ProgressCallback {
|
||||||
@@ -956,9 +1047,11 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
|
|
||||||
return func(eventType, message string, data interface{}) {
|
return func(eventType, message string, data interface{}) {
|
||||||
// 如果提供了sendEventFunc,发送流式事件
|
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
sendEventFunc(eventType, message, data)
|
sendEventFunc(eventType, message, data)
|
||||||
|
} else {
|
||||||
|
h.publishProgressToTaskEventBus(conversationID, eventType, message, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存tool_call事件中的参数
|
// 保存tool_call事件中的参数
|
||||||
@@ -1420,10 +1513,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
title := safeTruncateString(req.Message, 50)
|
title := safeTruncateString(req.Message, 50)
|
||||||
var conv *database.Conversation
|
var conv *database.Conversation
|
||||||
var err error
|
var err error
|
||||||
|
meta := audit.ConversationCreateMetaFromGin(c, "agent_loop_stream")
|
||||||
if req.WebShellConnectionID != "" {
|
if req.WebShellConnectionID != "" {
|
||||||
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
|
meta.Source = "webshell_chat"
|
||||||
|
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title, meta)
|
||||||
} else {
|
} else {
|
||||||
conv, err = h.db.CreateConversation(title)
|
conv, err = h.db.CreateConversation(title, meta)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("创建对话失败", zap.Error(err))
|
h.logger.Error("创建对话失败", zap.Error(err))
|
||||||
@@ -2039,6 +2134,11 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
|
|||||||
queue = refreshed
|
queue = refreshed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "task", "create_queue", "创建批量任务队列", "batch_queue", queue.ID, map[string]interface{}{
|
||||||
|
"task_count": len(validTasks), "started": started,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"queueId": queue.ID,
|
"queueId": queue.ID,
|
||||||
"queue": queue,
|
"queue": queue,
|
||||||
@@ -2146,6 +2246,9 @@ func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "task", "start_queue", "启动批量任务队列", "batch_queue", queueID, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务已开始执行", "queueId": queueID})
|
c.JSON(http.StatusOK, gin.H{"message": "批量任务已开始执行", "queueId": queueID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2174,6 +2277,9 @@ func (h *AgentHandler) RerunBatchQueue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "启动失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "启动失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "task", "rerun_queue", "重跑批量任务队列", "batch_queue", queueID, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务已重新开始执行", "queueId": queueID})
|
c.JSON(http.StatusOK, gin.H{"message": "批量任务已重新开始执行", "queueId": queueID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2185,6 +2291,9 @@ func (h *AgentHandler) PauseBatchQueue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在或无法暂停"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在或无法暂停"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "task", "pause_queue", "暂停批量任务队列", "batch_queue", queueID, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"})
|
c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2280,6 +2389,16 @@ func (h *AgentHandler) DeleteBatchQueue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "task",
|
||||||
|
Action: "delete_queue",
|
||||||
|
Result: "success",
|
||||||
|
ResourceType: "batch_queue",
|
||||||
|
ResourceID: queueID,
|
||||||
|
Message: "删除批量任务队列",
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "批量任务队列已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "批量任务队列已删除"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2365,6 +2484,11 @@ func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "task", "delete_batch_task", "删除批量子任务", "batch_task", taskID, map[string]interface{}{
|
||||||
|
"batch_queue_id": queueID,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "任务已删除", "queue": queue})
|
c.JSON(http.StatusOK, gin.H{"message": "任务已删除", "queue": queue})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2523,7 +2647,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
|
|
||||||
// 创建新对话
|
// 创建新对话
|
||||||
title := safeTruncateString(task.Message, 50)
|
title := safeTruncateString(task.Message, 50)
|
||||||
conv, err := h.db.CreateConversation(title)
|
conv, err := h.db.CreateConversation(title, audit.ConversationCreateMeta("batch_task"))
|
||||||
var conversationID string
|
var conversationID string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditHandler serves platform audit log APIs.
|
||||||
|
type AuditHandler struct {
|
||||||
|
db *database.DB
|
||||||
|
audit *audit.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuditHandler creates an audit log handler.
|
||||||
|
func NewAuditHandler(db *database.DB, auditSvc *audit.Service, logger *zap.Logger) *AuditHandler {
|
||||||
|
return &AuditHandler{db: db, audit: auditSvc, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta GET /api/audit/meta
|
||||||
|
func (h *AuditHandler) Meta(c *gin.Context) {
|
||||||
|
enabled := false
|
||||||
|
retentionDays := 0
|
||||||
|
if h.audit != nil {
|
||||||
|
enabled = h.audit.Enabled()
|
||||||
|
retentionDays = h.audit.RetentionDays()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"enabled": enabled,
|
||||||
|
"retention_days": retentionDays,
|
||||||
|
"default_page_size": 20,
|
||||||
|
"max_page_size": 100,
|
||||||
|
"max_export": 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary GET /api/audit/summary
|
||||||
|
func (h *AuditHandler) Summary(c *gin.Context) {
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := auditFilterFromQuery(c)
|
||||||
|
total, err := h.db.CountAuditLogs(base)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failFilter := base
|
||||||
|
failFilter.Result = "failure"
|
||||||
|
failures, err := h.db.CountAuditLogs(failFilter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since := time.Now().AddDate(0, 0, -7)
|
||||||
|
recentFilter := base
|
||||||
|
recentFilter.Since = &since
|
||||||
|
recent7d, err := h.db.CountAuditLogs(recentFilter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"total": total,
|
||||||
|
"failures": failures,
|
||||||
|
"recent_7d": recent7d,
|
||||||
|
"has_filters": c.Query("category") != "" || c.Query("action") != "" || c.Query("result") != "" ||
|
||||||
|
c.Query("q") != "" || c.Query("since") != "" || c.Query("until") != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLogs GET /api/audit/logs
|
||||||
|
func (h *AuditHandler) ListLogs(c *gin.Context) {
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := auditFilterFromQuery(c)
|
||||||
|
page, pageSize := auditPaginationFromQuery(c)
|
||||||
|
filter.Limit = pageSize
|
||||||
|
filter.Offset = (page - 1) * pageSize
|
||||||
|
|
||||||
|
logs, err := h.db.ListAuditLogs(filter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.db.CountAuditLogs(filter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"logs": logs,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLog GET /api/audit/logs/:id
|
||||||
|
func (h *AuditHandler) GetLog(c *gin.Context) {
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.db.GetAuditLogByID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "审计记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.ApplyResourceAvailability(h.db, row)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"log": row})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportLogs GET /api/audit/logs/export — JSON or CSV (?format=csv), max 5000 rows.
|
||||||
|
func (h *AuditHandler) ExportLogs(c *gin.Context) {
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := auditFilterFromQuery(c)
|
||||||
|
filter.Limit = 5000
|
||||||
|
filter.Offset = 0
|
||||||
|
|
||||||
|
logs, err := h.db.ListAuditLogs(filter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Query("format") == "csv" {
|
||||||
|
writeAuditLogsCSV(c, logs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="audit-logs.json"`)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"logs": logs,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeAuditLogsCSV(c *gin.Context, logs []*database.AuditLog) {
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="audit-logs-%s.csv"`, time.Now().Format("20060102")))
|
||||||
|
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
"id", "created_at", "level", "category", "action", "result", "actor",
|
||||||
|
"session_hint", "client_ip", "resource_type", "resource_id", "message",
|
||||||
|
})
|
||||||
|
for _, row := range logs {
|
||||||
|
if row == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = w.Write([]string{
|
||||||
|
row.ID,
|
||||||
|
row.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
row.Level,
|
||||||
|
row.Category,
|
||||||
|
row.Action,
|
||||||
|
row.Result,
|
||||||
|
row.Actor,
|
||||||
|
row.SessionHint,
|
||||||
|
row.ClientIP,
|
||||||
|
row.ResourceType,
|
||||||
|
row.ResourceID,
|
||||||
|
row.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter {
|
||||||
|
filter := database.ListAuditLogsFilter{
|
||||||
|
Level: c.Query("level"),
|
||||||
|
Category: c.Query("category"),
|
||||||
|
Action: c.Query("action"),
|
||||||
|
Result: c.Query("result"),
|
||||||
|
Query: c.Query("q"),
|
||||||
|
ResourceType: c.Query("resource_type"),
|
||||||
|
ResourceID: c.Query("resource_id"),
|
||||||
|
}
|
||||||
|
if since := c.Query("since"); since != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||||
|
filter.Since = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if until := c.Query("until"); until != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, until); err == nil {
|
||||||
|
filter.Until = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditPaginationFromQuery(c *gin.Context) (page, pageSize int) {
|
||||||
|
page = 1
|
||||||
|
pageSize = 20
|
||||||
|
if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
if ps, err := strconv.Atoi(c.DefaultQuery("page_size", "20")); err == nil && ps > 0 {
|
||||||
|
pageSize = ps
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ type AuthHandler struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *AuthHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler creates a new AuthHandler.
|
// NewAuthHandler creates a new AuthHandler.
|
||||||
@@ -49,10 +56,32 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
token, expiresAt, err := h.manager.Authenticate(req.Password)
|
token, expiresAt, err := h.manager.Authenticate(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Level: "warn",
|
||||||
|
Category: "auth",
|
||||||
|
Action: "login",
|
||||||
|
Result: "failure",
|
||||||
|
Message: "登录失败:密码错误",
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "auth",
|
||||||
|
Action: "login",
|
||||||
|
Result: "success",
|
||||||
|
SessionHint: audit.HintFromToken(token),
|
||||||
|
Message: "登录成功",
|
||||||
|
Detail: map[string]interface{}{
|
||||||
|
"expires_at": expiresAt.UTC().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
"expires_at": expiresAt.UTC().Format(time.RFC3339),
|
"expires_at": expiresAt.UTC().Format(time.RFC3339),
|
||||||
@@ -73,6 +102,14 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.manager.RevokeToken(token)
|
h.manager.RevokeToken(token)
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "auth",
|
||||||
|
Action: "logout",
|
||||||
|
Result: "success",
|
||||||
|
Message: "退出登录",
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
|
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +140,15 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !h.manager.CheckPassword(oldPassword) {
|
if !h.manager.CheckPassword(oldPassword) {
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Level: "warn",
|
||||||
|
Category: "auth",
|
||||||
|
Action: "change_password",
|
||||||
|
Result: "failure",
|
||||||
|
Message: "修改密码失败:当前密码不正确",
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码不正确"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码不正确"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -132,6 +178,15 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|||||||
h.logger.Info("登录密码已更新,所有会话已失效")
|
h.logger.Info("登录密码已更新,所有会话已失效")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "auth",
|
||||||
|
Action: "change_password",
|
||||||
|
Result: "success",
|
||||||
|
Message: "登录密码已修改",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "密码已更新,请使用新密码重新登录"})
|
c.JSON(http.StatusOK, gin.H{"message": "密码已更新,请使用新密码重新登录"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/c2"
|
"cyberstrike-ai/internal/c2"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
@@ -25,6 +26,12 @@ import (
|
|||||||
type C2Handler struct {
|
type C2Handler struct {
|
||||||
mgrPtr atomic.Pointer[c2.Manager]
|
mgrPtr atomic.Pointer[c2.Manager]
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *C2Handler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewC2Handler 创建 C2 处理器;manager 可为 nil(功能关闭时)
|
// NewC2Handler 创建 C2 处理器;manager 可为 nil(功能关闭时)
|
||||||
@@ -104,6 +111,11 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
|||||||
implantToken := listener.ImplantToken
|
implantToken := listener.ImplantToken
|
||||||
listener.EncryptionKey = ""
|
listener.EncryptionKey = ""
|
||||||
listener.ImplantToken = ""
|
listener.ImplantToken = ""
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "listener_create", "创建 C2 监听器", "c2_listener", listener.ID, map[string]interface{}{
|
||||||
|
"name": listener.Name, "bind": listener.BindHost, "port": listener.BindPort,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"listener": listener, "implant_token": implantToken})
|
c.JSON(http.StatusOK, gin.H{"listener": listener, "implant_token": implantToken})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +217,9 @@ func (h *C2Handler) DeleteListener(c *gin.Context) {
|
|||||||
c.JSON(code, gin.H{"error": err.Error()})
|
c.JSON(code, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "listener_delete", "删除 C2 监听器", "c2_listener", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +237,9 @@ func (h *C2Handler) StartListener(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
listener.EncryptionKey = ""
|
listener.EncryptionKey = ""
|
||||||
listener.ImplantToken = ""
|
listener.ImplantToken = ""
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "listener_start", "启动 C2 监听器", "c2_listener", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"listener": listener})
|
c.JSON(http.StatusOK, gin.H{"listener": listener})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +254,9 @@ func (h *C2Handler) StopListener(c *gin.Context) {
|
|||||||
c.JSON(code, gin.H{"error": err.Error()})
|
c.JSON(code, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "listener_stop", "停止 C2 监听器", "c2_listener", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"stopped": true})
|
c.JSON(http.StatusOK, gin.H{"stopped": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +318,9 @@ func (h *C2Handler) DeleteSession(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "session_delete", "删除 C2 会话", "c2_session", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +431,11 @@ func (h *C2Handler) DeleteTasks(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "task_delete", "批量删除 C2 任务", "c2_task", "", map[string]interface{}{
|
||||||
|
"count": n, "ids": req.IDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +486,11 @@ func (h *C2Handler) CreateTask(c *gin.Context) {
|
|||||||
c.JSON(code, gin.H{"error": err.Error()})
|
c.JSON(code, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "task_create", "创建 C2 任务", "c2_task", task.ID, map[string]interface{}{
|
||||||
|
"session_id": req.SessionID, "task_type": req.TaskType,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"task": task})
|
c.JSON(http.StatusOK, gin.H{"task": task})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +505,9 @@ func (h *C2Handler) CancelTask(c *gin.Context) {
|
|||||||
c.JSON(code, gin.H{"error": err.Error()})
|
c.JSON(code, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "task_cancel", "取消 C2 任务", "c2_task", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,12 @@ const (
|
|||||||
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
|
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
|
||||||
type ChatUploadsHandler struct {
|
type ChatUploadsHandler struct {
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *ChatUploadsHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatUploadsHandler 创建处理器
|
// NewChatUploadsHandler 创建处理器
|
||||||
@@ -230,6 +238,9 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "file", "delete", "删除对话附件", "chat_upload", body.Path, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,6 +514,11 @@ func (h *ChatUploadsHandler) Upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(root, fullPath)
|
rel, _ := filepath.Rel(root, fullPath)
|
||||||
absSaved, _ := filepath.Abs(fullPath)
|
absSaved, _ := filepath.Abs(fullPath)
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "file", "upload", "上传对话附件", "chat_upload", filepath.ToSlash(rel), map[string]interface{}{
|
||||||
|
"name": unique,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"relativePath": filepath.ToSlash(rel),
|
"relativePath": filepath.ToSlash(rel),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agents"
|
"cyberstrike-ai/internal/agents"
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/knowledge"
|
"cyberstrike-ai/internal/knowledge"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
@@ -87,6 +88,7 @@ type ConfigHandler struct {
|
|||||||
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
|
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
|
||||||
appUpdater AppUpdater // App更新器(可选)
|
appUpdater AppUpdater // App更新器(可选)
|
||||||
robotRestarter RobotRestarter // 机器人连接重启器(可选),ApplyConfig 时重启钉钉/飞书
|
robotRestarter RobotRestarter // 机器人连接重启器(可选),ApplyConfig 时重启钉钉/飞书
|
||||||
|
audit *audit.Service
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更)
|
lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更)
|
||||||
@@ -206,6 +208,13 @@ func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) {
|
|||||||
h.robotRestarter = restarter
|
h.robotRestarter = restarter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *ConfigHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.audit = s
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyWechatRobotBinding 微信 iLink 扫码绑定成功后写入配置并重启机器人连接
|
// ApplyWechatRobotBinding 微信 iLink 扫码绑定成功后写入配置并重启机器人连接
|
||||||
func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) error {
|
func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) error {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
@@ -310,7 +319,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
multiPub := config.MultiAgentPublic{
|
multiPub := config.MultiAgentPublic{
|
||||||
Enabled: h.config.MultiAgent.Enabled,
|
Enabled: h.config.MultiAgent.Enabled,
|
||||||
RobotUseMultiAgent: h.config.MultiAgent.RobotUseMultiAgent,
|
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
|
||||||
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
|
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
|
||||||
SubAgentCount: subAgentCount,
|
SubAgentCount: subAgentCount,
|
||||||
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
|
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
|
||||||
@@ -770,8 +779,12 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
// 多代理标量(sub_agents 等仍由 config.yaml 维护)
|
// 多代理标量(sub_agents 等仍由 config.yaml 维护)
|
||||||
if req.MultiAgent != nil {
|
if req.MultiAgent != nil {
|
||||||
h.config.MultiAgent.Enabled = req.MultiAgent.Enabled
|
h.config.MultiAgent.Enabled = req.MultiAgent.Enabled
|
||||||
h.config.MultiAgent.RobotUseMultiAgent = req.MultiAgent.RobotUseMultiAgent
|
|
||||||
h.config.MultiAgent.BatchUseMultiAgent = req.MultiAgent.BatchUseMultiAgent
|
h.config.MultiAgent.BatchUseMultiAgent = req.MultiAgent.BatchUseMultiAgent
|
||||||
|
if mode := strings.TrimSpace(req.MultiAgent.RobotDefaultAgentMode); mode != "" {
|
||||||
|
h.config.MultiAgent.RobotDefaultAgentMode = mode
|
||||||
|
} else {
|
||||||
|
h.config.MultiAgent.RobotDefaultAgentMode = "react"
|
||||||
|
}
|
||||||
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
||||||
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
||||||
}
|
}
|
||||||
@@ -780,7 +793,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
h.logger.Info("更新多代理配置",
|
h.logger.Info("更新多代理配置",
|
||||||
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
||||||
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
|
zap.String("robot_default_agent_mode", config.NormalizeRobotAgentMode(h.config.MultiAgent)),
|
||||||
zap.Bool("batch_use_multi_agent", h.config.MultiAgent.BatchUseMultiAgent),
|
zap.Bool("batch_use_multi_agent", h.config.MultiAgent.BatchUseMultiAgent),
|
||||||
zap.Int("plan_execute_loop_max_iterations", h.config.MultiAgent.PlanExecuteLoopMaxIterations),
|
zap.Int("plan_execute_loop_max_iterations", h.config.MultiAgent.PlanExecuteLoopMaxIterations),
|
||||||
zap.Int("tool_search_always_visible_tools", len(h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools)),
|
zap.Int("tool_search_always_visible_tools", len(h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools)),
|
||||||
@@ -903,6 +916,9 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "config", "update", "更新内存配置", "config", "", nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,6 +1049,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
h.logger.Info("检测到知识库从禁用变为启用,开始动态初始化知识库组件")
|
h.logger.Info("检测到知识库从禁用变为启用,开始动态初始化知识库组件")
|
||||||
if _, err := knowledgeInitializer(); err != nil {
|
if _, err := knowledgeInitializer(); err != nil {
|
||||||
h.logger.Error("动态初始化知识库失败", zap.Error(err))
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "初始化知识库失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1067,6 +1086,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
h.logger.Info("开始重新初始化知识库组件(嵌入模型配置已变更)")
|
h.logger.Info("开始重新初始化知识库组件(嵌入模型配置已变更)")
|
||||||
if _, err := reinitKnowledgeInitializer(); err != nil {
|
if _, err := reinitKnowledgeInitializer(); err != nil {
|
||||||
h.logger.Error("重新初始化知识库失败", zap.Error(err))
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "重新初始化知识库失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1080,6 +1102,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
if c2Rt != nil {
|
if c2Rt != nil {
|
||||||
if err := c2Rt.ReconcileC2AfterConfigApply(); err != nil {
|
if err := c2Rt.ReconcileC2AfterConfigApply(); err != nil {
|
||||||
h.logger.Error("C2 配置应用失败", zap.Error(err))
|
h.logger.Error("C2 配置应用失败", zap.Error(err))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordFail(c, "config", "apply", "应用配置失败:C2", map[string]interface{}{"error": err.Error()})
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "C2 启动失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "C2 启动失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1246,20 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
|||||||
zap.Int("tools_count", len(h.config.Security.Tools)),
|
zap.Int("tools_count", len(h.config.Security.Tools)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "config",
|
||||||
|
Action: "apply",
|
||||||
|
Result: "success",
|
||||||
|
Message: "配置已应用",
|
||||||
|
Detail: map[string]interface{}{
|
||||||
|
"tools_count": len(h.config.Security.Tools),
|
||||||
|
"knowledge_enabled": h.config.Knowledge.Enabled,
|
||||||
|
"c2_enabled": h.config.C2.EnabledEffective(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "配置已应用",
|
"message": "配置已应用",
|
||||||
"tools_count": len(h.config.Security.Tools),
|
"tools_count": len(h.config.Security.Tools),
|
||||||
@@ -1536,7 +1575,7 @@ func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
|||||||
root := doc.Content[0]
|
root := doc.Content[0]
|
||||||
maNode := ensureMap(root, "multi_agent")
|
maNode := ensureMap(root, "multi_agent")
|
||||||
setBoolInMap(maNode, "enabled", cfg.Enabled)
|
setBoolInMap(maNode, "enabled", cfg.Enabled)
|
||||||
setBoolInMap(maNode, "robot_use_multi_agent", cfg.RobotUseMultiAgent)
|
setStringInMap(maNode, "robot_default_agent_mode", config.NormalizeRobotAgentMode(cfg))
|
||||||
setBoolInMap(maNode, "batch_use_multi_agent", cfg.BatchUseMultiAgent)
|
setBoolInMap(maNode, "batch_use_multi_agent", cfg.BatchUseMultiAgent)
|
||||||
setIntInMap(maNode, "plan_execute_loop_max_iterations", cfg.PlanExecuteLoopMaxIterations)
|
setIntInMap(maNode, "plan_execute_loop_max_iterations", cfg.PlanExecuteLoopMaxIterations)
|
||||||
mwNode := ensureMap(maNode, "eino_middleware")
|
mwNode := ensureMap(maNode, "eino_middleware")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -14,6 +15,12 @@ import (
|
|||||||
type ConversationHandler struct {
|
type ConversationHandler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *ConversationHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConversationHandler 创建新的对话处理器
|
// NewConversationHandler 创建新的对话处理器
|
||||||
@@ -42,7 +49,7 @@ func (h *ConversationHandler) CreateConversation(c *gin.Context) {
|
|||||||
title = "新对话"
|
title = "新对话"
|
||||||
}
|
}
|
||||||
|
|
||||||
conv, err := h.db.CreateConversation(title)
|
conv, err := h.db.CreateConversation(title, audit.ConversationCreateMetaFromGin(c, "api"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("创建对话失败", zap.Error(err))
|
h.logger.Error("创建对话失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -189,6 +196,17 @@ func (h *ConversationHandler) DeleteConversation(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "conversation",
|
||||||
|
Action: "delete",
|
||||||
|
Result: "success",
|
||||||
|
ResourceType: "conversation",
|
||||||
|
ResourceID: id,
|
||||||
|
Message: "删除对话",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +245,12 @@ func (h *ConversationHandler) DeleteConversationTurn(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "conversation", "delete_turn", "删除对话轮次", "conversation", conversationID, map[string]interface{}{
|
||||||
|
"message_id": req.MessageID,
|
||||||
|
"deleted": len(deletedIDs),
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"deletedMessageIds": deletedIDs,
|
"deletedMessageIds": deletedIDs,
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AgentHandler) einoRunRetryMaxAttempts() int {
|
||||||
|
if h.config != nil {
|
||||||
|
return multiagent.RunRetryMaxAttemptsFromConfig(&h.config.MultiAgent.EinoMiddleware)
|
||||||
|
}
|
||||||
|
return multiagent.RunRetryMaxAttemptsFromConfig(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) einoRunRetryMaxBackoffSec() int {
|
||||||
|
if h.config != nil && h.config.MultiAgent.EinoMiddleware.RunRetryMaxBackoffSec > 0 {
|
||||||
|
return h.config.MultiAgent.EinoMiddleware.RunRetryMaxBackoffSec
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEinoTraceResumeSegment 中断并继续:persist last_react_* → loadHistory,可选替换下一段 user 文案。
|
||||||
|
func (h *AgentHandler) applyEinoTraceResumeSegment(
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
segmentUserMessage string,
|
||||||
|
) {
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(context.Background()) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
*curHistory = hist
|
||||||
|
}
|
||||||
|
if segmentUserMessage != "" {
|
||||||
|
*curFinalMessage = segmentUserMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEinoTransientRetrySegment 临时错误重试:恢复轨迹并保留本请求原始 user 文案(不注入续跑说明)。
|
||||||
|
// segmentUserMessage 为本轮 HTTP 请求开始时用户发送的内容,避免因清空 finalMessage 而丢失「你好」等短句。
|
||||||
|
func (h *AgentHandler) applyEinoTransientRetrySegment(
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
segmentUserMessage string,
|
||||||
|
) {
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(context.Background()) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
*curHistory = hist
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(segmentUserMessage); s != "" {
|
||||||
|
*curFinalMessage = segmentUserMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinoTransientRetryContinue 在 SSE 任务循环内处理临时错误重试;返回 true 表示外层 for 应 continue。
|
||||||
|
func (h *AgentHandler) handleEinoTransientRetryContinue(
|
||||||
|
baseCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
runErr error,
|
||||||
|
transientAttempts *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
segmentUserMessage string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
sendProgress func(msg string, extra map[string]interface{}),
|
||||||
|
) (handled bool, fatal error) {
|
||||||
|
if !errors.Is(runErr, multiagent.ErrTransientRetryContinue) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
maxAttempts := h.einoRunRetryMaxAttempts()
|
||||||
|
*transientAttempts++
|
||||||
|
if *transientAttempts > maxAttempts {
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
|
return false, errors.New("transient retry exhausted: " + runErr.Error())
|
||||||
|
}
|
||||||
|
attemptNo := *transientAttempts
|
||||||
|
backoff := multiagent.TransientRetryBackoff(attemptNo-1, h.einoRunRetryMaxBackoffSec())
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("eino_run_retry", fmt.Sprintf("遇到临时错误,%d 秒后第 %d/%d 次重试…", int(backoff.Seconds()), attemptNo, maxAttempts), map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"attempt": attemptNo,
|
||||||
|
"maxAttempts": maxAttempts,
|
||||||
|
"backoffSec": int(backoff.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-baseCtx.Done():
|
||||||
|
return false, context.Cause(baseCtx)
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
h.applyEinoTransientRetrySegment(conversationID, result, curHistory, curFinalMessage, segmentUserMessage)
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("eino_run_retry", "已恢复上下文,正在重试…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"attempt": attemptNo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if sendProgress != nil {
|
||||||
|
sendProgress("正在重试…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "transient_retry",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
zap.String("conversationId", req.ConversationID),
|
zap.String("conversationId", req.ConversationID),
|
||||||
)
|
)
|
||||||
|
|
||||||
prep, err := h.prepareMultiAgentSession(&req)
|
prep, err := h.prepareMultiAgentSession(&req, c, "eino_agent_stream")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent("error", err.Error(), nil)
|
sendEvent("error", err.Error(), nil)
|
||||||
sendEvent("done", "", nil)
|
sendEvent("done", "", nil)
|
||||||
@@ -119,6 +119,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
var cancelWithCause context.CancelCauseFunc
|
var cancelWithCause context.CancelCauseFunc
|
||||||
curFinalMessage := prep.FinalMessage
|
curFinalMessage := prep.FinalMessage
|
||||||
|
segmentUserMessage := prep.FinalMessage // 本请求原始用户句,临时重试时不得丢失
|
||||||
curHistory := prep.History
|
curHistory := prep.History
|
||||||
roleTools := prep.RoleTools
|
roleTools := prep.RoleTools
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
taskOwned = true
|
taskOwned = true
|
||||||
|
|
||||||
var cumulativeMCPExecutionIDs []string
|
var cumulativeMCPExecutionIDs []string
|
||||||
|
var transientRunAttempts int
|
||||||
|
|
||||||
for {
|
for {
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
@@ -198,16 +200,33 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
progressCallback,
|
progressCallback,
|
||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
)
|
)
|
||||||
timeoutCancel()
|
|
||||||
|
|
||||||
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handled, fatalErr := h.handleEinoTransientRetryContinue(
|
||||||
|
baseCtx, conversationID, result, runErr, &transientRunAttempts,
|
||||||
|
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
|
||||||
|
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
|
||||||
|
)
|
||||||
|
if handled {
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fatalErr != nil {
|
||||||
|
runErr = fatalErr
|
||||||
|
}
|
||||||
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
@@ -231,10 +250,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "interrupt_continue",
|
"source": "interrupt_continue",
|
||||||
})
|
})
|
||||||
h.tasks.UpdateTaskStatus(conversationID, "running")
|
timeoutCancel()
|
||||||
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +281,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
})
|
})
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +299,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
"errorType": "timeout",
|
"errorType": "timeout",
|
||||||
})
|
})
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,9 +316,12 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
})
|
})
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||||
}
|
}
|
||||||
@@ -326,7 +351,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
|
|
||||||
h.logger.Info("收到 Eino ADK 单代理非流式请求", zap.String("conversationId", req.ConversationID))
|
h.logger.Info("收到 Eino ADK 单代理非流式请求", zap.String("conversationId", req.ConversationID))
|
||||||
|
|
||||||
prep, err := h.prepareMultiAgentSession(&req)
|
prep, err := h.prepareMultiAgentSession(&req, c, "eino_agent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
@@ -20,9 +21,15 @@ type ExternalMCPHandler struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *ExternalMCPHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
|
}
|
||||||
|
|
||||||
// NewExternalMCPHandler 创建外部MCP处理器
|
// NewExternalMCPHandler 创建外部MCP处理器
|
||||||
func NewExternalMCPHandler(manager *mcp.ExternalMCPManager, cfg *config.Config, configPath string, logger *zap.Logger) *ExternalMCPHandler {
|
func NewExternalMCPHandler(manager *mcp.ExternalMCPManager, cfg *config.Config, configPath string, logger *zap.Logger) *ExternalMCPHandler {
|
||||||
return &ExternalMCPHandler{
|
return &ExternalMCPHandler{
|
||||||
@@ -180,6 +187,16 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("外部MCP配置已更新", zap.String("name", name))
|
h.logger.Info("外部MCP配置已更新", zap.String("name", name))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "external_mcp",
|
||||||
|
Action: "upsert",
|
||||||
|
Result: "success",
|
||||||
|
ResourceType: "external_mcp",
|
||||||
|
ResourceID: name,
|
||||||
|
Message: "更新外部 MCP 配置",
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +226,16 @@ func (h *ExternalMCPHandler) DeleteExternalMCP(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("外部MCP配置已删除", zap.String("name", name))
|
h.logger.Info("外部MCP配置已删除", zap.String("name", name))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "external_mcp",
|
||||||
|
Action: "delete",
|
||||||
|
Result: "success",
|
||||||
|
ResourceType: "external_mcp",
|
||||||
|
ResourceID: name,
|
||||||
|
Message: "删除外部 MCP 配置",
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "配置已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "配置已删除"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -616,6 +616,11 @@ func (h *AgentHandler) DecideHITLInterrupt(c *gin.Context) {
|
|||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "hitl", "decision", "HITL 审批决策", "hitl_interrupt", req.InterruptID, map[string]interface{}{
|
||||||
|
"decision": req.Decision,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/knowledge"
|
"cyberstrike-ai/internal/knowledge"
|
||||||
|
|
||||||
@@ -20,6 +21,12 @@ type KnowledgeHandler struct {
|
|||||||
indexer *knowledge.Indexer
|
indexer *knowledge.Indexer
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *KnowledgeHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKnowledgeHandler 创建新的知识库处理器
|
// NewKnowledgeHandler 创建新的知识库处理器
|
||||||
@@ -303,6 +310,9 @@ func (h *KnowledgeHandler) DeleteItem(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "knowledge", "item_delete", "删除知识项", "knowledge_item", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +326,9 @@ func (h *KnowledgeHandler) RebuildIndex(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "knowledge", "index_rebuild", "重建知识库索引", "knowledge", "", nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "索引重建已开始,将在后台进行"})
|
c.JSON(http.StatusOK, gin.H{"message": "索引重建已开始,将在后台进行"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agents"
|
"cyberstrike-ai/internal/agents"
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -18,7 +19,8 @@ var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.m
|
|||||||
|
|
||||||
// MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。
|
// MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。
|
||||||
type MarkdownAgentsHandler struct {
|
type MarkdownAgentsHandler struct {
|
||||||
dir string
|
dir string
|
||||||
|
audit *audit.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMarkdownAgentsHandler dir 须为已解析的绝对路径。
|
// NewMarkdownAgentsHandler dir 须为已解析的绝对路径。
|
||||||
@@ -26,6 +28,11 @@ func NewMarkdownAgentsHandler(dir string) *MarkdownAgentsHandler {
|
|||||||
return &MarkdownAgentsHandler{dir: strings.TrimSpace(dir)}
|
return &MarkdownAgentsHandler{dir: strings.TrimSpace(dir)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *MarkdownAgentsHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MarkdownAgentsHandler) safeJoin(filename string) (string, error) {
|
func (h *MarkdownAgentsHandler) safeJoin(filename string) (string, error) {
|
||||||
filename = strings.TrimSpace(filename)
|
filename = strings.TrimSpace(filename)
|
||||||
if filename == "" || !markdownAgentFilenameRe.MatchString(filename) {
|
if filename == "" || !markdownAgentFilenameRe.MatchString(filename) {
|
||||||
@@ -227,6 +234,9 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "agent", "markdown_create", "创建 Markdown 子代理", "markdown_agent", filepath.Base(path), nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"filename": filepath.Base(path), "message": "已创建"})
|
c.JSON(http.StatusOK, gin.H{"filename": filepath.Base(path), "message": "已创建"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +304,9 @@ func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "agent", "markdown_update", "更新 Markdown 子代理", "markdown_agent", filename, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "已保存"})
|
c.JSON(http.StatusOK, gin.H{"message": "已保存"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,5 +326,8 @@ func (h *MarkdownAgentsHandler) DeleteMarkdownAgent(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "agent", "markdown_delete", "删除 Markdown 子代理", "markdown_agent", filename, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
@@ -23,6 +24,12 @@ type MonitorHandler struct {
|
|||||||
executor *security.Executor
|
executor *security.Executor
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *MonitorHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMonitorHandler 创建新的监控处理器
|
// NewMonitorHandler 创建新的监控处理器
|
||||||
@@ -365,6 +372,11 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("执行记录已从数据库删除", zap.String("executionId", id), zap.String("toolName", exec.ToolName))
|
h.logger.Info("执行记录已从数据库删除", zap.String("executionId", id), zap.String("toolName", exec.ToolName))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "tool", "execution_delete", "删除工具执行记录", "tool_execution", id, map[string]interface{}{
|
||||||
|
"tool_name": exec.ToolName,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -440,6 +452,11 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("批量删除执行记录成功", zap.Int("count", len(request.IDs)))
|
h.logger.Info("批量删除执行记录成功", zap.Int("count", len(request.IDs)))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "tool", "execution_delete_batch", "批量删除工具执行记录", "tool_execution", "", map[string]interface{}{
|
||||||
|
"count": len(request.IDs),
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "成功删除执行记录", "deleted": len(executions)})
|
c.JSON(http.StatusOK, gin.H{"message": "成功删除执行记录", "deleted": len(executions)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
zap.String("conversationId", req.ConversationID),
|
zap.String("conversationId", req.ConversationID),
|
||||||
)
|
)
|
||||||
|
|
||||||
prep, err := h.prepareMultiAgentSession(&req)
|
prep, err := h.prepareMultiAgentSession(&req, c, "multi_agent_stream")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent("error", err.Error(), nil)
|
sendEvent("error", err.Error(), nil)
|
||||||
sendEvent("done", "", nil)
|
sendEvent("done", "", nil)
|
||||||
@@ -136,6 +136,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
var cancelWithCause context.CancelCauseFunc
|
var cancelWithCause context.CancelCauseFunc
|
||||||
curFinalMessage := prep.FinalMessage
|
curFinalMessage := prep.FinalMessage
|
||||||
|
segmentUserMessage := prep.FinalMessage // 本请求原始用户句,临时重试时不得丢失
|
||||||
curHistory := prep.History
|
curHistory := prep.History
|
||||||
roleTools := prep.RoleTools
|
roleTools := prep.RoleTools
|
||||||
orch := strings.TrimSpace(req.Orchestration)
|
orch := strings.TrimSpace(req.Orchestration)
|
||||||
@@ -186,6 +187,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
|
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
|
||||||
var cumulativeMCPExecutionIDs []string
|
var cumulativeMCPExecutionIDs []string
|
||||||
|
var transientRunAttempts int
|
||||||
|
|
||||||
for {
|
for {
|
||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||||
@@ -210,16 +212,33 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
orch,
|
orch,
|
||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
)
|
)
|
||||||
timeoutCancel()
|
|
||||||
|
|
||||||
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handled, fatalErr := h.handleEinoTransientRetryContinue(
|
||||||
|
baseCtx, conversationID, result, runErr, &transientRunAttempts,
|
||||||
|
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
|
||||||
|
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
|
||||||
|
)
|
||||||
|
if handled {
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fatalErr != nil {
|
||||||
|
runErr = fatalErr
|
||||||
|
}
|
||||||
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
@@ -243,10 +262,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "interrupt_continue",
|
"source": "interrupt_continue",
|
||||||
})
|
})
|
||||||
h.tasks.UpdateTaskStatus(conversationID, "running")
|
timeoutCancel()
|
||||||
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +293,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
})
|
})
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +311,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
"errorType": "timeout",
|
"errorType": "timeout",
|
||||||
})
|
})
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +328,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
})
|
})
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
|
timeoutCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||||
}
|
}
|
||||||
@@ -347,7 +372,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
|
|
||||||
h.logger.Info("收到 Eino DeepAgent 非流式请求", zap.String("conversationId", req.ConversationID))
|
h.logger.Info("收到 Eino DeepAgent 非流式请求", zap.String("conversationId", req.ConversationID))
|
||||||
|
|
||||||
prep, err := h.prepareMultiAgentSession(&req)
|
prep, err := h.prepareMultiAgentSession(&req, c, "multi_agent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status, msg := multiAgentHTTPErrorStatus(err)
|
status, msg := multiAgentHTTPErrorStatus(err)
|
||||||
c.JSON(status, gin.H{"error": msg})
|
c.JSON(status, gin.H{"error": msg})
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ type multiAgentPrepared struct {
|
|||||||
UserMessageID string
|
UserMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPrepared, error) {
|
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
||||||
if len(req.Attachments) > maxAttachments {
|
if len(req.Attachments) > maxAttachments {
|
||||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||||
}
|
}
|
||||||
@@ -33,10 +35,13 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
title := safeTruncateString(req.Message, 50)
|
title := safeTruncateString(req.Message, 50)
|
||||||
var conv *database.Conversation
|
var conv *database.Conversation
|
||||||
var err error
|
var err error
|
||||||
|
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||||
if strings.TrimSpace(req.WebShellConnectionID) != "" {
|
if strings.TrimSpace(req.WebShellConnectionID) != "" {
|
||||||
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
|
meta.Source = source + "_webshell"
|
||||||
|
meta.WebShellConnectionID = strings.TrimSpace(req.WebShellConnectionID)
|
||||||
|
conv, err = h.db.CreateConversationWithWebshell(meta.WebShellConnectionID, title, meta)
|
||||||
} else {
|
} else {
|
||||||
conv, err = h.db.CreateConversation(title)
|
conv, err = h.db.CreateConversation(title, meta)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建对话失败: %w", err)
|
return nil, fmt.Errorf("创建对话失败: %w", err)
|
||||||
|
|||||||
@@ -6254,7 +6254,7 @@ func (h *OpenAPIHandler) GetConversationResults(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取漏洞列表
|
// 获取漏洞列表
|
||||||
vulnList, err := h.db.ListVulnerabilities(1000, 0, "", conversationID, "", "", "", "", "")
|
vulnList, err := h.db.ListVulnerabilities(1000, 0, database.VulnerabilityListFilter{ConversationID: conversationID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("获取漏洞列表失败", zap.Error(err))
|
h.logger.Warn("获取漏洞列表失败", zap.Error(err))
|
||||||
vulnList = []*database.Vulnerability{}
|
vulnList = []*database.Vulnerability{}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
|
|||||||
} else {
|
} else {
|
||||||
t = safeTruncateString(t, 50)
|
t = safeTruncateString(t, 50)
|
||||||
}
|
}
|
||||||
conv, err := h.db.CreateConversation(t)
|
conv, err := h.db.CreateConversation(t, database.ConversationCreateMeta{Source: "robot:" + platform})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("创建机器人会话失败", zap.Error(err))
|
h.logger.Warn("创建机器人会话失败", zap.Error(err))
|
||||||
return "", false
|
return "", false
|
||||||
@@ -188,7 +188,7 @@ func (h *RobotHandler) setRole(platform, userID, roleName string) {
|
|||||||
// clearConversation 清空当前会话(切换到新对话)
|
// clearConversation 清空当前会话(切换到新对话)
|
||||||
func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) {
|
func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) {
|
||||||
title := "新对话 " + time.Now().Format("01-02 15:04")
|
title := "新对话 " + time.Now().Format("01-02 15:04")
|
||||||
conv, err := h.db.CreateConversation(title)
|
conv, err := h.db.CreateConversation(title, database.ConversationCreateMeta{Source: "robot:" + platform + ":new"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("创建新对话失败", zap.Error(err))
|
h.logger.Warn("创建新对话失败", zap.Error(err))
|
||||||
return ""
|
return ""
|
||||||
@@ -242,7 +242,7 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
|||||||
h.cancelMu.Unlock()
|
h.cancelMu.Unlock()
|
||||||
}()
|
}()
|
||||||
role := h.getRole(platform, userID)
|
role := h.getRole(platform, userID)
|
||||||
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, convID, text, role)
|
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, platform, convID, text, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
|
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -21,6 +22,12 @@ type RoleHandler struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *RoleHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRoleHandler 创建新的角色处理器
|
// NewRoleHandler 创建新的角色处理器
|
||||||
@@ -174,6 +181,9 @@ func (h *RoleHandler) UpdateRole(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("更新角色", zap.String("oldKey", roleName), zap.String("newKey", finalKey), zap.String("name", req.Name))
|
h.logger.Info("更新角色", zap.String("oldKey", roleName), zap.String("newKey", finalKey), zap.String("name", req.Name))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "role", "update", "更新角色", "role", finalKey, map[string]interface{}{"name": req.Name})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "角色已更新",
|
"message": "角色已更新",
|
||||||
"role": req,
|
"role": req,
|
||||||
@@ -219,6 +229,9 @@ func (h *RoleHandler) CreateRole(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("创建角色", zap.String("roleName", req.Name))
|
h.logger.Info("创建角色", zap.String("roleName", req.Name))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "role", "create", "创建角色", "role", req.Name, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "角色已创建",
|
"message": "角色已创建",
|
||||||
"role": req,
|
"role": req,
|
||||||
@@ -287,6 +300,9 @@ func (h *RoleHandler) DeleteRole(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("删除角色", zap.String("roleName", roleName))
|
h.logger.Info("删除角色", zap.String("roleName", roleName))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "role", "delete", "删除角色", "role", roleName, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "角色已删除",
|
"message": "角色已删除",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/skillpackage"
|
"cyberstrike-ai/internal/skillpackage"
|
||||||
@@ -23,6 +24,12 @@ type SkillsHandler struct {
|
|||||||
configPath string
|
configPath string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
db *database.DB // 数据库连接(遗留统计;MCP list/read 已移除)
|
db *database.DB // 数据库连接(遗留统计;MCP list/read 已移除)
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *SkillsHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSkillsHandler 创建新的Skills处理器
|
// NewSkillsHandler 创建新的Skills处理器
|
||||||
@@ -365,6 +372,9 @@ func (h *SkillsHandler) CreateSkill(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("创建skill成功", zap.String("skill", req.Name))
|
h.logger.Info("创建skill成功", zap.String("skill", req.Name))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "skill", "create", "创建 Skill", "skill", req.Name, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "skill已创建",
|
"message": "skill已创建",
|
||||||
"skill": map[string]interface{}{
|
"skill": map[string]interface{}{
|
||||||
@@ -425,6 +435,9 @@ func (h *SkillsHandler) UpdateSkill(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("更新skill成功", zap.String("skill", skillName))
|
h.logger.Info("更新skill成功", zap.String("skill", skillName))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "skill", "update", "更新 Skill", "skill", skillName, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "skill已更新",
|
"message": "skill已更新",
|
||||||
})
|
})
|
||||||
@@ -459,6 +472,11 @@ func (h *SkillsHandler) DeleteSkill(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("删除skill成功", zap.String("skill", skillName))
|
h.logger.Info("删除skill成功", zap.String("skill", skillName))
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "skill", "delete", "删除 Skill", "skill", skillName, map[string]interface{}{
|
||||||
|
"affected_roles": affectedRoles,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": responseMsg,
|
"message": responseMsg,
|
||||||
"affected_roles": affectedRoles,
|
"affected_roles": affectedRoles,
|
||||||
|
|||||||
@@ -253,5 +253,5 @@ func (h *TerminalHandler) RunCommandStream(c *gin.Context) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
runCommandStreamImpl(cmd, sendEvent, ctx)
|
_ = runCommandStreamImpl(cmd, sendEvent, ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const ptyCols = 256
|
|||||||
const ptyRows = 40
|
const ptyRows = 40
|
||||||
|
|
||||||
// runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真)
|
// runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真)
|
||||||
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
|
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) int {
|
||||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(streamEvent{T: "exit", C: -1})
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
return
|
return -1
|
||||||
}
|
}
|
||||||
defer ptmx.Close()
|
defer ptmx.Close()
|
||||||
|
|
||||||
@@ -43,4 +43,5 @@ func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx contex
|
|||||||
exitCode = -1
|
exitCode = -1
|
||||||
}
|
}
|
||||||
sendEvent(streamEvent{T: "exit", C: exitCode})
|
sendEvent(streamEvent{T: "exit", C: exitCode})
|
||||||
|
return exitCode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行
|
// runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行
|
||||||
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
|
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) int {
|
||||||
stdoutPipe, err := cmd.StdoutPipe()
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(streamEvent{T: "exit", C: -1})
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
return
|
return -1
|
||||||
}
|
}
|
||||||
stderrPipe, err := cmd.StderrPipe()
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(streamEvent{T: "exit", C: -1})
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
return
|
return -1
|
||||||
}
|
}
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
sendEvent(streamEvent{T: "exit", C: -1})
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
return
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
normalize := func(s string) string {
|
normalize := func(s string) string {
|
||||||
@@ -62,4 +62,5 @@ func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx contex
|
|||||||
exitCode = -1
|
exitCode = -1
|
||||||
}
|
}
|
||||||
sendEvent(streamEvent{T: "exit", C: exitCode})
|
sendEvent(streamEvent{T: "exit", C: exitCode})
|
||||||
|
return exitCode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -16,6 +17,12 @@ import (
|
|||||||
type VulnerabilityHandler struct {
|
type VulnerabilityHandler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *VulnerabilityHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVulnerabilityHandler 创建新的漏洞处理器
|
// NewVulnerabilityHandler 创建新的漏洞处理器
|
||||||
@@ -72,6 +79,11 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "vulnerability", "create", "创建漏洞记录", "vulnerability", created.ID, map[string]interface{}{
|
||||||
|
"severity": created.Severity, "title": created.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, created)
|
c.JSON(http.StatusOK, created)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,18 +110,29 @@ type ListVulnerabilitiesResponse struct {
|
|||||||
TotalPages int `json:"total_pages"`
|
TotalPages int `json:"total_pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseVulnerabilityListFilter(c *gin.Context) database.VulnerabilityListFilter {
|
||||||
|
q := strings.TrimSpace(c.Query("q"))
|
||||||
|
if q == "" {
|
||||||
|
q = strings.TrimSpace(c.Query("search"))
|
||||||
|
}
|
||||||
|
return database.VulnerabilityListFilter{
|
||||||
|
ID: c.Query("id"),
|
||||||
|
Search: q,
|
||||||
|
ConversationID: c.Query("conversation_id"),
|
||||||
|
Severity: c.Query("severity"),
|
||||||
|
Status: c.Query("status"),
|
||||||
|
TaskID: c.Query("task_id"),
|
||||||
|
ConversationTag: c.Query("conversation_tag"),
|
||||||
|
TaskTag: c.Query("task_tag"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListVulnerabilities 列出漏洞
|
// ListVulnerabilities 列出漏洞
|
||||||
func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
|
func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
|
||||||
limitStr := c.DefaultQuery("limit", "20")
|
limitStr := c.DefaultQuery("limit", "20")
|
||||||
offsetStr := c.DefaultQuery("offset", "0")
|
offsetStr := c.DefaultQuery("offset", "0")
|
||||||
pageStr := c.Query("page")
|
pageStr := c.Query("page")
|
||||||
id := c.Query("id")
|
filter := parseVulnerabilityListFilter(c)
|
||||||
conversationID := c.Query("conversation_id")
|
|
||||||
severity := c.Query("severity")
|
|
||||||
status := c.Query("status")
|
|
||||||
taskID := c.Query("task_id")
|
|
||||||
conversationTag := c.Query("conversation_tag")
|
|
||||||
taskTag := c.Query("task_tag")
|
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(limitStr)
|
limit, _ := strconv.Atoi(limitStr)
|
||||||
offset, _ := strconv.Atoi(offsetStr)
|
offset, _ := strconv.Atoi(offsetStr)
|
||||||
@@ -131,7 +154,7 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取总数
|
// 获取总数
|
||||||
total, err := h.db.CountVulnerabilities(id, conversationID, severity, status, taskID, conversationTag, taskTag)
|
total, err := h.db.CountVulnerabilities(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("获取漏洞总数失败", zap.Error(err))
|
h.logger.Error("获取漏洞总数失败", zap.Error(err))
|
||||||
// 继续执行,使用0作为总数
|
// 继续执行,使用0作为总数
|
||||||
@@ -139,7 +162,7 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取漏洞列表
|
// 获取漏洞列表
|
||||||
vulnerabilities, err := h.db.ListVulnerabilities(limit, offset, id, conversationID, severity, status, taskID, conversationTag, taskTag)
|
vulnerabilities, err := h.db.ListVulnerabilities(limit, offset, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("获取漏洞列表失败", zap.Error(err))
|
h.logger.Error("获取漏洞列表失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -249,6 +272,11 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "vulnerability", "update", "更新漏洞记录", "vulnerability", id, map[string]interface{}{
|
||||||
|
"severity": updated.Severity, "status": updated.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, updated)
|
c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,15 +290,25 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.Record(c, audit.Entry{
|
||||||
|
Category: "vulnerability",
|
||||||
|
Action: "delete",
|
||||||
|
Result: "success",
|
||||||
|
ResourceType: "vulnerability",
|
||||||
|
ResourceID: id,
|
||||||
|
Message: "删除漏洞记录",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVulnerabilityStats 获取漏洞统计
|
// GetVulnerabilityStats 获取漏洞统计
|
||||||
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
||||||
conversationID := c.Query("conversation_id")
|
filter := parseVulnerabilityListFilter(c)
|
||||||
taskID := c.Query("task_id")
|
|
||||||
|
|
||||||
stats, err := h.db.GetVulnerabilityStats(conversationID, taskID)
|
stats, err := h.db.GetVulnerabilityStats(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("获取漏洞统计失败", zap.Error(err))
|
h.logger.Error("获取漏洞统计失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -304,15 +342,9 @@ func (h *VulnerabilityHandler) ExportVulnerabilities(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := c.Query("id")
|
filter := parseVulnerabilityListFilter(c)
|
||||||
conversationID := c.Query("conversation_id")
|
|
||||||
severity := c.Query("severity")
|
|
||||||
status := c.Query("status")
|
|
||||||
taskID := c.Query("task_id")
|
|
||||||
conversationTag := c.Query("conversation_tag")
|
|
||||||
taskTag := c.Query("task_tag")
|
|
||||||
|
|
||||||
total, err := h.db.CountVulnerabilities(id, conversationID, severity, status, taskID, conversationTag, taskTag)
|
total, err := h.db.CountVulnerabilities(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -322,7 +354,7 @@ func (h *VulnerabilityHandler) ExportVulnerabilities(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := h.db.ListVulnerabilities(total, 0, id, conversationID, severity, status, taskID, conversationTag, taskTag)
|
items, err := h.db.ListVulnerabilities(total, 0, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -304,6 +306,12 @@ type WebShellHandler struct {
|
|||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
db *database.DB
|
db *database.DB
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAudit wires platform audit logging.
|
||||||
|
func (h *WebShellHandler) SetAudit(s *audit.Service) {
|
||||||
|
h.audit = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用)
|
// NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用)
|
||||||
@@ -311,8 +319,12 @@ func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler {
|
|||||||
return &WebShellHandler{
|
return &WebShellHandler{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{DisableKeepAlives: false},
|
Transport: &http.Transport{
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
// WebShell 场景常见自签证书或 IP 访问(证书无 IP SAN);默认跳过校验,与蚁剑等客户端一致。
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for webshell proxy
|
||||||
|
},
|
||||||
},
|
},
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
@@ -403,6 +415,15 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
host := req.URL
|
||||||
|
if u, err := url.Parse(req.URL); err == nil {
|
||||||
|
host = u.Host
|
||||||
|
}
|
||||||
|
h.audit.RecordOK(c, "webshell", "connection_create", "创建 WebShell 连接", "webshell_connection", conn.ID, map[string]interface{}{
|
||||||
|
"host": host, "type": shellType,
|
||||||
|
})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, conn)
|
c.JSON(http.StatusOK, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +506,9 @@ func (h *WebShellHandler) DeleteConnection(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "webshell", "connection_delete", "删除 WebShell 连接", "webshell_connection", id, nil)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,8 +738,9 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
|
|||||||
output := decodeWebshellOutput(out, req.Encoding)
|
output := decodeWebshellOutput(out, req.Encoding)
|
||||||
httpCode := resp.StatusCode
|
httpCode := resp.StatusCode
|
||||||
|
|
||||||
|
ok := resp.StatusCode == http.StatusOK
|
||||||
c.JSON(http.StatusOK, ExecResponse{
|
c.JSON(http.StatusOK, ExecResponse{
|
||||||
OK: resp.StatusCode == http.StatusOK,
|
OK: ok,
|
||||||
Output: output,
|
Output: output,
|
||||||
HTTPCode: httpCode,
|
HTTPCode: httpCode,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ type einoADKRunLoopArgs struct {
|
|||||||
StreamsMainAssistant func(agent string) bool
|
StreamsMainAssistant func(agent string) bool
|
||||||
EinoRoleTag func(agent string) string
|
EinoRoleTag func(agent string) string
|
||||||
CheckpointDir string
|
CheckpointDir string
|
||||||
|
// RunRetryMaxAttempts / RunRetryMaxBackoffSec:429、5xx、网络抖动时的指数退避续跑(0=默认 10 次 / 30s 上限)。
|
||||||
|
RunRetryMaxAttempts int
|
||||||
|
RunRetryMaxBackoffSec int
|
||||||
|
|
||||||
McpIDsMu *sync.Mutex
|
McpIDsMu *sync.Mutex
|
||||||
McpIDs *[]string
|
McpIDs *[]string
|
||||||
@@ -437,6 +440,28 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
return runErr
|
return runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeRetryTransientRun:不在此层 runner.Run/Resume;由 handler 落库 + loadHistoryFromAgentTrace 分段续跑(同中断并继续)。
|
||||||
|
maybeRetryTransientRun := func(runErr error) (retry bool, fatal error) {
|
||||||
|
if runErr == nil || !isEinoTransientRunError(runErr) {
|
||||||
|
return false, handleRunErr(runErr)
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("eino transient error, ending run segment for handler resume",
|
||||||
|
zap.Error(runErr),
|
||||||
|
zap.String("orchestration", orchMode))
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress("eino_run_retry", "遇到临时错误(限流或网络波动),将保存上下文并重试…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"orchestration": orchMode,
|
||||||
|
"error": runErr.Error(),
|
||||||
|
"resumeKind": "trace_segment",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false, ErrTransientRetryContinue
|
||||||
|
}
|
||||||
|
|
||||||
takePartial := func(runErr error) (*RunResult, error) {
|
takePartial := func(runErr error) (*RunResult, error) {
|
||||||
if len(runAccumulatedMsgs) <= baseAccumulatedCount {
|
if len(runAccumulatedMsgs) <= baseAccumulatedCount {
|
||||||
return nil, runErr
|
return nil, runErr
|
||||||
@@ -519,7 +544,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ev.Err != nil {
|
if ev.Err != nil {
|
||||||
if retErr := handleRunErr(ev.Err); retErr != nil {
|
if _, retErr := maybeRetryTransientRun(ev.Err); retErr != nil {
|
||||||
return takePartial(retErr)
|
return takePartial(retErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,7 +846,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": einoRoleTag(ev.AgentName),
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if retErr := handleRunErr(streamRecvErr); retErr != nil {
|
if _, retErr := maybeRetryTransientRun(streamRecvErr); retErr != nil {
|
||||||
return takePartial(retErr)
|
return takePartial(retErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
"github.com/cloudwego/eino/compose"
|
"github.com/cloudwego/eino/compose"
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,7 +212,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
|
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
|
||||||
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
|
baseMsgs = appendUserMessageIfNeeded(baseMsgs, userMessage)
|
||||||
|
|
||||||
streamsMainAssistant := func(agent string) bool {
|
streamsMainAssistant := func(agent string) bool {
|
||||||
return agent == "" || agent == einoSingleAgentName
|
return agent == "" || agent == einoSingleAgentName
|
||||||
@@ -233,6 +232,8 @@ func RunEinoSingleChatModelAgent(
|
|||||||
StreamsMainAssistant: streamsMainAssistant,
|
StreamsMainAssistant: streamsMainAssistant,
|
||||||
EinoRoleTag: einoRoleTag,
|
EinoRoleTag: einoRoleTag,
|
||||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||||
|
RunRetryMaxAttempts: ma.EinoMiddleware.RunRetryMaxAttempts,
|
||||||
|
RunRetryMaxBackoffSec: ma.EinoMiddleware.RunRetryMaxBackoffSec,
|
||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
FilesystemMonitorAgent: ag,
|
FilesystemMonitorAgent: ag,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultEinoRunRetryMaxAttempts = 10
|
||||||
|
defaultEinoRunRetryMaxBackoff = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// isEinoTransientRunError 判断 ADK 运行期错误是否适合指数退避续跑(429、5xx、网络抖动等)。
|
||||||
|
// 用户取消、超时、迭代上限等由 run loop 单独处理,不在此列。
|
||||||
|
func isEinoTransientRunError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isEinoIterationLimitError(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
if msg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
transientMarkers := []string{
|
||||||
|
"406",
|
||||||
|
"429",
|
||||||
|
"too many requests",
|
||||||
|
"rate limit",
|
||||||
|
"rate_limit",
|
||||||
|
"ratelimit",
|
||||||
|
"quota exceeded",
|
||||||
|
"overloaded",
|
||||||
|
"capacity",
|
||||||
|
"temporarily unavailable",
|
||||||
|
"service unavailable",
|
||||||
|
"bad gateway",
|
||||||
|
"gateway timeout",
|
||||||
|
"internal server error",
|
||||||
|
"connection reset",
|
||||||
|
"connection refused",
|
||||||
|
"connection closed",
|
||||||
|
"i/o timeout",
|
||||||
|
"no such host",
|
||||||
|
"network is unreachable",
|
||||||
|
"broken pipe",
|
||||||
|
"eof",
|
||||||
|
"read tcp",
|
||||||
|
"write tcp",
|
||||||
|
"dial tcp",
|
||||||
|
"tls handshake timeout",
|
||||||
|
"stream error",
|
||||||
|
"unexpected eof",
|
||||||
|
"unexpected end of json",
|
||||||
|
"status code: 406",
|
||||||
|
"status code: 502",
|
||||||
|
"502",
|
||||||
|
"503",
|
||||||
|
"504",
|
||||||
|
"500",
|
||||||
|
}
|
||||||
|
for _, m := range transientMarkers {
|
||||||
|
if strings.Contains(msg, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func einoRunRetryMaxAttempts(args *einoADKRunLoopArgs) int {
|
||||||
|
if args != nil && args.RunRetryMaxAttempts > 0 {
|
||||||
|
return args.RunRetryMaxAttempts
|
||||||
|
}
|
||||||
|
return defaultEinoRunRetryMaxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRetryMaxAttemptsFromConfig 供 handler 分段续跑计数(与 eino_middleware.run_retry_max_attempts 一致)。
|
||||||
|
func RunRetryMaxAttemptsFromConfig(mw *config.MultiAgentEinoMiddlewareConfig) int {
|
||||||
|
if mw != nil && mw.RunRetryMaxAttempts > 0 {
|
||||||
|
return mw.RunRetryMaxAttempts
|
||||||
|
}
|
||||||
|
return defaultEinoRunRetryMaxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransientRetryBackoff 供 handler 在分段续跑前退避。
|
||||||
|
func TransientRetryBackoff(attempt int, maxBackoffSec int) time.Duration {
|
||||||
|
max := defaultEinoRunRetryMaxBackoff
|
||||||
|
if maxBackoffSec > 0 {
|
||||||
|
max = time.Duration(maxBackoffSec) * time.Second
|
||||||
|
}
|
||||||
|
return einoTransientRetryBackoff(attempt, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func einoRunRetryMaxBackoff(args *einoADKRunLoopArgs) time.Duration {
|
||||||
|
if args != nil && args.RunRetryMaxBackoffSec > 0 {
|
||||||
|
return time.Duration(args.RunRetryMaxBackoffSec) * time.Second
|
||||||
|
}
|
||||||
|
return defaultEinoRunRetryMaxBackoff
|
||||||
|
}
|
||||||
|
|
||||||
|
// einoRunRestartContextSource 描述无 checkpoint Resume 时 Run 使用的消息来源(日志/SSE)。
|
||||||
|
type einoRunRestartContextSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
einoRestartContextInitial einoRunRestartContextSource = "initial"
|
||||||
|
einoRestartContextAccumulated einoRunRestartContextSource = "accumulated"
|
||||||
|
einoRestartContextModelTrace einoRunRestartContextSource = "model_trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// einoMessagesForRunRestart 在退避后重新 Run 时选用最完整的上下文:
|
||||||
|
// 1) ModelFacingTrace(与模型实际入参一致) 2) 事件流累积的 runAccumulatedMsgs 3) 初始 msgs。
|
||||||
|
func einoMessagesForRunRestart(args *einoADKRunLoopArgs, baseMsgs, accumulated []adk.Message, baseCount int) ([]adk.Message, einoRunRestartContextSource) {
|
||||||
|
if trace := persistTraceSource(args, nil); len(trace) > 0 {
|
||||||
|
return append([]adk.Message(nil), trace...), einoRestartContextModelTrace
|
||||||
|
}
|
||||||
|
if len(accumulated) > baseCount {
|
||||||
|
return append([]adk.Message(nil), accumulated...), einoRestartContextAccumulated
|
||||||
|
}
|
||||||
|
return append([]adk.Message(nil), baseMsgs...), einoRestartContextInitial
|
||||||
|
}
|
||||||
|
|
||||||
|
// adkMessagesHasUserContent 从尾部向前查找,是否已有与 want 相同的 user 消息(避免重复 append)。
|
||||||
|
func adkMessagesHasUserContent(msgs []adk.Message, want string) bool {
|
||||||
|
want = strings.TrimSpace(want)
|
||||||
|
if want == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
|
m := msgs[i]
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Role == schema.User {
|
||||||
|
return strings.TrimSpace(m.Content) == want
|
||||||
|
}
|
||||||
|
if m.Role == schema.Assistant || m.Role == schema.Tool {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendUserMessageIfNeeded 在 history 轨迹之后追加本轮 user 消息(仅当轨迹中尚未包含该句)。
|
||||||
|
func appendUserMessageIfNeeded(msgs []adk.Message, userMessage string) []adk.Message {
|
||||||
|
if strings.TrimSpace(userMessage) == "" || adkMessagesHasUserContent(msgs, userMessage) {
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
return append(msgs, schema.UserMessage(userMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
// einoTransientRetryBackoff 指数退避:2s, 4s, 8s… capped by maxBackoff。
|
||||||
|
func einoTransientRetryBackoff(attempt int, maxBackoff time.Duration) time.Duration {
|
||||||
|
if attempt < 0 {
|
||||||
|
attempt = 0
|
||||||
|
}
|
||||||
|
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
|
||||||
|
if maxBackoff > 0 && backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
return backoff
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsEinoTransientRunError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, false},
|
||||||
|
{"429", errors.New("HTTP 429 Too Many Requests"), true},
|
||||||
|
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
||||||
|
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||||
|
{"503", errors.New("upstream returned 503"), true},
|
||||||
|
{"iteration limit", errors.New("max iteration reached"), false},
|
||||||
|
{"canceled", context.Canceled, false},
|
||||||
|
{"deadline", context.DeadlineExceeded, false},
|
||||||
|
{"auth", errors.New("invalid api key"), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := isEinoTransientRunError(tc.err); got != tc.want {
|
||||||
|
t.Fatalf("isEinoTransientRunError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoTransientRetryBackoff(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
max := 30 * time.Second
|
||||||
|
if got := einoTransientRetryBackoff(0, max); got != 2*time.Second {
|
||||||
|
t.Fatalf("attempt 0: got %v", got)
|
||||||
|
}
|
||||||
|
if got := einoTransientRetryBackoff(4, max); got != 30*time.Second {
|
||||||
|
t.Fatalf("attempt 4 capped: got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoMessagesForRunRestart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
base := []adk.Message{schema.UserMessage("hi")}
|
||||||
|
acc := append([]adk.Message(nil), base...)
|
||||||
|
acc = append(acc, schema.AssistantMessage("step1", nil))
|
||||||
|
|
||||||
|
got, src := einoMessagesForRunRestart(nil, base, acc, len(base))
|
||||||
|
if src != einoRestartContextAccumulated || len(got) != 2 {
|
||||||
|
t.Fatalf("accumulated: src=%s len=%d", src, len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
holder := newModelFacingTraceHolder()
|
||||||
|
holder.storeFromState(&adk.ChatModelAgentState{
|
||||||
|
Messages: []adk.Message{schema.UserMessage("u"), schema.AssistantMessage("model-view", nil)},
|
||||||
|
})
|
||||||
|
got2, src2 := einoMessagesForRunRestart(&einoADKRunLoopArgs{ModelFacingTrace: holder}, base, acc, len(base))
|
||||||
|
if src2 != einoRestartContextModelTrace || len(got2) != 2 {
|
||||||
|
t.Fatalf("model trace: src=%s len=%d", src2, len(got2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoRunRetryMaxAttemptsFromArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if einoRunRetryMaxAttempts(nil) != defaultEinoRunRetryMaxAttempts {
|
||||||
|
t.Fatal("nil args should use default")
|
||||||
|
}
|
||||||
|
if einoRunRetryMaxAttempts(&einoADKRunLoopArgs{RunRetryMaxAttempts: 3}) != 3 {
|
||||||
|
t.Fatal("custom max attempts")
|
||||||
|
}
|
||||||
|
if RunRetryMaxAttemptsFromConfig(nil) != defaultEinoRunRetryMaxAttempts {
|
||||||
|
t.Fatal("config nil should use default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendUserMessageIfNeeded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
msgs := []adk.Message{schema.UserMessage("old task")}
|
||||||
|
out := appendUserMessageIfNeeded(msgs, "你好,你是谁")
|
||||||
|
if len(out) != 2 || out[1].Content != "你好,你是谁" {
|
||||||
|
t.Fatalf("should append user: len=%d", len(out))
|
||||||
|
}
|
||||||
|
dup := appendUserMessageIfNeeded(out, "你好,你是谁")
|
||||||
|
if len(dup) != 2 {
|
||||||
|
t.Fatalf("should not duplicate user message: len=%d", len(dup))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrTransientRetryContinue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if !errors.Is(ErrTransientRetryContinue, ErrTransientRetryContinue) {
|
||||||
|
t.Fatal("sentinel should match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,7 @@ import "errors"
|
|||||||
// ErrInterruptContinue 作为 context.CancelCause 使用:用户选择「中断并继续」且当前无进行中的 MCP 工具时,
|
// ErrInterruptContinue 作为 context.CancelCause 使用:用户选择「中断并继续」且当前无进行中的 MCP 工具时,
|
||||||
// 取消当前推理/流式输出,并在同一会话任务内携带用户补充说明自动续跑下一轮(类似 Hermes 式人机回合)。
|
// 取消当前推理/流式输出,并在同一会话任务内携带用户补充说明自动续跑下一轮(类似 Hermes 式人机回合)。
|
||||||
var ErrInterruptContinue = errors.New("agent interrupt: continue with user-supplied context")
|
var ErrInterruptContinue = errors.New("agent interrupt: continue with user-supplied context")
|
||||||
|
|
||||||
|
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
|
||||||
|
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
|
||||||
|
var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace")
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
|
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
|
||||||
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
|
baseMsgs = appendUserMessageIfNeeded(baseMsgs, userMessage)
|
||||||
|
|
||||||
streamsMainAssistant := func(agent string) bool {
|
streamsMainAssistant := func(agent string) bool {
|
||||||
if orchMode == "plan_execute" {
|
if orchMode == "plan_execute" {
|
||||||
@@ -566,6 +566,8 @@ func RunDeepAgent(
|
|||||||
StreamsMainAssistant: streamsMainAssistant,
|
StreamsMainAssistant: streamsMainAssistant,
|
||||||
EinoRoleTag: einoRoleTag,
|
EinoRoleTag: einoRoleTag,
|
||||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||||
|
RunRetryMaxAttempts: ma.EinoMiddleware.RunRetryMaxAttempts,
|
||||||
|
RunRetryMaxBackoffSec: ma.EinoMiddleware.RunRetryMaxBackoffSec,
|
||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
FilesystemMonitorAgent: ag,
|
FilesystemMonitorAgent: ag,
|
||||||
|
|||||||
@@ -149,13 +149,18 @@ func effectiveEffort(sr *config.OpenAIReasoningConfig, client *ClientIntent, all
|
|||||||
func normalizeEffort(s string) string {
|
func normalizeEffort(s string) string {
|
||||||
e := strings.ToLower(strings.TrimSpace(s))
|
e := strings.ToLower(strings.TrimSpace(s))
|
||||||
switch e {
|
switch e {
|
||||||
case "low", "medium", "high", "max":
|
case "low", "medium", "high", "max", "xhigh":
|
||||||
return e
|
return e
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// usesExtraFieldsReasoningEffort 为 Eino 无枚举的最高档 effort,经 ExtraFields 原样下发(max / xhigh 由网关自行识别,不做互转)。
|
||||||
|
func usesExtraFieldsReasoningEffort(e string) bool {
|
||||||
|
return e == "max" || e == "xhigh"
|
||||||
|
}
|
||||||
|
|
||||||
func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile {
|
func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile {
|
||||||
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") {
|
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") {
|
||||||
return wireClaude
|
return wireClaude
|
||||||
@@ -210,11 +215,11 @@ func applyOpenAICompat(cfg *einoopenai.ChatModelConfig, mode, effort string) {
|
|||||||
if e == "" {
|
if e == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if e == "max" {
|
if usesExtraFieldsReasoningEffort(e) {
|
||||||
if cfg.ExtraFields == nil {
|
if cfg.ExtraFields == nil {
|
||||||
cfg.ExtraFields = make(map[string]any)
|
cfg.ExtraFields = make(map[string]any)
|
||||||
}
|
}
|
||||||
cfg.ExtraFields["reasoning_effort"] = "max"
|
cfg.ExtraFields["reasoning_effort"] = effortStringForAPI(e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch e {
|
switch e {
|
||||||
@@ -245,6 +250,6 @@ func applyOutputConfigEffort(cfg *einoopenai.ChatModelConfig, mode, effort strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func effortStringForAPI(e string) string {
|
func effortStringForAPI(e string) string {
|
||||||
// Gateways expect lowercase strings; "max" kept as max.
|
// 原样透传:OpenAI 官方多为 xhigh,部分兼容网关为 max,由配置/对话 effort 选择。
|
||||||
return strings.ToLower(strings.TrimSpace(e))
|
return strings.ToLower(strings.TrimSpace(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package reasoning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEffortStringForAPI_passthrough(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"max": "max",
|
||||||
|
"xhigh": "xhigh",
|
||||||
|
"HIGH": "high",
|
||||||
|
"Medium": "medium",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := effortStringForAPI(in); got != want {
|
||||||
|
t.Fatalf("%q -> %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeEffort_maxAndXhigh(t *testing.T) {
|
||||||
|
if normalizeEffort("xhigh") != "xhigh" {
|
||||||
|
t.Fatal("xhigh not accepted")
|
||||||
|
}
|
||||||
|
if normalizeEffort("max") != "max" {
|
||||||
|
t.Fatal("max not accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Profile: "openai_compat",
|
||||||
|
Mode: "on",
|
||||||
|
Effort: "xhigh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
if cfg.ExtraFields == nil {
|
||||||
|
t.Fatal("expected ExtraFields")
|
||||||
|
}
|
||||||
|
if got, _ := cfg.ExtraFields["reasoning_effort"].(string); got != "xhigh" {
|
||||||
|
t.Fatalf("reasoning_effort=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Profile: "openai_compat",
|
||||||
|
Mode: "on",
|
||||||
|
Effort: "max",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
got, _ := cfg.ExtraFields["reasoning_effort"].(string)
|
||||||
|
if got != "max" {
|
||||||
|
t.Fatalf("max effort wire=%q, want max", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+400
-84
@@ -4268,6 +4268,214 @@ header {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 系统设置 - 日志审计 */
|
||||||
|
.audit-logs-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-logs-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-logs-filters > .btn-secondary {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-logs-filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 事件类型:两个下拉与「结果」等控件同款边框,无外层套框 */
|
||||||
|
.audit-filter-cascade {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-filter-cascade select {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 148px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-filter-cascade select:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: var(--bg-secondary, #f5f6f8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-filter-cascade-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-logs-filters select,
|
||||||
|
.audit-logs-filters input[type="text"],
|
||||||
|
.audit-logs-filters input[type="datetime-local"] {
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-logs-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表 + 底部分页合并为一张卡片,避免双边框/底部分隔线 */
|
||||||
|
#settings-section-audit .audit-log-list.c2-event-list {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-section-audit .audit-logs-pagination {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-section-audit .audit-logs-pagination .monitor-pagination {
|
||||||
|
margin-top: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-log-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-detail-pre {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-summary-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-stat-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary, rgba(255, 255, 255, 0.04));
|
||||||
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-stat-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-retention-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-export-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-export-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-export-caret {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-export-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-export-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-export-menu-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-section-audit .audit-logs-pagination .pagination-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-detail-body p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-resource-removed {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-resource-meta code {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
/* 系统设置 - 终端 */
|
/* 系统设置 - 终端 */
|
||||||
.terminal-wrapper {
|
.terminal-wrapper {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -16782,8 +16990,8 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vulnerability-controls {
|
.vulnerability-controls {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 12px;
|
||||||
padding: 10px 12px;
|
padding: 8px 10px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -16808,6 +17016,117 @@ header {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vulnerability-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-search-wrap .vulnerability-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-search-wrap input[type="search"] {
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-anchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-btn.is-active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #2563eb;
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 120;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-md, 0 8px 24px rgba(15, 23, 42, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover-body .vulnerability-filter-field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover-body .vulnerability-filter-field {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover-body .vulnerability-filter-field > span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.vulnerability-more-filters-popover-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vulnerability-more-filters-popover {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.vulnerability-filter-field {
|
.vulnerability-filter-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -16853,6 +17172,10 @@ header {
|
|||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vulnerability-filter-clear-btn[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.vulnerability-filter-clear-btn {
|
.vulnerability-filter-clear-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -16870,69 +17193,7 @@ header {
|
|||||||
background: rgba(59, 130, 246, 0.06);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tasks-filters 的 display:flex 会覆盖 [hidden],必须显式隐藏 */
|
/* 更多筛选生效数量:弱提示文字 */
|
||||||
#vulnerability-advanced-filters[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced-wrap {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced-wrap.is-expanded {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px dashed var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced .vulnerability-filter-field {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced .vulnerability-filter-field > span {
|
|
||||||
max-width: none;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced .vulnerability-filter-field input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.vulnerability-filter-advanced {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: color 0.15s ease, background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced-toggle:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 高级筛选生效数量:弱提示文字,避免实心蓝点过于抢眼 */
|
|
||||||
.vulnerability-filter-advanced-badge {
|
.vulnerability-filter-advanced-badge {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
@@ -16947,30 +17208,23 @@ header {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vulnerability-filter-advanced-chevron {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
border-right: 4px solid transparent;
|
|
||||||
border-top: 5px solid currentColor;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-advanced-toggle[aria-expanded="true"] .vulnerability-filter-advanced-chevron {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vulnerability-filter-chips {
|
.vulnerability-filter-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px 8px;
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
padding-top: 10px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vulnerability-filter-chips-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.vulnerability-filter-chips-list {
|
.vulnerability-filter-chips-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -17014,8 +17268,13 @@ header {
|
|||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vulnerability-filter-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.vulnerability-filter-clear-btn {
|
.vulnerability-filter-clear-btn {
|
||||||
align-self: flex-end;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20015,9 +20274,66 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文件管理:重命名 / 新建文件夹 — 紧凑表单弹窗,无全局紫色标题条 */
|
||||||
|
.chat-files-form-modal {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 48px rgba(15, 23, 42, 0.14),
|
||||||
|
0 4px 12px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-header {
|
||||||
|
padding: 18px 24px !important;
|
||||||
|
border-bottom: 1px solid var(--border-color) !important;
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-header::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-header h2 {
|
||||||
|
font-size: 1.0625rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
background: none !important;
|
||||||
|
-webkit-background-clip: unset !important;
|
||||||
|
-webkit-text-fill-color: var(--text-primary) !important;
|
||||||
|
background-clip: unset !important;
|
||||||
|
letter-spacing: -0.02em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-header h2::before {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-close {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-files-form-modal-close:hover {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
transform: none !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 新建文件夹弹窗:层次清晰、留白舒适,无强装饰 */
|
/* 新建文件夹弹窗:层次清晰、留白舒适,无强装饰 */
|
||||||
.chat-files-mkdir-modal-content {
|
.chat-files-mkdir-modal-content {
|
||||||
max-width: 480px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-files-mkdir-body {
|
.chat-files-mkdir-body {
|
||||||
|
|||||||
+112
-2
@@ -820,6 +820,7 @@
|
|||||||
"robots": "Bots",
|
"robots": "Bots",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
|
"audit": "Audit logs",
|
||||||
"infocollect": "Recon"
|
"infocollect": "Recon"
|
||||||
},
|
},
|
||||||
"infocollect": {
|
"infocollect": {
|
||||||
@@ -1530,6 +1531,7 @@
|
|||||||
"confirmDelete": "Delete this file?",
|
"confirmDelete": "Delete this file?",
|
||||||
"editTitle": "Edit file",
|
"editTitle": "Edit file",
|
||||||
"renameTitle": "Rename",
|
"renameTitle": "Rename",
|
||||||
|
"renameCurrentFile": "Current file",
|
||||||
"newFileName": "New file name",
|
"newFileName": "New file name",
|
||||||
"empty": "No chat uploads yet",
|
"empty": "No chat uploads yet",
|
||||||
"errorLoad": "Failed to load",
|
"errorLoad": "Failed to load",
|
||||||
@@ -1543,6 +1545,10 @@
|
|||||||
"statClickAll": "View all (clear severity filter)",
|
"statClickAll": "View all (clear severity filter)",
|
||||||
"statClickFilter": "Click to filter by this severity; click again to clear",
|
"statClickFilter": "Click to filter by this severity; click again to clear",
|
||||||
"advancedFilters": "Advanced filters",
|
"advancedFilters": "Advanced filters",
|
||||||
|
"moreFilters": "More filters",
|
||||||
|
"applyFilters": "Apply",
|
||||||
|
"clearAdvanced": "Clear",
|
||||||
|
"clearAll": "Reset all",
|
||||||
"activeFilters": "Active filters",
|
"activeFilters": "Active filters",
|
||||||
"chipRemove": "Remove",
|
"chipRemove": "Remove",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
@@ -1562,6 +1568,10 @@
|
|||||||
"statusFixed": "Fixed",
|
"statusFixed": "Fixed",
|
||||||
"statusFalsePositive": "False positive",
|
"statusFalsePositive": "False positive",
|
||||||
"searchVulnId": "Search vuln ID",
|
"searchVulnId": "Search vuln ID",
|
||||||
|
"searchKeyword": "Search title, description, type, target…",
|
||||||
|
"searchKeywordShort": "Keyword",
|
||||||
|
"filterExactId": "Exact vuln ID",
|
||||||
|
"filterEnterHint": "Press Enter to filter",
|
||||||
"filterConversation": "Filter by conversation",
|
"filterConversation": "Filter by conversation",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"loadListFailed": "Failed to load",
|
"loadListFailed": "Failed to load",
|
||||||
@@ -1689,8 +1699,8 @@
|
|||||||
"multiAgentPeLoop": "plan_execute outer loop limit",
|
"multiAgentPeLoop": "plan_execute outer loop limit",
|
||||||
"multiAgentPeLoopPlaceholder": "0 uses Eino default (10)",
|
"multiAgentPeLoopPlaceholder": "0 uses Eino default (10)",
|
||||||
"multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.",
|
"multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.",
|
||||||
"multiAgentRobotUse": "Use multi-agent for WeCom / DingTalk / Lark bots",
|
"multiAgentRobotMode": "Default conversation mode for bots",
|
||||||
"multiAgentRobotUseHint": "Requires 'Enable multi-agent' to be checked; usage and cost will be higher.",
|
"multiAgentRobotModeHint": "Execution mode for WeCom / DingTalk / Lark bot messages. Deep / Plan-Execute / Supervisor require multi-agent to be enabled.",
|
||||||
"multiAgentBatchUse": "Use multi-agent for batch task queues",
|
"multiAgentBatchUse": "Use multi-agent for batch task queues",
|
||||||
"multiAgentBatchUseHint": "When enabled, each sub-task executed by queue in Task Management will run through Eino DeepAgent (requires multi-agent).",
|
"multiAgentBatchUseHint": "When enabled, each sub-task executed by queue in Task Management will run through Eino DeepAgent (requires multi-agent).",
|
||||||
"enableKnowledge": "Enable knowledge retrieval",
|
"enableKnowledge": "Enable knowledge retrieval",
|
||||||
@@ -1782,6 +1792,106 @@
|
|||||||
"close": "×",
|
"close": "×",
|
||||||
"newTerminal": "+"
|
"newTerminal": "+"
|
||||||
},
|
},
|
||||||
|
"settingsAudit": {
|
||||||
|
"title": "Audit logs",
|
||||||
|
"description": "Platform admin actions (login, config, deletes). Does not log chat content, per-command terminal/WebShell runs, or per-tool invocations.",
|
||||||
|
"filterCategory": "Category",
|
||||||
|
"filterAction": "Action",
|
||||||
|
"filterEvent": "Event type",
|
||||||
|
"filterAllCategories": "All categories",
|
||||||
|
"filterAllActions": "All actions",
|
||||||
|
"filterCascadeHint": "Select a category to filter by action",
|
||||||
|
"filterResult": "Result",
|
||||||
|
"pageSize": "Per page",
|
||||||
|
"statTotal": "Filtered total",
|
||||||
|
"statFailures": "Failures",
|
||||||
|
"statRecent7d": "Last 7 days",
|
||||||
|
"retentionHint": "Audit records are kept for {{days}} days, then purged automatically.",
|
||||||
|
"disabledHint": "Audit logging is disabled; new actions are not written.",
|
||||||
|
"filterSince": "From",
|
||||||
|
"filterUntil": "Until",
|
||||||
|
"filterQuery": "Keyword",
|
||||||
|
"filterQueryPlaceholder": "Message / resource ID / action",
|
||||||
|
"cat": {
|
||||||
|
"auth": "Auth",
|
||||||
|
"config": "Config",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"c2": "C2",
|
||||||
|
"webshell": "WebShell",
|
||||||
|
"knowledge": "Knowledge",
|
||||||
|
"conversation": "Conversation",
|
||||||
|
"vulnerability": "Vulnerability",
|
||||||
|
"externalMcp": "External MCP",
|
||||||
|
"task": "Tasks",
|
||||||
|
"tool": "Tools",
|
||||||
|
"file": "Files",
|
||||||
|
"hitl": "HITL",
|
||||||
|
"role": "Roles",
|
||||||
|
"skill": "Skills",
|
||||||
|
"agent": "Sub-agents"
|
||||||
|
},
|
||||||
|
"act": {
|
||||||
|
"login": "Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"login_failed": "Login failed",
|
||||||
|
"password_change": "Password change",
|
||||||
|
"change_password": "Change password",
|
||||||
|
"apply": "Apply config",
|
||||||
|
"update": "Update",
|
||||||
|
"exec": "Terminal exec",
|
||||||
|
"exec_stream": "Terminal stream",
|
||||||
|
"listener_create": "Create listener",
|
||||||
|
"listener_delete": "Delete listener",
|
||||||
|
"listener_start": "Start listener",
|
||||||
|
"listener_stop": "Stop listener",
|
||||||
|
"session_delete": "Delete session",
|
||||||
|
"task_create": "Create task",
|
||||||
|
"task_cancel": "Cancel task",
|
||||||
|
"task_delete": "Delete task",
|
||||||
|
"connection_create": "Create connection",
|
||||||
|
"connection_delete": "Delete connection",
|
||||||
|
"item_delete": "Delete knowledge item",
|
||||||
|
"index_rebuild": "Rebuild index",
|
||||||
|
"delete": "Delete",
|
||||||
|
"delete_turn": "Delete turn",
|
||||||
|
"create": "Create",
|
||||||
|
"upsert": "Upsert external MCP",
|
||||||
|
"create_queue": "Create batch queue",
|
||||||
|
"start_queue": "Start batch queue",
|
||||||
|
"delete_queue": "Delete batch queue",
|
||||||
|
"pause_queue": "Pause batch queue",
|
||||||
|
"rerun_queue": "Rerun batch queue",
|
||||||
|
"delete_batch_task": "Delete batch subtask",
|
||||||
|
"execution_delete": "Delete execution",
|
||||||
|
"execution_delete_batch": "Batch delete executions",
|
||||||
|
"upload": "Upload",
|
||||||
|
"decision": "HITL decision",
|
||||||
|
"markdown_create": "Create sub-agent",
|
||||||
|
"markdown_update": "Update sub-agent",
|
||||||
|
"markdown_delete": "Delete sub-agent"
|
||||||
|
},
|
||||||
|
"openResource": "Open linked resource",
|
||||||
|
"openResourceChat": "Open linked resource (chat)",
|
||||||
|
"resourceIdLabel": "Resource ID",
|
||||||
|
"resourceRemoved": "(resource no longer exists)",
|
||||||
|
"filterAll": "All",
|
||||||
|
"filterBtn": "Filter",
|
||||||
|
"resetBtn": "Reset",
|
||||||
|
"exportBtn": "Export",
|
||||||
|
"exportJson": "Export JSON",
|
||||||
|
"export": "Export JSON",
|
||||||
|
"exportCsv": "Export CSV",
|
||||||
|
"exportDone": "Export complete",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"empty": "No audit records",
|
||||||
|
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
||||||
|
"detailTitle": "Audit detail",
|
||||||
|
"detailTime": "Time",
|
||||||
|
"detailCategory": "Category",
|
||||||
|
"detailResult": "Result",
|
||||||
|
"detailMessage": "Message",
|
||||||
|
"detailSession": "Session"
|
||||||
|
},
|
||||||
"settingsSecurity": {
|
"settingsSecurity": {
|
||||||
"changePasswordTitle": "Change password",
|
"changePasswordTitle": "Change password",
|
||||||
"changePasswordDesc": "After changing password, sign in again with the new password.",
|
"changePasswordDesc": "After changing password, sign in again with the new password.",
|
||||||
|
|||||||
+113
-3
@@ -809,6 +809,7 @@
|
|||||||
"robots": "机器人设置",
|
"robots": "机器人设置",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"security": "安全设置",
|
"security": "安全设置",
|
||||||
|
"audit": "日志审计",
|
||||||
"infocollect": "信息收集"
|
"infocollect": "信息收集"
|
||||||
},
|
},
|
||||||
"infocollect": {
|
"infocollect": {
|
||||||
@@ -1519,6 +1520,7 @@
|
|||||||
"confirmDelete": "确定删除该文件?",
|
"confirmDelete": "确定删除该文件?",
|
||||||
"editTitle": "编辑文件",
|
"editTitle": "编辑文件",
|
||||||
"renameTitle": "重命名",
|
"renameTitle": "重命名",
|
||||||
|
"renameCurrentFile": "当前文件",
|
||||||
"newFileName": "新文件名",
|
"newFileName": "新文件名",
|
||||||
"empty": "暂无对话附件",
|
"empty": "暂无对话附件",
|
||||||
"errorLoad": "加载失败",
|
"errorLoad": "加载失败",
|
||||||
@@ -1532,6 +1534,10 @@
|
|||||||
"statClickAll": "查看全部(清除严重度筛选)",
|
"statClickAll": "查看全部(清除严重度筛选)",
|
||||||
"statClickFilter": "点击按此严重度筛选;再次点击清除",
|
"statClickFilter": "点击按此严重度筛选;再次点击清除",
|
||||||
"advancedFilters": "高级筛选",
|
"advancedFilters": "高级筛选",
|
||||||
|
"moreFilters": "更多筛选",
|
||||||
|
"applyFilters": "应用",
|
||||||
|
"clearAdvanced": "清空",
|
||||||
|
"clearAll": "重置全部",
|
||||||
"activeFilters": "已选条件",
|
"activeFilters": "已选条件",
|
||||||
"chipRemove": "移除",
|
"chipRemove": "移除",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
@@ -1550,7 +1556,11 @@
|
|||||||
"statusConfirmed": "已确认",
|
"statusConfirmed": "已确认",
|
||||||
"statusFixed": "已修复",
|
"statusFixed": "已修复",
|
||||||
"statusFalsePositive": "误报",
|
"statusFalsePositive": "误报",
|
||||||
"searchVulnId": "搜索漏洞ID",
|
"searchVulnId": "搜索漏洞 ID",
|
||||||
|
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||||
|
"searchKeywordShort": "关键词",
|
||||||
|
"filterExactId": "精确匹配漏洞 ID",
|
||||||
|
"filterEnterHint": "回车筛选",
|
||||||
"filterConversation": "筛选特定会话",
|
"filterConversation": "筛选特定会话",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"loadListFailed": "加载失败",
|
"loadListFailed": "加载失败",
|
||||||
@@ -1678,8 +1688,8 @@
|
|||||||
"multiAgentPeLoop": "plan_execute 外层循环上限",
|
"multiAgentPeLoop": "plan_execute 外层循环上限",
|
||||||
"multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10",
|
"multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10",
|
||||||
"multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。",
|
"multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。",
|
||||||
"multiAgentRobotUse": "企业微信 / 钉钉 / 飞书机器人也使用多代理",
|
"multiAgentRobotMode": "机器人默认对话模式",
|
||||||
"multiAgentRobotUseHint": "需同时勾选「启用多代理」;调用量与成本更高。",
|
"multiAgentRobotModeHint": "企业微信 / 钉钉 / 飞书机器人每条消息使用的执行模式;Deep / Plan-Execute / Supervisor 需启用多代理。",
|
||||||
"multiAgentBatchUse": "批量任务队列也使用多代理",
|
"multiAgentBatchUse": "批量任务队列也使用多代理",
|
||||||
"multiAgentBatchUseHint": "开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。",
|
"multiAgentBatchUseHint": "开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。",
|
||||||
"enableKnowledge": "启用知识检索功能",
|
"enableKnowledge": "启用知识检索功能",
|
||||||
@@ -1771,6 +1781,106 @@
|
|||||||
"close": "×",
|
"close": "×",
|
||||||
"newTerminal": "+"
|
"newTerminal": "+"
|
||||||
},
|
},
|
||||||
|
"settingsAudit": {
|
||||||
|
"title": "日志审计",
|
||||||
|
"description": "记录平台管理类操作(登录、配置、删除等),不记录对话正文、终端/WebShell 每次命令与工具调用明细。",
|
||||||
|
"filterCategory": "类别",
|
||||||
|
"filterAction": "操作",
|
||||||
|
"filterEvent": "事件类型",
|
||||||
|
"filterAllCategories": "全部类别",
|
||||||
|
"filterAllActions": "全部操作",
|
||||||
|
"filterCascadeHint": "选择类别后可筛选具体操作",
|
||||||
|
"filterResult": "结果",
|
||||||
|
"pageSize": "每页",
|
||||||
|
"statTotal": "当前筛选",
|
||||||
|
"statFailures": "失败",
|
||||||
|
"statRecent7d": "近 7 天",
|
||||||
|
"retentionHint": "审计记录保留 {{days}} 天,超期自动清理。",
|
||||||
|
"disabledHint": "审计功能已关闭,新操作不会写入审计表。",
|
||||||
|
"filterSince": "开始时间",
|
||||||
|
"filterUntil": "结束时间",
|
||||||
|
"filterQuery": "关键词",
|
||||||
|
"filterQueryPlaceholder": "消息 / 资源 ID / 操作名",
|
||||||
|
"cat": {
|
||||||
|
"auth": "认证",
|
||||||
|
"config": "配置",
|
||||||
|
"terminal": "终端",
|
||||||
|
"c2": "C2",
|
||||||
|
"webshell": "WebShell",
|
||||||
|
"knowledge": "知识库",
|
||||||
|
"conversation": "对话",
|
||||||
|
"vulnerability": "漏洞",
|
||||||
|
"externalMcp": "外部 MCP",
|
||||||
|
"task": "任务",
|
||||||
|
"tool": "工具",
|
||||||
|
"file": "文件",
|
||||||
|
"hitl": "人机协同",
|
||||||
|
"role": "角色",
|
||||||
|
"skill": "Skill",
|
||||||
|
"agent": "子代理"
|
||||||
|
},
|
||||||
|
"act": {
|
||||||
|
"login": "登录",
|
||||||
|
"logout": "登出",
|
||||||
|
"login_failed": "登录失败",
|
||||||
|
"password_change": "修改密码",
|
||||||
|
"change_password": "修改密码",
|
||||||
|
"apply": "应用配置",
|
||||||
|
"update": "更新",
|
||||||
|
"exec": "终端执行",
|
||||||
|
"exec_stream": "终端流式执行",
|
||||||
|
"listener_create": "创建监听器",
|
||||||
|
"listener_delete": "删除监听器",
|
||||||
|
"listener_start": "启动监听器",
|
||||||
|
"listener_stop": "停止监听器",
|
||||||
|
"session_delete": "删除会话",
|
||||||
|
"task_create": "创建任务",
|
||||||
|
"task_cancel": "取消任务",
|
||||||
|
"task_delete": "删除任务",
|
||||||
|
"connection_create": "创建连接",
|
||||||
|
"connection_delete": "删除连接",
|
||||||
|
"item_delete": "删除知识项",
|
||||||
|
"index_rebuild": "重建索引",
|
||||||
|
"delete": "删除",
|
||||||
|
"delete_turn": "删除轮次",
|
||||||
|
"create": "创建",
|
||||||
|
"upsert": "保存外部 MCP",
|
||||||
|
"create_queue": "创建批量队列",
|
||||||
|
"start_queue": "启动批量队列",
|
||||||
|
"delete_queue": "删除批量队列",
|
||||||
|
"pause_queue": "暂停批量队列",
|
||||||
|
"rerun_queue": "重跑批量队列",
|
||||||
|
"delete_batch_task": "删除批量子任务",
|
||||||
|
"execution_delete": "删除执行记录",
|
||||||
|
"execution_delete_batch": "批量删除执行",
|
||||||
|
"upload": "上传",
|
||||||
|
"decision": "HITL 决策",
|
||||||
|
"markdown_create": "创建子代理",
|
||||||
|
"markdown_update": "更新子代理",
|
||||||
|
"markdown_delete": "删除子代理"
|
||||||
|
},
|
||||||
|
"openResource": "打开关联资源",
|
||||||
|
"openResourceChat": "打开关联资源(chat)",
|
||||||
|
"resourceIdLabel": "资源 ID",
|
||||||
|
"resourceRemoved": "(关联对象已删除)",
|
||||||
|
"filterAll": "全部",
|
||||||
|
"filterBtn": "筛选",
|
||||||
|
"resetBtn": "重置",
|
||||||
|
"exportBtn": "导出",
|
||||||
|
"exportJson": "导出 JSON",
|
||||||
|
"export": "导出 JSON",
|
||||||
|
"exportCsv": "导出 CSV",
|
||||||
|
"exportDone": "导出完成",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"empty": "暂无审计记录",
|
||||||
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
||||||
|
"detailTitle": "审计详情",
|
||||||
|
"detailTime": "时间",
|
||||||
|
"detailCategory": "类别",
|
||||||
|
"detailResult": "结果",
|
||||||
|
"detailMessage": "说明",
|
||||||
|
"detailSession": "会话"
|
||||||
|
},
|
||||||
"settingsSecurity": {
|
"settingsSecurity": {
|
||||||
"changePasswordTitle": "修改密码",
|
"changePasswordTitle": "修改密码",
|
||||||
"changePasswordDesc": "修改登录密码后,需要使用新密码重新登录。",
|
"changePasswordDesc": "修改登录密码后,需要使用新密码重新登录。",
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
/**
|
||||||
|
* 系统设置 - 平台操作审计日志
|
||||||
|
*/
|
||||||
|
let auditLogsPage = 1;
|
||||||
|
let auditLogsPageSize = 20;
|
||||||
|
let auditLogsTotal = 0;
|
||||||
|
|
||||||
|
const AUDIT_PAGE_SIZE_KEY = 'cyberstrike_audit_page_size';
|
||||||
|
|
||||||
|
/** 按类别列出的操作(用于 datalist 提示,避免超长下拉) */
|
||||||
|
const AUDIT_ACTIONS_BY_CATEGORY = {
|
||||||
|
auth: ['login', 'logout', 'change_password'],
|
||||||
|
config: ['apply', 'update'],
|
||||||
|
c2: ['listener_create', 'listener_delete', 'listener_start', 'listener_stop',
|
||||||
|
'session_delete', 'task_create', 'task_cancel', 'task_delete'],
|
||||||
|
webshell: ['connection_create', 'connection_delete'],
|
||||||
|
knowledge: ['item_delete', 'index_rebuild'],
|
||||||
|
conversation: ['create', 'delete', 'delete_turn'],
|
||||||
|
vulnerability: ['create', 'update', 'delete'],
|
||||||
|
external_mcp: ['upsert', 'delete'],
|
||||||
|
task: ['create_queue', 'start_queue', 'delete_queue', 'pause_queue', 'rerun_queue', 'delete_batch_task'],
|
||||||
|
tool: ['execution_delete', 'execution_delete_batch'],
|
||||||
|
file: ['upload', 'delete'],
|
||||||
|
hitl: ['decision'],
|
||||||
|
role: ['create', 'update', 'delete'],
|
||||||
|
skill: ['create', 'update', 'delete'],
|
||||||
|
agent: ['markdown_create', 'markdown_update', 'markdown_delete']
|
||||||
|
};
|
||||||
|
|
||||||
|
function auditT(key, opts, fallback) {
|
||||||
|
if (typeof t === 'function') {
|
||||||
|
const v = t(key, opts);
|
||||||
|
if (v && v !== key) return v;
|
||||||
|
}
|
||||||
|
return fallback != null ? fallback : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditCategoryI18nKey(category) {
|
||||||
|
if (!category) return '';
|
||||||
|
if (category === 'external_mcp') return 'externalMcp';
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditCategoryLabel(category) {
|
||||||
|
if (!category) return '';
|
||||||
|
const key = 'settingsAudit.cat.' + auditCategoryI18nKey(category);
|
||||||
|
return auditT(key, null, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditActionLabel(action) {
|
||||||
|
if (!action) return '';
|
||||||
|
return auditT('settingsAudit.act.' + action, null, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString();
|
||||||
|
} catch (_) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditDatetimeLocalToRFC3339(value) {
|
||||||
|
if (!value || !value.trim()) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAuditPageSizeFromStorage() {
|
||||||
|
try {
|
||||||
|
const saved = parseInt(localStorage.getItem(AUDIT_PAGE_SIZE_KEY), 10);
|
||||||
|
if ([10, 20, 50, 100].indexOf(saved) >= 0) {
|
||||||
|
auditLogsPageSize = saved;
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
const sel = document.getElementById('audit-page-size');
|
||||||
|
if (sel) sel.value = String(auditLogsPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAuditPageSizeChange() {
|
||||||
|
const sel = document.getElementById('audit-page-size');
|
||||||
|
if (!sel) return;
|
||||||
|
const n = parseInt(sel.value, 10);
|
||||||
|
if ([10, 20, 50, 100].indexOf(n) < 0) return;
|
||||||
|
auditLogsPageSize = n;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUDIT_PAGE_SIZE_KEY, String(n));
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
auditLogsPage = 1;
|
||||||
|
loadAuditLogs(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildAuditActionSelect() {
|
||||||
|
const catEl = document.getElementById('audit-filter-category');
|
||||||
|
const actEl = document.getElementById('audit-filter-action');
|
||||||
|
if (!actEl) return;
|
||||||
|
|
||||||
|
const category = catEl ? catEl.value : '';
|
||||||
|
const prev = actEl.value;
|
||||||
|
const allLabel = auditT('settingsAudit.filterAllActions', null, '全部操作');
|
||||||
|
const hint = auditT('settingsAudit.filterCascadeHint', null, '选择类别后可筛选具体操作');
|
||||||
|
actEl.innerHTML = '';
|
||||||
|
const allOpt = document.createElement('option');
|
||||||
|
allOpt.value = '';
|
||||||
|
allOpt.textContent = allLabel;
|
||||||
|
actEl.appendChild(allOpt);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
actEl.disabled = true;
|
||||||
|
actEl.value = '';
|
||||||
|
actEl.title = hint;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actEl.disabled = false;
|
||||||
|
actEl.title = '';
|
||||||
|
|
||||||
|
const actions = AUDIT_ACTIONS_BY_CATEGORY[category] || [];
|
||||||
|
actions.forEach(function (action) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = action;
|
||||||
|
opt.textContent = auditActionLabel(action);
|
||||||
|
actEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && Array.prototype.some.call(actEl.options, function (o) { return o.value === prev; })) {
|
||||||
|
actEl.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAuditCategoryFilterChange() {
|
||||||
|
rebuildAuditActionSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditQueryParams(forExport) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (!forExport) {
|
||||||
|
params.set('page', String(auditLogsPage));
|
||||||
|
params.set('page_size', String(auditLogsPageSize));
|
||||||
|
}
|
||||||
|
const cat = document.getElementById('audit-filter-category');
|
||||||
|
const act = document.getElementById('audit-filter-action');
|
||||||
|
const res = document.getElementById('audit-filter-result');
|
||||||
|
const q = document.getElementById('audit-filter-q');
|
||||||
|
const since = document.getElementById('audit-filter-since');
|
||||||
|
const until = document.getElementById('audit-filter-until');
|
||||||
|
if (cat && cat.value) params.set('category', cat.value);
|
||||||
|
if (act && !act.disabled && act.value) params.set('action', act.value);
|
||||||
|
if (res && res.value) params.set('result', res.value);
|
||||||
|
if (q && q.value.trim()) params.set('q', q.value.trim());
|
||||||
|
const sinceISO = since ? auditDatetimeLocalToRFC3339(since.value) : '';
|
||||||
|
const untilISO = until ? auditDatetimeLocalToRFC3339(until.value) : '';
|
||||||
|
if (sinceISO) params.set('since', sinceISO);
|
||||||
|
if (untilISO) params.set('until', untilISO);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuditMeta() {
|
||||||
|
if (typeof apiFetch !== 'function') return;
|
||||||
|
const hint = document.getElementById('audit-retention-hint');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/audit/meta');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
if (!hint) return;
|
||||||
|
if (!data.enabled) {
|
||||||
|
hint.hidden = false;
|
||||||
|
hint.textContent = auditT('settingsAudit.disabledHint', null, '审计功能已关闭,新操作不会写入审计表。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const days = data.retention_days;
|
||||||
|
if (days > 0) {
|
||||||
|
hint.hidden = false;
|
||||||
|
hint.textContent = auditT('settingsAudit.retentionHint', { days: days },
|
||||||
|
'审计记录保留 ' + days + ' 天,超期自动清理。');
|
||||||
|
} else {
|
||||||
|
hint.hidden = true;
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuditSummary() {
|
||||||
|
if (typeof apiFetch !== 'function') return;
|
||||||
|
const wrap = document.getElementById('audit-summary-stats');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/audit/summary?' + buildAuditQueryParams(true));
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
if (wrap) wrap.hidden = false;
|
||||||
|
const elTotal = document.getElementById('audit-stat-total');
|
||||||
|
const elFail = document.getElementById('audit-stat-failures');
|
||||||
|
const elRecent = document.getElementById('audit-stat-recent');
|
||||||
|
if (elTotal) elTotal.textContent = String(data.total != null ? data.total : 0);
|
||||||
|
if (elFail) elFail.textContent = String(data.failures != null ? data.failures : 0);
|
||||||
|
if (elRecent) elRecent.textContent = String(data.recent_7d != null ? data.recent_7d : 0);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuditLogs(page) {
|
||||||
|
if (typeof apiFetch !== 'function') return;
|
||||||
|
auditLogsPage = page != null ? page : auditLogsPage;
|
||||||
|
const listEl = document.getElementById('audit-log-list');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '<div class="loading-spinner">' + (typeof escapeHtml === 'function' ? escapeHtml(auditT('settingsAudit.loading', null, '加载中...')) : '加载中...') + '</div>';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const qs = buildAuditQueryParams(false);
|
||||||
|
const r = await apiFetch('/api/audit/logs?' + qs);
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(function () { return {}; });
|
||||||
|
throw new Error(err.error || r.statusText);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
renderAuditLogs(data.logs || []);
|
||||||
|
auditLogsTotal = typeof data.total === 'number' ? data.total : 0;
|
||||||
|
const maxPage = Math.max(1, Math.ceil(auditLogsTotal / auditLogsPageSize));
|
||||||
|
if (auditLogsPage > maxPage) {
|
||||||
|
loadAuditLogs(maxPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderAuditLogsPagination();
|
||||||
|
loadAuditSummary();
|
||||||
|
} catch (e) {
|
||||||
|
if (listEl) {
|
||||||
|
const msg = typeof escapeHtml === 'function' ? escapeHtml(e.message || String(e)) : (e.message || String(e));
|
||||||
|
listEl.innerHTML = '<div class="monitor-empty">' + msg + '</div>';
|
||||||
|
}
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(e.message || String(e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditLogs(logs) {
|
||||||
|
const listEl = document.getElementById('audit-log-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
|
if (!logs.length) {
|
||||||
|
listEl.innerHTML = '<div class="c2-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = logs.map(function (log) {
|
||||||
|
const lvl = log.result === 'failure' ? 'warn' : (log.level || 'info');
|
||||||
|
const catLabel = esc(auditCategoryLabel(log.category || ''));
|
||||||
|
const actionLabel = esc(auditActionLabel(log.action || ''));
|
||||||
|
const msg = esc(log.message || '');
|
||||||
|
const ip = esc(log.clientIp || '');
|
||||||
|
const when = esc(formatAuditTime(log.createdAt));
|
||||||
|
const res = esc(log.result || '');
|
||||||
|
const rid = log.resourceId || '';
|
||||||
|
const meta = rid ? (' · ' + esc(rid)) : '';
|
||||||
|
const eid = esc(log.id || '');
|
||||||
|
return (
|
||||||
|
'<div class="c2-event-item audit-log-item" role="button" tabindex="0" ' +
|
||||||
|
'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
||||||
|
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}">' +
|
||||||
|
'<div class="c2-event-level ' + esc(lvl) + '"></div>' +
|
||||||
|
'<div class="c2-event-content">' +
|
||||||
|
'<div class="c2-event-message">' + msg + '</div>' +
|
||||||
|
'<div class="c2-event-meta">' + when + ' · ' + catLabel + '/' + actionLabel + ' · ' + res + meta +
|
||||||
|
(ip ? ' · IP ' + ip : '') +
|
||||||
|
'</div></div></div>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
if (typeof applyTranslations === 'function') {
|
||||||
|
applyTranslations(listEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditLogsPagination() {
|
||||||
|
const container = document.getElementById('audit-logs-pagination');
|
||||||
|
if (!container) return;
|
||||||
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
|
const total = auditLogsTotal || 0;
|
||||||
|
const currentPage = auditLogsPage || 1;
|
||||||
|
const pageSize = auditLogsPageSize || 20;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||||
|
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||||||
|
const infoText = auditT('mcpMonitor.paginationInfo', { start: start, end: end, total: total },
|
||||||
|
'显示 ' + start + '-' + end + ' / 共 ' + total + ' 条记录');
|
||||||
|
const perPageLabel = auditT('mcpMonitor.perPageLabel', null, '每页显示');
|
||||||
|
const firstPageLabel = auditT('mcp.firstPage', null, '首页');
|
||||||
|
const prevPageLabel = auditT('mcp.prevPage', null, '上一页');
|
||||||
|
const pageInfoText = auditT('mcp.pageInfo', { page: currentPage, total: totalPages },
|
||||||
|
'第 ' + currentPage + ' / ' + totalPages + ' 页');
|
||||||
|
const nextPageLabel = auditT('mcp.nextPage', null, '下一页');
|
||||||
|
const lastPageLabel = auditT('mcp.lastPage', null, '末页');
|
||||||
|
const disabledFirst = currentPage === 1 || total === 0;
|
||||||
|
const disabledLast = currentPage >= totalPages || total === 0;
|
||||||
|
let html = '<div class="monitor-pagination">';
|
||||||
|
html += '<div class="pagination-info">';
|
||||||
|
html += '<span>' + esc(infoText) + '</span>';
|
||||||
|
html += '<label class="pagination-page-size">' + esc(perPageLabel);
|
||||||
|
html += '<select id="audit-page-size" onchange="onAuditPageSizeChange()">';
|
||||||
|
[10, 20, 50, 100].forEach(function (n) {
|
||||||
|
html += '<option value="' + n + '"' + (pageSize === n ? ' selected' : '') + '>' + n + '</option>';
|
||||||
|
});
|
||||||
|
html += '</select></label></div>';
|
||||||
|
html += '<div class="pagination-controls">';
|
||||||
|
html += '<button type="button" class="btn-secondary" onclick="goAuditLogsPage(1)"' + (disabledFirst ? ' disabled' : '') + '>' + esc(firstPageLabel) + '</button>';
|
||||||
|
html += '<button type="button" class="btn-secondary" onclick="goAuditLogsPage(' + (currentPage - 1) + ')"' + (disabledFirst ? ' disabled' : '') + '>' + esc(prevPageLabel) + '</button>';
|
||||||
|
html += '<span class="pagination-page">' + esc(pageInfoText) + '</span>';
|
||||||
|
html += '<button type="button" class="btn-secondary" onclick="goAuditLogsPage(' + (currentPage + 1) + ')"' + (disabledLast ? ' disabled' : '') + '>' + esc(nextPageLabel) + '</button>';
|
||||||
|
html += '<button type="button" class="btn-secondary" onclick="goAuditLogsPage(' + totalPages + ')"' + (disabledLast ? ' disabled' : '') + '>' + esc(lastPageLabel) + '</button>';
|
||||||
|
html += '</div></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAuditLogsPage(p) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil((auditLogsTotal || 0) / (auditLogsPageSize || 20)));
|
||||||
|
if (p < 1 || p > totalPages) return;
|
||||||
|
loadAuditLogs(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAuditLogs() {
|
||||||
|
auditLogsPage = 1;
|
||||||
|
loadAuditLogs(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAuditLogFilters() {
|
||||||
|
const cat = document.getElementById('audit-filter-category');
|
||||||
|
const act = document.getElementById('audit-filter-action');
|
||||||
|
const res = document.getElementById('audit-filter-result');
|
||||||
|
const q = document.getElementById('audit-filter-q');
|
||||||
|
const since = document.getElementById('audit-filter-since');
|
||||||
|
const until = document.getElementById('audit-filter-until');
|
||||||
|
if (cat) cat.value = '';
|
||||||
|
if (res) res.value = '';
|
||||||
|
if (q) q.value = '';
|
||||||
|
if (since) since.value = '';
|
||||||
|
if (until) until.value = '';
|
||||||
|
rebuildAuditActionSelect();
|
||||||
|
filterAuditLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资源已被删除/移除的审计操作,不再提供「打开关联资源」 */
|
||||||
|
const AUDIT_ACTIONS_RESOURCE_REMOVED = {
|
||||||
|
delete: true,
|
||||||
|
item_delete: true,
|
||||||
|
connection_delete: true,
|
||||||
|
listener_delete: true,
|
||||||
|
session_delete: true,
|
||||||
|
task_delete: true,
|
||||||
|
execution_delete: true,
|
||||||
|
execution_delete_batch: true,
|
||||||
|
delete_queue: true,
|
||||||
|
delete_batch_task: true,
|
||||||
|
markdown_delete: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function auditResourceWasRemoved(log) {
|
||||||
|
if (!log || !log.action) return false;
|
||||||
|
return !!AUDIT_ACTIONS_RESOURCE_REMOVED[log.action];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除类操作,或关联资源已不存在(由详情 API resourceAvailable 判定) */
|
||||||
|
function auditResourceUnavailable(log) {
|
||||||
|
if (!log) return false;
|
||||||
|
if (auditResourceWasRemoved(log)) return true;
|
||||||
|
return log.resourceAvailable === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditResourceMeta(log) {
|
||||||
|
if (!log || !log.resourceId) return '';
|
||||||
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
|
const id = esc(log.resourceId);
|
||||||
|
if (auditResourceUnavailable(log)) {
|
||||||
|
const idLabel = esc(auditT('settingsAudit.resourceIdLabel', null, '资源 ID'));
|
||||||
|
const removed = esc(auditT('settingsAudit.resourceRemoved', null, '(关联对象已删除)'));
|
||||||
|
return '<p class="audit-resource-meta"><strong>' + idLabel + ':</strong> <code>' + id +
|
||||||
|
'</code> <span class="audit-resource-removed">' + removed + '</span></p>';
|
||||||
|
}
|
||||||
|
const link = auditResourceLink(log);
|
||||||
|
return link || ('<p><strong>ID:</strong> ' + id + '</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditOpenConversationChat(conversationId) {
|
||||||
|
const id = String(conversationId || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (typeof apiFetch === 'function') {
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/conversations/' + encodeURIComponent(id));
|
||||||
|
if (!r.ok) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(auditT('settingsAudit.resourceRemoved', null, '(关联对象已删除)'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeAuditDetailModal();
|
||||||
|
if (typeof switchPage === 'function') {
|
||||||
|
switchPage('chat');
|
||||||
|
}
|
||||||
|
if (typeof loadConversation === 'function') {
|
||||||
|
void loadConversation(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.auditOpenConversationChat = auditOpenConversationChat;
|
||||||
|
|
||||||
|
function auditResourceLink(log) {
|
||||||
|
if (!log || auditResourceUnavailable(log)) return '';
|
||||||
|
const type = log.resourceType || '';
|
||||||
|
const id = log.resourceId || '';
|
||||||
|
if (!id) return '';
|
||||||
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
|
const label = esc(auditT('settingsAudit.openResource', null, '打开关联资源'));
|
||||||
|
if (type === 'conversation' || (type === '' && id.length > 8 && !id.startsWith('c2_'))) {
|
||||||
|
const chatLabel = esc(auditT('settingsAudit.openResourceChat', null, '打开关联资源(chat)'));
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small audit-open-chat-btn" data-conversation-id="' +
|
||||||
|
esc(id) + '">' + chatLabel + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'vulnerability' || type === 'batch_queue') {
|
||||||
|
const page = type === 'batch_queue' ? 'tasks' : 'vulnerabilities';
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchPage===\'function\'){switchPage(\'' + page + '\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'c2_listener' || type === 'c2_session' || type === 'c2_task') {
|
||||||
|
const page = type === 'c2_listener' ? 'c2-listeners' : (type === 'c2_session' ? 'c2-sessions' : 'c2-tasks');
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchPage===\'function\'){switchPage(\'' + page + '\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'webshell_connection') {
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchPage===\'function\'){switchPage(\'webshell\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'knowledge_item') {
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchPage===\'function\'){switchPage(\'knowledge-management\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'chat_upload') {
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchPage===\'function\'){switchPage(\'chat-files\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'tool_execution') {
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchPage===\'function\'){switchPage(\'mcp-monitor\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
if (type === 'role' || type === 'skill' || type === 'markdown_agent') {
|
||||||
|
return '<p><button type="button" class="btn-secondary btn-small" onclick="closeAuditDetailModal();if(typeof switchSettingsSection===\'function\'){switchPage(\'settings\');switchSettingsSection(\'roles\');}">' + label + '</button></p>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAuditLogs() {
|
||||||
|
loadAuditLogs(auditLogsPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAuditExport(url, filename) {
|
||||||
|
const r = await apiFetch(url);
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(function () { return {}; });
|
||||||
|
throw new Error(err.error || r.statusText);
|
||||||
|
}
|
||||||
|
const blob = await r.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAuditExportMenu() {
|
||||||
|
const menu = document.getElementById('audit-export-menu');
|
||||||
|
const trigger = document.getElementById('audit-export-trigger');
|
||||||
|
if (menu) menu.hidden = true;
|
||||||
|
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAuditExportMenu(ev) {
|
||||||
|
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||||
|
const menu = document.getElementById('audit-export-menu');
|
||||||
|
const trigger = document.getElementById('audit-export-trigger');
|
||||||
|
if (!menu) return;
|
||||||
|
const willOpen = menu.hidden;
|
||||||
|
if (willOpen) {
|
||||||
|
menu.hidden = false;
|
||||||
|
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
if (!window._auditExportMenuDocBound) {
|
||||||
|
window._auditExportMenuDocBound = true;
|
||||||
|
document.addEventListener('click', function () {
|
||||||
|
closeAuditExportMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closeAuditExportMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAuditExport(format) {
|
||||||
|
closeAuditExportMenu();
|
||||||
|
if (format === 'csv') {
|
||||||
|
await exportAuditLogsCsv();
|
||||||
|
} else {
|
||||||
|
await exportAuditLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAuditLogs() {
|
||||||
|
if (typeof apiFetch !== 'function') return;
|
||||||
|
try {
|
||||||
|
await downloadAuditExport(
|
||||||
|
'/api/audit/logs/export?' + buildAuditQueryParams(true),
|
||||||
|
'audit-logs-' + new Date().toISOString().slice(0, 10) + '.json'
|
||||||
|
);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(auditT('settingsAudit.exportDone', null, '导出完成'), 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(e.message || String(e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAuditLogsCsv() {
|
||||||
|
if (typeof apiFetch !== 'function') return;
|
||||||
|
try {
|
||||||
|
const qs = buildAuditQueryParams(true);
|
||||||
|
await downloadAuditExport(
|
||||||
|
'/api/audit/logs/export?' + (qs ? qs + '&' : '') + 'format=csv',
|
||||||
|
'audit-logs-' + new Date().toISOString().slice(0, 10) + '.csv'
|
||||||
|
);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(auditT('settingsAudit.exportDone', null, '导出完成'), 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(e.message || String(e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAuditDetailModal() {
|
||||||
|
const el = document.getElementById('audit-detail-modal');
|
||||||
|
if (el) el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAuditLogDetail(id) {
|
||||||
|
if (!id || typeof apiFetch !== 'function') return;
|
||||||
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/audit/logs/' + encodeURIComponent(id));
|
||||||
|
if (!r.ok) throw new Error('not found');
|
||||||
|
const data = await r.json();
|
||||||
|
const log = data.log || {};
|
||||||
|
const detail = log.detail ? JSON.stringify(log.detail, null, 2) : '';
|
||||||
|
closeAuditDetailModal();
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'audit-detail-modal';
|
||||||
|
overlay.className = 'modal';
|
||||||
|
overlay.style.display = 'block';
|
||||||
|
const catAction = esc(auditCategoryLabel(log.category || '')) + ' / ' + esc(auditActionLabel(log.action || ''));
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="modal-content" style="max-width: 720px;">' +
|
||||||
|
'<div class="modal-header">' +
|
||||||
|
'<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' +
|
||||||
|
'<span class="modal-close" onclick="closeAuditDetailModal()">×</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-body audit-detail-body">' +
|
||||||
|
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
|
||||||
|
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
|
||||||
|
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
|
||||||
|
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
|
||||||
|
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
|
||||||
|
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
|
||||||
|
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
|
||||||
|
auditResourceMeta(log) +
|
||||||
|
(detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' +
|
||||||
|
esc(auditT('common.close', null, '关闭')) + '</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const chatBtn = overlay.querySelector('.audit-open-chat-btn');
|
||||||
|
if (chatBtn) {
|
||||||
|
chatBtn.addEventListener('click', function () {
|
||||||
|
auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
overlay.addEventListener('click', function (ev) {
|
||||||
|
if (ev.target === overlay) closeAuditDetailModal();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(e.message || String(e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAuditLogsSection() {
|
||||||
|
if (!document.getElementById('audit-log-list')) return;
|
||||||
|
initAuditPageSizeFromStorage();
|
||||||
|
rebuildAuditActionSelect();
|
||||||
|
loadAuditMeta();
|
||||||
|
loadAuditLogs(1);
|
||||||
|
}
|
||||||
@@ -1052,15 +1052,26 @@ async function saveChatFilesEdit() {
|
|||||||
function openChatFilesRename(relativePath, currentName) {
|
function openChatFilesRename(relativePath, currentName) {
|
||||||
chatFilesRenameRelativePath = relativePath;
|
chatFilesRenameRelativePath = relativePath;
|
||||||
const input = document.getElementById('chat-files-rename-input');
|
const input = document.getElementById('chat-files-rename-input');
|
||||||
|
const hint = document.getElementById('chat-files-rename-path-hint');
|
||||||
const modal = document.getElementById('chat-files-rename-modal');
|
const modal = document.getElementById('chat-files-rename-modal');
|
||||||
if (input) input.value = currentName || '';
|
const pathText = relativePath ? ('chat_uploads/' + String(relativePath).replace(/\\/g, '/')) : 'chat_uploads';
|
||||||
if (modal) modal.style.display = 'block';
|
if (hint) hint.textContent = pathText;
|
||||||
setTimeout(() => { if (input) input.focus(); }, 100);
|
if (input) {
|
||||||
|
input.value = currentName || '';
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
if (modal) modal.style.display = 'flex';
|
||||||
|
if (modal && typeof window.applyTranslations === 'function') {
|
||||||
|
window.applyTranslations(modal);
|
||||||
|
}
|
||||||
|
setTimeout(() => { if (input) { input.focus(); input.select(); } }, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChatFilesRenameModal() {
|
function closeChatFilesRenameModal() {
|
||||||
const modal = document.getElementById('chat-files-rename-modal');
|
const modal = document.getElementById('chat-files-rename-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
if (modal) modal.style.display = 'none';
|
||||||
|
const hint = document.getElementById('chat-files-rename-path-hint');
|
||||||
|
if (hint) hint.textContent = '';
|
||||||
chatFilesRenameRelativePath = '';
|
chatFilesRenameRelativePath = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,7 +1106,7 @@ function openChatFilesMkdirModal() {
|
|||||||
const p = chatFilesBrowsePath.join('/');
|
const p = chatFilesBrowsePath.join('/');
|
||||||
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
|
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
if (modal) modal.style.display = 'block';
|
if (modal) modal.style.display = 'flex';
|
||||||
if (modal && typeof window.applyTranslations === 'function') {
|
if (modal && typeof window.applyTranslations === 'function') {
|
||||||
window.applyTranslations(modal);
|
window.applyTranslations(modal);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-5
@@ -574,7 +574,7 @@ function restoreChatReasoningControlsFromStorage() {
|
|||||||
}
|
}
|
||||||
if (e) {
|
if (e) {
|
||||||
const v = localStorage.getItem(REASONING_EFFORT_LS);
|
const v = localStorage.getItem(REASONING_EFFORT_LS);
|
||||||
if (v !== null && ['', 'low', 'medium', 'high', 'max'].indexOf(v) !== -1) {
|
if (v !== null && ['', 'low', 'medium', 'high', 'max', 'xhigh'].indexOf(v) !== -1) {
|
||||||
e.value = v;
|
e.value = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2424,11 +2424,8 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||||
const index = data.index || 0;
|
const index = data.index || 0;
|
||||||
const total = data.total || 0;
|
const total = data.total || 0;
|
||||||
const argsHint = typeof window.toolCallArgHint === 'function'
|
|
||||||
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(data) : {})
|
|
||||||
: '';
|
|
||||||
const callTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
const callTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
||||||
? window.formatToolCallTimelineTitle(toolName, index, total, argsHint)
|
? window.formatToolCallTimelineTitle(toolName, index, total)
|
||||||
: (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')');
|
: (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')');
|
||||||
itemTitle = agPx + '🔧 ' + callTitle;
|
itemTitle = agPx + '🔧 ' + callTitle;
|
||||||
} else if (eventType === 'tool_result') {
|
} else if (eventType === 'tool_result') {
|
||||||
@@ -3067,6 +3064,27 @@ function getConversationGroup(dateObj, todayStart, sevenDaysCutoff, yesterdaySta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 加载对话
|
// 加载对话
|
||||||
|
/** 轻量加载会话后,拉取最后一条助手消息的 process_details(机器人等无 SSE 场景) */
|
||||||
|
async function prefetchLastAssistantProcessDetails() {
|
||||||
|
const nodes = document.querySelectorAll('#chat-messages .message.assistant');
|
||||||
|
if (!nodes.length) return;
|
||||||
|
const last = nodes[nodes.length - 1];
|
||||||
|
if (!last || !last.id) return;
|
||||||
|
const container = document.getElementById('process-details-' + last.id);
|
||||||
|
if (!container || container.dataset.lazyNotLoaded !== '1') return;
|
||||||
|
const backendId = last.dataset && last.dataset.backendMessageId;
|
||||||
|
if (!backendId || typeof apiFetch !== 'function') return;
|
||||||
|
const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details');
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !Array.isArray(j.processDetails) || j.processDetails.length === 0) return;
|
||||||
|
if (typeof renderProcessDetails === 'function') {
|
||||||
|
renderProcessDetails(last.id, j.processDetails);
|
||||||
|
}
|
||||||
|
if (typeof window.expandProcessDetailsTimeline === 'function') {
|
||||||
|
window.expandProcessDetailsTimeline(last.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConversation(conversationId) {
|
async function loadConversation(conversationId) {
|
||||||
const seq = ++loadConversationRequestSeq;
|
const seq = ++loadConversationRequestSeq;
|
||||||
try {
|
try {
|
||||||
@@ -3286,6 +3304,11 @@ async function loadConversation(conversationId) {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.warn('attachRunningTaskEventStream on loadConversation failed', e);
|
console.warn('attachRunningTaskEventStream on loadConversation failed', e);
|
||||||
});
|
});
|
||||||
|
} else if (seq === loadConversationRequestSeq && currentConversationId === conversationId) {
|
||||||
|
// 机器人等非 Web 流式来源:会话已结束或未注册任务时,按需拉取最后一条助手消息的过程详情
|
||||||
|
prefetchLastAssistantProcessDetails().catch((e) => {
|
||||||
|
console.warn('prefetchLastAssistantProcessDetails failed', e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载对话失败:', error);
|
console.error('加载对话失败:', error);
|
||||||
|
|||||||
@@ -1592,9 +1592,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const index = toolInfo.index || 0;
|
const index = toolInfo.index || 0;
|
||||||
const total = toolInfo.total || 0;
|
const total = toolInfo.total || 0;
|
||||||
const toolCallId = toolInfo.toolCallId || null;
|
const toolCallId = toolInfo.toolCallId || null;
|
||||||
const toolCallArgs = parseToolCallArgsFromData(toolInfo);
|
const toolCallTitle = formatToolCallTimelineTitle(toolName, index, total);
|
||||||
const toolCallHint = toolCallArgHint(toolCallArgs);
|
|
||||||
const toolCallTitle = formatToolCallTimelineTitle(toolName, index, total, toolCallHint);
|
|
||||||
const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
|
const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
|
||||||
title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle,
|
title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle,
|
||||||
message: event.message,
|
message: event.message,
|
||||||
@@ -2513,6 +2511,7 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
|
|
||||||
window.attachRunningTaskEventStream = attachRunningTaskEventStream;
|
window.attachRunningTaskEventStream = attachRunningTaskEventStream;
|
||||||
window.taskReplayProgressId = taskReplayProgressId;
|
window.taskReplayProgressId = taskReplayProgressId;
|
||||||
|
window.expandProcessDetailsTimeline = expandProcessDetailsTimeline;
|
||||||
|
|
||||||
/** 从工具参数提取短摘要(URL/命令等),便于同名工具批量调用时区分 */
|
/** 从工具参数提取短摘要(URL/命令等),便于同名工具批量调用时区分 */
|
||||||
function parseToolCallArgsFromData(data) {
|
function parseToolCallArgsFromData(data) {
|
||||||
@@ -2531,38 +2530,14 @@ function parseToolCallArgsFromData(data) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCallArgHint(args) {
|
function formatToolCallTimelineTitle(toolName, index, total) {
|
||||||
if (!args || typeof args !== 'object') return '';
|
|
||||||
const method = args.method != null ? String(args.method).trim().toUpperCase() : '';
|
|
||||||
const url = args.url || args.URL || args.target || args.uri;
|
|
||||||
if (url != null && String(url).trim() !== '') {
|
|
||||||
let s = String(url).trim();
|
|
||||||
if (method) s = method + ' ' + s;
|
|
||||||
return s.length > 56 ? s.slice(0, 53) + '...' : s;
|
|
||||||
}
|
|
||||||
if (method) {
|
|
||||||
return method;
|
|
||||||
}
|
|
||||||
const cmd = args.command || args.cmd || args.script;
|
|
||||||
if (cmd != null && String(cmd).trim() !== '') {
|
|
||||||
const s = String(cmd).trim();
|
|
||||||
return s.length > 48 ? s.slice(0, 45) + '...' : s;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolCallTimelineTitle(toolName, index, total, argsHint) {
|
|
||||||
const name = toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
const name = toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||||
const idx = index || 0;
|
const idx = index || 0;
|
||||||
const tot = total || 0;
|
const tot = total || 0;
|
||||||
let base;
|
|
||||||
if (typeof window.t === 'function') {
|
if (typeof window.t === 'function') {
|
||||||
base = window.t('chat.callTool', { name: name, index: idx, total: tot });
|
return window.t('chat.callTool', { name: name, index: idx, total: tot });
|
||||||
} else {
|
|
||||||
base = '调用工具: ' + name + (tot ? ' (' + idx + '/' + tot + ')' : '');
|
|
||||||
}
|
}
|
||||||
const hint = (argsHint && String(argsHint).trim()) ? String(argsHint).trim() : '';
|
return '调用工具: ' + name + (tot ? ' (' + idx + '/' + tot + ')' : '');
|
||||||
return hint ? (base + ' · ' + hint) : base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolResultSectionHtml(data, opts) {
|
function buildToolResultSectionHtml(data, opts) {
|
||||||
@@ -2741,7 +2716,6 @@ window.attachToolResultToCall = attachToolResultToCall;
|
|||||||
window.mergeToolResultIntoCallItem = mergeToolResultIntoCallItem;
|
window.mergeToolResultIntoCallItem = mergeToolResultIntoCallItem;
|
||||||
window.formatToolCallTimelineTitle = formatToolCallTimelineTitle;
|
window.formatToolCallTimelineTitle = formatToolCallTimelineTitle;
|
||||||
window.parseToolCallArgsFromData = parseToolCallArgsFromData;
|
window.parseToolCallArgsFromData = parseToolCallArgsFromData;
|
||||||
window.toolCallArgHint = toolCallArgHint;
|
|
||||||
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
|
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
|
||||||
|
|
||||||
// 更新工具调用状态
|
// 更新工具调用状态
|
||||||
@@ -2810,11 +2784,6 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
if (d.toolCallId != null && String(d.toolCallId).trim() !== '') {
|
if (d.toolCallId != null && String(d.toolCallId).trim() !== '') {
|
||||||
item.dataset.toolCallId = String(d.toolCallId).trim();
|
item.dataset.toolCallId = String(d.toolCallId).trim();
|
||||||
}
|
}
|
||||||
const callArgs = parseToolCallArgsFromData(d);
|
|
||||||
const argHint = toolCallArgHint(callArgs);
|
|
||||||
if (argHint) {
|
|
||||||
item.dataset.toolArgHint = argHint;
|
|
||||||
}
|
|
||||||
const merged = options.mergedResult || d._mergedResult;
|
const merged = options.mergedResult || d._mergedResult;
|
||||||
if (merged) {
|
if (merged) {
|
||||||
item.dataset.toolResultMerged = '1';
|
item.dataset.toolResultMerged = '1';
|
||||||
@@ -4320,9 +4289,8 @@ function refreshProgressAndTimelineI18n() {
|
|||||||
const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool');
|
const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool');
|
||||||
const index = parseInt(item.dataset.toolIndex, 10) || 0;
|
const index = parseInt(item.dataset.toolIndex, 10) || 0;
|
||||||
const total = parseInt(item.dataset.toolTotal, 10) || 0;
|
const total = parseInt(item.dataset.toolTotal, 10) || 0;
|
||||||
const hint = item.dataset.toolArgHint || '';
|
|
||||||
const callTitle = typeof formatToolCallTimelineTitle === 'function'
|
const callTitle = typeof formatToolCallTimelineTitle === 'function'
|
||||||
? formatToolCallTimelineTitle(name, index, total, hint)
|
? formatToolCallTimelineTitle(name, index, total)
|
||||||
: _t('chat.callTool', { name: name, index: index, total: total });
|
: _t('chat.callTool', { name: name, index: index, total: total });
|
||||||
titleSpan.textContent = ap + '\uD83D\uDD27 ' + callTitle;
|
titleSpan.textContent = ap + '\uD83D\uDD27 ' + callTitle;
|
||||||
} else if (type === 'tool_result' && (item.dataset.toolName !== undefined || item.dataset.toolSuccess !== undefined)) {
|
} else if (type === 'tool_result' && (item.dataset.toolName !== undefined || item.dataset.toolSuccess !== undefined)) {
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ let toolsPagination = {
|
|||||||
|
|
||||||
let c2NavSyncedOnce = false;
|
let c2NavSyncedOnce = false;
|
||||||
|
|
||||||
|
/** 根据是否启用多代理,禁用/启用机器人模式中的 Eino 编排选项 */
|
||||||
|
function syncRobotAgentModeSelectOptions(multiEnabled) {
|
||||||
|
const sel = document.getElementById('multi-agent-robot-mode');
|
||||||
|
if (!sel) return;
|
||||||
|
['deep', 'plan_execute', 'supervisor'].forEach(function (v) {
|
||||||
|
const opt = sel.querySelector('option[value="' + v + '"]');
|
||||||
|
if (opt) opt.disabled = !multiEnabled;
|
||||||
|
});
|
||||||
|
if (!multiEnabled && ['deep', 'plan_execute', 'supervisor'].indexOf(sel.value) >= 0) {
|
||||||
|
sel.value = 'react';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 首次进入仪表盘等页面前拉一次配置,隐藏侧栏 C2(避免禁用后仍显示) */
|
/** 首次进入仪表盘等页面前拉一次配置,隐藏侧栏 C2(避免禁用后仍显示) */
|
||||||
window.syncC2NavOnceFromServer = async function syncC2NavOnceFromServer() {
|
window.syncC2NavOnceFromServer = async function syncC2NavOnceFromServer() {
|
||||||
if (c2NavSyncedOnce || typeof apiFetch === 'undefined') {
|
if (c2NavSyncedOnce || typeof apiFetch === 'undefined') {
|
||||||
@@ -87,6 +100,9 @@ function switchSettingsSection(section) {
|
|||||||
if (section === 'terminal' && typeof initTerminal === 'function') {
|
if (section === 'terminal' && typeof initTerminal === 'function') {
|
||||||
setTimeout(initTerminal, 0);
|
setTimeout(initTerminal, 0);
|
||||||
}
|
}
|
||||||
|
if (section === 'audit' && typeof initAuditLogsSection === 'function') {
|
||||||
|
setTimeout(initAuditLogsSection, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开设置
|
// 打开设置
|
||||||
@@ -168,7 +184,7 @@ async function loadConfig(loadTools = true) {
|
|||||||
const orEffEl = document.getElementById('openai-reasoning-effort');
|
const orEffEl = document.getElementById('openai-reasoning-effort');
|
||||||
if (orEffEl) {
|
if (orEffEl) {
|
||||||
const ev = (orm.effort || '').toString().trim().toLowerCase();
|
const ev = (orm.effort || '').toString().trim().toLowerCase();
|
||||||
orEffEl.value = ['', 'low', 'medium', 'high', 'max'].includes(ev) ? ev : '';
|
orEffEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(ev) ? ev : '';
|
||||||
}
|
}
|
||||||
const orProfEl = document.getElementById('openai-reasoning-profile');
|
const orProfEl = document.getElementById('openai-reasoning-profile');
|
||||||
if (orProfEl) {
|
if (orProfEl) {
|
||||||
@@ -195,14 +211,27 @@ async function loadConfig(loadTools = true) {
|
|||||||
|
|
||||||
const ma = currentConfig.multi_agent || {};
|
const ma = currentConfig.multi_agent || {};
|
||||||
const maEn = document.getElementById('multi-agent-enabled');
|
const maEn = document.getElementById('multi-agent-enabled');
|
||||||
if (maEn) maEn.checked = ma.enabled === true;
|
if (maEn) {
|
||||||
|
maEn.checked = ma.enabled === true;
|
||||||
|
if (!maEn.dataset.robotModeBound) {
|
||||||
|
maEn.dataset.robotModeBound = '1';
|
||||||
|
maEn.addEventListener('change', function () {
|
||||||
|
syncRobotAgentModeSelectOptions(maEn.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const maPeLoop = document.getElementById('multi-agent-pe-loop');
|
const maPeLoop = document.getElementById('multi-agent-pe-loop');
|
||||||
if (maPeLoop) {
|
if (maPeLoop) {
|
||||||
const v = ma.plan_execute_loop_max_iterations;
|
const v = ma.plan_execute_loop_max_iterations;
|
||||||
maPeLoop.value = (v !== undefined && v !== null && !Number.isNaN(Number(v))) ? String(Number(v)) : '0';
|
maPeLoop.value = (v !== undefined && v !== null && !Number.isNaN(Number(v))) ? String(Number(v)) : '0';
|
||||||
}
|
}
|
||||||
const maRobot = document.getElementById('multi-agent-robot-use');
|
const maRobotMode = document.getElementById('multi-agent-robot-mode');
|
||||||
if (maRobot) maRobot.checked = ma.robot_use_multi_agent === true;
|
if (maRobotMode) {
|
||||||
|
let mode = (ma.robot_default_agent_mode || 'react').trim().toLowerCase();
|
||||||
|
if (mode === 'single') mode = 'react';
|
||||||
|
maRobotMode.value = mode;
|
||||||
|
syncRobotAgentModeSelectOptions(ma.enabled === true);
|
||||||
|
}
|
||||||
|
|
||||||
// 填充知识库配置
|
// 填充知识库配置
|
||||||
const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled');
|
const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled');
|
||||||
@@ -1130,9 +1159,14 @@ async function applySettings() {
|
|||||||
const peRaw = document.getElementById('multi-agent-pe-loop')?.value;
|
const peRaw = document.getElementById('multi-agent-pe-loop')?.value;
|
||||||
const peParsed = parseInt(peRaw, 10);
|
const peParsed = parseInt(peRaw, 10);
|
||||||
const peLoop = Number.isNaN(peParsed) ? 0 : Math.max(0, peParsed);
|
const peLoop = Number.isNaN(peParsed) ? 0 : Math.max(0, peParsed);
|
||||||
|
const maEnabled = document.getElementById('multi-agent-enabled')?.checked === true;
|
||||||
|
let robotMode = document.getElementById('multi-agent-robot-mode')?.value || 'react';
|
||||||
|
if (!maEnabled && ['deep', 'plan_execute', 'supervisor'].indexOf(robotMode) >= 0) {
|
||||||
|
robotMode = 'react';
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
enabled: maEnabled,
|
||||||
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
robot_default_agent_mode: robotMode,
|
||||||
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
||||||
plan_execute_loop_max_iterations: peLoop
|
plan_execute_loop_max_iterations: peLoop
|
||||||
};
|
};
|
||||||
@@ -1381,7 +1415,7 @@ async function saveToolsConfig() {
|
|||||||
agent: currentConfig.agent || {},
|
agent: currentConfig.agent || {},
|
||||||
multi_agent: {
|
multi_agent: {
|
||||||
enabled: currentConfig?.multi_agent?.enabled === true,
|
enabled: currentConfig?.multi_agent?.enabled === true,
|
||||||
robot_use_multi_agent: currentConfig?.multi_agent?.robot_use_multi_agent === true,
|
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'react',
|
||||||
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
||||||
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
|
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
|
||||||
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name))
|
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name))
|
||||||
|
|||||||
+152
-77
@@ -46,6 +46,7 @@ const getVulnerabilityPageSize = () => {
|
|||||||
|
|
||||||
let currentVulnerabilityId = null;
|
let currentVulnerabilityId = null;
|
||||||
let vulnerabilityFilters = {
|
let vulnerabilityFilters = {
|
||||||
|
q: '',
|
||||||
id: '',
|
id: '',
|
||||||
conversation_id: '',
|
conversation_id: '',
|
||||||
task_id: '',
|
task_id: '',
|
||||||
@@ -65,11 +66,14 @@ const VULN_STAT_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|||||||
let vulnerabilityStatCardsBound = false;
|
let vulnerabilityStatCardsBound = false;
|
||||||
let vulnerabilityFilterPanelBound = false;
|
let vulnerabilityFilterPanelBound = false;
|
||||||
let vulnerabilityFilterOptionsCache = null;
|
let vulnerabilityFilterOptionsCache = null;
|
||||||
const VULNERABILITY_ADVANCED_OPEN_KEY = 'vulnerabilityAdvancedFiltersOpen';
|
let vulnerabilityMoreFiltersPopoverOpen = false;
|
||||||
|
let vulnerabilityFilterDebounceTimer = null;
|
||||||
|
const VULNERABILITY_FILTER_DEBOUNCE_MS = 400;
|
||||||
const VULNERABILITY_DATALIST_MAX = 8;
|
const VULNERABILITY_DATALIST_MAX = 8;
|
||||||
const VULNERABILITY_DATALIST_MIN_QUERY = 2;
|
const VULNERABILITY_DATALIST_MIN_QUERY = 2;
|
||||||
|
|
||||||
const VULN_FILTER_CHIP_FIELDS = [
|
const VULN_FILTER_CHIP_FIELDS = [
|
||||||
|
{ key: 'q', labelKey: 'vulnerabilityPage.searchKeywordShort' },
|
||||||
{ key: 'id', labelKey: 'vulnerabilityPage.vulnId' },
|
{ key: 'id', labelKey: 'vulnerabilityPage.vulnId' },
|
||||||
{ key: 'status', labelKey: null, format: 'status' },
|
{ key: 'status', labelKey: null, format: 'status' },
|
||||||
{ key: 'severity', labelKey: null, format: 'severity' },
|
{ key: 'severity', labelKey: null, format: 'severity' },
|
||||||
@@ -94,10 +98,12 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
|||||||
const st = (params.get('status') || '').trim();
|
const st = (params.get('status') || '').trim();
|
||||||
const convTag = (params.get('conversation_tag') || '').trim();
|
const convTag = (params.get('conversation_tag') || '').trim();
|
||||||
const taskTag = (params.get('task_tag') || '').trim();
|
const taskTag = (params.get('task_tag') || '').trim();
|
||||||
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag) {
|
const q = (params.get('q') || params.get('search') || '').trim();
|
||||||
|
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag && !q) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vulnerabilityFilters.q = '';
|
||||||
vulnerabilityFilters.id = '';
|
vulnerabilityFilters.id = '';
|
||||||
vulnerabilityFilters.conversation_id = '';
|
vulnerabilityFilters.conversation_id = '';
|
||||||
vulnerabilityFilters.task_id = '';
|
vulnerabilityFilters.task_id = '';
|
||||||
@@ -105,14 +111,16 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
|||||||
vulnerabilityFilters.task_tag = '';
|
vulnerabilityFilters.task_tag = '';
|
||||||
vulnerabilityFilters.severity = '';
|
vulnerabilityFilters.severity = '';
|
||||||
vulnerabilityFilters.status = '';
|
vulnerabilityFilters.status = '';
|
||||||
const idEl = document.getElementById('vulnerability-id-filter');
|
const searchEl = document.getElementById('vulnerability-search-filter');
|
||||||
|
const exactIdEl = document.getElementById('vulnerability-exact-id-filter');
|
||||||
const convEl = document.getElementById('vulnerability-conversation-filter');
|
const convEl = document.getElementById('vulnerability-conversation-filter');
|
||||||
const taskEl = document.getElementById('vulnerability-task-filter');
|
const taskEl = document.getElementById('vulnerability-task-filter');
|
||||||
const convTagEl = document.getElementById('vulnerability-conversation-tag-filter');
|
const convTagEl = document.getElementById('vulnerability-conversation-tag-filter');
|
||||||
const taskTagEl = document.getElementById('vulnerability-task-tag-filter');
|
const taskTagEl = document.getElementById('vulnerability-task-tag-filter');
|
||||||
const sevEl = document.getElementById('vulnerability-severity-filter');
|
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||||||
const stEl = document.getElementById('vulnerability-status-filter');
|
const stEl = document.getElementById('vulnerability-status-filter');
|
||||||
if (idEl) idEl.value = '';
|
if (searchEl) searchEl.value = '';
|
||||||
|
if (exactIdEl) exactIdEl.value = '';
|
||||||
if (convEl) convEl.value = '';
|
if (convEl) convEl.value = '';
|
||||||
if (taskEl) taskEl.value = '';
|
if (taskEl) taskEl.value = '';
|
||||||
if (convTagEl) convTagEl.value = '';
|
if (convTagEl) convTagEl.value = '';
|
||||||
@@ -120,9 +128,13 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
|||||||
if (sevEl) sevEl.value = '';
|
if (sevEl) sevEl.value = '';
|
||||||
if (stEl) stEl.value = '';
|
if (stEl) stEl.value = '';
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
vulnerabilityFilters.q = q;
|
||||||
|
if (searchEl) searchEl.value = q;
|
||||||
|
}
|
||||||
if (vid) {
|
if (vid) {
|
||||||
vulnerabilityFilters.id = vid;
|
vulnerabilityFilters.id = vid;
|
||||||
if (idEl) idEl.value = vid;
|
if (exactIdEl) exactIdEl.value = vid;
|
||||||
}
|
}
|
||||||
if (cid) {
|
if (cid) {
|
||||||
vulnerabilityFilters.conversation_id = cid;
|
vulnerabilityFilters.conversation_id = cid;
|
||||||
@@ -149,9 +161,6 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
|||||||
if (stEl) stEl.value = st;
|
if (stEl) stEl.value = st;
|
||||||
}
|
}
|
||||||
vulnerabilityPagination.currentPage = 1;
|
vulnerabilityPagination.currentPage = 1;
|
||||||
if (hasVulnerabilityAdvancedFiltersActive()) {
|
|
||||||
setVulnerabilityAdvancedFiltersOpen(true, false);
|
|
||||||
}
|
|
||||||
syncVulnerabilityStatCardActiveState();
|
syncVulnerabilityStatCardActiveState();
|
||||||
updateVulnerabilityFilterPanelState();
|
updateVulnerabilityFilterPanelState();
|
||||||
renderVulnerabilityFilterChips();
|
renderVulnerabilityFilterChips();
|
||||||
@@ -213,7 +222,8 @@ function applyVulnerabilitySeverityFilter(severity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readVulnerabilityFiltersFromForm() {
|
function readVulnerabilityFiltersFromForm() {
|
||||||
vulnerabilityFilters.id = (document.getElementById('vulnerability-id-filter')?.value || '').trim();
|
vulnerabilityFilters.q = (document.getElementById('vulnerability-search-filter')?.value || '').trim();
|
||||||
|
vulnerabilityFilters.id = (document.getElementById('vulnerability-exact-id-filter')?.value || '').trim();
|
||||||
vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim();
|
vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim();
|
||||||
vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim();
|
vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim();
|
||||||
vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim();
|
vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim();
|
||||||
@@ -225,13 +235,13 @@ function readVulnerabilityFiltersFromForm() {
|
|||||||
|
|
||||||
function hasVulnerabilityAdvancedFiltersActive() {
|
function hasVulnerabilityAdvancedFiltersActive() {
|
||||||
const f = vulnerabilityFilters;
|
const f = vulnerabilityFilters;
|
||||||
return Boolean(f.conversation_id || f.task_id || f.conversation_tag || f.task_tag);
|
return Boolean(f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyVulnerabilityFilterActive() {
|
function hasAnyVulnerabilityFilterActive() {
|
||||||
const f = vulnerabilityFilters;
|
const f = vulnerabilityFilters;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
|
f.q || f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +263,7 @@ function updateVulnerabilityLocationHashFromFilters() {
|
|||||||
const params = new URLSearchParams(hashParts.length >= 2 ? hashParts.slice(1).join('?') : '');
|
const params = new URLSearchParams(hashParts.length >= 2 ? hashParts.slice(1).join('?') : '');
|
||||||
const f = vulnerabilityFilters;
|
const f = vulnerabilityFilters;
|
||||||
const pairs = [
|
const pairs = [
|
||||||
|
['q', f.q],
|
||||||
['id', f.id],
|
['id', f.id],
|
||||||
['conversation_id', f.conversation_id],
|
['conversation_id', f.conversation_id],
|
||||||
['task_id', f.task_id],
|
['task_id', f.task_id],
|
||||||
@@ -279,17 +290,82 @@ function updateVulnerabilityLocationHashFromFilters() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVulnerabilityAdvancedFilters(ev) {
|
function scheduleVulnerabilityFilterApply(immediate) {
|
||||||
|
if (vulnerabilityFilterDebounceTimer) {
|
||||||
|
clearTimeout(vulnerabilityFilterDebounceTimer);
|
||||||
|
vulnerabilityFilterDebounceTimer = null;
|
||||||
|
}
|
||||||
|
if (immediate) {
|
||||||
|
applyVulnerabilityFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vulnerabilityFilterDebounceTimer = setTimeout(function () {
|
||||||
|
vulnerabilityFilterDebounceTimer = null;
|
||||||
|
applyVulnerabilityFilters();
|
||||||
|
}, VULNERABILITY_FILTER_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVulnerabilityAdvancedFieldsFromFilters() {
|
||||||
|
const map = {
|
||||||
|
id: 'vulnerability-exact-id-filter',
|
||||||
|
conversation_id: 'vulnerability-conversation-filter',
|
||||||
|
task_id: 'vulnerability-task-filter',
|
||||||
|
conversation_tag: 'vulnerability-conversation-tag-filter',
|
||||||
|
task_tag: 'vulnerability-task-tag-filter'
|
||||||
|
};
|
||||||
|
Object.keys(map).forEach(function (key) {
|
||||||
|
const el = document.getElementById(map[key]);
|
||||||
|
if (el) el.value = vulnerabilityFilters[key] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVulnerabilityMoreFiltersPopoverOpen(open) {
|
||||||
|
const btn = document.getElementById('vulnerability-more-filters-btn');
|
||||||
|
const popover = document.getElementById('vulnerability-more-filters-popover');
|
||||||
|
if (!btn || !popover) return;
|
||||||
|
vulnerabilityMoreFiltersPopoverOpen = open;
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
btn.classList.toggle('is-active', open);
|
||||||
|
popover.hidden = !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVulnerabilityMoreFiltersPopover(ev) {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
}
|
}
|
||||||
const toggleBtn = document.getElementById('vulnerability-advanced-toggle');
|
const opening = !vulnerabilityMoreFiltersPopoverOpen;
|
||||||
if (!toggleBtn) return;
|
if (opening) {
|
||||||
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
readVulnerabilityFiltersFromForm();
|
||||||
setVulnerabilityAdvancedFiltersOpen(!expanded, true);
|
syncVulnerabilityAdvancedFieldsFromFilters();
|
||||||
|
}
|
||||||
|
setVulnerabilityMoreFiltersPopoverOpen(opening);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVulnerabilityMoreFiltersPopover(revertDraft) {
|
||||||
|
if (revertDraft) syncVulnerabilityAdvancedFieldsFromFilters();
|
||||||
|
setVulnerabilityMoreFiltersPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVulnerabilityAdvancedFilterFields() {
|
||||||
|
['vulnerability-exact-id-filter', 'vulnerability-conversation-filter', 'vulnerability-task-filter',
|
||||||
|
'vulnerability-conversation-tag-filter', 'vulnerability-task-tag-filter'].forEach(function (id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVulnerabilityMoreFiltersFromPopover() {
|
||||||
|
closeVulnerabilityMoreFiltersPopover(false);
|
||||||
|
scheduleVulnerabilityFilterApply(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVulnerabilityFilterDocumentPointerDown(ev) {
|
||||||
|
if (!vulnerabilityMoreFiltersPopoverOpen) return;
|
||||||
|
const anchor = document.querySelector('.vulnerability-more-filters-anchor');
|
||||||
|
if (anchor && anchor.contains(ev.target)) return;
|
||||||
|
closeVulnerabilityMoreFiltersPopover(true);
|
||||||
}
|
}
|
||||||
window.toggleVulnerabilityAdvancedFilters = toggleVulnerabilityAdvancedFilters;
|
|
||||||
|
|
||||||
function initVulnerabilityFilterPanel() {
|
function initVulnerabilityFilterPanel() {
|
||||||
const panel = document.getElementById('vulnerability-filter-panel');
|
const panel = document.getElementById('vulnerability-filter-panel');
|
||||||
@@ -301,55 +377,69 @@ function initVulnerabilityFilterPanel() {
|
|||||||
}
|
}
|
||||||
vulnerabilityFilterPanelBound = true;
|
vulnerabilityFilterPanelBound = true;
|
||||||
|
|
||||||
let savedOpen = false;
|
|
||||||
try {
|
|
||||||
savedOpen = localStorage.getItem(VULNERABILITY_ADVANCED_OPEN_KEY) === 'true';
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
setVulnerabilityAdvancedFiltersOpen(savedOpen, false);
|
|
||||||
|
|
||||||
const stEl = document.getElementById('vulnerability-status-filter');
|
const stEl = document.getElementById('vulnerability-status-filter');
|
||||||
if (stEl) stEl.addEventListener('change', applyVulnerabilityFilters);
|
if (stEl) stEl.addEventListener('change', function () { scheduleVulnerabilityFilterApply(true); });
|
||||||
|
|
||||||
const textIds = [
|
const searchEl = document.getElementById('vulnerability-search-filter');
|
||||||
'vulnerability-id-filter',
|
if (searchEl) {
|
||||||
|
searchEl.addEventListener('input', function () { scheduleVulnerabilityFilterApply(false); });
|
||||||
|
searchEl.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
scheduleVulnerabilityFilterApply(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchEl.addEventListener('search', function () {
|
||||||
|
if (searchEl.value === '') scheduleVulnerabilityFilterApply(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const moreBtn = document.getElementById('vulnerability-more-filters-btn');
|
||||||
|
if (moreBtn) moreBtn.addEventListener('click', toggleVulnerabilityMoreFiltersPopover);
|
||||||
|
|
||||||
|
const applyBtn = document.getElementById('vulnerability-more-filters-apply');
|
||||||
|
if (applyBtn) applyBtn.addEventListener('click', applyVulnerabilityMoreFiltersFromPopover);
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById('vulnerability-more-filters-reset');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', function () {
|
||||||
|
clearVulnerabilityAdvancedFilterFields();
|
||||||
|
applyVulnerabilityMoreFiltersFromPopover();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const advancedTextIds = [
|
||||||
|
'vulnerability-exact-id-filter',
|
||||||
'vulnerability-conversation-filter',
|
'vulnerability-conversation-filter',
|
||||||
'vulnerability-task-filter',
|
'vulnerability-task-filter',
|
||||||
'vulnerability-conversation-tag-filter',
|
'vulnerability-conversation-tag-filter',
|
||||||
'vulnerability-task-tag-filter'
|
'vulnerability-task-tag-filter'
|
||||||
];
|
];
|
||||||
textIds.forEach(function (id) {
|
advancedTextIds.forEach(function (id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.addEventListener('keydown', function (ev) {
|
el.addEventListener('keydown', function (ev) {
|
||||||
if (ev.key === 'Enter') {
|
if (ev.key === 'Enter') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
applyVulnerabilityFilters();
|
applyVulnerabilityMoreFiltersFromPopover();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onVulnerabilityFilterDocumentPointerDown);
|
||||||
|
document.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Escape' && vulnerabilityMoreFiltersPopoverOpen) {
|
||||||
|
closeVulnerabilityMoreFiltersPopover(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
bindVulnerabilityFilterTypeaheads();
|
bindVulnerabilityFilterTypeaheads();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVulnerabilityAdvancedFiltersOpen(open, persist) {
|
|
||||||
const toggleBtn = document.getElementById('vulnerability-advanced-toggle');
|
|
||||||
const advanced = document.getElementById('vulnerability-advanced-filters');
|
|
||||||
const wrap = document.querySelector('#vulnerability-filter-panel .vulnerability-filter-advanced-wrap');
|
|
||||||
if (!toggleBtn || !advanced) return;
|
|
||||||
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
||||||
advanced.hidden = !open;
|
|
||||||
advanced.classList.toggle('is-open', open);
|
|
||||||
if (wrap) wrap.classList.toggle('is-expanded', open);
|
|
||||||
if (persist) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(VULNERABILITY_ADVANCED_OPEN_KEY, open ? 'true' : 'false');
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countVulnerabilityAdvancedFiltersActive() {
|
function countVulnerabilityAdvancedFiltersActive() {
|
||||||
const f = vulnerabilityFilters;
|
const f = vulnerabilityFilters;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
if (f.id) n++;
|
||||||
if (f.conversation_id) n++;
|
if (f.conversation_id) n++;
|
||||||
if (f.task_id) n++;
|
if (f.task_id) n++;
|
||||||
if (f.conversation_tag) n++;
|
if (f.conversation_tag) n++;
|
||||||
@@ -379,6 +469,8 @@ function updateVulnerabilityFilterPanelState() {
|
|||||||
readVulnerabilityFiltersFromForm();
|
readVulnerabilityFiltersFromForm();
|
||||||
panel.classList.toggle('is-filtered', hasAnyVulnerabilityFilterActive());
|
panel.classList.toggle('is-filtered', hasAnyVulnerabilityFilterActive());
|
||||||
updateVulnerabilityAdvancedBadge();
|
updateVulnerabilityAdvancedBadge();
|
||||||
|
const clearBtn = document.getElementById('vulnerability-filter-clear-btn');
|
||||||
|
if (clearBtn) clearBtn.hidden = !hasAnyVulnerabilityFilterActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVulnerabilityFilterChipValue(key, value) {
|
function formatVulnerabilityFilterChipValue(key, value) {
|
||||||
@@ -431,7 +523,8 @@ function renderVulnerabilityFilterChips() {
|
|||||||
|
|
||||||
function removeVulnerabilityFilterByKey(key) {
|
function removeVulnerabilityFilterByKey(key) {
|
||||||
const map = {
|
const map = {
|
||||||
id: 'vulnerability-id-filter',
|
q: 'vulnerability-search-filter',
|
||||||
|
id: 'vulnerability-exact-id-filter',
|
||||||
conversation_id: 'vulnerability-conversation-filter',
|
conversation_id: 'vulnerability-conversation-filter',
|
||||||
task_id: 'vulnerability-task-filter',
|
task_id: 'vulnerability-task-filter',
|
||||||
conversation_tag: 'vulnerability-conversation-tag-filter',
|
conversation_tag: 'vulnerability-conversation-tag-filter',
|
||||||
@@ -609,13 +702,7 @@ async function loadVulnerabilityStats() {
|
|||||||
throw new Error('apiFetch未定义');
|
throw new Error('apiFetch未定义');
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = buildVulnerabilityFilterParams();
|
||||||
if (vulnerabilityFilters.conversation_id) {
|
|
||||||
params.append('conversation_id', vulnerabilityFilters.conversation_id);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.task_id) {
|
|
||||||
params.append('task_id', vulnerabilityFilters.task_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -689,31 +776,9 @@ async function loadVulnerabilities(page = null) {
|
|||||||
vulnerabilityPagination.currentPage = page;
|
vulnerabilityPagination.currentPage = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = buildVulnerabilityFilterParams();
|
||||||
params.append('page', vulnerabilityPagination.currentPage.toString());
|
params.append('page', vulnerabilityPagination.currentPage.toString());
|
||||||
params.append('limit', vulnerabilityPagination.pageSize.toString());
|
params.append('limit', vulnerabilityPagination.pageSize.toString());
|
||||||
|
|
||||||
if (vulnerabilityFilters.id) {
|
|
||||||
params.append('id', vulnerabilityFilters.id);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.conversation_id) {
|
|
||||||
params.append('conversation_id', vulnerabilityFilters.conversation_id);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.task_id) {
|
|
||||||
params.append('task_id', vulnerabilityFilters.task_id);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.conversation_tag) {
|
|
||||||
params.append('conversation_tag', vulnerabilityFilters.conversation_tag);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.task_tag) {
|
|
||||||
params.append('task_tag', vulnerabilityFilters.task_tag);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.severity) {
|
|
||||||
params.append('severity', vulnerabilityFilters.severity);
|
|
||||||
}
|
|
||||||
if (vulnerabilityFilters.status) {
|
|
||||||
params.append('status', vulnerabilityFilters.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiFetch(`/api/vulnerabilities?${params.toString()}`);
|
const response = await apiFetch(`/api/vulnerabilities?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1090,8 +1155,14 @@ function filterVulnerabilities() {
|
|||||||
|
|
||||||
// 清除筛选
|
// 清除筛选
|
||||||
function clearVulnerabilityFilters() {
|
function clearVulnerabilityFilters() {
|
||||||
|
closeVulnerabilityMoreFiltersPopover(false);
|
||||||
|
if (vulnerabilityFilterDebounceTimer) {
|
||||||
|
clearTimeout(vulnerabilityFilterDebounceTimer);
|
||||||
|
vulnerabilityFilterDebounceTimer = null;
|
||||||
|
}
|
||||||
const fields = [
|
const fields = [
|
||||||
'vulnerability-id-filter',
|
'vulnerability-search-filter',
|
||||||
|
'vulnerability-exact-id-filter',
|
||||||
'vulnerability-conversation-filter',
|
'vulnerability-conversation-filter',
|
||||||
'vulnerability-task-filter',
|
'vulnerability-task-filter',
|
||||||
'vulnerability-conversation-tag-filter',
|
'vulnerability-conversation-tag-filter',
|
||||||
@@ -1105,6 +1176,7 @@ function clearVulnerabilityFilters() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vulnerabilityFilters = {
|
vulnerabilityFilters = {
|
||||||
|
q: '',
|
||||||
id: '',
|
id: '',
|
||||||
conversation_id: '',
|
conversation_id: '',
|
||||||
task_id: '',
|
task_id: '',
|
||||||
@@ -1277,8 +1349,11 @@ function formatVulnerabilityAsMarkdown(vuln) {
|
|||||||
|
|
||||||
function buildVulnerabilityFilterParams() {
|
function buildVulnerabilityFilterParams() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
if (vulnerabilityFilters.q) {
|
||||||
|
params.append('q', vulnerabilityFilters.q);
|
||||||
|
}
|
||||||
const keys = ['id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
|
const keys = ['id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
|
||||||
keys.forEach((k) => {
|
keys.forEach(function (k) {
|
||||||
if (vulnerabilityFilters[k]) {
|
if (vulnerabilityFilters[k]) {
|
||||||
params.append(k, vulnerabilityFilters[k]);
|
params.append(k, vulnerabilityFilters[k]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1666,11 +1666,8 @@ function buildWebshellTimelineItemFromDetail(detail) {
|
|||||||
var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具');
|
var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具');
|
||||||
var idx = data.index || 0;
|
var idx = data.index || 0;
|
||||||
var total = data.total || 0;
|
var total = data.total || 0;
|
||||||
var wsHint = typeof window.toolCallArgHint === 'function'
|
|
||||||
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(data) : {})
|
|
||||||
: '';
|
|
||||||
var wsCallTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
var wsCallTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
||||||
? window.formatToolCallTimelineTitle(tn, idx, total, wsHint)
|
? window.formatToolCallTimelineTitle(tn, idx, total)
|
||||||
: ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
: ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
||||||
title = ap + '🔧 ' + wsCallTitle;
|
title = ap + '🔧 ' + wsCallTitle;
|
||||||
} else if (eventType === 'tool_result') {
|
} else if (eventType === 'tool_result') {
|
||||||
@@ -3064,11 +3061,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
var tn = _ed.toolName || '未知工具';
|
var tn = _ed.toolName || '未知工具';
|
||||||
var idx = _ed.index || 0;
|
var idx = _ed.index || 0;
|
||||||
var total = _ed.total || 0;
|
var total = _ed.total || 0;
|
||||||
var wsHintLive = typeof window.toolCallArgHint === 'function'
|
|
||||||
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(_ed) : {})
|
|
||||||
: '';
|
|
||||||
var callTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
var callTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
||||||
? window.formatToolCallTimelineTitle(tn, idx, total, wsHintLive)
|
? window.formatToolCallTimelineTitle(tn, idx, total)
|
||||||
: (wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
: (wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
||||||
var callItem = appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
|
var callItem = appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
|
||||||
if (_ed.toolCallId && callItem) {
|
if (_ed.toolCallId && callItem) {
|
||||||
|
|||||||
+176
-53
@@ -833,6 +833,7 @@
|
|||||||
<option value="low">low</option>
|
<option value="low">low</option>
|
||||||
<option value="medium">medium</option>
|
<option value="medium">medium</option>
|
||||||
<option value="high">high</option>
|
<option value="high">high</option>
|
||||||
|
<option value="xhigh">xhigh</option>
|
||||||
<option value="max">max</option>
|
<option value="max">max</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1446,10 +1447,14 @@
|
|||||||
<div class="vulnerability-controls" id="vulnerability-filter-panel">
|
<div class="vulnerability-controls" id="vulnerability-filter-panel">
|
||||||
<div class="vulnerability-filter-toolbar">
|
<div class="vulnerability-filter-toolbar">
|
||||||
<div class="vulnerability-filter-primary">
|
<div class="vulnerability-filter-primary">
|
||||||
<label class="vulnerability-filter-field vulnerability-filter-field--grow">
|
<label class="vulnerability-filter-field vulnerability-filter-field--grow vulnerability-search-wrap">
|
||||||
<span class="sr-only" data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
<span class="sr-only" data-i18n="vulnerabilityPage.searchKeyword">关键词搜索</span>
|
||||||
<input type="search" id="vulnerability-id-filter" autocomplete="off"
|
<svg class="vulnerability-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞 ID,回车筛选" />
|
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input type="search" id="vulnerability-search-filter" autocomplete="off"
|
||||||
|
data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />
|
||||||
</label>
|
</label>
|
||||||
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||||
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
||||||
@@ -1461,7 +1466,52 @@
|
|||||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="vulnerability-filter-clear-btn" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
|
<div class="vulnerability-filter-actions">
|
||||||
|
<div class="vulnerability-more-filters-anchor">
|
||||||
|
<button type="button" class="btn-secondary vulnerability-more-filters-btn" id="vulnerability-more-filters-btn"
|
||||||
|
aria-expanded="false" aria-haspopup="dialog" aria-controls="vulnerability-more-filters-popover">
|
||||||
|
<span data-i18n="vulnerabilityPage.moreFilters">更多筛选</span>
|
||||||
|
<span class="vulnerability-filter-advanced-badge" id="vulnerability-advanced-badge" hidden></span>
|
||||||
|
</button>
|
||||||
|
<div class="vulnerability-more-filters-popover" id="vulnerability-more-filters-popover" hidden role="dialog"
|
||||||
|
aria-labelledby="vulnerability-more-filters-title">
|
||||||
|
<p class="vulnerability-more-filters-popover-title" id="vulnerability-more-filters-title" data-i18n="vulnerabilityPage.moreFilters">更多筛选</p>
|
||||||
|
<div class="vulnerability-more-filters-popover-body">
|
||||||
|
<label class="vulnerability-filter-field vulnerability-filter-field--full">
|
||||||
|
<span data-i18n="vulnerabilityPage.vulnId">漏洞 ID</span>
|
||||||
|
<input type="text" id="vulnerability-exact-id-filter"
|
||||||
|
data-i18n="vulnerabilityPage.filterExactId" data-i18n-attr="placeholder" placeholder="精确匹配漏洞 ID" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.conversationId">会话 ID</span>
|
||||||
|
<input type="text" id="vulnerability-conversation-filter" list="vulnerability-conversation-suggestions"
|
||||||
|
data-i18n="vulnerabilityPage.filterConversation" data-i18n-attr="placeholder" placeholder="筛选特定会话" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务 / 队列 ID</span>
|
||||||
|
<input type="text" id="vulnerability-task-filter" list="vulnerability-task-suggestions"
|
||||||
|
data-i18n="vulnerabilityPage.filterTaskOrQueue" data-i18n-attr="placeholder" placeholder="筛选任务或队列 ID" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
|
||||||
|
<input type="text" id="vulnerability-conversation-tag-filter" list="vulnerability-conversation-tag-suggestions"
|
||||||
|
data-i18n="vulnerabilityPage.filterConversationTag" data-i18n-attr="placeholder" placeholder="筛选对话标签" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
|
||||||
|
<input type="text" id="vulnerability-task-tag-filter" list="vulnerability-task-tag-suggestions"
|
||||||
|
data-i18n="vulnerabilityPage.filterTaskTag" data-i18n-attr="placeholder" placeholder="筛选任务标签" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="vulnerability-more-filters-popover-footer">
|
||||||
|
<button type="button" class="btn-secondary" id="vulnerability-more-filters-reset" data-i18n="vulnerabilityPage.clearAdvanced">清空</button>
|
||||||
|
<button type="button" class="btn-primary" id="vulnerability-more-filters-apply" data-i18n="vulnerabilityPage.applyFilters">应用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vulnerability-filter-clear-btn" id="vulnerability-filter-clear-btn"
|
||||||
|
onclick="clearVulnerabilityFilters()" hidden data-i18n="vulnerabilityPage.clearAll">重置全部</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<select id="vulnerability-severity-filter" class="vulnerability-severity-sync" hidden aria-hidden="true" tabindex="-1">
|
<select id="vulnerability-severity-filter" class="vulnerability-severity-sync" hidden aria-hidden="true" tabindex="-1">
|
||||||
<option value=""></option>
|
<option value=""></option>
|
||||||
@@ -1472,34 +1522,8 @@
|
|||||||
<option value="info">info</option>
|
<option value="info">info</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="vulnerability-filter-advanced-wrap">
|
|
||||||
<button type="button" class="vulnerability-filter-advanced-toggle" id="vulnerability-advanced-toggle"
|
|
||||||
aria-expanded="false" aria-controls="vulnerability-advanced-filters"
|
|
||||||
onclick="toggleVulnerabilityAdvancedFilters(event)">
|
|
||||||
<span class="vulnerability-filter-advanced-chevron" aria-hidden="true"></span>
|
|
||||||
<span data-i18n="vulnerabilityPage.advancedFilters">高级筛选</span>
|
|
||||||
<span class="vulnerability-filter-advanced-badge" id="vulnerability-advanced-badge" hidden></span>
|
|
||||||
</button>
|
|
||||||
<div class="vulnerability-filter-advanced" id="vulnerability-advanced-filters" hidden>
|
|
||||||
<label class="vulnerability-filter-field">
|
|
||||||
<span data-i18n="vulnerabilityPage.conversationId">会话 ID</span>
|
|
||||||
<input type="text" id="vulnerability-conversation-filter" list="vulnerability-conversation-suggestions" placeholder="回车筛选" />
|
|
||||||
</label>
|
|
||||||
<label class="vulnerability-filter-field">
|
|
||||||
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务 / 队列 ID</span>
|
|
||||||
<input type="text" id="vulnerability-task-filter" list="vulnerability-task-suggestions" placeholder="回车筛选" />
|
|
||||||
</label>
|
|
||||||
<label class="vulnerability-filter-field">
|
|
||||||
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
|
|
||||||
<input type="text" id="vulnerability-conversation-tag-filter" list="vulnerability-conversation-tag-suggestions" placeholder="回车筛选" />
|
|
||||||
</label>
|
|
||||||
<label class="vulnerability-filter-field">
|
|
||||||
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
|
|
||||||
<input type="text" id="vulnerability-task-tag-filter" list="vulnerability-task-tag-suggestions" placeholder="回车筛选" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vulnerability-filter-chips" id="vulnerability-filter-chips" hidden>
|
<div class="vulnerability-filter-chips" id="vulnerability-filter-chips" hidden>
|
||||||
|
<span class="vulnerability-filter-chips-label" data-i18n="vulnerabilityPage.activeFilters">已选条件</span>
|
||||||
<div class="vulnerability-filter-chips-list" id="vulnerability-filter-chips-list" role="list"></div>
|
<div class="vulnerability-filter-chips-list" id="vulnerability-filter-chips-list" role="list"></div>
|
||||||
</div>
|
</div>
|
||||||
<datalist id="vulnerability-conversation-suggestions"></datalist>
|
<datalist id="vulnerability-conversation-suggestions"></datalist>
|
||||||
@@ -2041,6 +2065,9 @@
|
|||||||
<div class="settings-nav-item" data-section="security" onclick="switchSettingsSection('security')">
|
<div class="settings-nav-item" data-section="security" onclick="switchSettingsSection('security')">
|
||||||
<span data-i18n="settings.nav.security">安全设置</span>
|
<span data-i18n="settings.nav.security">安全设置</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-nav-item" data-section="audit" onclick="switchSettingsSection('audit')">
|
||||||
|
<span data-i18n="settings.nav.audit">日志审计</span>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -2096,6 +2123,7 @@
|
|||||||
<option value="low">low</option>
|
<option value="low">low</option>
|
||||||
<option value="medium">medium</option>
|
<option value="medium">medium</option>
|
||||||
<option value="high">high</option>
|
<option value="high">high</option>
|
||||||
|
<option value="xhigh">xhigh</option>
|
||||||
<option value="max">max</option>
|
<option value="max">max</option>
|
||||||
</select>
|
</select>
|
||||||
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
||||||
@@ -2141,12 +2169,15 @@
|
|||||||
<small class="form-hint" data-i18n="settingsBasic.multiAgentPeLoopHint">仅 orchestration=plan_execute 时有效;execute 与 replan 之间的最大轮次。</small>
|
<small class="form-hint" data-i18n="settingsBasic.multiAgentPeLoopHint">仅 orchestration=plan_execute 时有效;execute 与 replan 之间的最大轮次。</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label for="multi-agent-robot-mode" data-i18n="settingsBasic.multiAgentRobotMode">机器人默认对话模式</label>
|
||||||
<input type="checkbox" id="multi-agent-robot-use" class="modern-checkbox" />
|
<select id="multi-agent-robot-mode" class="form-select">
|
||||||
<span class="checkbox-custom"></span>
|
<option value="react" data-i18n="chat.agentModeReactNative">原生 ReAct</option>
|
||||||
<span class="checkbox-text" data-i18n="settingsBasic.multiAgentRobotUse">企业微信 / 钉钉 / 飞书机器人也使用多代理</span>
|
<option value="eino_single" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK)</option>
|
||||||
</label>
|
<option value="deep" data-i18n="chat.agentModeDeep">Deep(DeepAgent)</option>
|
||||||
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotUseHint">需同时勾选「启用多代理」;调用量与成本更高。</small>
|
<option value="plan_execute" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</option>
|
||||||
|
<option value="supervisor" data-i18n="chat.agentModeSupervisorLabel">Supervisor</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotModeHint">企业微信 / 钉钉 / 飞书机器人每条消息使用的执行模式;Deep / Plan-Execute / Supervisor 需启用多代理。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2572,6 +2603,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志审计 -->
|
||||||
|
<div id="settings-section-audit" class="settings-section-content">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h3 data-i18n="settingsAudit.title">日志审计</h3>
|
||||||
|
<p class="settings-description" data-i18n="settingsAudit.description">记录平台管理类操作(登录、配置、删除等),不记录对话正文、终端/WebShell 每次命令与工具调用明细。</p>
|
||||||
|
<p id="audit-retention-hint" class="settings-description audit-retention-hint" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div id="audit-summary-stats" class="audit-summary-stats" hidden>
|
||||||
|
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statTotal">当前筛选</span><strong id="audit-stat-total">0</strong></div>
|
||||||
|
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statFailures">失败</span><strong id="audit-stat-failures">0</strong></div>
|
||||||
|
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statRecent7d">近 7 天</span><strong id="audit-stat-recent">0</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-logs-toolbar">
|
||||||
|
<div class="audit-logs-filters">
|
||||||
|
<label class="audit-filter-cascade-group">
|
||||||
|
<span data-i18n="settingsAudit.filterEvent">事件类型</span>
|
||||||
|
<div class="audit-filter-cascade">
|
||||||
|
<select id="audit-filter-category" onchange="onAuditCategoryFilterChange()" aria-label="类别">
|
||||||
|
<option value="" data-i18n="settingsAudit.filterAllCategories">全部类别</option>
|
||||||
|
<option value="auth" data-i18n="settingsAudit.cat.auth">认证</option>
|
||||||
|
<option value="config" data-i18n="settingsAudit.cat.config">配置</option>
|
||||||
|
<option value="c2" data-i18n="settingsAudit.cat.c2">C2</option>
|
||||||
|
<option value="webshell" data-i18n="settingsAudit.cat.webshell">WebShell</option>
|
||||||
|
<option value="knowledge" data-i18n="settingsAudit.cat.knowledge">知识库</option>
|
||||||
|
<option value="conversation" data-i18n="settingsAudit.cat.conversation">对话</option>
|
||||||
|
<option value="vulnerability" data-i18n="settingsAudit.cat.vulnerability">漏洞</option>
|
||||||
|
<option value="external_mcp" data-i18n="settingsAudit.cat.externalMcp">外部 MCP</option>
|
||||||
|
<option value="task" data-i18n="settingsAudit.cat.task">任务</option>
|
||||||
|
<option value="tool" data-i18n="settingsAudit.cat.tool">工具</option>
|
||||||
|
<option value="file" data-i18n="settingsAudit.cat.file">文件</option>
|
||||||
|
<option value="hitl" data-i18n="settingsAudit.cat.hitl">人机协同</option>
|
||||||
|
<option value="role" data-i18n="settingsAudit.cat.role">角色</option>
|
||||||
|
<option value="skill" data-i18n="settingsAudit.cat.skill">Skill</option>
|
||||||
|
<option value="agent" data-i18n="settingsAudit.cat.agent">子代理</option>
|
||||||
|
</select>
|
||||||
|
<span class="audit-filter-cascade-arrow" aria-hidden="true">→</span>
|
||||||
|
<select id="audit-filter-action" disabled aria-label="操作">
|
||||||
|
<option value="" data-i18n="settingsAudit.filterAllActions">全部操作</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="settingsAudit.filterResult">结果</span>
|
||||||
|
<select id="audit-filter-result">
|
||||||
|
<option value="" data-i18n="settingsAudit.filterAll">全部</option>
|
||||||
|
<option value="success">success</option>
|
||||||
|
<option value="failure">failure</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="settingsAudit.filterSince">开始时间</span>
|
||||||
|
<input type="datetime-local" id="audit-filter-since" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="settingsAudit.filterUntil">结束时间</span>
|
||||||
|
<input type="datetime-local" id="audit-filter-until" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span data-i18n="settingsAudit.filterQuery">关键词</span>
|
||||||
|
<input type="text" id="audit-filter-q" data-i18n="settingsAudit.filterQueryPlaceholder" data-i18n-attr="placeholder" placeholder="消息 / 资源 ID / 操作名" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-secondary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
|
||||||
|
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
|
||||||
|
</div>
|
||||||
|
<div class="audit-logs-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
|
||||||
|
<div class="audit-export-dropdown">
|
||||||
|
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<span data-i18n="settingsAudit.exportBtn">导出</span>
|
||||||
|
<span class="audit-export-caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
|
||||||
|
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
|
||||||
|
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="audit-log-list" class="audit-log-list c2-event-list"></div>
|
||||||
|
<div id="audit-logs-pagination" class="pagination-container audit-logs-pagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 安全设置 -->
|
<!-- 安全设置 -->
|
||||||
<div id="settings-section-security" class="settings-section-content">
|
<div id="settings-section-security" class="settings-section-content">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
@@ -2910,30 +3023,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="chat-files-rename-modal" class="modal">
|
<div id="chat-files-rename-modal" class="modal chat-files-form-modal">
|
||||||
<div class="modal-content" style="max-width: 480px;">
|
<div class="modal-content chat-files-form-modal-content chat-files-mkdir-modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header chat-files-form-modal-header">
|
||||||
<h2 data-i18n="chatFilesPage.renameTitle">重命名</h2>
|
<h2 data-i18n="chatFilesPage.renameTitle">重命名</h2>
|
||||||
<span class="modal-close" onclick="closeChatFilesRenameModal()">×</span>
|
<span class="modal-close chat-files-form-modal-close" onclick="closeChatFilesRenameModal()" aria-label="Close">×</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body chat-files-mkdir-body">
|
||||||
<label class="chat-files-rename-label">
|
<div class="chat-files-mkdir-location">
|
||||||
<span data-i18n="chatFilesPage.newFileName">新文件名</span>
|
<div class="chat-files-mkdir-location-caption" data-i18n="chatFilesPage.renameCurrentFile">当前文件</div>
|
||||||
<input type="text" id="chat-files-rename-input" class="form-control" />
|
<div class="chat-files-mkdir-path-box">
|
||||||
|
<code class="chat-files-mkdir-path" id="chat-files-rename-path-hint"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="chat-files-rename-label chat-files-mkdir-label">
|
||||||
|
<span class="chat-files-mkdir-field-name">
|
||||||
|
<svg class="chat-files-mkdir-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
<span data-i18n="chatFilesPage.newFileName">新文件名</span>
|
||||||
|
</span>
|
||||||
|
<input type="text" id="chat-files-rename-input" class="form-control chat-files-mkdir-input" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer chat-files-mkdir-footer">
|
||||||
<button type="button" class="btn-secondary" onclick="closeChatFilesRenameModal()" data-i18n="common.cancel">取消</button>
|
<button type="button" class="btn-secondary chat-files-mkdir-btn-cancel" onclick="closeChatFilesRenameModal()" data-i18n="common.cancel">取消</button>
|
||||||
<button type="button" class="btn-primary" onclick="submitChatFilesRename()" data-i18n="common.ok">确定</button>
|
<button type="button" class="btn-primary chat-files-mkdir-btn-submit" onclick="submitChatFilesRename()" data-i18n="common.ok">确定</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="chat-files-mkdir-modal" class="modal">
|
<div id="chat-files-mkdir-modal" class="modal chat-files-form-modal">
|
||||||
<div class="modal-content chat-files-mkdir-modal-content">
|
<div class="modal-content chat-files-form-modal-content chat-files-mkdir-modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header chat-files-form-modal-header">
|
||||||
<h2 data-i18n="chatFilesPage.newFolderTitle">新建文件夹</h2>
|
<h2 data-i18n="chatFilesPage.newFolderTitle">新建文件夹</h2>
|
||||||
<span class="modal-close" onclick="closeChatFilesMkdirModal()">×</span>
|
<span class="modal-close chat-files-form-modal-close" onclick="closeChatFilesMkdirModal()" aria-label="Close">×</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body chat-files-mkdir-body">
|
<div class="modal-body chat-files-mkdir-body">
|
||||||
<div class="chat-files-mkdir-location" aria-live="polite">
|
<div class="chat-files-mkdir-location" aria-live="polite">
|
||||||
@@ -3615,6 +3737,7 @@
|
|||||||
<script src="/static/js/chat.js"></script>
|
<script src="/static/js/chat.js"></script>
|
||||||
<script src="/static/js/hitl.js"></script>
|
<script src="/static/js/hitl.js"></script>
|
||||||
<script src="/static/js/settings.js"></script>
|
<script src="/static/js/settings.js"></script>
|
||||||
|
<script src="/static/js/audit.js"></script>
|
||||||
<script src="/static/js/wechat-robot.js"></script>
|
<script src="/static/js/wechat-robot.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user