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:
|
||||
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=始终压缩
|
||||
detail: auto # low | high | auto(Eino ImageURLDetail)
|
||||
timeout_seconds: 60
|
||||
# allowed_roots: [] # 额外允许的绝对路径根目录
|
||||
# ============================================
|
||||
# 信息收集(FOFA)配置(可选)
|
||||
# ============================================
|
||||
@@ -92,7 +91,7 @@ fofa:
|
||||
# Agent 配置
|
||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||
agent:
|
||||
max_iterations: 12000 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
||||
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
@@ -110,10 +109,8 @@ multi_agent:
|
||||
enabled: true
|
||||
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认:eino_single | deep | plan_execute | supervisor
|
||||
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
||||
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||
plan_execute_loop_max_iterations: 0
|
||||
sub_agent_max_iterations: 120
|
||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||
without_write_todos: false
|
||||
@@ -295,7 +292,7 @@ skills_dir: skills # Skills配置文件目录(相对于配置文件所在目
|
||||
# ============================================
|
||||
# 多代理子 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 子代理列表
|
||||
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
|
||||
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`)。 |
|
||||
| 配置 | `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*`。 |
|
||||
| MCP 桥 | `internal/einomcp`:`ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
|
||||
| 编排 | `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 压缩
|
||||
detail: low # low | high | auto
|
||||
timeout_seconds: 60
|
||||
# allowed_roots: [] # 额外绝对路径根
|
||||
```
|
||||
|
||||
`enabled: false` 时不注册工具。
|
||||
@@ -31,14 +30,9 @@ vision:
|
||||
|
||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||
|
||||
## 路径白名单
|
||||
## 路径
|
||||
|
||||
默认可读:
|
||||
|
||||
- 进程工作目录(`cwd`)及其子路径
|
||||
- `chat_uploads/`
|
||||
- `agent.result_storage_dir`(默认 `tmp/`)
|
||||
- `vision.allowed_roots` 中配置的绝对路径
|
||||
`analyze_image` 可读取服务器上任意可读的图片文件路径(绝对路径或相对于进程工作目录的相对路径)。仍校验图片扩展名与常规文件类型。
|
||||
|
||||
## Agent 使用
|
||||
|
||||
|
||||
@@ -880,6 +880,7 @@ func setupRoutes(
|
||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
||||
protected.GET("/monitor/calls-timeline", monitorHandler.GetCallsTimeline)
|
||||
protected.GET("/notifications/summary", notificationHandler.GetSummary)
|
||||
protected.POST("/notifications/read", notificationHandler.MarkRead)
|
||||
|
||||
@@ -1065,6 +1066,7 @@ func setupRoutes(
|
||||
// 漏洞管理
|
||||
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
|
||||
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
|
||||
protected.DELETE("/vulnerabilities/batch", vulnerabilityHandler.BatchDeleteVulnerabilities)
|
||||
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
|
||||
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
|
||||
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 多代理
|
||||
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
||||
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 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"`
|
||||
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
// 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"`
|
||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||
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"`
|
||||
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 时原图直传
|
||||
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
|
||||
AllowedRoots []string `yaml:"allowed_roots,omitempty" json:"allowed_roots,omitempty"`
|
||||
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
|
||||
}
|
||||
|
||||
func (v VisionConfig) TimeoutSecondsEffective() int {
|
||||
|
||||
@@ -493,6 +493,63 @@ func (db *DB) UpdateToolStats(toolName string, totalCalls, successCalls, failedC
|
||||
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 减少工具统计信息(用于删除执行记录时)
|
||||
// 如果统计信息变为0,则删除该统计记录
|
||||
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
|
||||
}
|
||||
|
||||
// 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 删除漏洞
|
||||
func (db *DB) DeleteVulnerability(id string) error {
|
||||
tx, err := db.Begin()
|
||||
|
||||
@@ -830,6 +830,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
seenToolCallSigs := 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;
|
||||
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
||||
var respPlan responsePlanAgg
|
||||
@@ -891,6 +895,9 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
|
||||
return func(eventType, message string, data interface{}) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
|
||||
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
|
||||
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
|
||||
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) != "" {
|
||||
setStringInMap(visionNode, "detail", cfg.Detail)
|
||||
}
|
||||
if len(cfg.AllowedRoots) > 0 {
|
||||
setStringSliceInMap(visionNode, "allowed_roots", cfg.AllowedRoots)
|
||||
}
|
||||
}
|
||||
|
||||
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||
|
||||
@@ -327,6 +327,124 @@ func (h *MonitorHandler) GetStats(c *gin.Context) {
|
||||
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 删除执行记录
|
||||
func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
||||
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"},
|
||||
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
|
||||
"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"},
|
||||
"allowed_roots": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "额外允许读取的绝对路径根"},
|
||||
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
|
||||
},
|
||||
},
|
||||
"AnalyzeImageToolCall": map[string]interface{}{
|
||||
@@ -819,7 +818,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "图片路径(cwd、chat_uploads、result_storage_dir 或 allowed_roots 下)",
|
||||
"description": "图片绝对路径或相对于进程工作目录的路径",
|
||||
},
|
||||
"question": map[string]interface{}{
|
||||
"type": "string",
|
||||
|
||||
@@ -311,6 +311,38 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) {
|
||||
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 获取漏洞统计
|
||||
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
||||
filter := parseVulnerabilityListFilter(c)
|
||||
|
||||
@@ -160,13 +160,7 @@ func RunEinoSingleChatModelAgent(
|
||||
handlers = append(handlers, capMw)
|
||||
}
|
||||
|
||||
maxIter := ma.MaxIteration
|
||||
if maxIter <= 0 {
|
||||
maxIter = appCfg.Agent.MaxIterations
|
||||
}
|
||||
if maxIter <= 0 {
|
||||
maxIter = 40
|
||||
}
|
||||
maxIter := agentMaxIterations(appCfg)
|
||||
|
||||
mainToolsCfg := adk.ToolsConfig{
|
||||
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)
|
||||
|
||||
deepMaxIter := ma.MaxIteration
|
||||
if deepMaxIter <= 0 {
|
||||
deepMaxIter = appCfg.Agent.MaxIterations
|
||||
}
|
||||
if deepMaxIter <= 0 {
|
||||
deepMaxIter = 40
|
||||
}
|
||||
|
||||
subDefaultIter := ma.SubAgentMaxIterations
|
||||
if subDefaultIter <= 0 {
|
||||
subDefaultIter = 20
|
||||
}
|
||||
deepMaxIter := agentMaxIterations(appCfg)
|
||||
|
||||
var subAgents []adk.Agent
|
||||
if orchMode != "plan_execute" {
|
||||
@@ -230,10 +219,7 @@ func RunDeepAgent(
|
||||
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
||||
}
|
||||
|
||||
subMax := sub.MaxIterations
|
||||
if subMax <= 0 {
|
||||
subMax = subDefaultIter
|
||||
}
|
||||
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
|
||||
|
||||
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
||||
if err != nil {
|
||||
|
||||
+9
-79
@@ -7,35 +7,26 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chatUploadsDirName = "chat_uploads"
|
||||
|
||||
var allowedImageExt = map[string]struct{}{
|
||||
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {},
|
||||
".bmp": {}, ".tif": {}, ".tiff": {},
|
||||
}
|
||||
|
||||
// PathOptions 图片路径白名单根目录。
|
||||
type PathOptions struct {
|
||||
CWD string
|
||||
ResultStorageDir string // 相对 CWD,如 tmp
|
||||
ExtraRoots []string // vision.allowed_roots 绝对路径
|
||||
}
|
||||
|
||||
// ResolveImagePath 解析并校验可读图片路径(防穿越、symlink 逃逸)。
|
||||
func ResolveImagePath(path string, opt PathOptions) (string, error) {
|
||||
// ResolveImagePath 解析并校验可读图片路径(支持任意目录;仍校验扩展名与常规文件)。
|
||||
func ResolveImagePath(path string, cwd string) (string, error) {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
cwd := strings.TrimSpace(opt.CWD)
|
||||
if cwd == "" {
|
||||
cwdTrim := strings.TrimSpace(cwd)
|
||||
if cwdTrim == "" {
|
||||
var err error
|
||||
cwd, err = os.Getwd()
|
||||
cwdTrim, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getwd: %w", err)
|
||||
}
|
||||
}
|
||||
cwdAbs, err := filepath.Abs(filepath.Clean(cwd))
|
||||
cwdAbs, err := filepath.Abs(filepath.Clean(cwdTrim))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -46,22 +37,16 @@ func ResolveImagePath(path string, opt PathOptions) (string, error) {
|
||||
} else {
|
||||
candidate = filepath.Clean(filepath.Join(cwdAbs, p))
|
||||
}
|
||||
candidate = normalizeAbsPath(candidate)
|
||||
if candidate == "" {
|
||||
resolved := normalizeAbsPath(candidate)
|
||||
if resolved == "" {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(candidate))
|
||||
ext := strings.ToLower(filepath.Ext(resolved))
|
||||
if _, ok := allowedImageExt[ext]; !ok {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat: %w", err)
|
||||
@@ -85,58 +70,3 @@ func normalizeAbsPath(p string) string {
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveImagePath(img, PathOptions{CWD: dir, ResultStorageDir: "tmp"})
|
||||
got, err := ResolveImagePath(img, dir)
|
||||
if err != nil {
|
||||
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()
|
||||
_, err := ResolveImagePath("../../../etc/passwd", PathOptions{CWD: dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path outside roots")
|
||||
cwd := t.TempDir()
|
||||
img := filepath.Join(dir, "remote.png")
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := ResolveImagePath(f, PathOptions{CWD: dir})
|
||||
_, err := ResolveImagePath(f, dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-image extension")
|
||||
}
|
||||
|
||||
@@ -33,11 +33,6 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
|
||||
return
|
||||
}
|
||||
|
||||
pathOpt := PathOptions{
|
||||
CWD: cwd,
|
||||
ResultStorageDir: cfg.Agent.ResultStorageDir,
|
||||
ExtraRoots: cfg.Vision.AllowedRoots,
|
||||
}
|
||||
preOpt := PreprocessOptions{
|
||||
MaxImageBytes: cfg.Vision.MaxImageBytesEffective(),
|
||||
MaxDimension: cfg.Vision.MaxDimensionEffective(),
|
||||
@@ -73,7 +68,7 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger
|
||||
path, _ := args["path"].(string)
|
||||
question, _ := args["question"].(string)
|
||||
|
||||
abs, err := ResolveImagePath(path, pathOpt)
|
||||
abs, err := ResolveImagePath(path, cwd)
|
||||
if err != 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...",
|
||||
"noStatsData": "No statistical data",
|
||||
"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",
|
||||
"paginationInfo": "Show {{start}}-{{end}} of {{total}} records",
|
||||
"perPageLabel": "Per page",
|
||||
"firstPage": "First",
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"lastPage": "Last",
|
||||
"pageInfo": "Page {{page}} of {{total}}",
|
||||
"loadStatsError": "Failed to load statistics",
|
||||
"loadExecutionsError": "Failed to load execution records",
|
||||
"totalCalls": "Total calls",
|
||||
@@ -1514,6 +1520,17 @@
|
||||
"unknownTool": "Unknown tool",
|
||||
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
||||
"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",
|
||||
"clickToFilterTool": "Click a row to filter records below",
|
||||
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
|
||||
@@ -1526,9 +1543,21 @@
|
||||
"rateWarning": "Some failures detected",
|
||||
"rateCritical": "High failure rate",
|
||||
"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",
|
||||
"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",
|
||||
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
|
||||
"distOthersNoFilter": "Other tools cannot be filtered individually",
|
||||
@@ -1758,6 +1787,12 @@
|
||||
"loadListFailed": "Failed to load",
|
||||
"noRecords": "No vulnerability records",
|
||||
"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",
|
||||
"exportNoResults": "No vulnerabilities match the current filters",
|
||||
"exportStarted": "Started downloading {{count}} file(s)",
|
||||
@@ -1836,7 +1871,7 @@
|
||||
"descPlaceholder": "When the orchestrator should delegate to this agent",
|
||||
"fieldTools": "Tools (comma-separated; same keys as role tools)",
|
||||
"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)",
|
||||
"instructionPlaceholder": "You are a specialist agent...",
|
||||
"nameRequired": "Display name is required",
|
||||
@@ -1974,8 +2009,6 @@
|
||||
"visionSkipPreprocessHint": "0 = always JPEG compress; must also fit long-edge and payload limits.",
|
||||
"visionDetail": "Image detail",
|
||||
"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)",
|
||||
"testConnection": "Test Connection",
|
||||
"testFillRequired": "Please fill in API Key and Model first",
|
||||
|
||||
@@ -1488,9 +1488,15 @@
|
||||
"loading": "加载中...",
|
||||
"noStatsData": "暂无统计数据",
|
||||
"noExecutions": "暂无执行记录",
|
||||
"emptyHint": "在对话或任务中调用 MCP 工具后,执行记录将显示在此处",
|
||||
"noRecordsWithFilter": "当前筛选条件下暂无记录",
|
||||
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
||||
"perPageLabel": "每页显示",
|
||||
"firstPage": "首页",
|
||||
"prevPage": "上一页",
|
||||
"nextPage": "下一页",
|
||||
"lastPage": "末页",
|
||||
"pageInfo": "第 {{page}} / {{total}} 页",
|
||||
"loadStatsError": "无法加载统计信息",
|
||||
"loadExecutionsError": "无法加载执行记录",
|
||||
"totalCalls": "总调用次数",
|
||||
@@ -1503,6 +1509,17 @@
|
||||
"unknownTool": "未知工具",
|
||||
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
||||
"topToolsTitle": "工具调用 Top {{n}}",
|
||||
"toolRankingTitle": "工具调用排行",
|
||||
"toolStatsTitle": "工具统计",
|
||||
"toolStatsHint": "点击色条或列表行筛选下方执行记录;悬停联动高亮",
|
||||
"scopeCumulative": "累计",
|
||||
"scopeTimeline": "趋势时段",
|
||||
"filterActive": "已筛选:{{tool}}",
|
||||
"kpiScopeNote": "累计统计(全时段)",
|
||||
"columnCalls": "调用",
|
||||
"columnShare": "占比",
|
||||
"columnSuccessRate": "成功率",
|
||||
"rankingSummary": "Top {{n}} 占 {{pct}}% · 共 {{total}} 次调用",
|
||||
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
|
||||
"clickToFilterTool": "点击行筛选下方执行记录",
|
||||
"toolRowAriaLabel": "{{name}},{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
|
||||
@@ -1515,9 +1532,21 @@
|
||||
"rateWarning": "存在失败调用",
|
||||
"rateCritical": "失败率偏高",
|
||||
"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": "调用分布",
|
||||
"distLegend": "扇区面积为占全部调用比例",
|
||||
"distClickHint": "点击图例或扇区筛选执行记录",
|
||||
"distClickHint": "点击色条筛选执行记录",
|
||||
"distHeaderHint": "共 {{n}} 次调用",
|
||||
"distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次",
|
||||
"distOthersNoFilter": "其他工具无法单独筛选",
|
||||
@@ -1747,6 +1776,12 @@
|
||||
"loadListFailed": "加载失败",
|
||||
"noRecords": "暂无漏洞记录",
|
||||
"batchExport": "批量导出",
|
||||
"batchDelete": "批量删除",
|
||||
"batchDeleteNoResults": "当前筛选条件下没有可删除的漏洞",
|
||||
"batchDeleteConfirm": "确定要删除当前筛选条件下的 {{count}} 条漏洞吗?此操作不可恢复。",
|
||||
"batchDeleteConfirmAll": "未设置筛选条件,将删除全部 {{count}} 条漏洞。此操作不可恢复,确定继续?",
|
||||
"batchDeleteSuccess": "成功删除 {{count}} 条漏洞",
|
||||
"batchDeleteFailed": "批量删除失败",
|
||||
"downloadMarkdownTitle": "下载 Markdown",
|
||||
"exportNoResults": "当前筛选条件下无可导出漏洞",
|
||||
"exportStarted": "已开始下载 {{count}} 份报告",
|
||||
@@ -1825,7 +1860,7 @@
|
||||
"descPlaceholder": "何时由协调者调度该子代理",
|
||||
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
|
||||
"fieldBindRole": "绑定角色(可选)",
|
||||
"fieldMaxIter": "子代理最大迭代(0=使用全局默认)",
|
||||
"fieldMaxIter": "最大迭代(0=沿用设置页 agent.max_iterations)",
|
||||
"fieldInstruction": "系统提示词(Markdown 正文)",
|
||||
"instructionPlaceholder": "You are a specialist agent...",
|
||||
"nameRequired": "请填写显示名称",
|
||||
@@ -1963,8 +1998,6 @@
|
||||
"visionSkipPreprocessHint": "0 表示始终 JPEG 压缩;需同时满足长边与 payload 限制。",
|
||||
"visionDetail": "Image detail",
|
||||
"visionTimeout": "超时(秒)",
|
||||
"visionAllowedRoots": "额外允许路径根目录",
|
||||
"visionAllowedRootsPlaceholder": "每行一个绝对路径,可选",
|
||||
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||
"testConnection": "测试连接",
|
||||
"testFillRequired": "请先填写 API Key 和模型",
|
||||
|
||||
+6
-41
@@ -343,48 +343,13 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMarkdown(text) {
|
||||
const sanitizeConfig = {
|
||||
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,
|
||||
};
|
||||
|
||||
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>');
|
||||
/** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */
|
||||
function formatMarkdown(text, options) {
|
||||
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||
return window.csMarkdownSanitize.formatMarkdownToHtml(text, options);
|
||||
}
|
||||
const raw = text == null ? '' : String(text);
|
||||
return escapeHtml(raw).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function setupLoginUI() {
|
||||
|
||||
+10
-116
@@ -1862,25 +1862,9 @@ function refreshSystemReadyMessageBubbles() {
|
||||
div.textContent = s;
|
||||
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;
|
||||
if (typeof marked !== 'undefined') {
|
||||
try {
|
||||
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>');
|
||||
}
|
||||
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(text, { profile: 'chat' });
|
||||
} else {
|
||||
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
@@ -1936,13 +1920,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
|
||||
// 解析 Markdown 或 HTML 格式
|
||||
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) => {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
@@ -1950,31 +1927,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
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;
|
||||
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解析,以保留所有特殊字符
|
||||
if (role === 'user') {
|
||||
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
||||
} else if (typeof DOMPurify !== 'undefined') {
|
||||
// 直接解析Markdown(代码块会被包裹在<code>/<pre>中,DOMPurify会保留其文本内容)
|
||||
let parsedContent = parseMarkdown(role === 'assistant' ? displayContent : content);
|
||||
if (!parsedContent) {
|
||||
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 if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(
|
||||
role === 'assistant' ? displayContent : content,
|
||||
{ profile: 'chat' }
|
||||
);
|
||||
} else {
|
||||
const rawForEscape = role === 'assistant' ? displayContent : content;
|
||||
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '<br>');
|
||||
@@ -2047,21 +1953,9 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
|
||||
bubble.innerHTML = formattedContent;
|
||||
|
||||
// 最后的安全检查:只处理明显的可疑图片(防止404和XSS)
|
||||
// DOMPurify已经处理了大部分XSS向量,这里只做必要的补充
|
||||
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();
|
||||
}
|
||||
});
|
||||
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||
window.csMarkdownSanitize.stripSuspiciousImages(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();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1388,8 +1383,6 @@ function collectVisionConfigFromForm() {
|
||||
const n = parseInt(document.getElementById(id)?.value, 10);
|
||||
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() || '';
|
||||
return {
|
||||
enabled: document.getElementById('vision-enabled')?.checked === true,
|
||||
@@ -1403,8 +1396,7 @@ function collectVisionConfigFromForm() {
|
||||
jpeg_quality: parseIntOr('vision-jpeg-quality', 82),
|
||||
max_payload_bytes: parseIntOr('vision-max-payload-bytes', 524288),
|
||||
skip_preprocess_below_bytes: parseIntOr('vision-skip-preprocess-bytes', 2097152),
|
||||
detail: document.getElementById('vision-detail')?.value || 'low',
|
||||
allowed_roots: allowed_roots
|
||||
detail: document.getElementById('vision-detail')?.value || 'low'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -720,7 +720,7 @@ async function loadVulnerabilityStats() {
|
||||
throw new Error('apiFetch未定义');
|
||||
}
|
||||
|
||||
const params = buildVulnerabilityFilterParams();
|
||||
const params = buildVulnerabilityDashboardStatsParams();
|
||||
|
||||
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
@@ -1531,6 +1531,13 @@ function buildVulnerabilityFilterParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
/** 看板统计:保留项目/关键词等筛选,但不带严重度(卡片本身用于切换严重度筛选) */
|
||||
function buildVulnerabilityDashboardStatsParams() {
|
||||
const params = buildVulnerabilityFilterParams();
|
||||
params.delete('severity');
|
||||
return params;
|
||||
}
|
||||
|
||||
function triggerTextDownload(fileName, content) {
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1543,6 +1550,53 @@ function triggerTextDownload(fileName, content) {
|
||||
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() {
|
||||
try {
|
||||
const params = buildVulnerabilityFilterParams();
|
||||
|
||||
+15
-15
@@ -1114,20 +1114,22 @@
|
||||
</div>
|
||||
|
||||
<!-- MCP状态监控页面 -->
|
||||
<div id="page-mcp-monitor" class="page">
|
||||
<div id="page-mcp-monitor" class="page mcp-monitor-page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="mcp.monitorTitle">MCP 状态监控</h2>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel()"><span data-i18n="common.refresh">刷新</span></button>
|
||||
<div class="page-header-main">
|
||||
<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 class="page-content">
|
||||
<div class="monitor-sections">
|
||||
<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 class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
|
||||
</div>
|
||||
@@ -1712,6 +1714,7 @@
|
||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<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-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
||||
</div>
|
||||
@@ -2348,7 +2351,7 @@
|
||||
<input type="text" id="agent-md-bind-role" placeholder="" autocomplete="off" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -2544,10 +2547,6 @@
|
||||
<label for="vision-timeout-seconds" data-i18n="settingsBasic.visionTimeout">超时(秒)</label>
|
||||
<input type="number" id="vision-timeout-seconds" min="5" step="1" placeholder="60" />
|
||||
</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>
|
||||
</details>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||
@@ -2564,7 +2563,7 @@
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label class="checkbox-label">
|
||||
@@ -3512,6 +3511,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<!-- 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="/static/js/sanitize-markdown.js"></script>
|
||||
<!-- Cytoscape.js for attack chain visualization -->
|
||||
<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) -->
|
||||
|
||||
Reference in New Issue
Block a user