mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-06 06:13:58 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f1a2001e | |||
| 905dd519ed | |||
| 60ea106301 | |||
| 92c0ae19bb | |||
| 43c6a0648d | |||
| 6b96e77120 | |||
| a397922361 | |||
| 1e6e92b4af | |||
| 444f85b9c4 | |||
| 679a8192ae | |||
| 9a3f5e54b0 | |||
| ce2eb56253 | |||
| da6cb347df | |||
| fb2658b2eb | |||
| e791782c46 | |||
| 9b0efbb90f |
+4
-7
@@ -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
|
||||||
|
|||||||
@@ -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 使用
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -493,6 +493,63 @@ func (db *DB) UpdateToolStats(toolName string, totalCalls, successCalls, failedC
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallsTimelineBucket 调用趋势时间桶
|
||||||
|
type CallsTimelineBucket struct {
|
||||||
|
BucketTime time.Time
|
||||||
|
Total int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
|
||||||
|
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
|
||||||
|
var bucketExpr string
|
||||||
|
if dailyBuckets {
|
||||||
|
bucketExpr = `strftime('%Y-%m-%d 00:00:00', start_time)`
|
||||||
|
} else {
|
||||||
|
bucketExpr = `strftime('%Y-%m-%d %H:00:00', start_time)`
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT ` + bucketExpr + ` AS bucket,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
|
||||||
|
FROM tool_executions
|
||||||
|
WHERE start_time >= ?
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY bucket ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var buckets []CallsTimelineBucket
|
||||||
|
for rows.Next() {
|
||||||
|
var bucketStr string
|
||||||
|
var total, failed int
|
||||||
|
if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
|
||||||
|
db.logger.Warn("加载调用趋势失败", zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, parseErr := time.ParseInLocation("2006-01-02 15:04:05", bucketStr, time.Local)
|
||||||
|
if parseErr != nil {
|
||||||
|
t, parseErr = time.Parse("2006-01-02 15:04:05", bucketStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
db.logger.Warn("解析趋势时间桶失败", zap.String("bucket", bucketStr), zap.Error(parseErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buckets = append(buckets, CallsTimelineBucket{
|
||||||
|
BucketTime: t,
|
||||||
|
Total: total,
|
||||||
|
Failed: failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+1136
-714
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",
|
||||||
|
|||||||
@@ -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 和模型",
|
||||||
|
|||||||
+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() {
|
||||||
|
|||||||
+10
-116
@@ -1862,25 +1862,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 +1920,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 +1927,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 +1941,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 +1953,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);
|
||||||
|
|||||||
+876
-249
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+15
-15
@@ -1114,20 +1114,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 +1714,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 +2351,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 +2547,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 +2563,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 +3511,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) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user