Compare commits

...

31 Commits

Author SHA1 Message Date
公明 a1c7e0dc7d Add files via upload 2026-06-07 20:20:41 +08:00
公明 23e08b1697 Add files via upload 2026-06-07 20:20:09 +08:00
公明 9002505569 Add files via upload 2026-06-07 20:18:36 +08:00
公明 b1aaaa79c7 Add files via upload 2026-06-07 20:17:16 +08:00
公明 4edbeb8f2d Add files via upload 2026-06-07 20:15:44 +08:00
公明 5b5a532d4f Add files via upload 2026-06-07 19:12:43 +08:00
公明 c1bd94684c Add files via upload 2026-06-07 15:35:49 +08:00
公明 8b48e5e396 Add files via upload 2026-06-07 05:11:00 +08:00
公明 c2f8ebc743 Add files via upload 2026-06-06 21:43:50 +08:00
公明 15e1a15671 Add files via upload 2026-06-05 17:57:23 +08:00
公明 5c3b157159 Add files via upload 2026-06-05 17:15:50 +08:00
公明 e5f6175277 Add files via upload 2026-06-05 16:54:25 +08:00
公明 1dc5d18fb3 Add files via upload 2026-06-05 15:21:50 +08:00
公明 00ea3d7a9c Update config.yaml 2026-06-05 15:00:17 +08:00
公明 8d48ccdfe4 Add files via upload 2026-06-05 11:41:29 +08:00
公明 c9f1a2001e Update config.yaml 2026-06-05 11:31:27 +08:00
公明 905dd519ed Add files via upload 2026-06-05 11:22:35 +08:00
公明 60ea106301 Add files via upload 2026-06-05 10:38:24 +08:00
公明 92c0ae19bb Add files via upload 2026-06-05 10:35:41 +08:00
公明 43c6a0648d Add files via upload 2026-06-05 10:17:10 +08:00
公明 6b96e77120 Add files via upload 2026-06-05 10:13:00 +08:00
公明 a397922361 Add files via upload 2026-06-04 17:57:12 +08:00
公明 1e6e92b4af Add files via upload 2026-06-04 13:37:24 +08:00
公明 444f85b9c4 Add files via upload 2026-06-04 13:36:46 +08:00
公明 679a8192ae Add files via upload 2026-06-04 13:34:26 +08:00
公明 9a3f5e54b0 Add files via upload 2026-06-04 10:54:16 +08:00
公明 ce2eb56253 Add files via upload 2026-06-04 10:50:00 +08:00
公明 da6cb347df Add files via upload 2026-06-04 10:48:09 +08:00
公明 fb2658b2eb Add files via upload 2026-06-04 10:44:48 +08:00
公明 e791782c46 Add files via upload 2026-06-04 10:33:39 +08:00
公明 9b0efbb90f Add files via upload 2026-06-04 10:29:42 +08:00
41 changed files with 4355 additions and 1485 deletions
+6 -9
View File
@@ -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 | autoEino 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配置文件目录(相对于配置文件所在目
# ============================================
# 多代理子 AgentMarkdown,唯一维护处)
# ============================================
# 每个 .mdYAML front mattername / id / description / tools / bind_role / max_iterations / 可选 kind: orchestrator+ 正文为系统提示词
# 每个 .mdYAML front mattername / 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
@@ -313,7 +310,7 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
project:
enabled: true
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
fact_index_max_runes: 3500
fact_summary_max_runes: 240
fact_index_max_runes: 6500
fact_summary_max_runes: 2400
default_inject_deprecated: false
+1 -1
View File
@@ -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
View File
@@ -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 使用
+1 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/creack/pty v1.1.24
github.com/disintegration/imaging v1.6.2
github.com/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0
@@ -49,7 +50,6 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+2
View File
@@ -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)
+41 -2
View File
@@ -298,6 +298,12 @@ func (l *TCPReverseListener) runTaskOnConn(c *tcpReverseConn, env TaskEnvelope)
return
}
cleaned := cleanShellOutput(output, cmd)
if TaskType(env.TaskType) == TaskTypeDownload {
if errMsg := detectDownloadShellError(cleaned); errMsg != "" {
l.reportTaskResult(env.TaskID, startedAt, false, cleaned, errMsg, "", "")
return
}
}
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
}
@@ -316,8 +322,8 @@ func (l *TCPReverseListener) reportTaskResult(taskID string, startedAtMS int64,
}
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;upload/download/screenshot 这些
// 需要二进制传输的能力建议使用 http_beacon。
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;download 通过 base64 输出文本结果,
// upload/screenshot 等需要二进制传输的能力建议使用 http_beacon。
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
switch t {
case TaskTypeExec, TaskTypeShell:
@@ -345,6 +351,16 @@ func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool)
return "", false
}
return "cd " + shellQuote(path) + " && pwd", true
case TaskTypeDownload:
path, _ := payload["remote_path"].(string)
if strings.TrimSpace(path) == "" {
return "", false
}
q := shellQuote(path)
return fmt.Sprintf(
`f=%s; if [ ! -e "$f" ]; then echo 'C2_DOWNLOAD_ERR: no such file or directory' >&2; exit 1; elif [ -d "$f" ]; then echo 'C2_DOWNLOAD_ERR: is a directory' >&2; exit 1; elif [ ! -r "$f" ]; then echo 'C2_DOWNLOAD_ERR: permission denied' >&2; exit 1; else base64 "$f" 2>/dev/null || base64 < "$f"; fi`,
q,
), true
case TaskTypeExit:
return "exit 0", true
}
@@ -382,6 +398,29 @@ func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// detectDownloadShellError 识别 download 任务中 shell/base64 返回的错误信息。
func detectDownloadShellError(output string) string {
trimmed := strings.TrimSpace(output)
if trimmed == "" {
return ""
}
lower := strings.ToLower(trimmed)
markers := []string{
"c2_download_err:",
"no such file",
"permission denied",
"is a directory",
"cannot open",
"not a regular file",
}
for _, m := range markers {
if strings.Contains(lower, m) {
return trimmed
}
}
return ""
}
func isAddrInUse(err error) bool {
if err == nil {
return false
+43
View File
@@ -0,0 +1,43 @@
package c2
import (
"strings"
"testing"
)
func TestDetectDownloadShellError(t *testing.T) {
tests := []struct {
name string
output string
want string
}{
{name: "empty ok", output: "", want: ""},
{name: "base64 ok", output: "aGVsbG8=", want: ""},
{name: "marker", output: "C2_DOWNLOAD_ERR: no such file or directory", want: "C2_DOWNLOAD_ERR: no such file or directory"},
{name: "bash missing file", output: "bash: ../0: No such file or directory", want: "bash: ../0: No such file or directory"},
{name: "permission denied", output: "C2_DOWNLOAD_ERR: permission denied", want: "C2_DOWNLOAD_ERR: permission denied"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectDownloadShellError(tt.output)
if got != tt.want {
t.Fatalf("detectDownloadShellError(%q) = %q, want %q", tt.output, got, tt.want)
}
})
}
}
func TestBuildTCPCommandDownload(t *testing.T) {
cmd, ok := buildTCPCommand(TaskTypeDownload, map[string]interface{}{
"remote_path": "/tmp/demo.txt",
})
if !ok {
t.Fatal("expected download command to be supported")
}
if want := "f='/tmp/demo.txt'"; !strings.Contains(cmd, want) {
t.Fatalf("command %q should contain %q", cmd, want)
}
if !strings.Contains(cmd, "C2_DOWNLOAD_ERR") {
t.Fatalf("command should validate file before base64: %q", cmd)
}
}
+5 -3
View File
@@ -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_iterationsYAML 中保留字段仅为兼容旧配置,运行时不读取)。
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_iterationsMarkdown 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"`
+1 -2
View File
@@ -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 {
+63
View File
@@ -3,6 +3,7 @@ package database
import (
"database/sql"
"encoding/json"
"sort"
"strings"
"time"
@@ -493,6 +494,68 @@ func (db *DB) UpdateToolStats(toolName string, totalCalls, successCalls, failedC
return nil
}
// CallsTimelineBucket 调用趋势时间桶
type CallsTimelineBucket struct {
BucketTime time.Time
Total int
Failed int
}
// truncateCallsTimelineBucket 将时间截断到趋势图桶边界(本地时区,与 handler 侧 truncateToBucket 一致)
func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
t = t.In(time.Local)
if dailyBuckets {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
return t.Truncate(time.Hour)
}
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
query := `
SELECT start_time,
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
FROM tool_executions
WHERE start_time >= ?
`
rows, err := db.Query(query, since)
if err != nil {
return nil, err
}
defer rows.Close()
bucketMap := make(map[time.Time]struct{ total, failed int })
for rows.Next() {
var startTime time.Time
var failed int
if err := rows.Scan(&startTime, &failed); err != nil {
db.logger.Warn("加载调用趋势失败", zap.Error(err))
continue
}
key := truncateCallsTimelineBucket(startTime, dailyBuckets)
entry := bucketMap[key]
entry.total++
entry.failed += failed
bucketMap[key] = entry
}
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
for bucketTime, counts := range bucketMap {
buckets = append(buckets, CallsTimelineBucket{
BucketTime: bucketTime,
Total: counts.total,
Failed: counts.failed,
})
}
sort.Slice(buckets, func(i, j int) bool {
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
})
return buckets, nil
}
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
// 如果统计信息变为0,则删除该统计记录
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
+33
View File
@@ -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()
+7
View File
@@ -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 中并发回调
// progressToolInvokeNotifyHolder.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()
}
-3
View File
@@ -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) {
+118
View File
@@ -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")
+2 -3
View File
@@ -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",
+32
View File
@@ -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)
+1 -7
View File
@@ -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{
+22
View File
@@ -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)
}
}
+2 -16
View File
@@ -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
View File
@@ -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)
}
+15 -6
View File
@@ -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")
}
+1 -6
View File
@@ -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
}
+222 -8
View File
@@ -772,6 +772,66 @@
border: 1px solid var(--c2-border);
}
#c2-file-upload-btn.is-disabled,
#c2-file-upload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
color: var(--c2-text-dim, #94a3b8);
border-color: var(--c2-border, #e2e8f0);
}
.c2-file-upload-hint {
font-size: 12px;
color: #b45309;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--c2-radius-xs, 4px);
padding: 8px 12px;
margin: -8px 0 12px;
line-height: 1.5;
word-break: break-word;
}
.c2-file-upload-hint[hidden] {
display: none !important;
}
.c2-file-upload-progress {
display: flex;
align-items: center;
gap: 10px;
margin: -8px 0 12px;
padding: 0 4px;
}
.c2-file-upload-progress[hidden] {
display: none !important;
}
.c2-file-upload-progress-track {
flex: 1;
height: 4px;
background: var(--c2-border);
border-radius: 2px;
overflow: hidden;
}
.c2-file-upload-progress-fill {
height: 100%;
width: 0;
background: var(--c2-accent, #3b82f6);
transition: width 0.2s ease;
}
.c2-file-upload-progress-label {
font-size: 11px;
color: var(--c2-text-dim);
white-space: nowrap;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.c2-file-list {
background: var(--c2-surface);
border-radius: var(--c2-radius);
@@ -1218,32 +1278,172 @@
Task Detail Modal
============================================================================ */
.c2-task-detail { line-height: 2; }
.c2-task-detail > div { margin-bottom: 6px; font-size: 13px; }
.c2-modal.c2-modal--wide {
max-width: 720px;
}
.c2-task-modal-header {
align-items: flex-start;
}
.c2-task-modal-heading {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.c2-task-modal-heading h3 {
margin: 0;
}
.c2-task-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.c2-task-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.c2-task-kv {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: var(--c2-surface-alt);
border: 1px solid var(--c2-border);
border-radius: var(--c2-radius-sm);
}
.c2-task-kv__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c2-text-muted);
}
.c2-task-kv__value {
font-size: 13px;
font-weight: 500;
color: var(--c2-text);
word-break: break-all;
line-height: 1.45;
}
.c2-task-kv__value--mono {
font-family: var(--c2-mono);
font-size: 12px;
color: var(--c2-text-dim);
}
.c2-task-kv__value--accent {
font-family: var(--c2-mono);
font-weight: 600;
color: var(--c2-accent);
}
.c2-task-timeline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(59, 130, 246, 0.02));
border: 1px solid rgba(59, 130, 246, 0.14);
border-radius: var(--c2-radius-sm);
}
.c2-task-time-card {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.c2-task-time-card:not(:last-child) {
padding-right: 10px;
border-right: 1px solid rgba(59, 130, 246, 0.12);
}
.c2-task-code-section,
.c2-task-error-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.c2-task-code-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.c2-task-code-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c2-text-dim);
}
.c2-task-error {
color: var(--c2-red);
padding: 14px;
padding: 14px 16px;
background: var(--c2-red-dim);
border: 1px solid rgba(239, 68, 68, 0.15);
border-radius: var(--c2-radius-sm);
margin-top: 12px;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.c2-task-result pre {
.c2-task-result-pre,
.c2-task-command-pre {
background: #0f172a;
color: #e2e8f0;
padding: 16px;
padding: 14px 16px;
border-radius: var(--c2-radius-sm);
overflow-x: auto;
font-family: var(--c2-mono);
font-size: 12px;
margin-top: 8px;
max-height: 400px;
margin: 0;
max-height: 360px;
overflow-y: auto;
border: 1px solid #1e293b;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.c2-task-command-pre {
max-height: 140px;
}
.c2-task-command-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--c2-mono);
font-size: 12px;
color: var(--c2-text-muted, #64748b);
}
.c2-task-item-compact .c2-task-command {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--c2-mono);
font-size: 11px;
color: var(--c2-text-muted, #64748b);
}
/* ============================================================================
@@ -1277,6 +1477,11 @@
Modal
============================================================================ */
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
#c2-toast-container {
z-index: 10100 !important;
}
.c2-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@@ -1388,4 +1593,13 @@
.c2-stats { flex-direction: column; gap: 12px; }
.c2-payload-grid { grid-template-columns: 1fr; }
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
.c2-task-detail-grid { grid-template-columns: 1fr; }
.c2-task-timeline { grid-template-columns: 1fr; }
.c2-task-time-card:not(:last-child) {
padding-right: 0;
padding-bottom: 10px;
border-right: none;
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
}
.c2-modal.c2-modal--wide { max-width: 100%; }
}
+1272 -728
View File
File diff suppressed because it is too large Load Diff
+57 -5
View File
@@ -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",
@@ -2224,6 +2257,9 @@
"descriptionPlaceholder": "Short description",
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
"packageFiles": "Package files",
"packageFilesHint": "Click a file to edit; folders are labels only and cannot be opened",
"folderHint": "Folder (not editable)",
"clickToEdit": "Click to edit this file",
"editingFile": "Editing",
"newFile": "New file",
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
@@ -2510,6 +2546,15 @@
"files": {
"parent": "Parent",
"refresh": "Refresh",
"upload": "Upload",
"uploading": "Uploading {{name}} · {{percent}}%",
"uploadOk": "Uploaded",
"uploadQueued": "Upload task queued",
"uploadPendingApproval": "Upload task pending HITL approval",
"uploadUnsupported": "Upload is not supported for this session",
"uploadCurlBeacon": "Curl beacons cannot upload files; use an HTTP Beacon",
"uploadTcpShell": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
"uploadTcpReverse": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
"loading": "Loading…",
"timeout": "Timed out loading files",
"emptyDir": "Empty directory",
@@ -2519,6 +2564,7 @@
"colActions": "Actions",
"open": "Open",
"download": "Download",
"downloadOk": "Downloaded",
"failed": "Failed"
},
"listeners": {
@@ -2635,7 +2681,7 @@
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
"toastExitSent": "Exit command sent",
"toastSessionDeleted": "Session record deleted",
"terminalWelcome": "CyberStrikeAI C2 Terminal — AI-Native Command & Control",
"terminalWelcome": "CyberStrikeAI C2 Terminal — Enter to run; ↑↓ history; Ctrl+L clear; Ctrl+C cancel input",
"termStatusReady": "Ready",
"termStatusExec": "Executing…",
"termStatusErr": "Error",
@@ -2644,6 +2690,9 @@
"termWaitTimeout": "[Timed out waiting for result]",
"termCleared": "Terminal cleared",
"termNoSelection": "No text selected",
"termWaitFinish": "Please wait for the current command to finish",
"termCtrlC": "Remote interrupt is not supported in this version",
"termQueued": "[Command queued — will run after the current task completes]",
"clearTerminal": "Clear"
},
"tasks": {
@@ -2670,6 +2719,7 @@
"colTask": "Task",
"colSession": "Session",
"colType": "Type",
"colCommand": "Command",
"colStatus": "Status",
"colDuration": "Duration",
"colCreated": "Created",
@@ -2680,6 +2730,8 @@
"labelId": "ID",
"labelSession": "Session",
"labelType": "Type",
"labelCommand": "Command",
"labelPayload": "Payload",
"labelStatus": "Status",
"labelCreated": "Created",
"labelSent": "Sent",
+57 -5
View File
@@ -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 和模型",
@@ -2213,6 +2246,9 @@
"descriptionPlaceholder": "Skill的简短描述",
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
"packageFiles": "包内文件",
"packageFilesHint": "点击文件进行编辑;文件夹仅作分组展示,不可点击",
"folderHint": "文件夹(不可编辑)",
"clickToEdit": "点击编辑此文件",
"editingFile": "正在编辑",
"newFile": "新建文件",
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
@@ -2499,6 +2535,15 @@
"files": {
"parent": "上级目录",
"refresh": "刷新",
"upload": "上传",
"uploading": "正在上传 {{name}} · {{percent}}%",
"uploadOk": "上传成功",
"uploadQueued": "上传任务已入队",
"uploadPendingApproval": "上传任务待人机协同审批",
"uploadUnsupported": "当前会话不支持上传",
"uploadCurlBeacon": "Curl 轻量信标不支持文件上传,请使用 HTTP Beacon",
"uploadTcpShell": "当前为 TCP 反弹 Shellbash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
"uploadTcpReverse": "当前为 TCP 反弹 Shellbash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
"loading": "加载中…",
"timeout": "加载文件超时",
"emptyDir": "空目录",
@@ -2508,6 +2553,7 @@
"colActions": "操作",
"open": "打开",
"download": "下载",
"downloadOk": "下载成功",
"failed": "失败"
},
"listeners": {
@@ -2624,7 +2670,7 @@
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
"toastExitSent": "退出指令已发送",
"toastSessionDeleted": "会话记录已删除",
"terminalWelcome": "CyberStrikeAI C2 终端 — AI-Native 命令与控制",
"terminalWelcome": "CyberStrikeAI C2 终端 — 回车执行;↑↓ 历史;Ctrl+L 清屏;Ctrl+C 取消输入",
"termStatusReady": "就绪",
"termStatusExec": "执行中…",
"termStatusErr": "错误",
@@ -2633,6 +2679,9 @@
"termWaitTimeout": "[等待结果超时]",
"termCleared": "终端已清屏",
"termNoSelection": "未选中文本",
"termWaitFinish": "请等待当前命令执行完成",
"termCtrlC": "当前版本暂不支持中断远程命令",
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
"clearTerminal": "清屏"
},
"tasks": {
@@ -2659,6 +2708,7 @@
"colTask": "任务",
"colSession": "会话",
"colType": "类型",
"colCommand": "命令",
"colStatus": "状态",
"colDuration": "耗时",
"colCreated": "创建时间",
@@ -2669,6 +2719,8 @@
"labelId": "ID",
"labelSession": "会话",
"labelType": "类型",
"labelCommand": "命令",
"labelPayload": "参数",
"labelStatus": "状态",
"labelCreated": "创建时间",
"labelSent": "发送时间",
+6 -41
View File
@@ -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() {
+1064 -127
View File
File diff suppressed because it is too large Load Diff
+19 -116
View File
@@ -423,10 +423,18 @@ if (typeof window !== 'undefined') {
window.updateHitlStatusUI = updateHitlStatusUI;
}
function syncHitlSidebarAriaExpanded() {
var card = document.getElementById('hitl-sidebar-card');
var toggle = document.getElementById('hitl-sidebar-toggle');
if (!card || !toggle) return;
toggle.setAttribute('aria-expanded', card.classList.contains('hitl-sidebar-collapsed') ? 'false' : 'true');
}
function toggleHitlSidebarCard() {
var card = document.getElementById('hitl-sidebar-card');
if (!card) return;
card.classList.toggle('hitl-sidebar-collapsed');
syncHitlSidebarAriaExpanded();
try {
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
} catch (e) {}
@@ -438,6 +446,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
card.classList.remove('hitl-sidebar-collapsed');
}
syncHitlSidebarAriaExpanded();
});
function getAgentModeLabelForValue(mode) {
@@ -1862,25 +1871,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 +1929,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 +1936,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 +1950,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 +1962,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);
+1 -1
View File
@@ -2055,7 +2055,7 @@ function showToastNotification(message, type = 'info') {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
z-index: 10100;
display: flex;
flex-direction: column;
gap: 12px;
+881 -249
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -575,8 +575,8 @@ async function loadProjectFacts() {
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
: '';
return `<tr>
<td><code>${keyEsc}</code>${vulnLink}</td>
<td>${formatCategoryBadge(f.category)}</td>
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${vulnLink}</td>
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td>${formatFactBodyBadge(f)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td>
+1 -19
View File
@@ -105,6 +105,7 @@ function updateNavState(pageId) {
// 移除所有活动状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
item.classList.remove('expanded');
});
document.querySelectorAll('.nav-submenu-item').forEach(item => {
@@ -202,16 +203,6 @@ function getNavSubmenuItems(navItem) {
return Array.from(submenu.querySelectorAll('.nav-submenu-item'));
}
/** 仅一个子页时直接进入,避免展开后菜单在侧栏底部不可见 */
function navigateSingleSubmenuPage(navItem) {
const items = getNavSubmenuItems(navItem);
if (items.length !== 1) return false;
const pageId = items[0].getAttribute('data-page');
if (!pageId) return false;
switchPage(pageId);
return true;
}
// 切换子菜单
function toggleSubmenu(menuId) {
const sidebar = document.getElementById('main-sidebar');
@@ -228,11 +219,6 @@ function toggleSubmenu(menuId) {
return;
}
// 展开侧栏且仅一个子项(角色、Agents 等):单击直接进入,无需再点二级菜单
if (navigateSingleSubmenuPage(navItem)) {
return;
}
// 展开状态下切换子菜单,并滚入视口以便看到子项
const willExpand = !navItem.classList.contains('expanded');
navItem.classList.toggle('expanded');
@@ -261,10 +247,6 @@ function showSubmenuPopup(navItem, menuId) {
}
}
if (navigateSingleSubmenuPage(navItem)) {
return;
}
const navItemContent = navItem.querySelector('.nav-item-content');
const submenu = navItem.querySelector('.nav-submenu');
+181
View File
@@ -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);
+1 -9
View File
@@ -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'
};
}
+16 -5
View File
@@ -468,6 +468,11 @@ function showAddSkillModal() {
modal.style.display = 'flex';
}
function skillPackagePathDepth(path) {
if (!path) return 0;
return (String(path).replace(/\/$/, '').match(/\//g) || []).length;
}
function renderSkillPackageTree() {
const el = document.getElementById('skill-package-tree');
if (!el) return;
@@ -479,13 +484,19 @@ function renderSkillPackageTree() {
}
el.innerHTML = rows.map(f => {
const path = f.path || '';
const indent = 8 + skillPackagePathDepth(path) * 14;
if (f.is_dir) {
return `<div style="padding:4px 6px;opacity:0.85;font-weight:600;">${escapeHtml(path)}/</div>`;
const dirLabel = path.endsWith('/') ? path : path + '/';
return `<div class="skill-tree-row skill-tree-dir" style="padding-left:${indent}px" title="${escapeHtml(_t('skillModal.folderHint'))}">` +
`<span class="skill-tree-icon" aria-hidden="true">📁</span>` +
`<span class="skill-tree-label">${escapeHtml(dirLabel)}</span>` +
`</div>`;
}
const sel = path === skillActivePath
? 'font-weight:600;background:rgba(99,102,241,0.12);'
: '';
return `<div style="padding:4px 6px;cursor:pointer;border-radius:4px;margin-bottom:2px;${sel}" data-skill-tree-path="${escapeHtml(path)}" class="skill-tree-item">${escapeHtml(path)}</div>`;
const selected = path === skillActivePath ? ' is-selected' : '';
return `<div class="skill-tree-row skill-tree-file${selected}" style="padding-left:${indent}px" data-skill-tree-path="${escapeHtml(path)}" title="${escapeHtml(_t('skillModal.clickToEdit'))}">` +
`<span class="skill-tree-icon" aria-hidden="true">📄</span>` +
`<span class="skill-tree-label">${escapeHtml(path)}</span>` +
`</div>`;
}).join('');
el.querySelectorAll('[data-skill-tree-path]').forEach(node => {
node.addEventListener('click', () => {
+55 -1
View File
@@ -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();
+33 -22
View File
@@ -831,6 +831,11 @@
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
</div>
</div>
<span class="sidebar-card-chevron conversation-reasoning-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</button>
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
@@ -859,7 +864,7 @@
</div>
</div>
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
<div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()">
<div class="hitl-sidebar-card-header" id="hitl-sidebar-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="hitl-sidebar-body" onclick="toggleHitlSidebarCard()" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleHitlSidebarCard(); }">
<div class="hitl-sidebar-heading">
<span class="hitl-sidebar-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -872,11 +877,11 @@
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
</div>
</div>
<div class="hitl-sidebar-header-actions">
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="event.stopPropagation(); window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
<span data-i18n="chat.hitlApply">应用</span>
</button>
</div>
<span class="sidebar-card-chevron hitl-sidebar-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
@@ -894,6 +899,11 @@
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
</div>
<div class="hitl-config-actions">
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
<span data-i18n="chat.hitlApply">应用</span>
</button>
</div>
</div>
</div>
</div>
@@ -1114,20 +1124,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 +1724,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 +2361,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 +2557,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 +2573,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 +3521,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) -->
@@ -3545,8 +3555,9 @@
</div>
<div class="form-group" id="skill-package-editor" style="display: none;">
<label data-i18n="skillModal.packageFiles">包内文件(标准 Agent Skills 布局)</label>
<small class="skill-package-tree-hint" data-i18n="skillModal.packageFilesHint">点击文件进行编辑;文件夹仅作分组展示,不可点击</small>
<div style="display: flex; gap: 12px; align-items: flex-start; min-height: 300px;">
<div id="skill-package-tree" style="flex: 0 0 240px; max-height: 440px; overflow: auto; border: 1px solid rgba(127,127,127,0.25); border-radius: 6px; padding: 8px; font-size: 13px; line-height: 1.4;"></div>
<div id="skill-package-tree"></div>
<div style="flex: 1; min-width: 0;">
<div style="margin-bottom: 8px; font-size: 13px;">
<span data-i18n="skillModal.editingFile">正在编辑</span> <code id="skill-active-path">SKILL.md</code>