Compare commits

..

46 Commits

Author SHA1 Message Date
公明 46f68cc1d4 Update config.yaml 2026-06-20 16:19:57 +08:00
公明 7003cdb2e3 Add files via upload 2026-06-20 15:34:58 +08:00
公明 4e5e6208bd Add files via upload 2026-06-20 15:29:36 +08:00
公明 6a7e78a846 Add files via upload 2026-06-20 15:28:10 +08:00
公明 88c6fbfb75 Add files via upload 2026-06-20 15:26:49 +08:00
公明 1cd6d0fa90 Add files via upload 2026-06-20 15:24:40 +08:00
公明 24390db100 Add files via upload 2026-06-19 01:41:32 +08:00
公明 c000fe5195 Add files via upload 2026-06-19 01:39:53 +08:00
公明 0b4a11d01a Add files via upload 2026-06-19 01:38:30 +08:00
公明 d433e44a7d Add files via upload 2026-06-19 01:36:52 +08:00
公明 7de51fe0ea Update config.yaml 2026-06-19 00:05:50 +08:00
公明 a354cf97e5 Add files via upload 2026-06-19 00:04:38 +08:00
公明 c180f07c7e Add files via upload 2026-06-19 00:02:53 +08:00
公明 15730d3ef4 Add files via upload 2026-06-19 00:01:20 +08:00
公明 b7fa18b6d4 Add files via upload 2026-06-18 23:44:04 +08:00
公明 8d622f63ff Update version to v1.6.40 in config.yaml 2026-06-18 23:24:14 +08:00
公明 20b05146fb Add files via upload 2026-06-18 23:23:48 +08:00
公明 d8768eae76 Add files via upload 2026-06-18 23:21:58 +08:00
公明 9232cee38d Add files via upload 2026-06-18 23:20:39 +08:00
公明 6c975e63d2 Add files via upload 2026-06-18 23:19:09 +08:00
公明 e175523b82 Add files via upload 2026-06-18 23:17:30 +08:00
公明 ae23427d9e Add files via upload 2026-06-18 21:53:20 +08:00
公明 93a2504ce3 Add files via upload 2026-06-18 21:52:36 +08:00
公明 09b0479fb3 Add files via upload 2026-06-18 21:50:44 +08:00
公明 2bdc9d4fe0 Add files via upload 2026-06-18 21:48:33 +08:00
公明 01b3d8056c Add files via upload 2026-06-18 21:09:00 +08:00
公明 ed479d5e4d Update config.yaml 2026-06-18 12:53:56 +08:00
公明 a49f595231 Update config.yaml 2026-06-18 12:49:38 +08:00
公明 82cf014a5e Update config.yaml 2026-06-18 12:48:07 +08:00
公明 508de5fad0 Add files via upload 2026-06-18 12:47:24 +08:00
公明 6712344411 Add files via upload 2026-06-18 12:46:46 +08:00
公明 7eadccbff6 Add files via upload 2026-06-18 12:44:42 +08:00
公明 01b361e4a7 Add files via upload 2026-06-18 12:42:56 +08:00
公明 f6ce31c961 Delete internal/图片画质提升.jpeg 2026-06-18 12:41:18 +08:00
公明 d5a0f93c6c Add files via upload 2026-06-18 12:40:54 +08:00
公明 56faefaaf9 Add files via upload 2026-06-18 12:39:09 +08:00
公明 16e9c5874a Delete internal/图片画质提升.jpeg 2026-06-18 12:38:53 +08:00
公明 41b5cdde6b Add files via upload 2026-06-18 12:38:36 +08:00
公明 cf1f8515d9 Delete internal directory 2026-06-18 12:37:39 +08:00
公明 5e2b30c029 Add files via upload 2026-06-17 14:00:23 +08:00
公明 8c7c22369e Add files via upload 2026-06-17 12:30:20 +08:00
公明 9b1aba692b Add files via upload 2026-06-17 12:08:23 +08:00
公明 db730b48c1 Add files via upload 2026-06-17 12:06:23 +08:00
公明 dfb7dd7390 Add files via upload 2026-06-17 12:04:17 +08:00
公明 9f6eb33047 Add files via upload 2026-06-17 12:02:24 +08:00
公明 616d87f4cc Add files via upload 2026-06-17 10:50:19 +08:00
78 changed files with 6868 additions and 2404 deletions
+1 -1
View File
@@ -312,7 +312,7 @@ Requirements / tips:
### Tool Orchestration & Extensions
- **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.
- **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.
**Creating a custom tool (typical flow)**
+1 -1
View File
@@ -310,7 +310,7 @@ go build -o cyberstrike-ai cmd/server/main.go
### 工具编排与扩展
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
- `security.tools_dir` 指向目录即可批量启用;仍支持在主配置里内联定义。
- **大结果分页**:超过 200KB 的输出会保存为附件,可通过 `query_execution_result` 工具分页、过滤、正则检索
- **大工具输出**:超过 `reduction_max_length_for_trunc` 时由 Eino reduction 摘要,完整内容落盘至 `tmp/reduction/`;按 `<persisted-output>` 中的路径用 `read_file` 读取
- **结果压缩/摘要**:多兆字节日志可先压缩或生成摘要再写入 SQLite,减小档案体积。
**自定义工具的一般步骤**
-19
View File
@@ -5,7 +5,6 @@ import (
"cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/storage"
"flag"
"fmt"
"os"
@@ -33,23 +32,6 @@ func main() {
// 创建安全工具执行器
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)
@@ -61,4 +43,3 @@ func main() {
os.Exit(1)
}
}
+2 -4
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.38"
version: "v1.6.41"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -58,7 +58,7 @@ openai:
api_key: sk-xxxxxxx # API 密钥(必填)
model: qwen3-max # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effortClaude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
reasoning:
mode: on # auto | on | offoff 时不附加任何推理扩展字段
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
@@ -92,8 +92,6 @@ fofa:
# 达到最大迭代次数时,AI 会自动总结测试结果
agent:
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 表示不限制(不推荐,易出现长时间挂起)
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
+17 -135
View File
@@ -18,7 +18,6 @@ import (
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/storage"
"go.uber.org/zap"
)
@@ -32,8 +31,6 @@ type Agent struct {
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
logger *zap.Logger
maxIterations int
resultStorage ResultStorage // 结果存储
largeResultThreshold int // 大结果阈值(字节)
mu sync.RWMutex // 添加互斥锁以支持并发更新
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
currentConversationID string // 当前对话ID(用于自动传递给工具)
@@ -41,18 +38,6 @@ type Agent struct {
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{}
func withAgentConversationID(ctx context.Context, id string) context.Context {
@@ -83,26 +68,6 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
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,优化连接管理和超时设置
transport := &http.Transport{
DialContext: (&net.Dialer{
@@ -133,20 +98,11 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
externalMCPMgr: externalMCPMgr,
logger: logger,
maxIterations: maxIterations,
resultStorage: resultStorage,
largeResultThreshold: largeResultThreshold,
toolNameMapping: make(map[string]string), // 初始化工具名称映射
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 所在目录)。
func (a *Agent) SetPromptBaseDir(dir string) {
a.mu.Lock()
@@ -663,46 +619,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
}
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{
Result: resultStr,
@@ -711,57 +627,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
}, 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配置
func (a *Agent) UpdateConfig(cfg *config.OpenAIConfig) {
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)
}
// 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 非空时合并进返回给模型的文本。
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
executionID = strings.TrimSpace(executionID)
+4 -222
View File
@@ -1,21 +1,16 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/storage"
"go.uber.org/zap"
)
// setupTestAgent 创建测试用的Agent
func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
func setupTestAgent(t *testing.T) *Agent {
logger := zap.NewNop()
mcpServer := mcp.NewServer(logger)
@@ -26,205 +21,10 @@ func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
}
agentCfg := &config.AgentConfig{
MaxIterations: 10,
LargeResultThreshold: 100, // 设置较小的阈值便于测试
ResultStorageDir: "",
MaxIterations: 10,
}
agent := 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)
return NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 10)
}
func TestAgent_NewAgent_DefaultValues(t *testing.T) {
@@ -243,14 +43,6 @@ func TestAgent_NewAgent_DefaultValues(t *testing.T) {
if agent.maxIterations != 30 {
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) {
@@ -264,9 +56,7 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
}
agentCfg := &config.AgentConfig{
MaxIterations: 20,
LargeResultThreshold: 100 * 1024, // 100KB
ResultStorageDir: "custom_tmp",
MaxIterations: 20,
}
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 15)
@@ -274,12 +64,4 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
if agent.maxIterations != 15 {
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)
}
}
+4 -25
View File
@@ -28,7 +28,6 @@ import (
"cyberstrike-ai/internal/robot"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/skillpackage"
"cyberstrike-ai/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -130,23 +129,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
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
maxIterations := cfg.Agent.MaxIterations
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.UpdateToolDescriptionMode(cfg.Security.ToolDescriptionMode)
// 设置结果存储到Agent
agent.SetResultStorage(resultStorage)
// 设置结果存储到Executor(用于查询工具)
executor.SetResultStorage(resultStorage)
// 初始化知识库模块(如果启用)
var knowledgeManager *knowledge.Manager
var knowledgeRetriever *knowledge.Retriever
@@ -394,7 +370,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
conversationHandler.SetAudit(auditSvc)
auditHandler := handler.NewAuditHandler(db, auditSvc, 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{
@@ -853,6 +829,7 @@ func setupRoutes(
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
protected.POST("/batch-tasks/:queueId/tasks/:taskId/run", agentHandler.RunSingleBatchTask)
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
@@ -900,6 +877,7 @@ func setupRoutes(
protected.POST("/config/apply", configHandler.ApplyConfig)
protected.POST("/config/test-openai", configHandler.TestOpenAI)
protected.POST("/config/test-vision", configHandler.TestVision)
protected.POST("/config/list-models", configHandler.ListModels)
// 系统设置 - 终端(执行命令,提高运维效率)
protected.POST("/terminal/run", terminalHandler.RunCommand)
@@ -1131,6 +1109,7 @@ func setupRoutes(
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
c2Routes.GET("/sessions", c2Handler.ListSessions)
c2Routes.DELETE("/sessions", c2Handler.DeleteSessions)
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
+38 -9
View File
@@ -61,6 +61,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
- stop: 停止监听器 listener_id
- delete: 删除监听器 listener_id
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-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),
InputSchema: map[string]interface{}{
"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},
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
"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"},
},
@@ -222,20 +223,23 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Session,
Description: `C2 会话管理通过 action 参数选择操作
- list: 列出会话可按 listener_id/status/os/search 过滤
- list: 列出会话可按 listener_id/status/os/search/suspicious 过滤
- get: 获取会话详情及最近任务历史 session_id
- set_sleep: 设置心跳间隔 session_id
- kill: 下发 exit 任务让 implant 退出 session_id
- delete: 删除会话记录 session_id`,
- delete: 删除单个会话记录 session_id
- delete_batch: 批量删除会话 session_ids 数组`,
InputSchema: map[string]interface{}{
"type": "object",
"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": "会话 IDget/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"},
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killedlist"},
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwinlist"},
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IPlist"},
"suspicious": map[string]interface{}{"type": "boolean", "description": "仅疑似误报:离线且 tcp_* / unknown / PID 0list"},
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list"},
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep"},
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100set_sleep"},
@@ -257,6 +261,9 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
if limit := int(getFloat64(params, "limit")); limit > 0 {
filter.Limit = limit
}
if v, ok := params["suspicious"].(bool); ok && v {
filter.Suspicious = true
}
sessions, err := m.DB().ListC2Sessions(filter)
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":
sleep := int(getFloat64(params, "sleep_seconds"))
jitter := int(getFloat64(params, "jitter_percent"))
err := m.DB().SetC2SessionSleep(id, sleep, jitter)
return makeC2Result(map[string]interface{}{"updated": err == nil, "sleep_seconds": sleep, "jitter_percent": jitter}, err)
task, err := m.SetSessionSleep(id, sleep, jitter)
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":
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)
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:
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,
Description: fmt.Sprintf(`C2 Payload 生成通过 action 参数选择操作
- oneliner: 生成单行 payloadkind 必须与监听器协议一致否则会失败
tcp_reverse TCP 反弹可用 kind: bash, nc, nc_mkfifo, python, perl, powershellbash /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+curltcp 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
- build: 交叉编译 beacon 二进制支持 http_beacon / https_beacon / websocket / tcp_reversetcp_reverse 植入端回连后先发魔数 CSB1走与 HTTP 相同的 AES-GCM JSON 语义未发魔数的连接仍按经典交互 shell 处理
- build: 交叉编译 beacon 二进制支持 http_beacon / https_beacon / websocket / tcp_reversetcp_reverse 植入端回连后先发魔数 CSB1 AES-GCM 解密且校验 ImplantToken 后才登记会话
依赖的监听器 bind_port 须避开本服务 Web 端口 %d配置 server.port c2_listener 描述一致否则 Beacon 无法正确回连`, webListenPort),
InputSchema: map[string]interface{}{
"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))
}
if err := c2.ValidateOnelinerForListener(listener, kind); err != nil {
return makeC2Result(nil, err)
}
input := c2.OnelinerInput{
Kind: kind,
Host: host,
+18 -9
View File
@@ -20,10 +20,9 @@ import (
)
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容
// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go
// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session
// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
// 默认仅接受加密 TCP Beacon:连接后先发送魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话
// 可选经典模式(config.allow_legacy_shell=true):纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容,无鉴权,仅建议内网实验
// 任务派发(经典模式):同步 exec —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
type TCPReverseListener struct {
rec *database.C2Listener
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) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
prefix, err := br.Peek(4)
if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
remote := conn.RemoteAddr().String()
_ = 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 {
_ = conn.Close()
return
@@ -136,14 +137,22 @@ func (l *TCPReverseListener) handleConn(conn net.Conn) {
l.handleTCPBeaconSession(conn, br)
return
}
if !l.cfg.AllowLegacyShell {
l.logger.Debug("tcp_reverse 拒绝未加密连接", zap.String("remote", remote))
_ = conn.Close()
return
}
_ = conn.SetReadDeadline(time.Time{})
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) {
remote := conn.RemoteAddr().String()
host, _, _ := net.SplitHostPort(remote)
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
hash := sha256.Sum256([]byte(uuidSeed))
+41 -1
View File
@@ -381,8 +381,10 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
Metadata: req.Metadata,
}
if existing != nil {
// 保留原 ID/FirstSeenAt/Note,避免被覆盖
// 保留原 ID/FirstSeenAt/Note 与操作员设置的 sleep/jitter,避免被 beacon 心跳上报覆盖
session.FirstSeenAt = existing.FirstSeenAt
session.SleepSeconds = existing.SleepSeconds
session.JitterPercent = existing.JitterPercent
if session.Note == "" {
session.Note = existing.Note
}
@@ -413,6 +415,44 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
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
func (m *Manager) MarkSessionDead(sessionID string) error {
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
+118
View File
@@ -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)
}
}
+20
View File
@@ -1,9 +1,12 @@
package c2
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"cyberstrike-ai/internal/database"
)
// OnelinerKind 单行 payload 的语言/形式
@@ -79,6 +82,23 @@ type OnelinerInput struct {
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_shelltcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + Token);请用 build 生成 beacon,或显式开启 allow_legacy_shell(公网不推荐)")
}
}
return nil
}
// GenerateOneliner 生成单行 payload。
// 设计要点:
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
+3
View File
@@ -23,6 +23,9 @@ import (
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
const tcpBeaconMagic = "CSB1"
// tcpBeaconPeekTimeout 等待 CSB1 魔数的探测窗口;合法 Beacon 连接后立即发送魔数。
const tcpBeaconPeekTimeout = 2 * time.Second
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
const tcpBeaconMaxFrame = 64 << 20
+2
View File
@@ -141,6 +141,8 @@ type ListenerConfig struct {
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
CallbackHost string `json:"callback_host,omitempty"`
// AllowLegacyShell 为 true 时 tcp_reverse 允许未加密的经典 bash/nc 反弹 shell 登记会话(默认 false,公网部署强烈不建议开启)
AllowLegacyShell bool `json:"allow_legacy_shell,omitempty"`
}
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
+3 -5
View File
@@ -231,7 +231,7 @@ type MultiAgentEinoMiddlewareConfig struct {
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
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
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"`
@@ -593,10 +593,8 @@ type DatabaseConfig struct {
}
type AgentConfig struct {
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
LargeResultThreshold int `yaml:"large_result_threshold" json:"large_result_threshold"` // 大结果阈值(字节),默认50KB
ResultStorageDir string `yaml:"result_storage_dir" json:"result_storage_dir"` // 结果存储目录,默认tmp
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
// SystemPromptPath 单代理系统提示 Markdown/文本文件路径(相对 config.yaml 所在目录,或可写绝对路径)。非空且可读时替换内置单代理提示;留空用内置。
SystemPromptPath string `yaml:"system_prompt_path,omitempty" json:"system_prompt_path,omitempty"`
}
+9 -7
View File
@@ -69,12 +69,12 @@ func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
args = append(args, filter.ResourceID)
}
if filter.Since != nil {
conditions = append(conditions, "created_at >= ?")
args = append(args, *filter.Since)
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
args = append(args, formatSQLiteUTC(*filter.Since))
}
if filter.Until != nil {
conditions = append(conditions, "created_at <= ?")
args = append(args, *filter.Until)
conditions = append(conditions, sqliteEpochGE("created_at", "<="))
args = append(args, formatSQLiteUTC(*filter.Until))
}
if q := strings.TrimSpace(filter.Query); q != "" {
like := "%" + q + "%"
@@ -93,7 +93,9 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
return errors.New("audit id is required")
}
if row.CreatedAt.IsZero() {
row.CreatedAt = time.Now()
row.CreatedAt = time.Now().UTC()
} else {
row.CreatedAt = row.CreatedAt.UTC()
}
if strings.TrimSpace(row.Level) == "" {
row.Level = "info"
@@ -111,7 +113,7 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, 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.ResourceType, row.ResourceID, row.Message, detailJSON,
)
@@ -202,7 +204,7 @@ func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) {
// DeleteAuditLogsBefore removes rows older than cutoff.
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 {
return 0, err
}
+62
View File
@@ -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)
}
}
}
+36
View File
@@ -507,6 +507,42 @@ func (db *DB) CancelPendingBatchTasks(queueID string, completedAt time.Time) err
return nil
}
// PrepareBatchSingleTaskRun 准备单条执行:可选重置子任务,并更新队列索引与状态
func (db *DB) PrepareBatchSingleTaskRun(queueID, taskID string, taskIndex int, resetTask, resumeQueue bool) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始事务失败: %w", err)
}
defer tx.Rollback()
if resetTask {
_, err = tx.Exec(
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ? AND id = ?",
"pending", queueID, taskID,
)
if err != nil {
return fmt.Errorf("重置批量任务状态失败: %w", err)
}
}
if resumeQueue {
_, err = tx.Exec(
"UPDATE batch_task_queues SET status = ?, current_index = ?, completed_at = NULL, last_run_error = NULL WHERE id = ?",
"paused", taskIndex, queueID,
)
} else {
_, err = tx.Exec(
"UPDATE batch_task_queues SET current_index = ?, last_run_error = NULL WHERE id = ?",
taskIndex, queueID,
)
}
if err != nil {
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
}
return tx.Commit()
}
// DeleteBatchTask 删除批量任务
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
_, err := db.Exec(
+47
View File
@@ -17,6 +17,9 @@ var ErrNoValidC2EventIDs = errors.New("no valid event ids")
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
// ErrNoValidC2SessionIDs 批量删除会话时未提供任何合法 ID
var ErrNoValidC2SessionIDs = errors.New("no valid session ids")
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
func validC2TextIDForDelete(id string) bool {
if len(id) < 2 || len(id) > 80 {
@@ -473,6 +476,7 @@ type ListC2SessionsFilter struct {
Status string // active|sleeping|dead|killed;空表示全部
OS string
Search string // 模糊匹配 hostname/username/internal_ip
Suspicious bool // 疑似误报:离线且 hostname 为 tcp_* / 用户名为 unknown / PID 为 0
Limit int // 0 表示无限制
}
@@ -497,6 +501,11 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
kw := "%" + filter.Search + "%"
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 := `
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
@@ -554,6 +563,44 @@ func (db *DB) DeleteC2Session(id string) error {
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()
}
// ----------------------------------------------------------------------------
// CRUDC2 任务
// ----------------------------------------------------------------------------
+20 -5
View File
@@ -382,26 +382,40 @@ func (db *DB) CountConversations(search string) (int, error) {
return count, nil
}
func conversationOrderClause(sortBy, tableAlias string) string {
col := "updated_at"
if strings.TrimSpace(strings.ToLower(sortBy)) == "created_at" {
col = "created_at"
}
prefix := tableAlias
if prefix != "" {
prefix += "."
}
return "ORDER BY " + prefix + col + " DESC"
}
// ListConversations 列出所有对话
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Conversation, error) {
var rows *sql.Rows
var err error
if search != "" {
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
searchPattern := "%" + search + "%"
orderClause := conversationOrderClause(sortBy, "c")
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
FROM conversations c
WHERE c.title LIKE ?
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
ORDER BY c.updated_at DESC
`+orderClause+`
LIMIT ? OFFSET ?`,
searchPattern, searchPattern, limit, offset,
)
} else {
orderClause := conversationOrderClause(sortBy, "")
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?",
limit, offset,
)
}
@@ -467,11 +481,12 @@ func (db *DB) CountUngroupedConversations() (int, error) {
}
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*Conversation, error) {
orderClause := conversationOrderClause(sortBy, "c")
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
ungroupedConversationsSQL+`
ORDER BY c.updated_at DESC
`+orderClause+`
LIMIT ? OFFSET ?`,
limit, offset,
)
+17
View File
@@ -72,6 +72,23 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
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 统计工具执行记录总数
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
query := `SELECT COUNT(*) FROM tool_executions`
+33
View File
@@ -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
}
+3 -2
View File
@@ -16,7 +16,8 @@ import (
)
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
type ExecutionRecorder func(executionID string)
// toolCallID 来自 Eino compose.GetToolCallID,用于与 reduction 后的展示结果关联。
type ExecutionRecorder func(executionID, toolCallID string)
// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。
// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。
@@ -178,7 +179,7 @@ func runMCPToolInvocation(
return "", nil
}
if res.ExecutionID != "" && record != nil {
record(res.ExecutionID)
record(res.ExecutionID, compose.GetToolCallID(ctx))
}
if res.IsError {
return ToolErrorPrefix + res.Result, nil
+2 -2
View File
@@ -2,8 +2,8 @@ package einomcp
import "sync"
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP 桥在每次 InvokableRun 结束时 Fire
// 用于 ADK 未透出 schema.Tool 事件时仍推送 tool_result、清 pending,避免 UI 卡在「执行中」或迭代末 force-close
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire
// 用于清除 pending tool_calltool_result 由 ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)
type ToolInvokeNotifyHolder struct {
mu sync.RWMutex
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
+69 -6
View File
@@ -640,8 +640,8 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
var emptyResponseAttempts int
for {
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger,
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
@@ -689,8 +689,8 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
var emptyResponseAttempts int
for {
resultMA, errMA = multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback,
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger,
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback,
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
@@ -1678,6 +1678,7 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
// StartBatchQueue 开始执行批量任务队列
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
queueID := c.Param("queueId")
h.batchTaskManager.ClearSingleRunTask(queueID)
ok, err := h.startBatchQueueExecution(queueID, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -1709,6 +1710,7 @@ func (h *AgentHandler) RerunBatchQueue(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置队列失败"})
return
}
h.batchTaskManager.ClearSingleRunTask(queueID)
ok, err := h.startBatchQueueExecution(queueID, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -1908,6 +1910,53 @@ func (h *AgentHandler) AddBatchTask(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "任务已添加", "task": task, "queue": queue})
}
// RunSingleBatchTask 单条执行指定子任务(可覆盖已成功项),完成后暂停队列
func (h *AgentHandler) RunSingleBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
taskID := c.Param("taskId")
if err := h.batchTaskManager.PrepareSingleTaskRun(queueID, taskID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.batchTaskManager.SetSingleRunTask(queueID, taskID)
// 暂停态单条执行:旧批量协程可能仍占用执行槽,先回收以便重新启动
if queue, ok := h.batchTaskManager.GetBatchQueue(queueID); ok && queue.Status == BatchQueueStatusPaused {
h.forceUnmarkBatchQueueRunning(queueID)
}
autoStarted := true
autoStartMsg := "已开始单条执行"
ok, startErr := h.startBatchQueueExecution(queueID, false)
if startErr != nil {
h.batchTaskManager.ClearSingleRunTask(queueID)
autoStarted = false
autoStartMsg = "任务已准备就绪,但自动启动失败: " + startErr.Error()
} else if !ok {
h.batchTaskManager.ClearSingleRunTask(queueID)
autoStarted = false
autoStartMsg = "任务已准备就绪,但队列不存在"
}
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "task", "run_single_batch_task", "单条执行批量子任务", "batch_task", taskID, map[string]interface{}{
"batch_queue_id": queueID,
"auto_started": autoStarted,
})
}
c.JSON(http.StatusOK, gin.H{
"message": autoStartMsg,
"queue": queue,
"autoStarted": autoStarted,
})
}
// DeleteBatchTask 删除批量任务
func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
@@ -1949,6 +1998,10 @@ func (h *AgentHandler) unmarkBatchQueueRunning(queueID string) {
delete(h.batchRunning, queueID)
}
func (h *AgentHandler) forceUnmarkBatchQueueRunning(queueID string) {
h.unmarkBatchQueueRunning(queueID)
}
func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*time.Time, error) {
expr := strings.TrimSpace(cronExpr)
if expr == "" {
@@ -2096,6 +2149,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", "创建对话失败: "+err.Error())
h.batchTaskManager.MoveToNextTask(queueID)
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
break
}
continue
}
conversationID = conv.ID
@@ -2233,12 +2290,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
var runErr error
switch {
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.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
default:
if h.config == nil {
runErr = fmt.Errorf("服务器配置未加载")
} 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.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
}
}
@@ -2352,6 +2409,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 移动到下一个任务
h.batchTaskManager.MoveToNextTask(queueID)
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
h.logger.Info("单条执行完成,队列已暂停", zap.String("queueId", queueID), zap.String("taskId", task.ID))
break
}
// 检查是否被取消或暂停
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
if queue.Status == "cancelled" || queue.Status == "paused" {
+2 -3
View File
@@ -2,7 +2,6 @@ package handler
import (
"strconv"
"time"
"cyberstrike-ai/internal/database"
@@ -20,12 +19,12 @@ func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter {
ResourceID: c.Query("resource_id"),
}
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
}
}
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
}
}
+161 -8
View File
@@ -77,11 +77,12 @@ type BatchTaskQueue struct {
// BatchTaskManager 批量任务管理器
type BatchTaskManager struct {
db *database.DB
logger *zap.Logger
queues map[string]*BatchTaskQueue
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
mu sync.RWMutex
db *database.DB
logger *zap.Logger
queues map[string]*BatchTaskQueue
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
singleRunTasks map[string]string // queueID -> taskID,单条执行完成后暂停队列
mu sync.RWMutex
}
// NewBatchTaskManager 创建批量任务管理器
@@ -90,9 +91,10 @@ func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
logger = zap.NewNop()
}
return &BatchTaskManager{
logger: logger,
queues: make(map[string]*BatchTaskQueue),
taskCancels: make(map[string]context.CancelFunc),
logger: logger,
queues: make(map[string]*BatchTaskQueue),
taskCancels: make(map[string]context.CancelFunc),
singleRunTasks: make(map[string]string),
}
}
@@ -864,6 +866,138 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
return task, nil
}
// PrepareSingleTaskRun 准备单条执行:重置目标任务(若已有结果)并定位队列索引
func (m *BatchTaskManager) PrepareSingleTaskRun(queueID, taskID string) error {
var cancelFunc context.CancelFunc
var siblingRunningIDs []string
m.mu.Lock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return fmt.Errorf("队列不存在")
}
var task *BatchTask
taskIndex := -1
for i, t := range queue.Tasks {
if t.ID == taskID {
taskIndex = i
task = t
break
}
}
if task == nil {
m.mu.Unlock()
return fmt.Errorf("任务不存在")
}
if !queueAllowsSingleTaskRunLocked(queue, task) {
m.mu.Unlock()
return fmt.Errorf("队列正在执行或未就绪,无法单条执行")
}
// 暂停态:中止在途子任务并收口仍标记 running 的其它子任务,以便单条执行非冲突项
if queue.Status == BatchQueueStatusPaused {
if c, ok := m.taskCancels[queueID]; ok {
cancelFunc = c
delete(m.taskCancels, queueID)
}
for _, t := range queue.Tasks {
if t != nil && t.ID != taskID && t.Status == BatchTaskStatusRunning {
siblingRunningIDs = append(siblingRunningIDs, t.ID)
}
}
}
needsReset := task.Status != BatchTaskStatusPending
resumeQueue := queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled
m.mu.Unlock()
if cancelFunc != nil {
cancelFunc()
}
const staleRunMsg = "为单条执行其它任务,已中止"
for _, sid := range siblingRunningIDs {
m.UpdateTaskStatus(queueID, sid, BatchTaskStatusCancelled, "", staleRunMsg)
}
m.mu.Lock()
defer m.mu.Unlock()
queue, exists = m.queues[queueID]
if !exists {
return fmt.Errorf("队列不存在")
}
task = nil
taskIndex = -1
for i, t := range queue.Tasks {
if t.ID == taskID {
taskIndex = i
task = t
break
}
}
if task == nil {
return fmt.Errorf("任务不存在")
}
if m.db != nil {
if err := m.db.PrepareBatchSingleTaskRun(queueID, taskID, taskIndex, needsReset, resumeQueue); err != nil {
return fmt.Errorf("准备单条执行失败: %w", err)
}
}
if needsReset {
task.Status = BatchTaskStatusPending
task.ConversationID = ""
task.StartedAt = nil
task.CompletedAt = nil
task.Error = ""
task.Result = ""
}
queue.CurrentIndex = taskIndex
queue.LastRunError = ""
if resumeQueue {
queue.Status = BatchQueueStatusPaused
queue.CompletedAt = nil
}
return nil
}
// SetSingleRunTask 标记队列仅执行指定子任务,完成后自动暂停
func (m *BatchTaskManager) SetSingleRunTask(queueID, taskID string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.singleRunTasks == nil {
m.singleRunTasks = make(map[string]string)
}
m.singleRunTasks[queueID] = taskID
}
// ClearSingleRunTask 清除单条执行标记
func (m *BatchTaskManager) ClearSingleRunTask(queueID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.singleRunTasks, queueID)
}
// TakeSingleRunTaskIfMatch 若刚完成的子任务为单条执行目标,则清除标记并返回 true
func (m *BatchTaskManager) TakeSingleRunTaskIfMatch(queueID, taskID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.singleRunTasks == nil {
return false
}
if m.singleRunTasks[queueID] != taskID {
return false
}
delete(m.singleRunTasks, queueID)
return true
}
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
m.mu.Lock()
@@ -936,6 +1070,25 @@ func queueAllowsTaskListMutationLocked(queue *BatchTaskQueue) bool {
}
}
// queueAllowsSingleTaskRunLocked 是否允许对指定子任务发起单条执行(必须在持有 BatchTaskManager.mu 下调用)
func queueAllowsSingleTaskRunLocked(queue *BatchTaskQueue, task *BatchTask) bool {
if queue == nil || task == nil {
return false
}
if task.Status == BatchTaskStatusRunning {
return false
}
if queue.Status == BatchQueueStatusRunning {
return false
}
switch queue.Status {
case BatchQueueStatusPending, BatchQueueStatusPaused, BatchQueueStatusCompleted, BatchQueueStatusCancelled:
return true
default:
return false
}
}
// GetNextTask 获取下一个待执行的任务
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
m.mu.Lock()
+58 -3
View File
@@ -1,6 +1,7 @@
package handler
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -277,6 +278,9 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
filter.Limit = n
}
}
if c.Query("suspicious") == "1" || strings.EqualFold(c.Query("suspicious"), "true") {
filter.Suspicious = true
}
sessions, err := h.mgr().DB().ListC2Sessions(filter)
if err != nil {
@@ -324,7 +328,37 @@ func (h *C2Handler) DeleteSession(c *gin.Context) {
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) {
id := c.Param("id")
var req struct {
@@ -335,12 +369,33 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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()})
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)
}
// ============================================================================
+77 -13
View File
@@ -688,11 +688,9 @@ type UpdateConfigRequest struct {
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
type AgentConfigUpdate struct {
MaxIterations *int `json:"max_iterations,omitempty"`
LargeResultThreshold *int `json:"large_result_threshold,omitempty"`
ResultStorageDir *string `json:"result_storage_dir,omitempty"`
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
MaxIterations *int `json:"max_iterations,omitempty"`
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
}
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
@@ -702,12 +700,6 @@ func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
if src.MaxIterations != nil {
dst.MaxIterations = *src.MaxIterations
}
if src.LargeResultThreshold != nil {
dst.LargeResultThreshold = *src.LargeResultThreshold
}
if src.ResultStorageDir != nil {
dst.ResultStorageDir = *src.ResultStorageDir
}
if src.ToolTimeoutMinutes != nil {
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
}
@@ -1076,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 段作回退。
type TestVisionRequest struct {
Vision config.VisionConfig `json:"vision"`
@@ -1532,8 +1598,6 @@ func updateAgentConfig(doc *yaml.Node, agent config.AgentConfig) {
agentNode := ensureMap(root, "agent")
setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
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)
}
+3 -2
View File
@@ -105,17 +105,18 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
excludeGrouped := strings.TrimSpace(search) == "" &&
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
sortBy := strings.TrimSpace(c.Query("sort_by"))
var conversations []*database.Conversation
var total int
var err error
if excludeGrouped {
conversations, err = h.db.ListUngroupedConversations(limit, offset)
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy)
if err == nil {
total, err = h.db.CountUngroupedConversations()
}
} else {
conversations, err = h.db.ListConversations(limit, offset, search)
conversations, err = h.db.ListConversations(limit, offset, search, sortBy)
if err == nil {
total, err = h.db.CountConversations(search)
}
+4
View File
@@ -224,8 +224,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
conversationID,
h.conversationProjectID(conversationID),
curFinalMessage,
curHistory,
roleTools,
@@ -454,8 +456,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
prep.ConversationID,
h.conversationProjectID(prep.ConversationID),
curMsg,
curHist,
prep.RoleTools,
+4
View File
@@ -234,8 +234,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
conversationID,
h.conversationProjectID(conversationID),
curFinalMessage,
curHistory,
roleTools,
@@ -466,8 +468,10 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
h.config,
&h.config.MultiAgent,
h.agent,
h.db,
h.logger,
prep.ConversationID,
h.conversationProjectID(prep.ConversationID),
curMsg,
curHist,
prep.RoleTools,
+47 -33
View File
@@ -2,10 +2,8 @@ package handler
import (
"net/http"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/storage"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -15,17 +13,15 @@ import (
type OpenAPIHandler struct {
db *database.DB
logger *zap.Logger
resultStorage storage.ResultStorage
conversationHdlr *ConversationHandler
agentHdlr *AgentHandler
}
// 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{
db: db,
logger: logger,
resultStorage: resultStorage,
conversationHdlr: conversationHdlr,
agentHdlr: agentHdlr,
}
@@ -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{}{
@@ -6354,35 +6395,8 @@ func (h *OpenAPIHandler) GetConversationResults(c *gin.Context) {
vulnerabilities[i] = *v
}
// 获取执行结果(从MCP执行记录中获取
// 获取执行结果(历史大结果由 Eino reduction 落盘,此处不再聚合文件存储
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{}{
"conversationId": conv.ID,
+16
View File
@@ -30,3 +30,19 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
}
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)
}
+1 -1
View File
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
}
func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "")
convs, err := h.db.ListConversations(50, 0, "", "")
if err != nil {
return "获取对话列表失败: " + err.Error()
}
+21
View File
@@ -21,6 +21,7 @@ import (
// MonitorStorage 监控数据存储接口
type MonitorStorage interface {
SaveToolExecution(exec *ToolExecution) error
UpdateToolExecutionResult(id string, result *ToolResult) error
LoadToolExecutions() ([]*ToolExecution, error)
GetToolExecution(id string) (*ToolExecution, error)
SaveToolStats(toolName string, stats *ToolStats) error
@@ -963,6 +964,26 @@ func (s *Server) RecordCompletedToolInvocation(toolName string, args map[string]
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 清理旧的执行记录,防止内存无限增长
func (s *Server) cleanupOldExecutions() {
if len(s.executions) <= s.maxExecutionsInMemory {
+99 -82
View File
@@ -88,6 +88,7 @@ type einoADKRunLoopArgs struct {
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
FilesystemMonitorAgent *agent.Agent
FilesystemMonitorRecord einomcp.ExecutionRecorder
MCPExecutionBinder *MCPExecutionBinder
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 SetMCP 桥 Fire 以补全 tool_result。
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
@@ -285,53 +286,63 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
executeStdoutDupMu.Unlock()
}
var toolResultSent sync.Map // toolCallID -> struct{}ADK Tool 消息去重,避免 bridge 与事件流各推一次
if args.ToolInvokeNotify != nil {
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
tid := strings.TrimSpace(toolCallID)
removePendingByID(tid)
if tid == "" || progress == nil {
return
var toolResultSent sync.Map // toolCallID -> struct{}ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) {
if progress == nil {
return
}
toolName = strings.TrimSpace(toolName)
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 {
return
}
isErr := !success || invokeErr != nil
body := content
if invokeErr != nil {
// 保留已流式累计的 stdout(如 execute 超时前的一半输出),避免 tool_result 只剩错误串、模型与 UI 丢失上下文
tail := friendlyEinoExecuteInvokeTail(invokeErr)
// execute 流式包装可能已把超时句写入 content(供 ADK tool 与流式 delta);勿重复拼接
if tail != "" && strings.Contains(content, tail) {
body = content
} else if strings.TrimSpace(content) != "" {
body = strings.TrimRight(content, "\n") + "\n\n" + tail
} else {
body = tail
}
isErr = true
data["toolCallId"] = tid
toolCallID = tid
}
recordPendingExecuteStdoutDup(toolName, content, isErr)
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
if args.FilesystemMonitorAgent != nil && args.MCPExecutionBinder != nil {
if execID := args.MCPExecutionBinder.ExecutionID(toolCallID); execID != "" {
args.FilesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
}
recordPendingExecuteStdoutDup(toolName, body, isErr)
preview := body
if len(preview) > 200 {
preview = preview[:200] + "..."
}
agentTag := strings.TrimSpace(einoAgent)
if agentTag == "" {
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",
})
}
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
}
if args.ToolInvokeNotify != nil {
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
removePendingByID(strings.TrimSpace(toolCallID))
// tool_result 仅由下方 ADK schema.Tool 事件推送,正文与送入模型的上下文一致(含 reduction 截断)。
})
}
@@ -632,6 +643,50 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}
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 {
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
streamHeaderSent := false
@@ -973,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
if toolName == "" {
toolName = mv.ToolName
@@ -986,46 +1041,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
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)
if toolCallID == "" {
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)
tryEmitToolResultProgress(toolName, content, toolCallID, isErr, ev.AgentName)
}
}
+3 -3
View File
@@ -9,8 +9,8 @@ import (
// newEinoExecuteMonitorCallback 在 Eino filesystem execute 结束时写入 MCP 监控库并 recorder(executionId)
// 与 CallTool 路径一致,供助手消息展示「渗透测试详情」芯片。
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(command, stdout string, success bool, invokeErr error) {
return 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(toolCallID, command, stdout string, success bool, invokeErr error) {
if ag == nil || recorder == nil {
return
}
@@ -25,7 +25,7 @@ func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRe
args := map[string]interface{}{"command": command}
id := ag.RecordLocalToolExecution("execute", args, stdout, err)
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 int
// 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) {
@@ -84,7 +84,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
execCancel()
}
if w.recordMonitor != nil {
w.recordMonitor(userCmd, "", false, err)
w.recordMonitor(tid, userCmd, "", false, err)
}
if w.invokeNotify != nil && tid != "" {
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
const maxCapture = 16 * 1024
success := true
var invokeErr error
exitCode := 0
@@ -130,15 +129,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
exitCode = *resp.ExitCode
}
var appended string
if remain := maxCapture - sb.Len(); remain > 0 {
out := resp.Output
if len(out) > remain {
out = out[:remain]
}
sb.WriteString(out)
appended = out
if resp.Output != "" {
sb.WriteString(resp.Output)
appended = resp.Output
}
// 仅推送写入 sb 的片段,与末尾 Fire/recordMonitor 的截断累计一致,避免最终 tool_result 短于已展示增量。
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
w.outputChunk("execute", tid, appended)
}
@@ -167,16 +161,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if w.outputChunk != nil && tid != "" {
w.outputChunk("execute", tid, hint)
}
if remain := maxCapture - sb.Len(); remain > 0 {
h := hint
if len(h) > remain {
h = h[:remain]
}
sb.WriteString(h)
}
sb.WriteString(hint)
}
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)
outW.Close()
@@ -96,6 +96,6 @@ func recordEinoADKFilesystemToolMonitor(
}
id := ag.RecordLocalToolExecution(storedName, args, resultText, invErr)
if id != "" {
rec(id)
rec(id, toolCallID)
}
}
+19 -6
View File
@@ -103,14 +103,26 @@ func mergeAlwaysVisibleToolNames(configured []string) []string {
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 {
return nil, fmt.Errorf("reduction: local backend nil")
}
root := strings.TrimSpace(mw.ReductionRootDir)
if root == "" {
root = filepath.Join(os.TempDir(), "cyberstrike-reduction", sanitizeEinoPathSegment(convID))
}
root := reductionCacheRootDir(mw.ReductionRootDir, projectID, convID)
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("reduction root: %w", err)
}
@@ -148,6 +160,7 @@ func prependEinoMiddlewares(
einoLoc *localbk.Local,
skillsRoot string,
conversationID string,
projectID string,
logger *zap.Logger,
) (outTools []tool.BaseTool, extraHandlers []adk.ChatModelAgentMiddleware, toolSearchActive bool, err error) {
if mw == nil {
@@ -167,7 +180,7 @@ func prependEinoMiddlewares(
if place == einoMWSub && !mw.ReductionSubAgents {
// skip
} else {
redMW, rerr := buildReductionMiddleware(ctx, *mw, conversationID, einoLoc, logger)
redMW, rerr := buildReductionMiddleware(ctx, *mw, projectID, conversationID, einoLoc, logger)
if rerr != nil {
return nil, nil, false, rerr
}
@@ -3,12 +3,31 @@ package multiagent
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/cloudwego/eino/components/tool"
"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 }
func (s stubTool) Info(_ context.Context) (*schema.ToolInfo, error) {
+5 -2
View File
@@ -7,6 +7,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
@@ -29,7 +30,9 @@ type PlanExecuteRootArgs struct {
MwCfg *config.MultiAgentEinoMiddlewareConfig
// ConversationID is used for transcript/isolation paths in middleware.
ConversationID string
Logger *zap.Logger
DB *database.DB
ProjectID string
Logger *zap.Logger
// ModelName is used for model input token estimation logs.
ModelName string
// ExecPreMiddlewares 是由 prependEinoMiddlewares 构建的前置中间件(patchtoolcalls, reduction, toolsearch, plantask),
@@ -93,7 +96,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
}
// 4. summarization(最后,与 Deep/Supervisor 一致)
if a.AppCfg != nil {
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.Logger)
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
if sumErr != nil {
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
}
+11 -19
View File
@@ -11,6 +11,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
@@ -32,8 +33,10 @@ func RunEinoSingleChatModelAgent(
appCfg *config.Config,
ma *config.MultiAgentConfig,
ag *agent.Agent,
db *database.DB,
logger *zap.Logger,
conversationID string,
projectID string,
userMessage string,
history []agent.ChatMessage,
roleTools []string,
@@ -58,10 +61,12 @@ func RunEinoSingleChatModelAgent(
var mcpIDsMu sync.Mutex
var mcpIDs []string
recorder := func(id string) {
mcpExecBinder := NewMCPExecutionBinder()
recorder := func(id, toolCallID string) {
if id == "" {
return
}
mcpExecBinder.Bind(toolCallID, id)
mcpIDsMu.Lock()
mcpIDs = append(mcpIDs, id)
mcpIDsMu.Unlock()
@@ -75,29 +80,15 @@ func RunEinoSingleChatModelAgent(
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()
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
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 {
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 {
return nil, fmt.Errorf("eino single eino 中间件: %w", err)
}
@@ -132,7 +123,7 @@ func RunEinoSingleChatModelAgent(
return nil, fmt.Errorf("eino single 模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
if err != nil {
return nil, fmt.Errorf("eino single summarization: %w", err)
}
@@ -145,7 +136,7 @@ func RunEinoSingleChatModelAgent(
}
if einoSkillMW != 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 {
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
}
@@ -237,6 +228,7 @@ func RunEinoSingleChatModelAgent(
McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: recorder,
MCPExecutionBinder: mcpExecBinder,
ToolInvokeNotify: toolInvokeNotify,
DA: chatAgent,
ModelFacingTrace: modelFacingTrace,
+1 -1
View File
@@ -81,7 +81,7 @@ func subAgentFilesystemMiddleware(
loc *localbk.Local,
invokeNotify *einomcp.ToolInvokeNotifyHolder,
einoAgentName string,
recordMonitor func(command, stdout string, success bool, invokeErr error),
recordMonitor func(toolCallID, command, stdout string, success bool, invokeErr error),
toolTimeoutMinutes int,
outputChunk func(toolName, toolCallID, chunk string),
) (adk.ChatModelAgentMiddleware, error) {
+56 -1
View File
@@ -9,7 +9,9 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
copenai "cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
"github.com/bytedance/sonic"
"github.com/cloudwego/eino/adk"
@@ -40,6 +42,8 @@ func newEinoSummarizationMiddleware(
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
conversationID string,
db *database.DB,
projectID string,
logger *zap.Logger,
) (adk.ChatModelAgentMiddleware, error) {
if summaryModel == nil || appCfg == nil {
@@ -143,7 +147,14 @@ func newEinoSummarizationMiddleware(
},
},
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
if ferr != nil {
return nil, ferr
}
if appCfg != nil {
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
}
return out, nil
},
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
if transcriptPath != "" && len(before.Messages) > 0 {
@@ -176,6 +187,50 @@ func newEinoSummarizationMiddleware(
return mw, nil
}
// refreshFactIndexInMessages 在 summarization 压缩后,用 DB 最新索引替换 system 中已有的项目黑板索引段。
func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID string, cfg config.ProjectConfig, logger *zap.Logger) []adk.Message {
if db == nil || !cfg.Enabled {
return msgs
}
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return msgs
}
freshIndex, err := project.BuildFactIndexBlock(db, projectID, cfg)
if err != nil {
if logger != nil {
logger.Warn("summarization: 刷新项目黑板索引失败", zap.String("projectId", projectID), zap.Error(err))
}
return msgs
}
freshIndex = strings.TrimSpace(freshIndex)
if freshIndex == "" {
return msgs
}
changed := false
out := make([]adk.Message, len(msgs))
for i, msg := range msgs {
if msg == nil || msg.Role != schema.System {
out[i] = msg
continue
}
newContent, ok := project.ReplaceFactIndexSection(msg.Content, freshIndex)
if !ok {
out[i] = msg
continue
}
cloned := *msg
cloned.Content = newContent
out[i] = &cloned
changed = true
}
if changed && logger != nil {
logger.Info("summarization: 已刷新项目黑板索引", zap.String("projectId", projectID))
}
return out
}
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
//
// 关键不变量:tool_call ↔ tool_result 的 pair 必须整体保留或整体丢弃。
+56 -1
View File
@@ -7,9 +7,14 @@ import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// fixedTokenCounter 让 tool 消息按 tokensPerToolMessage 计,其它消息按 1 计。
@@ -389,9 +394,11 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
"你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。",
"高强度扫描要求:全力出击",
"",
project.FactIndexSectionStartMarker,
"## 项目黑板索引(project: 123, id: abc",
"(暂无事实)",
"需要写入请使用 upsert_project_fact。",
project.FactIndexSectionEndMarker,
"",
"# Skills System",
"**How to Use Skills**",
@@ -419,7 +426,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
t.Parallel()
msgs := []adk.Message{
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n# Skills System\nboiler"),
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n# Skills System\nboiler"),
schema.UserMessage("hello"),
schema.AssistantMessage("reply", nil),
}
@@ -434,3 +441,51 @@ func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
t.Fatalf("dynamic blackboard missing: %q", out)
}
}
func TestRefreshFactIndexInMessages(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "summarize-facts.db")
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
proj, err := db.CreateProject(&database.Project{Name: "summarize-proj"})
if err != nil {
t.Fatal(err)
}
cfg := config.ProjectConfig{Enabled: true}
oldIndex, err := project.BuildFactIndexBlock(db, proj.ID, cfg)
if err != nil {
t.Fatal(err)
}
_, err = db.UpsertProjectFact(&database.ProjectFact{
ProjectID: proj.ID,
FactKey: "target/host",
Category: "target",
Summary: "fresh host fact",
})
if err != nil {
t.Fatal(err)
}
msgs := []adk.Message{
schema.SystemMessage("instruction\n\n" + oldIndex),
schema.UserMessage("hi"),
}
out := refreshFactIndexInMessages(msgs, db, proj.ID, cfg, nil)
sys := out[0].Content
if strings.Contains(sys, "(暂无事实)") {
t.Fatalf("expected refreshed index, got: %q", sys)
}
if !strings.Contains(sys, "fresh host fact") {
t.Fatalf("expected new fact in index: %q", sys)
}
if !strings.Contains(sys, "instruction") {
t.Fatalf("non-index system content should be preserved: %q", sys)
}
}
@@ -6,6 +6,8 @@ import (
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"cyberstrike-ai/internal/project"
"github.com/bytedance/sonic"
)
@@ -19,7 +21,6 @@ const (
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
transcriptPersonaStartMarker = "你是CyberStrikeAI"
transcriptSkillsSystemMarker = "# Skills System"
transcriptProjectBlackboardMarker = "## 项目黑板索引"
)
// formatSummarizationTranscript renders pre-compaction messages for transcript.txt.
@@ -88,11 +89,17 @@ func stripSkillsSystemBoilerplate(s string) string {
}
func extractProjectBlackboardSection(s string) string {
idx := strings.Index(s, transcriptProjectBlackboardMarker)
if idx < 0 {
start := strings.Index(s, project.FactIndexSectionStartMarker)
if start < 0 {
return ""
}
return strings.TrimSpace(s[idx:])
section := s[start:]
end := strings.Index(section, project.FactIndexSectionEndMarker)
if end < 0 {
return ""
}
section = section[:end+len(project.FactIndexSectionEndMarker)]
return strings.TrimSpace(section)
}
func appendTranscriptSection(sb *strings.Builder, role schema.RoleType, body string) {
@@ -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)
}
}
+18 -25
View File
@@ -15,6 +15,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
@@ -56,8 +57,10 @@ func RunDeepAgent(
appCfg *config.Config,
ma *config.MultiAgentConfig,
ag *agent.Agent,
db *database.DB,
logger *zap.Logger,
conversationID string,
projectID string,
userMessage string,
history []agent.ChatMessage,
roleTools []string,
@@ -107,10 +110,12 @@ func RunDeepAgent(
var mcpIDsMu sync.Mutex
var mcpIDs []string
recorder := func(id string) {
mcpExecBinder := NewMCPExecutionBinder()
recorder := func(id, toolCallID string) {
if id == "" {
return
}
mcpExecBinder.Bind(toolCallID, id)
mcpIDsMu.Lock()
mcpIDs = append(mcpIDs, id)
mcpIDsMu.Unlock()
@@ -128,21 +133,6 @@ func RunDeepAgent(
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
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{
Timeout: 30 * time.Minute,
@@ -210,19 +200,19 @@ func RunDeepAgent(
}
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 {
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 {
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
}
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
if err != nil {
return nil, fmt.Errorf("子代理 %q summarization 中间件: %w", id, err)
}
@@ -233,7 +223,7 @@ func RunDeepAgent(
}
if einoSkillMW != 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 {
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
}
@@ -293,7 +283,7 @@ func RunDeepAgent(
return nil, fmt.Errorf("多代理主模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
if err != nil {
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
}
@@ -320,11 +310,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 {
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 {
return nil, err
}
@@ -371,7 +361,7 @@ func RunDeepAgent(
inner: einoLoc,
invokeNotify: toolInvokeNotify,
einoAgentName: orchestratorName,
outputChunk: toolOutputChunk,
outputChunk: nil,
recordMonitor: einoExecMonitor,
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
}
@@ -438,7 +428,7 @@ func RunDeepAgent(
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
var peFsMw adk.ChatModelAgentMiddleware
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 {
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
}
@@ -453,6 +443,8 @@ func RunDeepAgent(
AppCfg: appCfg,
MwCfg: &ma.EinoMiddleware,
ConversationID: conversationID,
DB: db,
ProjectID: projectID,
Logger: logger,
ModelName: appCfg.OpenAI.Model,
ExecPreMiddlewares: mainOrchestratorPre,
@@ -565,6 +557,7 @@ func RunDeepAgent(
McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: recorder,
MCPExecutionBinder: mcpExecBinder,
ToolInvokeNotify: toolInvokeNotify,
DA: da,
ModelFacingTrace: modelFacingTrace,
+10 -4
View File
@@ -10,7 +10,7 @@ package openai
// Auth: Bearer → x-api-key
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
//
// Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
// Extended thinking: 顶层 `thinking` / `output_config` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
import (
@@ -40,8 +40,9 @@ type claudeRequest struct {
System string `json:"system,omitempty"`
Messages []claudeMessage `json:"messages"`
Tools []claudeTool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
Stream bool `json:"stream,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
OutputConfig json.RawMessage `json:"output_config,omitempty"`
}
type claudeMessage struct {
@@ -304,12 +305,17 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
}
}
// Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras.
// Extended thinking + effort (Anthropic top-level); merged from Eino ExtraFields / admin extras.
if th, ok := oai["thinking"]; ok && th != nil {
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
req.Thinking = json.RawMessage(raw)
}
}
if oc, ok := oai["output_config"]; ok && oc != nil {
if raw, err := json.Marshal(oc); err == nil && len(raw) > 0 && string(raw) != "null" {
req.OutputConfig = json.RawMessage(raw)
}
}
return req, nil
}
@@ -73,6 +73,39 @@ func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
}
}
func TestConvertOpenAIToClaude_OutputConfigEffort(t *testing.T) {
payload := map[string]interface{}{
"model": "claude-opus-4-8",
"messages": []interface{}{
map[string]interface{}{"role": "user", "content": "hi"},
},
"thinking": map[string]interface{}{
"type": "adaptive",
"display": "summarized",
},
"output_config": map[string]interface{}{
"effort": "high",
},
}
req, err := convertOpenAIToClaude(payload)
if err != nil {
t.Fatal(err)
}
if len(req.Thinking) == 0 {
t.Fatal("expected thinking")
}
if len(req.OutputConfig) == 0 {
t.Fatal("expected output_config")
}
var oc map[string]interface{}
if err := json.Unmarshal(req.OutputConfig, &oc); err != nil {
t.Fatal(err)
}
if oc["effort"] != "high" {
t.Fatalf("effort=%v", oc["effort"])
}
}
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
claudeBody := []byte(`{
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
+79
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"unicode/utf8"
@@ -535,3 +536,81 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
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 -4
View File
@@ -22,6 +22,12 @@ func AppendSystemPromptBlock(base, block string) string {
return base + "\n\n" + block
}
const (
factIndexFooterGetDetail = "需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。"
factIndexFooterWriteHint = "写入事实时:summary 写「什么+在哪+如何验证」;body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。"
factIndexFooterEmpty = "需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。"
)
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(仅 key + summary,不含 body)。
func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
if db == nil || !cfg.Enabled {
@@ -42,7 +48,7 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
return "", err
}
if len(facts) == 0 {
return fmt.Sprintf("## 项目黑板索引(project: %s, id: %s\n(暂无事实)\n需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。", proj.Name, proj.ID), nil
return wrapFactIndexBlock(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s\n(暂无事实)\n%s", proj.Name, proj.ID, factIndexFooterEmpty)), nil
}
sort.SliceStable(facts, func(i, j int) bool {
@@ -72,7 +78,8 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
if omitted > 0 {
b.WriteString(fmt.Sprintf("\n(另有 %d 条未列入索引,请使用 list_project_facts 或 search_project_facts 查询。)\n", omitted))
}
b.WriteString("需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。\n")
b.WriteString("写入事实时:summary 写「什么+在哪+如何验证」;body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。\n")
return b.String(), nil
b.WriteString(factIndexFooterGetDetail)
b.WriteByte('\n')
b.WriteString(factIndexFooterWriteHint)
return wrapFactIndexBlock(b.String()), nil
}
+56
View File
@@ -0,0 +1,56 @@
package project
import "strings"
// FactIndexSectionHeading 黑板索引可读标题行前缀(块内保留,供 Agent 阅读)。
const FactIndexSectionHeading = "## 项目黑板索引"
// FactIndexSectionStartMarker / EndMarkerHTML 注释边界,供程序化替换;对模型无指令语义。
const (
FactIndexSectionStartMarker = "<!-- fact-index-start -->"
FactIndexSectionEndMarker = "<!-- fact-index-end -->"
)
// ReplaceFactIndexSection 用 freshIndex 替换 content 中已有的项目黑板索引段。
// freshIndex 须为 BuildFactIndexBlock 的完整输出。起止 HTML 注释缺失时返回 (_, false)。
func ReplaceFactIndexSection(content, freshIndex string) (string, bool) {
freshIndex = strings.TrimSpace(freshIndex)
if freshIndex == "" {
return content, false
}
start, ok := factIndexSectionStart(content)
if !ok {
return content, false
}
end, ok := factIndexSectionEnd(content, start)
if !ok || end <= start {
return content, false
}
return content[:start] + freshIndex + content[end:], true
}
// wrapFactIndexBlock 为 BuildFactIndexBlock 正文加上统一起止 HTML 注释边界。
func wrapFactIndexBlock(content string) string {
content = strings.TrimSpace(content)
return FactIndexSectionStartMarker + "\n" + content + "\n" + FactIndexSectionEndMarker + "\n"
}
func factIndexSectionStart(content string) (int, bool) {
idx := strings.Index(content, FactIndexSectionStartMarker)
if idx < 0 {
return 0, false
}
return idx, true
}
func factIndexSectionEnd(content string, start int) (int, bool) {
if start < 0 || start >= len(content) {
return 0, false
}
tail := content[start:]
idx := strings.LastIndex(tail, FactIndexSectionEndMarker)
if idx < 0 {
return 0, false
}
return start + idx + len(FactIndexSectionEndMarker), true
}
+154
View File
@@ -0,0 +1,154 @@
package project
import (
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func sampleFactIndexWithFacts(projectLabel, summary string) string {
return wrapFactIndexBlock("## 项目黑板索引(project: " + projectLabel + ", id: x\n" +
"- [target/a] target — " + summary + " (tentative)\n" +
factIndexFooterGetDetail + "\n" +
factIndexFooterWriteHint)
}
func TestReplaceFactIndexSection(t *testing.T) {
t.Parallel()
oldIndex := sampleFactIndexWithFacts("p1", "old summary")
newIndex := sampleFactIndexWithFacts("p1", "new summary")
t.Run("replaces index before next section", func(t *testing.T) {
content := "你是助手\n\n" + oldIndex + "\n\n## 图片分析\n看截图"
out, ok := ReplaceFactIndexSection(content, newIndex)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "old summary") {
t.Fatalf("old index should be gone: %q", out)
}
if !strings.Contains(out, "new summary") || !strings.Contains(out, "## 图片分析") {
t.Fatalf("expected new index and preserved vision section: %q", out)
}
if strings.Count(out, FactIndexSectionStartMarker) != 1 || strings.Count(out, FactIndexSectionEndMarker) != 1 {
t.Fatalf("expected exactly one start/end marker pair: %q", out)
}
})
t.Run("replaces index at end", func(t *testing.T) {
content := "## 项目测试范围\nscope\n\n" + oldIndex
out, ok := ReplaceFactIndexSection(content, newIndex)
if !ok {
t.Fatal("expected replacement")
}
if !strings.Contains(out, "## 项目测试范围") || !strings.Contains(out, "new summary") {
t.Fatalf("scope preserved, index updated: %q", out)
}
})
t.Run("summary with false markdown header does not truncate early", func(t *testing.T) {
summaryWithFakeHeader := "see\n\n## fake header in summary"
old := sampleFactIndexWithFacts("p1", summaryWithFakeHeader)
newIdx := sampleFactIndexWithFacts("p1", "new summary")
content := old + "\n\n## 图片分析\nvision"
out, ok := ReplaceFactIndexSection(content, newIdx)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "fake header in summary") {
t.Fatalf("old index tail should be fully removed: %q", out)
}
})
t.Run("summary containing end marker text does not truncate early", func(t *testing.T) {
summary := "note " + FactIndexSectionEndMarker + " in summary"
old := sampleFactIndexWithFacts("p1", summary)
newIdx := sampleFactIndexWithFacts("p1", "clean")
content := old + "\n\n## 图片分析\nvision"
out, ok := ReplaceFactIndexSection(content, newIdx)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "in summary") {
t.Fatalf("old block should be fully removed: %q", out)
}
})
t.Run("missing html markers does not replace", func(t *testing.T) {
legacy := "## 项目黑板索引(project: p1, id: x\n- [a] note — old (tentative)\n"
newIdx := sampleFactIndexWithFacts("p1", "new")
out, ok := ReplaceFactIndexSection("prefix\n\n"+legacy, newIdx)
if ok {
t.Fatalf("expected no replacement without markers: %q", out)
}
})
t.Run("empty facts block", func(t *testing.T) {
oldEmpty := wrapFactIndexBlock("## 项目黑板索引(project: p1, id: x\n(暂无事实)\n" + factIndexFooterEmpty)
newEmpty := sampleFactIndexWithFacts("p1", "first fact")
out, ok := ReplaceFactIndexSection(oldEmpty, newEmpty)
if !ok {
t.Fatal("expected replacement")
}
if strings.Contains(out, "(暂无事实)") {
t.Fatalf("old empty block should be gone: %q", out)
}
})
t.Run("no marker", func(t *testing.T) {
_, ok := ReplaceFactIndexSection("no blackboard here", newIndex)
if ok {
t.Fatal("expected false when marker missing")
}
})
t.Run("empty fresh index", func(t *testing.T) {
_, ok := ReplaceFactIndexSection(oldIndex, " ")
if ok {
t.Fatal("expected false for empty fresh index")
}
})
}
func TestFactIndexSectionBounds_useHTMLMarkers(t *testing.T) {
t.Parallel()
body := sampleFactIndexWithFacts("p", "line with\n\n## not a real section") + "TAIL_SHOULD_DROP"
start, ok := factIndexSectionStart(body)
if !ok || !strings.HasPrefix(body[start:], FactIndexSectionStartMarker) {
t.Fatalf("start should be at html start marker, got %d", start)
}
end, ok := factIndexSectionEnd(body, start)
if !ok || body[end:] != "\nTAIL_SHOULD_DROP" {
t.Fatalf("end should be after end marker, got remainder %q", body[end:])
}
}
func TestBuildFactIndexBlock_includesHTMLMarkers(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "facts.db")
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
proj, err := db.CreateProject(&database.Project{Name: "marker-proj"})
if err != nil {
t.Fatal(err)
}
block, err := BuildFactIndexBlock(db, proj.ID, config.ProjectConfig{Enabled: true})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(strings.TrimSpace(block), FactIndexSectionStartMarker) {
t.Fatalf("block should start with start marker: %q", block)
}
if !strings.Contains(block, FactIndexSectionEndMarker) {
t.Fatalf("block should include end marker: %q", block)
}
}
+5 -1
View File
@@ -2,10 +2,14 @@ package project
import "strings"
// VisionImageSectionMarker 图片分析 section 标题(与 AppendVisionImageAnalysisIfReady 注入一致)。
const VisionImageSectionMarker = "## 图片分析"
// VisionImageAnalysisSection 单/多代理共用的图片分析提示(analyze_image;上下文仅保留文字摘要)。
func VisionImageAnalysisSection() string {
var b strings.Builder
b.WriteString("## 图片分析\n\n")
b.WriteString(VisionImageSectionMarker)
b.WriteString("\n\n")
b.WriteString("- 遇到图片文件(截图、验证码、登录页、报告配图)时,若存在工具 analyze_image,请传入服务器上的文件路径进行分析。\n")
b.WriteString("- 不要对二进制图片使用 read_file 指望理解内容;用户消息中「📎 xxx.png: /path」即为可传给 analyze_image 的路径。\n")
b.WriteString("- 验证码类:若已从页面或接口保存为本地图片(如 captcha.png),用 analyze_imagequestion 写明「只输出验证码字符」;识别失败则刷新验证码后重新保存再识;复杂滑块/行为验证码勿指望单次识图成功。\n")
+46 -16
View File
@@ -84,8 +84,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
}
}
// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields.
// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget.
// applyClaudeExtendedThinking sets Anthropic Messages API fields per official guidance:
// - Adaptive models (4.6+): thinking.type=adaptive; output_config.effort only when user sets effort (API default is high).
// - Sonnet 3.7: thinking.type=enabled + budget_tokens=10000 (doc example); effort is not mapped — use extra_request_fields for custom budget.
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
if cfg == nil || mode == "off" {
return
@@ -93,31 +94,60 @@ func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort,
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
if _, exists := cfg.ExtraFields["thinking"]; exists {
return
}
m := strings.ToLower(strings.TrimSpace(model))
thinking := map[string]any{
"type": "adaptive",
"display": "summarized",
sonnet37 := isClaudeSonnet37(m)
if _, exists := cfg.ExtraFields["thinking"]; !exists {
cfg.ExtraFields["thinking"] = claudeThinkingForModel(m, sonnet37)
}
// Sonnet 3.7: manual extended thinking is the documented path.
if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") {
thinking = map[string]any{
applyClaudeOutputConfigEffort(cfg, effort, sonnet37)
}
// claudeSonnet37DefaultBudgetTokens matches Anthropic extended-thinking documentation examples (budget_tokens with max_tokens 16000).
const claudeSonnet37DefaultBudgetTokens = 10000
func isClaudeSonnet37(m string) bool {
return strings.Contains(m, "claude-3-7-sonnet") ||
strings.Contains(m, "3-7-sonnet") ||
strings.Contains(m, "sonnet-3.7")
}
func claudeThinkingForModel(m string, sonnet37 bool) map[string]any {
if sonnet37 {
return map[string]any{
"type": "enabled",
"budget_tokens": 10000,
"budget_tokens": claudeSonnet37DefaultBudgetTokens,
"display": "summarized",
}
}
// Opus 4.7+: manual enabled+budget rejected — keep adaptive only.
// Opus 4.7+: manual enabled+budget rejected — adaptive only.
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
thinking = map[string]any{
return map[string]any{
"type": "adaptive",
"display": "summarized",
}
}
_ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place
cfg.ExtraFields["thinking"] = thinking
return map[string]any{
"type": "adaptive",
"display": "summarized",
}
}
// applyClaudeOutputConfigEffort sets top-level output_config.effort only when effort is explicitly configured.
// Omitted effort uses the API default (high); do not inject effort on mode:on alone.
func applyClaudeOutputConfigEffort(cfg *einoopenai.ChatModelConfig, effort string, sonnet37 bool) {
if cfg == nil || sonnet37 {
return
}
if _, exists := cfg.ExtraFields["output_config"]; exists {
return
}
e := effortStringForAPI(effort)
if e == "" {
return
}
cfg.ExtraFields["output_config"] = map[string]any{"effort": e}
}
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
+77
View File
@@ -80,3 +80,80 @@ func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
t.Fatalf("max effort wire=%q, want max", got)
}
}
func TestApplyClaude_adaptiveOutputConfigEffort(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-opus-4-8",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
Effort: "xhigh",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || th["type"] != "adaptive" {
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
}
oc, ok := cfg.ExtraFields["output_config"].(map[string]any)
if !ok {
t.Fatal("expected output_config")
}
if oc["effort"] != "xhigh" {
t.Fatalf("effort=%v", oc["effort"])
}
}
func TestApplyClaude_sonnet37OfficialBudget(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-3-7-sonnet-latest",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
Effort: "low", // 3.7 has no output_config.effort; effort is not mapped to budget_tokens
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || th["type"] != "enabled" {
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
}
if th["budget_tokens"] != claudeSonnet37DefaultBudgetTokens {
t.Fatalf("budget_tokens=%v, want official example %d", th["budget_tokens"], claudeSonnet37DefaultBudgetTokens)
}
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
t.Fatal("sonnet 3.7 should not set output_config")
}
}
func TestApplyClaude_onWithoutEffortOmitsOutputConfig(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-sonnet-4-6",
Reasoning: config.OpenAIReasoningConfig{
Mode: "on",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
t.Fatal("on without explicit effort should omit output_config (API default high)")
}
}
func TestApplyClaude_autoWithoutEffortSkipsOutputConfig(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Provider: "claude",
Model: "claude-sonnet-4-6",
Reasoning: config.OpenAIReasoningConfig{
Mode: "auto",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
t.Fatal("auto without effort should omit output_config")
}
}
+11 -247
View File
@@ -16,7 +16,6 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/storage"
"github.com/creack/pty"
"go.uber.org/zap"
@@ -33,44 +32,25 @@ var ToolOutputCallbackCtxKey = toolOutputCallbackCtxKey{}
// Executor 安全工具执行器
type Executor struct {
config *config.SecurityConfig
toolIndex map[string]*config.ToolConfig // 工具索引,用于 O(1) 查找
mcpServer *mcp.Server
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
config *config.SecurityConfig
toolIndex map[string]*config.ToolConfig // 工具索引,用于 O(1) 查找
mcpServer *mcp.Server
logger *zap.Logger
}
// NewExecutor 创建新的执行器
func NewExecutor(cfg *config.SecurityConfig, mcpServer *mcp.Server, logger *zap.Logger) *Executor {
executor := &Executor{
config: cfg,
toolIndex: make(map[string]*config.ToolConfig),
mcpServer: mcpServer,
logger: logger,
resultStorage: nil, // 稍后通过 SetResultStorage 设置
config: cfg,
toolIndex: make(map[string]*config.ToolConfig),
mcpServer: mcpServer,
logger: logger,
}
// 构建工具索引
executor.buildToolIndex()
return executor
}
// SetResultStorage 设置结果存储
func (e *Executor) SetResultStorage(storage ResultStorage) {
e.resultStorage = storage
}
// buildToolIndex 构建工具索引,将 O(n) 查找优化为 O(1)
func (e *Executor) buildToolIndex() {
e.toolIndex = make(map[string]*config.ToolConfig)
@@ -1245,238 +1225,22 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
// executeInternalTool 执行内部工具(不执行外部命令)
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
// 提取内部工具类型(去掉 "internal:" 前缀)
internalToolType := strings.TrimPrefix(command, "internal:")
e.logger.Info("执行内部工具",
e.logger.Warn("未知的内部工具",
zap.String("toolName", toolName),
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{
Content: []mcp.Content{
{
Type: "text",
Text: sb.String(),
Text: fmt.Sprintf("错误: 未知的内部工具类型: %s", internalToolType),
},
},
IsError: false,
IsError: true,
}, 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 构建输入模式
func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]interface{} {
schema := map[string]interface{}{
+46 -208
View File
@@ -2,15 +2,12 @@ package security
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/storage"
"go.uber.org/zap"
)
@@ -28,137 +25,6 @@ func setupTestExecutor(t *testing.T) (*Executor, *mcp.Server) {
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) {
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) {
executor, _ := setupTestExecutor(t)
// 子进程先向 stdout 写无换行字符再长时间 sleep;若与 echo $pid 共享管道且未重定向子进程 stdout,
@@ -228,63 +71,58 @@ func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T)
}
}
func TestPaginateLines(t *testing.T) {
lines := []string{"Line 1", "Line 2", "Line 3", "Line 4", "Line 5"}
// 测试第一页
page := paginateLines(lines, 1, 2)
if page.Page != 1 {
t.Errorf("页码不匹配。期望: 1, 实际: %d", page.Page)
}
if page.Limit != 2 {
t.Errorf("每页行数不匹配。期望: 2, 实际: %d", page.Limit)
}
if page.TotalLines != 5 {
t.Errorf("总行数不匹配。期望: 5, 实际: %d", page.TotalLines)
}
if page.TotalPages != 3 {
t.Errorf("总页数不匹配。期望: 3, 实际: %d", page.TotalPages)
}
if len(page.Lines) != 2 {
t.Errorf("第一页行数不匹配。期望: 2, 实际: %d", len(page.Lines))
func TestBuildCommandArgs_NmapSkipsEmptyOptionalFlags(t *testing.T) {
pos1 := 1
executor, _ := setupTestExecutor(t)
toolConfig := &config.ToolConfig{
Name: "nmap",
Command: "nmap",
Args: []string{"-sT", "-sV", "-sC"},
Parameters: []config.ParameterConfig{
{Name: "target", Type: "string", Required: true, Position: &pos1, Format: "positional"},
{Name: "ports", Type: "string", Flag: "-p", Format: "flag"},
{Name: "timing", Type: "string", Template: "-T{value}", Format: "template"},
{Name: "nse_scripts", Type: "string", Flag: "--script", Format: "flag"},
{Name: "os_detection", Type: "bool", Flag: "-O", Format: "flag", Default: false},
{Name: "aggressive", Type: "bool", Flag: "-A", Format: "flag", Default: false},
{Name: "scan_type", Type: "string", Format: "template", Template: "{value}"},
{Name: "additional_args", Type: "string", Format: "positional"},
},
}
// 测试第二页
page2 := paginateLines(lines, 2, 2)
if len(page2.Lines) != 2 {
t.Errorf("第二页行数不匹配。期望: 2, 实际: %d", len(page2.Lines))
}
if page2.Lines[0] != "Line 3" {
t.Errorf("第二页第一行不匹配。期望: Line 3, 实际: %s", page2.Lines[0])
args := map[string]interface{}{
"target": "110.52.223.114",
"ports": "21, 22, 80, 443",
"timing": "4",
"nse_scripts": "",
"scan_type": "",
"os_detection": false,
"aggressive": false,
"additional_args": "-Pn",
}
// 测试最后一页
page3 := paginateLines(lines, 3, 2)
if len(page3.Lines) != 1 {
t.Errorf("第三页行数不匹配。期望: 1, 实际: %d", len(page3.Lines))
}
cmdArgs := executor.buildCommandArgs("nmap", toolConfig, args)
joined := strings.Join(cmdArgs, " ")
// 测试超出范围的页码(应该返回最后一页)
page4 := paginateLines(lines, 4, 2)
if page4.Page != 3 {
t.Errorf("超出范围的页码应该被修正为最后一页。期望: 3, 实际: %d", page4.Page)
if strings.Contains(joined, "--script") {
t.Fatalf("empty nse_scripts must not emit --script, got: %v", cmdArgs)
}
if len(page4.Lines) != 1 {
t.Errorf("最后一页应该只有1行。实际: %d行", len(page4.Lines))
if !strings.Contains(joined, "110.52.223.114") {
t.Fatalf("target missing from args: %v", cmdArgs)
}
// 测试无效页码(小于1
page0 := paginateLines(lines, 0, 2)
if page0.Page != 1 {
t.Errorf("无效页码应该被修正为1。实际: %d", page0.Page)
}
// 测试空列表
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))
// target 应出现在 -Pn 之前,避免被误当作 --script 的参数
pnIdx := indexOf(cmdArgs, "-Pn")
targetIdx := indexOf(cmdArgs, "110.52.223.114")
if pnIdx < 0 || targetIdx < 0 || targetIdx >= pnIdx {
t.Fatalf("expected target before -Pn, got: %v", cmdArgs)
}
}
func indexOf(slice []string, s string) int {
for i, v := range slice {
if v == s {
return i
}
}
return -1
}
-297
View File
@@ -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
}
-453
View File
@@ -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))
}
}
+1178 -47
View File
File diff suppressed because it is too large Load Diff
+1206 -68
View File
File diff suppressed because it is too large Load Diff
+167 -3
View File
@@ -436,6 +436,9 @@
"conversationGroups": "Conversation groups",
"addGroup": "New group",
"recentConversations": "Recent conversations",
"sortConversations": "Sort",
"sortByCreatedAt": "Created time",
"sortByUpdatedAt": "Updated time",
"batchManage": "Batch manage",
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
@@ -676,7 +679,12 @@
"viewConversation": "View conversation",
"viewVulnerabilities": "View vulnerabilities",
"viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue",
"retryTask": "Retry",
"runSingleTask": "Run task",
"confirmRunSingleTask": "Run this task only? The queue will pause when it finishes and will not continue other pending items.",
"runSingleTaskFailed": "Failed to run task",
"runSingleTaskUnavailable": "Unavailable while the queue or a task is running",
"runSingleTaskUnavailableSelf": "This task is running",
"runSingleTaskUnavailableQueue": "Queue is running; pause it before running another task individually",
"conversationIdLabel": "Conversation ID",
"statusPending": "Pending",
"statusPaused": "Paused",
@@ -1083,6 +1091,7 @@
"botAgent": "Bot Agent",
"ilinkBotId": "iLink Bot ID (filled after bind)",
"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"
},
"wecom": {
@@ -1938,6 +1947,13 @@
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
"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",
"maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
@@ -2086,14 +2102,35 @@
"filterResult": "Result",
"pageSize": "Per page",
"statTotal": "Filtered total",
"statSuccess": "Success",
"statFailures": "Failures",
"statRecent7d": "Last 7 days",
"retentionHint": "Audit records are kept for {{days}} days, then purged automatically.",
"disabledHint": "Audit logging is disabled; new actions are not written.",
"filterSince": "From",
"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",
"filterQueryPlaceholder": "Message / resource ID / action",
"colTime": "Time",
"colMessage": "Message",
"colCategory": "Category",
"colAction": "Action",
"colResult": "Result",
"colIp": "IP",
"colResource": "Resource ID",
"cat": {
"auth": "Auth",
"config": "Config",
@@ -2166,6 +2203,93 @@
"exportDone": "Export complete",
"loading": "Loading...",
"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}}",
"detailTitle": "Audit detail",
"detailTime": "Time",
@@ -2244,7 +2368,8 @@
"copyContent": "Copy content",
"correctInfo": "Correct 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": {
"title": "Attack chain",
@@ -2574,6 +2699,11 @@
},
"c2": {
"clipboardCopied": "Copied to clipboard",
"common": {
"justNow": "Just now",
"minutesAgo": "{{n}}m ago",
"hoursAgo": "{{n}}h ago"
},
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}s",
@@ -2631,6 +2761,8 @@
"bindHintExternal": "Use 0.0.0.0 to allow external access",
"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.",
"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",
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
"malleableProfileNone": "None",
@@ -2708,10 +2840,22 @@
"infoFirstSeen": "First seen",
"infoLastCheckin": "Last check-in",
"infoNote": "Note",
"infoNoteEmpty": "No notes",
"infoSectionIdentity": "Identity",
"infoSectionSystem": "System",
"infoSectionNetwork": "Network & beacon",
"infoSectionTimeline": "Timeline",
"infoSectionNote": "Notes",
"adminYes": "Yes",
"adminNo": "No",
"promptSleepSeconds": "Sleep interval (seconds)",
"promptJitterPercent": "Jitter percent (0100)",
"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",
"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.)",
@@ -2729,7 +2873,25 @@
"termWaitFinish": "Please wait for the current command to finish",
"termCtrlC": "Remote interrupt is not supported in this version",
"termQueued": "[Command queued — will run after the current task completes]",
"clearTerminal": "Clear"
"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": {
"title": "Task Management",
@@ -2752,6 +2914,8 @@
"pending": "Pending",
"emptyAll": "No tasks yet",
"emptySession": "No tasks for this session",
"sessionTaskHistory": "Task history",
"sessionTaskCount": "{{n}} tasks",
"colTask": "Task",
"colSession": "Session",
"colType": "Type",
+167 -3
View File
@@ -424,6 +424,9 @@
"conversationGroups": "对话分组",
"addGroup": "新建分组",
"recentConversations": "最近对话",
"sortConversations": "排序",
"sortByCreatedAt": "创建时间",
"sortByUpdatedAt": "更新时间",
"batchManage": "批量管理",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
@@ -664,7 +667,12 @@
"viewConversation": "查看对话",
"viewVulnerabilities": "查看漏洞",
"viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列",
"retryTask": "重试",
"runSingleTask": "单条执行",
"confirmRunSingleTask": "确定执行该任务?仅运行这一条,完成后队列会自动暂停,不会继续执行其他待执行项。",
"runSingleTaskFailed": "单条执行失败",
"runSingleTaskUnavailable": "队列或任务执行中,暂无法单条执行",
"runSingleTaskUnavailableSelf": "该任务正在执行中",
"runSingleTaskUnavailableQueue": "队列批量执行中,请暂停后再单条执行其它任务",
"conversationIdLabel": "对话ID",
"statusPending": "待执行",
"statusPaused": "已暂停",
@@ -1071,6 +1079,7 @@
"botAgent": "Bot Agent",
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
"boundSuccess": "绑定成功,微信机器人已启用。",
"alreadyBound": "该微信已绑定过,无需重复绑定。",
"openLink": "无法显示二维码?点击用手机微信打开链接"
},
"wecom": {
@@ -1926,6 +1935,13 @@
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
"modelPlaceholder": "gpt-4",
"fetchModels": "获取列表",
"modelsListFetching": "正在获取模型列表...",
"modelsListSelectPlaceholder": "请选择模型",
"modelsListSuccess": "已加载 {count} 个模型,请用右侧下拉框选择,或继续在左侧输入",
"modelsListFailed": "获取模型列表失败",
"modelsListNeedApiKey": "请先填写 API Key",
"modelsListClaudeHint": "Claude 不支持自动获取模型列表,请手动填写",
"maxTotalTokens": "最大上下文 Token 数",
"maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
@@ -2074,14 +2090,35 @@
"filterResult": "结果",
"pageSize": "每页",
"statTotal": "当前筛选",
"statSuccess": "成功",
"statFailures": "失败",
"statRecent7d": "近 7 天",
"retentionHint": "审计记录保留 {{days}} 天,超期自动清理。",
"disabledHint": "审计功能已关闭,新操作不会写入审计表。",
"filterSince": "开始时间",
"filterUntil": "结束时间",
"filterTimeZone": "时区:{{tz}}(筛选按浏览器本地时间)",
"datetimePlaceholder": "选择日期时间",
"timePresets": "快捷",
"preset15m": "最近15分钟",
"preset1h": "最近1小时",
"preset24h": "最近24小时",
"preset7d": "最近7天",
"presetToday": "今天",
"pickerHour": "时",
"pickerMinute": "分",
"pickerClear": "清除",
"pickerToday": "今天",
"pickerConfirm": "确定",
"filterQuery": "关键词",
"filterQueryPlaceholder": "消息 / 资源 ID / 操作名",
"colTime": "时间",
"colMessage": "说明",
"colCategory": "类别",
"colAction": "操作",
"colResult": "结果",
"colIp": "IP",
"colResource": "资源 ID",
"cat": {
"auth": "认证",
"config": "配置",
@@ -2154,6 +2191,93 @@
"exportDone": "导出完成",
"loading": "加载中...",
"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}} 条",
"detailTitle": "审计详情",
"detailTime": "时间",
@@ -2232,7 +2356,8 @@
"copyContent": "复制内容",
"correctInfo": "正确信息",
"errorInfo": "错误信息",
"copyError": "复制错误"
"copyError": "复制错误",
"contentTruncated": "…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)"
},
"attackChainModal": {
"title": "攻击链可视化",
@@ -2562,6 +2687,11 @@
},
"c2": {
"clipboardCopied": "已复制到剪贴板",
"common": {
"justNow": "刚刚",
"minutesAgo": "{{n}} 分钟前",
"hoursAgo": "{{n}} 小时前"
},
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}秒",
@@ -2619,6 +2749,8 @@
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
"callbackHost": "回连地址(可选)",
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
"allowLegacyShell": "允许未加密经典反弹 Shell(内网实验)",
"allowLegacyShellHint": "默认关闭。开启后 bash/nc 等裸 TCP 连接可登记会话,公网易被扫描器误连;生产环境请使用「生成 Beacon」加密上线。",
"malleableProfile": "Malleable Profile",
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
"malleableProfileNone": "不使用",
@@ -2696,10 +2828,22 @@
"infoFirstSeen": "首次上线",
"infoLastCheckin": "上次心跳",
"infoNote": "备注",
"infoNoteEmpty": "暂无备注",
"infoSectionIdentity": "身份信息",
"infoSectionSystem": "系统环境",
"infoSectionNetwork": "网络与信标",
"infoSectionTimeline": "时间线",
"infoSectionNote": "备注",
"adminYes": "是",
"adminNo": "否",
"promptSleepSeconds": "Sleep 间隔(秒)",
"promptJitterPercent": "抖动百分比(0100",
"sleepModalHint": "保存后将写入服务端并下发 sleep 任务;植入体在下次拉取任务后生效,同时后续心跳会同步该配置。",
"sleepModalTitle": "心跳配置",
"sleepModalCurrent": "当前 {{sec}} 秒 · 抖动 {{jitter}}%",
"sleepModalPreview": "预计间隔 {{min}} {{max}} 秒",
"sleepModalPresets": "快捷",
"toastSleepInvalid": "Sleep 间隔至少为 1 秒",
"toastSleepUpdated": "Sleep 设置已更新",
"confirmExitSession": "向该会话发送退出指令?",
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
@@ -2717,7 +2861,25 @@
"termWaitFinish": "请等待当前命令执行完成",
"termCtrlC": "当前版本暂不支持中断远程命令",
"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": {
"title": "任务管理",
@@ -2740,6 +2902,8 @@
"pending": "待处理",
"emptyAll": "暂无任务",
"emptySession": "该会话暂无任务",
"sessionTaskHistory": "任务历史",
"sessionTaskCount": "共 {{n}} 条",
"colTask": "任务",
"colSession": "会话",
"colType": "类型",
+428
View File
@@ -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">&lsaquo;</button>' +
'<span class="audit-dt-month-label"></span>' +
'<button type="button" class="audit-dt-nav" data-nav="next" aria-label="next">&rsaquo;</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();
}
};
})();
+352 -56
View File
@@ -4,6 +4,7 @@
let auditLogsPage = 1;
let auditLogsPageSize = 20;
let auditLogsTotal = 0;
let auditLogsCache = [];
const AUDIT_PAGE_SIZE_KEY = 'cyberstrike_audit_page_size';
@@ -52,24 +53,113 @@ function auditActionLabel(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) {
if (!iso) return '';
try {
const d = new Date(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 (_) {
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) {
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 '';
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() {
try {
const saved = parseInt(localStorage.getItem(AUDIT_PAGE_SIZE_KEY), 10);
@@ -113,6 +203,7 @@ function rebuildAuditActionSelect() {
actEl.disabled = true;
actEl.value = '';
actEl.title = hint;
syncAuditCustomSelect('audit-filter-action');
return;
}
@@ -129,6 +220,7 @@ function rebuildAuditActionSelect() {
if (prev && Array.prototype.some.call(actEl.options, function (o) { return o.value === prev; })) {
actEl.value = prev;
}
syncAuditCustomSelect('audit-filter-action');
}
function onAuditCategoryFilterChange() {
@@ -145,43 +237,17 @@ function buildAuditQueryParams(forExport) {
const act = document.getElementById('audit-filter-action');
const res = document.getElementById('audit-filter-result');
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 (act && !act.disabled && act.value) params.set('action', act.value);
if (res && res.value) params.set('result', res.value);
if (q && q.value.trim()) params.set('q', q.value.trim());
const sinceISO = since ? auditDatetimeLocalToRFC3339(since.value) : '';
const untilISO = until ? auditDatetimeLocalToRFC3339(until.value) : '';
const sinceISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-since'));
const untilISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-until'));
if (sinceISO) params.set('since', sinceISO);
if (untilISO) params.set('until', untilISO);
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() {
if (typeof apiFetch !== 'function') return;
const wrap = document.getElementById('audit-summary-stats');
@@ -191,10 +257,14 @@ async function loadAuditSummary() {
const data = await r.json();
if (wrap) wrap.hidden = false;
const elTotal = document.getElementById('audit-stat-total');
const elSuccess = document.getElementById('audit-stat-success');
const elFail = document.getElementById('audit-stat-failures');
const elRecent = document.getElementById('audit-stat-recent');
if (elTotal) elTotal.textContent = String(data.total != null ? data.total : 0);
if (elFail) elFail.textContent = String(data.failures != null ? data.failures : 0);
const total = data.total != null ? data.total : 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);
} catch (_) { /* ignore */ }
}
@@ -214,7 +284,8 @@ async function loadAuditLogs(page) {
throw new Error(err.error || r.statusText);
}
const data = await r.json();
renderAuditLogs(data.logs || []);
auditLogsCache = data.logs || [];
renderAuditLogs(auditLogsCache);
auditLogsTotal = typeof data.total === 'number' ? data.total : 0;
const maxPage = Math.max(1, Math.ceil(auditLogsTotal / auditLogsPageSize));
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) {
const listEl = document.getElementById('audit-log-list');
if (!listEl) return;
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
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;
}
listEl.innerHTML = logs.map(function (log) {
const lvl = log.result === 'failure' ? 'warn' : (log.level || 'info');
const dash = '<span class="audit-log-cell-muted">—</span>';
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 actionLabel = esc(auditActionLabel(log.action || ''));
const msg = esc(log.message || '');
const msg = esc(auditMessageLabel(log));
const ip = esc(log.clientIp || '');
const when = esc(formatAuditTime(log.createdAt));
const res = esc(log.result || '');
const rid = log.resourceId || '';
const meta = rid ? (' · ' + esc(rid)) : '';
const res = esc(auditResultLabel(log.result || ''));
const rid = log.resourceId ? esc(log.resourceId) : '';
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 (
'<div class="c2-event-item audit-log-item" role="button" tabindex="0" ' +
'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}">' +
'<div class="c2-event-level ' + esc(lvl) + '"></div>' +
'<div class="c2-event-content">' +
'<div class="c2-event-message">' + msg + '</div>' +
'<div class="c2-event-meta">' + when + ' · ' + catLabel + '/' + actionLabel + ' · ' + res + meta +
(ip ? ' · IP ' + ip : '') +
'</div></div></div>'
'<tr class="audit-log-row" role="button" tabindex="0" ' + rowClick + '>' +
'<td class="audit-log-col-time">' + when + '</td>' +
'<td class="audit-log-col-msg" title="' + msg + '">' + (msg || dash) + '</td>' +
'<td>' + (catLabel ? '<span class="audit-tag audit-tag--cat">' + catLabel + '</span>' : dash) + '</td>' +
'<td>' + (actionLabel ? '<span class="audit-tag audit-tag--act">' + actionLabel + '</span>' : dash) + '</td>' +
'<td>' + (res ? '<span class="audit-tag ' + resultCls + '">' + res + '</span>' : dash) + '</td>' +
'<td class="audit-log-col-ip">' + (ip || dash) + '</td>' +
'<td class="audit-log-col-resource" title="' + rid + '">' + (rid || dash) + '</td>' +
'</tr>'
);
}).join('');
listEl.innerHTML = head + rows + '</tbody></table></div>';
if (typeof applyTranslations === 'function') {
applyTranslations(listEl);
}
@@ -326,17 +417,58 @@ function resetAuditLogFilters() {
const act = document.getElementById('audit-filter-action');
const res = document.getElementById('audit-filter-result');
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 (res) res.value = '';
if (q) q.value = '';
if (since) since.value = '';
if (until) until.value = '';
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.clearAll === 'function') {
window.AuditDatetimePicker.clearAll();
}
rebuildAuditActionSelect();
syncAuditCustomSelect('audit-filter-category');
syncAuditCustomSelect('audit-filter-result');
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 = {
delete: true,
@@ -565,8 +697,8 @@ async function showAuditLogDetail(id) {
'<div class="modal-body audit-detail-body">' +
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</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>' +
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(auditMessageLabel(log)) + '</p>' +
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
@@ -597,7 +729,171 @@ async function showAuditLogDetail(id) {
function initAuditLogsSection() {
if (!document.getElementById('audit-log-list')) return;
initAuditPageSizeFromStorage();
initAuditFilterSelects();
rebuildAuditActionSelect();
loadAuditMeta();
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.init === 'function') {
window.AuditDatetimePicker.init();
}
initAuditTimePresets();
updateAuditTimezoneHint();
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');
}
+743 -138
View File
File diff suppressed because it is too large Load Diff
+159 -36
View File
@@ -2639,6 +2639,57 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
}
// 显示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) {
try {
openAppModal('mcp-detail-modal', { focus: false });
@@ -2700,42 +2751,22 @@ async function showMCPDetail(executionId) {
}
if (exec.result) {
const responseData = {
content: exec.result.content,
isError: exec.result.isError
};
responseElement.textContent = JSON.stringify(responseData, null, 2);
const agentVisibleText = truncateMCPDetailText(extractMCPResultText(exec.result), MCP_DETAIL_MAX_CHARS);
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
if (exec.result.isError) {
// 错误场景:响应结果标红 + 错误信息区块
responseElement.className = 'code-block error';
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
if (exec.error && errorSection && errorElement) {
errorSection.style.display = 'block';
errorElement.textContent = exec.error;
}
} else {
// 成功场景:响应结果保持普通样式,正确信息单独拎出来
responseElement.className = 'code-block';
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
if (successSection && successElement) {
successSection.style.display = 'block';
let successText = '';
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;
successElement.textContent = agentVisibleText || emptyText;
}
}
} else {
@@ -5732,6 +5763,95 @@ let conversationGroupMappingCache = {};
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by';
function getConversationSortBy() {
try {
const saved = localStorage.getItem(CONVERSATIONS_SORT_KEY);
if (saved === 'created_at' || saved === 'updated_at') return saved;
} catch (e) { /* ignore */ }
return 'updated_at';
}
let conversationSortBy = getConversationSortBy();
function getConversationSortTime(conv) {
const field = conversationSortBy === 'created_at' ? 'createdAt' : 'updatedAt';
const raw = conv && conv[field];
if (!raw) return new Date(0);
const date = new Date(raw);
return isNaN(date.getTime()) ? new Date(0) : date;
}
function updateConversationSortMenuUI() {
const menu = document.getElementById('conversation-sort-menu');
const btn = document.getElementById('conversation-sort-btn');
if (!menu) return;
menu.querySelectorAll('.conversation-sort-option').forEach((option) => {
const selected = option.dataset.sort === conversationSortBy;
option.classList.toggle('is-selected', selected);
option.setAttribute('aria-checked', selected ? 'true' : 'false');
});
if (btn) {
btn.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
}
}
function closeConversationSortMenu() {
const menu = document.getElementById('conversation-sort-menu');
const btn = document.getElementById('conversation-sort-btn');
if (menu) menu.hidden = true;
if (btn) btn.setAttribute('aria-expanded', 'false');
}
function toggleConversationSortMenu(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const menu = document.getElementById('conversation-sort-menu');
const btn = document.getElementById('conversation-sort-btn');
if (!menu || !btn) return;
const willOpen = menu.hidden;
closeConversationSortMenu();
if (willOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
updateConversationSortMenuUI();
}
}
function setConversationSortBy(sortBy) {
const next = sortBy === 'created_at' ? 'created_at' : 'updated_at';
if (next === conversationSortBy) {
closeConversationSortMenu();
return;
}
conversationSortBy = next;
try {
localStorage.setItem(CONVERSATIONS_SORT_KEY, next);
} catch (e) { /* ignore */ }
updateConversationSortMenuUI();
closeConversationSortMenu();
conversationsPagination.page = 1;
loadConversationsWithGroups(conversationsSearchQuery);
}
if (!window.__conversationSortMenuBound) {
window.__conversationSortMenuBound = true;
document.addEventListener('click', (event) => {
const dropdown = document.getElementById('conversation-sort-dropdown');
if (!dropdown || dropdown.contains(event.target)) return;
closeConversationSortMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeConversationSortMenu();
});
}
window.toggleConversationSortMenu = toggleConversationSortMenu;
window.setConversationSortBy = setConversationSortBy;
window.closeConversationSortMenu = closeConversationSortMenu;
function getConversationsPageSize() {
try {
@@ -5994,6 +6114,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
const pageSize = conversationsPagination.pageSize;
const offset = (conversationsPagination.page - 1) * pageSize;
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (conversationSortBy === 'created_at') {
convParams.set('sort_by', 'created_at');
}
if (searchQuery && searchQuery.trim()) {
convParams.set('search', searchQuery.trim());
} else {
@@ -6083,11 +6206,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
});
// 按时间排序
const sortByTime = (a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt) : new Date(0);
const timeB = b.updatedAt ? new Date(b.updatedAt) : new Date(0);
return timeB - timeA;
};
const sortByTime = (a, b) => getConversationSortTime(b) - getConversationSortTime(a);
pinnedConvs.sort(sortByTime);
normalConvs.sort(sortByTime);
@@ -6115,8 +6234,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
};
normalConvs.forEach(conv => {
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const dateObj = getConversationSortTime(conv);
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart);
groups[groupKey].push({
...conv,
@@ -6128,8 +6247,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (pinnedConvs.length > 0) {
pinnedConvs.forEach(conv => {
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const dateObj = getConversationSortTime(conv);
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
fragment.appendChild(createConversationListItemWithMenu({
...conv,
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
@@ -7468,8 +7587,11 @@ async function deleteSelectedConversations() {
for (const id of ids) {
await deleteConversation(id, true); // 跳过内部确认,因为批量删除时已经确认过了
}
closeBatchManageModal();
loadConversationsWithGroups();
// 删除后保持弹窗打开,便于继续管理剩余对话
const selectAll = document.getElementById('batch-select-all');
if (selectAll) {
selectAll.checked = false;
}
} catch (error) {
console.error('删除失败:', error);
const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.deleteFailed') : '删除失败';
@@ -8474,6 +8596,7 @@ function clearGroupSearch() {
// 初始化时加载分组
document.addEventListener('DOMContentLoaded', async () => {
updateConversationSortMenuUI();
await loadGroups();
await loadConversationsWithGroups();
+4 -40
View File
@@ -2059,45 +2059,9 @@ function handleStreamEvent(event, progressElement, progressId,
}
break;
case 'tool_result_delta': {
const deltaInfo = event.data || {};
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');
scheduleStreamPlainTextUpdate(pre, state.buffer);
}
}
case 'tool_result_delta':
// 工具执行过程不流式展示,仅等 tool_result 展示最终结果。
break;
}
case 'tool_result':
const resultInfo = event.data || {};
@@ -3887,7 +3851,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
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-total="${c.p.total || 0}"
data-failed="${c.p.failed || 0}" />`;
@@ -3895,7 +3859,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
const peakC = coords[peakIdx];
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">
+390 -1
View File
@@ -299,6 +299,7 @@ async function loadConfig(loadTools = true) {
}
fillVisionConfigFromCurrent(currentConfig.vision || {});
initModelListControls();
// 填充FOFA配置
const fofa = currentConfig.fofa || {};
@@ -1569,9 +1570,397 @@ function syncVisionFormEnabled() {
if (panel) {
panel.style.opacity = enabled ? '1' : '0.55';
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;
});
syncModelListFetchButtons();
}
}
const modelPickSelectMap = {};
let modelPickSelectDocListener = false;
function modelPickT(key) {
return typeof window.t === 'function' ? window.t(key) : key;
}
function closeAllModelPickDropdowns() {
Object.keys(modelPickSelectMap).forEach(function (id) {
modelPickSelectMap[id].wrapper.classList.remove('open');
});
}
function syncModelPickDropdown(selectId) {
const reg = modelPickSelectMap[selectId];
if (!reg) return;
const { select, dropdown, trigger, wrapper, menuList, countBadge } = reg;
const placeholder = modelPickT('settingsBasic.modelsListSelectPlaceholder');
menuList.innerHTML = '';
let optionCount = 0;
Array.prototype.forEach.call(select.options, function (opt) {
if (!opt.value) return;
optionCount += 1;
const item = document.createElement('div');
item.className = 'model-pick-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');
}
const check = document.createElement('span');
check.className = 'model-pick-option-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'model-pick-option-label';
label.textContent = opt.textContent;
item.appendChild(check);
item.appendChild(label);
menuList.appendChild(item);
});
const selectedOpt = select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
const labelEl = trigger.querySelector('.model-pick-trigger-label');
if (labelEl) {
labelEl.textContent = (selectedOpt && selectedOpt.value) ? selectedOpt.textContent : placeholder;
}
if (countBadge) {
countBadge.textContent = String(optionCount);
countBadge.style.display = optionCount > 0 ? '' : 'none';
}
const header = wrapper.querySelector('.model-pick-menu-header');
if (header) {
header.textContent = optionCount > 0
? placeholder + ' · ' + optionCount
: placeholder;
}
trigger.disabled = !!select.disabled;
wrapper.classList.toggle('is-disabled', !!select.disabled);
wrapper.style.display = optionCount > 0 ? '' : 'none';
select.style.display = 'none';
}
function enhanceModelPickSelect(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
if (select.dataset.modelPickEnhanced === '1') {
syncModelPickDropdown(selectId);
return;
}
select.dataset.modelPickEnhanced = '1';
select.classList.add('model-pick-native');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'model-pick-dropdown';
wrapper.style.display = 'none';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'model-pick-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
const labelSpan = document.createElement('span');
labelSpan.className = 'model-pick-trigger-label';
labelSpan.textContent = modelPickT('settingsBasic.modelsListSelectPlaceholder');
const meta = document.createElement('span');
meta.className = 'model-pick-trigger-meta';
const countBadge = document.createElement('span');
countBadge.className = 'model-pick-count';
countBadge.style.display = 'none';
const caret = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
caret.setAttribute('class', 'model-pick-caret');
caret.setAttribute('viewBox', '0 0 16 16');
caret.setAttribute('aria-hidden', 'true');
caret.innerHTML = '<path fill="currentColor" d="M4.47 6.47a.75.75 0 0 1 1.06 0L8 8.94l2.47-2.47a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 0-1.06z"/>';
meta.appendChild(countBadge);
meta.appendChild(caret);
trigger.appendChild(labelSpan);
trigger.appendChild(meta);
const menu = document.createElement('div');
menu.className = 'model-pick-menu';
const header = document.createElement('div');
header.className = 'model-pick-menu-header';
menu.appendChild(header);
const menuList = document.createElement('div');
menuList.className = 'model-pick-menu-list';
menuList.setAttribute('role', 'listbox');
menu.appendChild(menuList);
const parent = select.parentNode;
const fetchLink = parent.querySelector('.model-pick-fetch-link');
if (fetchLink) {
parent.insertBefore(wrapper, fetchLink);
} else {
parent.appendChild(wrapper);
}
wrapper.appendChild(trigger);
wrapper.appendChild(menu);
wrapper.appendChild(select);
modelPickSelectMap[selectId] = {
wrapper,
trigger,
menu,
menuList,
countBadge,
select
};
if (!modelPickSelectDocListener) {
document.addEventListener('click', closeAllModelPickDropdowns);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllModelPickDropdowns();
});
modelPickSelectDocListener = true;
}
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (select.disabled) return;
const open = wrapper.classList.contains('open');
closeAllModelPickDropdowns();
if (!open) wrapper.classList.add('open');
});
menuList.addEventListener('click', function (e) {
const opt = e.target.closest('.model-pick-option');
if (!opt) return;
const val = opt.getAttribute('data-value');
if (val === null || val === '') return;
if (select.value !== val) {
select.value = val;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
wrapper.classList.remove('open');
syncModelPickDropdown(selectId);
});
syncModelPickDropdown(selectId);
}
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';
enhanceModelPickSelect(selectId);
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';
const openaiWrap = modelPickSelectMap['openai-model-select'];
if (openaiWrap) openaiWrap.wrapper.style.display = 'none';
} else if (openaiSelect && !isClaudeOpenai) {
syncModelPickDropdown('openai-model-select');
}
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';
const visionWrap = modelPickSelectMap['vision-model-select'];
if (visionWrap) visionWrap.wrapper.style.display = 'none';
} else if (visionSelect && !isClaudeVision) {
syncModelPickDropdown('vision-model-select');
}
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 = '';
}
enhanceModelPickSelect(selectId);
syncModelPickDropdown(selectId);
}
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 = '';
}
}
}
+30 -26
View File
@@ -83,6 +83,21 @@ function batchQueueAllowsSubtaskMutation(queue) {
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
}
/** 是否允许对指定子任务发起单条执行(与后端 queueAllowsSingleTaskRunLocked 对齐) */
function batchQueueCanRunSingleTask(queue, task) {
if (!queue || !task) return false;
if (task.status === 'running') return false;
if (queue.status === 'running') return false;
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
}
function batchQueueRunSingleTaskDisabledReason(queue, task) {
if (!queue || !task) return _t('tasks.runSingleTaskUnavailable');
if (task.status === 'running') return _t('tasks.runSingleTaskUnavailableSelf');
if (queue.status === 'running') return _t('tasks.runSingleTaskUnavailableQueue');
return _t('tasks.runSingleTaskUnavailable');
}
// HTML转义函数(如果未定义)
if (typeof escapeHtml === 'undefined') {
function escapeHtml(text) {
@@ -1497,6 +1512,8 @@ async function showBatchQueueDetail(queueId) {
${queue.tasks.map((task, index) => {
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
const canEdit = allowSubtaskMutation && task.status !== 'running';
const canRunSingle = batchQueueCanRunSingleTask(queue, task);
const runSingleUnavailableTitle = escapeHtml(batchQueueRunSingleTaskDisabledReason(queue, task));
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "&#39;").replace(/"/g, "&quot;").replace(/\n/g, "\\n");
return `
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
@@ -1504,10 +1521,10 @@ async function showBatchQueueDetail(queueId) {
<span class="batch-task-index">#${index + 1}</span>
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
<button class="btn-secondary btn-small batch-task-run-btn" ${canRunSingle ? `onclick="runSingleBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();"` : `disabled title="${runSingleUnavailableTitle}"`}>` + _t('tasks.runSingleTask') + `</button>
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
</div>
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
@@ -2270,38 +2287,25 @@ async function saveInlineAgentMode() {
}
}
// --- 重试失败任务 ---
async function retryBatchTask(queueId, taskId) {
// --- 单条执行 ---
async function runSingleBatchTask(queueId, taskId) {
if (!queueId || !taskId) return;
if (!confirm(_t('tasks.confirmRunSingleTask'))) return;
try {
// 获取任务消息
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
const detail = await detailResp.json();
const task = detail.queue.tasks.find(t => t.id === taskId);
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
const message = task.message;
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
});
if (!addResp.ok) {
const r = await addResp.json().catch(() => ({}));
throw new Error(r.error || _t('tasks.addTaskFailed'));
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || _t('tasks.runSingleTaskFailed'));
}
// 新任务添加成功后才删除旧任务
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
if (!delResp.ok) {
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
console.warn('删除旧任务失败,但新任务已添加');
if (result.autoStarted === false && result.message) {
alert(result.message);
}
showBatchQueueDetail(queueId);
refreshBatchQueues();
} catch (e) {
console.error('重试任务失败:', e);
console.error('单条执行失败:', e);
alert(e.message);
}
}
@@ -2437,7 +2441,7 @@ window.startInlineEditRole = startInlineEditRole;
window.saveInlineRole = saveInlineRole;
window.startInlineEditAgentMode = startInlineEditAgentMode;
window.saveInlineAgentMode = saveInlineAgentMode;
window.retryBatchTask = retryBatchTask;
window.runSingleBatchTask = runSingleBatchTask;
window.startInlineEditSchedule = startInlineEditSchedule;
window.toggleInlineScheduleCron = toggleInlineScheduleCron;
window.saveInlineSchedule = saveInlineSchedule;
-22
View File
@@ -3391,28 +3391,6 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
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) ───
} else if (_et === 'tool_result' && _ed) {
var success = _ed.success !== false;
+69 -10
View File
@@ -2,6 +2,7 @@
let wechatBindSessionKey = null;
let wechatBindPollTimer = null;
let wechatBindFlashTimer = null;
function wechatT(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) {
const wc = wechat || {};
const wrap = document.getElementById('robot-wechat-qr-wrap');
const boundPanel = document.getElementById('robot-wechat-bound-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');
stopWechatBindPoll();
@@ -102,8 +140,8 @@ function showWechatBoundUI(wechat) {
setWechatBadge('bound');
setWechatCardBound(true);
if (wrap) wrap.hidden = false;
if (boundPanel) boundPanel.hidden = false;
if (wrap) wrap.hidden = true;
if (boundPanel) boundPanel.hidden = true;
if (scanPanel) scanPanel.hidden = true;
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
@@ -117,14 +155,15 @@ function showWechatBoundUI(wechat) {
}
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) {
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID') + id;
boundId.hidden = false;
const prefix = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID');
summary.innerHTML = `${prefix}<code>${escapeHtml(id)}</code>`;
summary.hidden = false;
} else {
boundId.textContent = '';
boundId.hidden = true;
summary.textContent = '';
summary.hidden = true;
}
}
@@ -133,21 +172,32 @@ function showWechatBoundUI(wechat) {
}
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 扫码绑定进行中 */
function showWechatScanUI() {
const wrap = document.getElementById('robot-wechat-qr-wrap');
const boundPanel = document.getElementById('robot-wechat-bound-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');
setWechatBadge('scanning');
setWechatCardBound(false);
clearWechatBindSuccessNotice();
ensureWechatSteps();
updateWechatSteps('generate');
if (wrap) wrap.hidden = false;
if (boundPanel) boundPanel.hidden = true;
if (scanPanel) scanPanel.hidden = false;
if (summary) summary.hidden = true;
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
if (verifyWrap) verifyWrap.hidden = true;
@@ -163,7 +213,10 @@ function showWechatScanUI() {
/** 未绑定且未在扫码:隐藏面板 */
function hideWechatQrWrap() {
const wrap = document.getElementById('robot-wechat-qr-wrap');
const summary = document.getElementById('robot-wechat-bound-summary');
if (wrap) wrap.hidden = true;
if (summary) summary.hidden = true;
clearWechatBindSuccessNotice();
setWechatBadge('idle');
setWechatCardBound(false);
}
@@ -278,6 +331,9 @@ async function pollWechatBindStatus() {
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
if (idEl) idEl.value = data.ilink_bot_id;
}
showWechatBindSuccessNotice(
data.message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。')
);
if (typeof loadConfig === 'function') {
await loadConfig(false);
} else {
@@ -299,6 +355,9 @@ async function pollWechatBindStatus() {
break;
case 'binded_redirect':
stopWechatBindPoll();
showWechatBindSuccessNotice(
data.message || wechatT('settings.robots.wechat.alreadyBound', '该微信已绑定过,无需重复绑定。')
);
showWechatBoundUI({ bound: true });
return;
case 'expired':
+132 -44
View File
@@ -808,16 +808,49 @@
<div class="recent-conversations-section">
<div class="section-header">
<span class="section-title" data-i18n="chat.recentConversations">最近对话</span>
<button class="batch-manage-btn" onclick="showBatchManageModal()" data-i18n="chat.batchManage" data-i18n-attr="title" data-i18n-skip-text="true" title="批量管理">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="8" cy="6" r="1" fill="currentColor"/>
<circle cx="8" cy="12" r="1" fill="currentColor"/>
<circle cx="8" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
<div class="section-header-actions">
<div class="conversation-sort-dropdown" id="conversation-sort-dropdown">
<button type="button" class="conversation-sort-btn" id="conversation-sort-btn" onclick="toggleConversationSortMenu(event)" aria-haspopup="menu" aria-expanded="false" aria-controls="conversation-sort-menu" data-i18n="chat.sortConversations" data-i18n-attr="title" data-i18n-skip-text="true" title="排序">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M3 6h18M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="conversation-sort-menu" id="conversation-sort-menu" role="menu" hidden>
<button type="button" class="conversation-sort-option" role="menuitemradio" data-sort="created_at" onclick="setConversationSortBy('created_at')">
<span class="conversation-sort-option-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="16" rx="2.5" stroke="currentColor" stroke-width="1.75"/>
<path d="M3 10h18" stroke="currentColor" stroke-width="1.75"/>
<path d="M8 3v3M16 3v3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<circle cx="12" cy="15" r="1.75" fill="currentColor"/>
</svg>
</span>
<span class="conversation-sort-option-label" data-i18n="chat.sortByCreatedAt">创建时间</span>
<span class="conversation-sort-option-check" aria-hidden="true"></span>
</button>
<button type="button" class="conversation-sort-option" role="menuitemradio" data-sort="updated_at" onclick="setConversationSortBy('updated_at')">
<span class="conversation-sort-option-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.75"/>
<path d="M12 8v4.5l3 2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="conversation-sort-option-label" data-i18n="chat.sortByUpdatedAt">更新时间</span>
<span class="conversation-sort-option-check" aria-hidden="true"></span>
</button>
</div>
</div>
<button class="batch-manage-btn" onclick="showBatchManageModal()" data-i18n="chat.batchManage" data-i18n-attr="title" data-i18n-skip-text="true" title="批量管理">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="8" cy="6" r="1" fill="currentColor"/>
<circle cx="8" cy="12" r="1" fill="currentColor"/>
<circle cx="8" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
</div>
</div>
<div id="conversations-list" class="conversations-list"></div>
</div>
@@ -2035,12 +2068,17 @@
<div class="page-header">
<h2 data-i18n="c2.sessions.title">会话管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-danger" id="c2-sessions-batch-delete" disabled onclick="C2.deleteSelectedSessions()"><span data-i18n="c2.sessions.batchDelete">批量删除</span></button>
<button type="button" class="btn-secondary" id="c2-sessions-delete-filtered" disabled onclick="C2.deleteFilteredSessions()"><span data-i18n="c2.sessions.deleteFiltered">删除筛选结果</span></button>
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="page-content" style="padding:0;">
<div class="c2-session-layout">
<div id="c2-session-list" class="c2-session-sidebar"></div>
<div class="c2-session-sidebar-wrap">
<div id="c2-session-toolbar" class="c2-sessions-toolbar"></div>
<div id="c2-session-list" class="c2-session-sidebar"></div>
</div>
<div id="c2-session-main" class="c2-session-main"></div>
</div>
</div>
@@ -2408,7 +2446,15 @@
</div>
<div class="form-group">
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
<div class="model-pick-row">
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
</select>
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
</div>
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
</div>
<div class="form-group">
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
@@ -2486,7 +2532,15 @@
</div>
<div class="form-group">
<label for="vision-model"><span data-i18n="settingsBasic.visionModel">视觉模型</span> <span style="color: red;">*</span></label>
<input type="text" id="vision-model" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
<div class="model-pick-row">
<input type="text" id="vision-model" class="model-pick-input" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
<select id="vision-model-select" class="model-pick-native" style="display: none;" aria-hidden="true" tabindex="-1">
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
</select>
<a href="javascript:void(0)" id="fetch-vision-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('vision')" data-i18n="settingsBasic.fetchModels">获取列表</a>
</div>
<small id="fetch-vision-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
<span id="fetch-vision-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
</div>
<details style="margin-top: 8px;">
<summary style="cursor: pointer; font-size: 0.875rem; color: var(--accent-color, #3182ce);" data-i18n="settingsBasic.visionAdvanced">高级:预处理与限制</summary>
@@ -2817,6 +2871,13 @@
<button type="button" class="btn-primary" id="robot-wechat-bind-btn" onclick="startWechatRobotBind()" data-i18n="settings.robots.wechat.bindButton">生成二维码并绑定</button>
<p class="robot-wechat-hint" id="robot-wechat-bind-hint" data-i18n="settings.robots.wechat.bindHint">用微信扫码确认后会自动保存并启用。</p>
</div>
<div id="robot-wechat-bound-flash" class="robot-wechat-bound-flash" hidden role="status">
<span class="robot-wechat-bound-flash-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</span>
<span id="robot-wechat-bound-flash-text" data-i18n="settings.robots.wechat.boundSuccess">绑定成功,微信机器人已启用。</span>
</div>
<p id="robot-wechat-bound-summary" class="robot-wechat-bound-summary" hidden></p>
<div id="robot-wechat-qr-wrap" class="robot-wechat-panel" hidden>
<div id="robot-wechat-bound-panel" class="robot-wechat-bound-panel" hidden>
<div class="robot-wechat-bound-icon" aria-hidden="true">
@@ -3010,19 +3071,27 @@
<!-- 日志审计 -->
<div id="settings-section-audit" class="settings-section-content">
<div class="settings-section-header">
<div class="audit-section-head">
<h3 data-i18n="settingsAudit.title">日志审计</h3>
<p class="settings-description" data-i18n="settingsAudit.description">记录平台管理类操作(登录、配置、删除等),不记录对话正文、终端/WebShell 每次命令与工具调用明细。</p>
<p id="audit-retention-hint" class="settings-description audit-retention-hint" hidden></p>
<div id="audit-summary-stats" class="audit-summary-tags" hidden>
<span class="audit-summary-tag"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statTotal">当前筛选</span><strong id="audit-stat-total">0</strong></span>
<span class="audit-summary-tag audit-summary-tag--ok"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statSuccess">成功</span><strong id="audit-stat-success">0</strong></span>
<span class="audit-summary-tag audit-summary-tag--warn"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statFailures">失败</span><strong id="audit-stat-failures">0</strong></span>
<span class="audit-summary-tag"><span class="audit-summary-tag-label" data-i18n="settingsAudit.statRecent7d">近 7 天</span><strong id="audit-stat-recent">0</strong></span>
</div>
</div>
<div id="audit-summary-stats" class="audit-summary-stats" hidden>
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statTotal">当前筛选</span><strong id="audit-stat-total">0</strong></div>
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statFailures">失败</span><strong id="audit-stat-failures">0</strong></div>
<div class="audit-stat-card"><span class="audit-stat-label" data-i18n="settingsAudit.statRecent7d">近 7 天</span><strong id="audit-stat-recent">0</strong></div>
</div>
<div class="audit-logs-toolbar">
<div class="audit-logs-filters">
<label class="audit-filter-cascade-group">
<div class="audit-filter-card">
<div class="audit-time-presets" id="audit-time-presets">
<span class="audit-time-presets-label" data-i18n="settingsAudit.timePresets">快捷</span>
<button type="button" class="audit-time-preset-btn" data-preset="15m" data-i18n="settingsAudit.preset15m">最近15分钟</button>
<button type="button" class="audit-time-preset-btn" data-preset="1h" data-i18n="settingsAudit.preset1h">最近1小时</button>
<button type="button" class="audit-time-preset-btn" data-preset="24h" data-i18n="settingsAudit.preset24h">最近24小时</button>
<button type="button" class="audit-time-preset-btn" data-preset="7d" data-i18n="settingsAudit.preset7d">最近7天</button>
<button type="button" class="audit-time-preset-btn" data-preset="today" data-i18n="settingsAudit.presetToday">今天</button>
</div>
<div class="audit-filter-fields">
<div class="audit-filter-row">
<label class="audit-field audit-field--event">
<span data-i18n="settingsAudit.filterEvent">事件类型</span>
<div class="audit-filter-cascade">
<select id="audit-filter-category" onchange="onAuditCategoryFilterChange()" aria-label="类别">
@@ -3049,7 +3118,7 @@
</select>
</div>
</label>
<label>
<label class="audit-field audit-field--result">
<span data-i18n="settingsAudit.filterResult">结果</span>
<select id="audit-filter-result">
<option value="" data-i18n="settingsAudit.filterAll">全部</option>
@@ -3057,36 +3126,54 @@
<option value="failure">failure</option>
</select>
</label>
<label>
<div class="audit-filter-time-group">
<label class="audit-field audit-field--time">
<span data-i18n="settingsAudit.filterSince">开始时间</span>
<input type="datetime-local" id="audit-filter-since" />
<div class="audit-datetime-field" id="audit-filter-since-field">
<input type="text" id="audit-filter-since" class="audit-datetime-input" readonly autocomplete="off" data-i18n="settingsAudit.datetimePlaceholder" data-i18n-attr="placeholder" placeholder="选择日期时间" />
<button type="button" class="audit-datetime-btn audit-datetime-clear-btn" title="Clear" aria-label="Clear" hidden>&times;</button>
<button type="button" class="audit-datetime-btn audit-datetime-open-btn" title="Open" aria-label="Open">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</label>
<label>
<label class="audit-field audit-field--time">
<span data-i18n="settingsAudit.filterUntil">结束时间</span>
<input type="datetime-local" id="audit-filter-until" />
<div class="audit-datetime-field" id="audit-filter-until-field">
<input type="text" id="audit-filter-until" class="audit-datetime-input" readonly autocomplete="off" data-i18n="settingsAudit.datetimePlaceholder" data-i18n-attr="placeholder" placeholder="选择日期时间" />
<button type="button" class="audit-datetime-btn audit-datetime-clear-btn" title="Clear" aria-label="Clear" hidden>&times;</button>
<button type="button" class="audit-datetime-btn audit-datetime-open-btn" title="Open" aria-label="Open">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</label>
<label>
</div>
</div>
</div>
<div class="audit-filter-bottom">
<label class="audit-field audit-field--keyword">
<span data-i18n="settingsAudit.filterQuery">关键词</span>
<input type="text" id="audit-filter-q" data-i18n="settingsAudit.filterQueryPlaceholder" data-i18n-attr="placeholder" placeholder="消息 / 资源 ID / 操作名" />
</label>
<button type="button" class="btn-secondary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
</div>
<div class="audit-logs-actions">
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
<div class="audit-export-dropdown">
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
<span data-i18n="settingsAudit.exportBtn">导出</span>
<span class="audit-export-caret" aria-hidden="true"></span>
</button>
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
<div class="audit-logs-actions">
<button type="button" class="btn-primary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
<div class="audit-export-dropdown">
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
<span data-i18n="settingsAudit.exportBtn">导出</span>
<span class="audit-export-caret" aria-hidden="true"></span>
</button>
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
</div>
</div>
</div>
</div>
<p id="audit-filter-timezone-hint" class="audit-timezone-hint" hidden></p>
</div>
<div id="audit-log-list" class="audit-log-list c2-event-list"></div>
<div id="audit-log-list" class="audit-log-list"></div>
<div id="audit-logs-pagination" class="pagination-container audit-logs-pagination"></div>
</div>
@@ -4301,6 +4388,7 @@
<script src="/static/js/chat.js"></script>
<script src="/static/js/hitl.js"></script>
<script src="/static/js/settings.js"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
<script src="/static/js/wechat-robot.js"></script>
<script src="/static/vendor/xterm.js"></script>