Compare commits

..

42 Commits

Author SHA1 Message Date
公明 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
公明 8d999792b8 Update config.yaml 2026-06-16 16:22:14 +08:00
公明 afae8970d1 Add files via upload 2026-06-16 16:21:24 +08:00
公明 4d7330c5c3 Add files via upload 2026-06-16 15:48:11 +08:00
公明 8884bfb0b4 Add files via upload 2026-06-16 13:07:04 +08:00
公明 fb351c80b6 Add files via upload 2026-06-15 22:06:46 +08:00
公明 664834e338 Add files via upload 2026-06-15 22:03:29 +08:00
公明 95bf62db88 Add files via upload 2026-06-15 21:56:42 +08:00
公明 656242614d Add files via upload 2026-06-15 21:41:02 +08:00
公明 a9d6d8c00e Add files via upload 2026-06-15 21:30:39 +08:00
公明 0d6a43c0a8 Add files via upload 2026-06-15 20:43:51 +08:00
公明 702f286eb1 Add files via upload 2026-06-15 20:24:17 +08:00
70 changed files with 12841 additions and 2334 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)
}
}
+1 -3
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.37"
version: "v1.6.40"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -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)
}
}
+3 -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{
@@ -900,6 +876,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 +1108,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: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
• 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+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
需要经典 bash 反弹 shell 时:先 c2_listener create type=tcp_reverse,再对该监听器用 kind=bash
公网部署 tcp_reverse 请用 build 生成加密 Beacon,勿开启 allow_legacy_shell
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
- 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)
}
}
}
+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 任务
// ----------------------------------------------------------------------------
+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)
+19 -4
View File
@@ -641,7 +641,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
for {
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
@@ -690,7 +690,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
for {
resultMA, errMA = multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback,
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback,
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
@@ -1185,6 +1185,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
}
flushResponsePlan()
// 助手正文开始前,推理流通常已结束;落库以便刷新后「渗透测试详情」可回放
flushThinkingStreams()
respPlan.meta = nil
if dataMap, ok := data.(map[string]interface{}); ok {
respPlan.meta = make(map[string]interface{}, len(dataMap))
@@ -1220,6 +1222,19 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
if eventType == "response" {
flushResponsePlan()
flushThinkingStreams()
return
}
if eventType == "done" {
flushResponsePlan()
flushThinkingStreams()
return
}
// 流式思考/推理结束:聚合落库(与 eino_agent_reply_stream_end 同理)
if eventType == "thinking_stream_end" || eventType == "reasoning_chain_stream_end" {
flushResponsePlan()
flushThinkingStreams()
return
}
@@ -2218,12 +2233,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.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.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
}
}
@@ -3,10 +3,14 @@ package handler
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/openai"
"go.uber.org/zap"
)
@@ -46,3 +50,50 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
}
wg.Wait()
}
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer os.RemoveAll(tmp)
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
h := &AgentHandler{logger: zap.NewNop(), db: db}
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
streamID := "eino-reasoning-test-1"
cb("reasoning_chain_stream_start", " ", map[string]interface{}{
"streamId": streamID,
"source": "eino",
})
cb("reasoning_chain_stream_delta", "step one", openai.WithSSEAccumulated(map[string]interface{}{
"streamId": streamID,
}, "step one"))
cb("done", "", map[string]interface{}{"conversationId": conv.ID})
details, err := db.GetProcessDetails(asst.ID)
if err != nil {
t.Fatalf("GetProcessDetails: %v", err)
}
found := false
for _, d := range details {
if d.EventType == "reasoning_chain" && d.Message == "step one" {
found = true
break
}
}
if !found {
t.Fatalf("expected reasoning_chain persisted on done, got %+v", details)
}
}
+2 -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
}
}
+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)
}
+2
View File
@@ -226,6 +226,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.agent,
h.logger,
conversationID,
h.conversationProjectID(conversationID),
curFinalMessage,
curHistory,
roleTools,
@@ -456,6 +457,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
h.agent,
h.logger,
prep.ConversationID,
h.conversationProjectID(prep.ConversationID),
curMsg,
curHist,
prep.RoleTools,
+2
View File
@@ -236,6 +236,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.agent,
h.logger,
conversationID,
h.conversationProjectID(conversationID),
curFinalMessage,
curHistory,
roleTools,
@@ -468,6 +469,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
h.agent,
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)
}
+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 {
+109 -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
@@ -785,6 +840,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}
}
}
if progress != nil && reasoningStreamID != "" && strings.TrimSpace(reasoningBuf) != "" {
progress("reasoning_chain_stream_end", openai.DisplayReasoningContent(strings.TrimSpace(reasoningBuf)), map[string]interface{}{
"streamId": reasoningStreamID,
"conversationId": conversationID,
"source": "eino",
"einoAgent": ev.AgentName,
"einoRole": einoRoleTag(ev.AgentName),
"orchestration": orchMode,
})
}
if streamsMainAssistant(ev.AgentName) {
s := strings.TrimSpace(mainAssistantBuf)
if mainAssistDupTarget != "" {
@@ -963,7 +1028,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}
}
if mv.Role == schema.Tool && progress != nil {
if (mv.Role == schema.Tool || msg.Role == schema.Tool) && progress != nil {
toolName := msg.ToolName
if toolName == "" {
toolName = mv.ToolName
@@ -976,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) {
+8 -18
View File
@@ -34,6 +34,7 @@ func RunEinoSingleChatModelAgent(
ag *agent.Agent,
logger *zap.Logger,
conversationID string,
projectID string,
userMessage string,
history []agent.ChatMessage,
roleTools []string,
@@ -58,10 +59,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 +78,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)
}
@@ -145,7 +134,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 +226,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) {
@@ -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)
}
}
+12 -23
View File
@@ -58,6 +58,7 @@ func RunDeepAgent(
ag *agent.Agent,
logger *zap.Logger,
conversationID string,
projectID string,
userMessage string,
history []agent.ChatMessage,
roleTools []string,
@@ -107,10 +108,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 +131,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,12 +198,12 @@ 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)
}
@@ -233,7 +221,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)
}
@@ -320,11 +308,11 @@ func RunDeepAgent(
}
}
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, orchestratorName)
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, orchestratorName)
if err != nil {
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 +359,7 @@ func RunDeepAgent(
inner: einoLoc,
invokeNotify: toolInvokeNotify,
einoAgentName: orchestratorName,
outputChunk: toolOutputChunk,
outputChunk: nil,
recordMonitor: einoExecMonitor,
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
}
@@ -438,7 +426,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)
}
@@ -565,6 +553,7 @@ func RunDeepAgent(
McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: recorder,
MCPExecutionBinder: mcpExecBinder,
ToolInvokeNotify: toolInvokeNotify,
DA: da,
ModelFacingTrace: modelFacingTrace,
+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 -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
+888 -70
View File
File diff suppressed because it is too large Load Diff
+158 -2
View File
@@ -1083,6 +1083,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 +1939,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 +2094,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 +2195,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 +2360,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 +2691,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 +2753,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 +2832,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 +2865,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 +2906,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",
+158 -2
View File
@@ -1071,6 +1071,7 @@
"botAgent": "Bot Agent",
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
"boundSuccess": "绑定成功,微信机器人已启用。",
"alreadyBound": "该微信已绑定过,无需重复绑定。",
"openLink": "无法显示二维码?点击用手机微信打开链接"
},
"wecom": {
@@ -1926,6 +1927,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 +2082,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 +2183,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 +2348,8 @@
"copyContent": "复制内容",
"correctInfo": "正确信息",
"errorInfo": "错误信息",
"copyError": "复制错误"
"copyError": "复制错误",
"contentTruncated": "…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)"
},
"attackChainModal": {
"title": "攻击链可视化",
@@ -2562,6 +2679,11 @@
},
"c2": {
"clipboardCopied": "已复制到剪贴板",
"common": {
"justNow": "刚刚",
"minutesAgo": "{{n}} 分钟前",
"hoursAgo": "{{n}} 小时前"
},
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}秒",
@@ -2619,6 +2741,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 +2820,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 +2853,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 +2894,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
+175 -32
View File
@@ -2164,6 +2164,97 @@ function showCopySuccess(button) {
}
}
/** Claude extended thinking 内部尾缀(与后端 DisplayReasoningContent 一致,UI 不展示) */
const CLAUDE_REASONING_UI_SUFFIX = '\n---CSAI_CLAUDE_THINKING_BLOCKS---\n';
function normalizeReasoningContentForDisplay(text) {
if (text == null) return '';
let s = String(text).trim();
if (!s) return '';
const idx = s.lastIndexOf(CLAUDE_REASONING_UI_SUFFIX);
if (idx >= 0) {
s = s.slice(0, idx).trim();
}
return s;
}
function setMessageReasoningContent(messageIdOrEl, reasoningContent) {
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
if (!el || !el.dataset) return;
const rc = normalizeReasoningContentForDisplay(reasoningContent);
if (rc) {
el.dataset.reasoningContent = rc;
} else {
delete el.dataset.reasoningContent;
}
}
function getMessageReasoningContent(messageIdOrEl) {
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
if (!el || !el.dataset) return '';
return normalizeReasoningContentForDisplay(el.dataset.reasoningContent || '');
}
function reasoningTextAlreadyInProcessDetails(processDetails, rc) {
if (!rc) return true;
const list = Array.isArray(processDetails) ? processDetails : [];
for (let i = 0; i < list.length; i++) {
const d = list[i];
if (!d) continue;
const et = d.eventType || '';
if (et !== 'reasoning_chain' && et !== 'thinking') continue;
const msg = normalizeReasoningContentForDisplay(d.message || '');
if (!msg) continue;
if (msg === rc || msg.includes(rc) || rc.includes(msg)) {
return true;
}
}
return false;
}
/** 合并 messages.reasoningContent 与 process_details 中的 reasoning_chain,两者都读、都展示(去重后) */
function mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningContent) {
const rc = normalizeReasoningContentForDisplay(reasoningContent);
const details = Array.isArray(processDetails) ? processDetails.slice() : [];
if (!rc || reasoningTextAlreadyInProcessDetails(details, rc)) {
return details;
}
details.push({
eventType: 'reasoning_chain',
message: rc,
data: { source: 'message.reasoningContent' }
});
return details;
}
async function syncAssistantReasoningContentFromServer(backendMessageId, domAssistantId) {
if (!backendMessageId || !domAssistantId || !currentConversationId || typeof apiFetch !== 'function') {
return;
}
try {
const convRes = await apiFetch(`/api/conversations/${encodeURIComponent(currentConversationId)}?include_process_details=0`);
const conv = await convRes.json().catch(() => ({}));
if (!convRes.ok || !Array.isArray(conv.messages)) return;
const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId));
if (!msg || !msg.reasoningContent) return;
setMessageReasoningContent(domAssistantId, msg.reasoningContent);
const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`);
const pdJson = await pdRes.json().catch(() => ({}));
const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : [];
if (typeof renderProcessDetails === 'function') {
renderProcessDetails(domAssistantId, details);
}
} catch (e) {
console.warn('syncAssistantReasoningContentFromServer failed', e);
}
}
window.normalizeReasoningContentForDisplay = normalizeReasoningContentForDisplay;
window.setMessageReasoningContent = setMessageReasoningContent;
window.getMessageReasoningContent = getMessageReasoningContent;
window.mergeMessageReasoningContentIntoProcessDetails = mergeMessageReasoningContentIntoProcessDetails;
window.syncAssistantReasoningContentFromServer = syncAssistantReasoningContentFromServer;
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
function dedupeConsecutiveProcessDetailRows(details) {
if (!Array.isArray(details) || details.length < 2) {
@@ -2282,20 +2373,27 @@ function renderProcessDetails(messageId, processDetails) {
detailsContainer.appendChild(contentDiv);
}
// processDetails === null 表示“尚未加载(懒加载)”
// processDetails === null 表示“尚未加载(懒加载)”messages.reasoningContent 可先展示
const isLazyNotLoaded = (processDetails === null);
if (isLazyNotLoaded) {
const reasoningFromMessage = getMessageReasoningContent(messageElement);
if (isLazyNotLoaded && !reasoningFromMessage) {
detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0';
timeline.innerHTML = '<div class="progress-timeline-empty">' +
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
'(点击后加载)</div>';
// 默认折叠
timeline.classList.remove('expanded');
return;
}
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
if (isLazyNotLoaded) {
detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0';
processDetails = [];
} else {
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
}
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
@@ -2426,6 +2524,14 @@ function renderProcessDetails(messageId, processDetails) {
}
addTimelineItem(timeline, eventType, timelineOpts);
});
if (isLazyNotLoaded && reasoningFromMessage) {
const lazyHint = document.createElement('div');
lazyHint.className = 'progress-timeline-empty progress-timeline-lazy-hint';
lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
'(点击后加载完整过程详情)';
timeline.appendChild(lazyHint);
}
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
@@ -2533,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 });
@@ -2594,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 {
@@ -3193,6 +3330,9 @@ async function loadConversation(conversationId) {
attachDeleteTurnButton(messageEl);
}
if (msg.role === 'assistant') {
if (messageEl && msg.reasoningContent) {
setMessageReasoningContent(messageEl, msg.reasoningContent);
}
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
if (msg.processDetails && msg.processDetails.length > 0) {
@@ -7359,8 +7499,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') : '删除失败';
+74 -47
View File
@@ -172,6 +172,59 @@ function einoMainStreamPlanningTitle(responseData) {
return prefix + '📝 ' + plan;
}
/**
* 主通道 response 结束时将流式占位条目固化为 planning与后端 flushResponsePlan 落库类型一致
* 避免 integrateProgressToMCPSection 快照前删除占位导致助手输出仅刷新后才出现
*/
function finalizeMainResponseStreamItem(streamState, finalMessage, responseData) {
if (!streamState || !streamState.itemId) return false;
const item = document.getElementById(streamState.itemId);
if (!item || !item.parentNode) return false;
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
? String(finalMessage)
: (streamState.buffer || '');
if (!String(fullText).trim()) {
item.parentNode.removeChild(item);
return false;
}
const meta = Object.assign({}, streamState.streamMeta || {}, responseData || {});
item.classList.remove('timeline-item-thinking');
item.classList.add('timeline-item-planning');
item.dataset.timelineType = 'planning';
delete item.dataset.responseStreamPlaceholder;
if (meta.orchestration != null && String(meta.orchestration).trim() !== '') {
item.dataset.orchestration = String(meta.orchestration).trim();
}
if (meta.einoAgent != null && String(meta.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(meta.einoAgent).trim();
}
const titleEl = item.querySelector('.timeline-item-title');
if (titleEl && typeof einoMainStreamPlanningTitle === 'function') {
titleEl.textContent = einoMainStreamPlanningTitle(meta);
}
let contentEl = item.querySelector('.timeline-item-content');
if (!contentEl) {
contentEl = document.createElement('div');
contentEl.className = 'timeline-item-content';
item.appendChild(contentEl);
}
flushStreamPlainTextUpdate(contentEl);
const body = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(fullText, meta)
: fullText;
if (typeof formatMarkdown === 'function') {
setTimelineItemContentStreamRich(contentEl, formatMarkdown(body, timelineMarkdownOpts));
} else {
setTimelineItemContentStreamPlain(contentEl, body);
}
return true;
}
function translateProgressMessage(message, data) {
if (!message || typeof message !== 'string') return message;
if (typeof window.t !== 'function') return message;
@@ -224,6 +277,7 @@ if (typeof window !== 'undefined') {
window.translateProgressMessage = translateProgressMessage;
window.translatePlanExecuteAgentName = translatePlanExecuteAgentName;
window.einoMainStreamPlanningTitle = einoMainStreamPlanningTitle;
window.finalizeMainResponseStreamItem = finalizeMainResponseStreamItem;
window.formatTimelineStreamBody = formatTimelineStreamBody;
}
@@ -2005,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 || {};
@@ -2401,14 +2419,18 @@ function handleStreamEvent(event, progressElement, progressId,
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
}
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
// response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
if (streamState && streamState.itemId) {
const planningItem = document.getElementById(streamState.itemId);
if (planningItem && planningItem.parentNode) {
planningItem.parentNode.removeChild(planningItem);
}
finalizeMainResponseStreamItem(streamState, event.message, responseData);
} else if (event.message && String(event.message).trim()) {
addTimelineItem(timeline, 'planning', {
title: typeof einoMainStreamPlanningTitle === 'function'
? einoMainStreamPlanningTitle(responseData)
: ('📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中')),
message: event.message,
data: responseData,
expanded: false
});
}
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
@@ -2429,6 +2451,11 @@ function handleStreamEvent(event, progressElement, progressId,
const respMid = responseData.messageId;
if (respMid) {
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
if (typeof window.syncAssistantReasoningContentFromServer === 'function') {
setTimeout(function () {
window.syncAssistantReasoningContentFromServer(respMid, assistantIdFinal);
}, 400);
}
}
setTimeout(() => {
@@ -3824,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}" />`;
@@ -3832,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">
+7 -1
View File
@@ -1270,12 +1270,18 @@ async function saveProjectModal() {
return;
}
const fromChat = !!window._projectModalFromChat;
const fromWebshellConnId = window._projectModalFromWebshellConnId || '';
window._projectModalFromChat = false;
window._projectModalFromWebshellConnId = '';
closeProjectModal();
const saved = await res.json();
await loadProjectsList();
if (saved.id) {
if (fromChat && !editId) {
if (fromWebshellConnId && !editId) {
if (typeof applyWebshellAiProjectSelection === 'function') {
await applyWebshellAiProjectSelection(saved.id);
}
} else if (fromChat && !editId) {
await applyChatProjectSelection(saved.id);
} else {
await selectProject(saved.id);
+207 -1
View File
@@ -299,6 +299,7 @@ async function loadConfig(loadTools = true) {
}
fillVisionConfigFromCurrent(currentConfig.vision || {});
initModelListControls();
// 填充FOFA配置
const fofa = currentConfig.fofa || {};
@@ -1569,9 +1570,214 @@ 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();
}
}
function initModelListControls() {
const providerEl = document.getElementById('openai-provider');
if (providerEl && !providerEl.dataset.modelListBound) {
providerEl.dataset.modelListBound = '1';
providerEl.addEventListener('change', syncModelListFetchButtons);
}
const visionProv = document.getElementById('vision-provider');
if (visionProv && !visionProv.dataset.modelListBound) {
visionProv.dataset.modelListBound = '1';
visionProv.addEventListener('change', syncModelListFetchButtons);
}
bindModelSelect('openai');
bindModelSelect('vision');
syncModelListFetchButtons();
}
function modelSelectIds(scope) {
if (scope === 'vision') {
return { selectId: 'vision-model-select', inputId: 'vision-model' };
}
return { selectId: 'openai-model-select', inputId: 'openai-model' };
}
function bindModelSelect(scope) {
const { selectId, inputId } = modelSelectIds(scope);
const select = document.getElementById(selectId);
if (!select || select.dataset.bound) return;
select.dataset.bound = '1';
select.addEventListener('change', function () {
if (!select.value) return;
const input = document.getElementById(inputId);
if (input) input.value = select.value;
});
}
function resolveModelListCredentials(scope) {
if (scope === 'vision') {
const vp = (document.getElementById('vision-provider')?.value || '').trim();
const provider = vp || document.getElementById('openai-provider')?.value || 'openai';
const baseUrl = (document.getElementById('vision-base-url')?.value || '').trim()
|| (document.getElementById('openai-base-url')?.value || '').trim();
const apiKey = (document.getElementById('vision-api-key')?.value || '').trim()
|| (document.getElementById('openai-api-key')?.value || '').trim();
return { provider, base_url: baseUrl, api_key: apiKey };
}
return {
provider: document.getElementById('openai-provider')?.value || 'openai',
base_url: (document.getElementById('openai-base-url')?.value || '').trim(),
api_key: (document.getElementById('openai-api-key')?.value || '').trim()
};
}
function syncModelListFetchButtons() {
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
const openaiProv = document.getElementById('openai-provider')?.value || 'openai';
const openaiBtn = document.getElementById('fetch-openai-models-btn');
const openaiHint = document.getElementById('fetch-openai-models-hint');
const openaiSelect = document.getElementById('openai-model-select');
const isClaudeOpenai = openaiProv === 'claude';
if (openaiBtn) {
openaiBtn.style.display = isClaudeOpenai ? 'none' : '';
}
if (openaiSelect && isClaudeOpenai) {
openaiSelect.style.display = 'none';
}
if (openaiHint) {
if (isClaudeOpenai) {
openaiHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
openaiHint.style.display = '';
} else {
openaiHint.textContent = '';
openaiHint.style.display = 'none';
}
}
const vp = (document.getElementById('vision-provider')?.value || '').trim();
const visionEffectiveProv = vp || openaiProv;
const visionBtn = document.getElementById('fetch-vision-models-btn');
const visionHint = document.getElementById('fetch-vision-models-hint');
const visionSelect = document.getElementById('vision-model-select');
const isClaudeVision = visionEffectiveProv === 'claude';
if (visionBtn) {
visionBtn.style.display = isClaudeVision ? 'none' : '';
}
if (visionSelect && isClaudeVision) {
visionSelect.style.display = 'none';
}
if (visionHint) {
if (isClaudeVision) {
visionHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
visionHint.style.display = '';
} else {
visionHint.textContent = '';
visionHint.style.display = 'none';
}
}
}
function populateModelSelect(scope, models, currentValue) {
const { selectId, inputId } = modelSelectIds(scope);
const select = document.getElementById(selectId);
const input = document.getElementById(inputId);
if (!select) return;
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
select.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.disabled = true;
placeholder.textContent = tFn('settingsBasic.modelsListSelectPlaceholder');
select.appendChild(placeholder);
const seen = new Set();
const addOption = (id) => {
const val = (id || '').trim();
if (!val || seen.has(val)) return;
seen.add(val);
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
select.appendChild(opt);
};
(models || []).forEach(addOption);
const cur = (currentValue || (input && input.value) || '').trim();
if (cur && seen.has(cur)) {
select.value = cur;
} else {
select.value = '';
}
select.style.display = select.options.length > 1 ? '' : 'none';
}
async function fetchModelList(scope) {
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
const creds = resolveModelListCredentials(scope);
const btnId = scope === 'vision' ? 'fetch-vision-models-btn' : 'fetch-openai-models-btn';
const resultId = scope === 'vision' ? 'fetch-vision-models-result' : 'fetch-openai-models-result';
const inputId = scope === 'vision' ? 'vision-model' : 'openai-model';
const btn = document.getElementById(btnId);
const resultEl = document.getElementById(resultId);
const inputEl = document.getElementById(inputId);
if (creds.provider === 'claude') {
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListClaudeHint');
resultEl.style.color = 'var(--text-muted, #718096)';
}
return;
}
if (!creds.api_key) {
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListNeedApiKey');
resultEl.style.color = 'var(--error-color, #e53e3e)';
}
return;
}
if (btn) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.5';
}
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListFetching');
resultEl.style.color = 'var(--text-muted, #718096)';
}
try {
const response = await apiFetch('/api/config/list-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || '请求失败');
}
if (!result.success) {
if (resultEl) {
resultEl.textContent = (result.supported === false
? tFn('settingsBasic.modelsListClaudeHint')
: tFn('settingsBasic.modelsListFailed')) + ': ' + (result.error || '');
resultEl.style.color = 'var(--error-color, #e53e3e)';
}
return;
}
const currentValue = inputEl ? inputEl.value.trim() : '';
populateModelSelect(scope, result.models || [], currentValue);
if (resultEl) {
const count = result.count != null ? result.count : (result.models || []).length;
resultEl.textContent = tFn('settingsBasic.modelsListSuccess').replace('{count}', String(count));
resultEl.style.color = 'var(--success-color, #38a169)';
}
} catch (error) {
if (resultEl) {
resultEl.textContent = tFn('settingsBasic.modelsListFailed') + ': ' + error.message;
resultEl.style.color = 'var(--error-color, #e53e3e)';
}
} finally {
if (btn) {
btn.style.pointerEvents = '';
btn.style.opacity = '';
}
}
}
+263 -30
View File
@@ -27,6 +27,9 @@ const WEBSHELL_HISTORY_MAX = 100;
let webshellClearInProgress = false;
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
let webshellAiConvMap = {};
// AI 助手:项目绑定(已有对话按 convId,新对话按 connId 草稿)
let webshellAiProjectByConvId = {};
let webshellAiDraftProjectByConn = {};
let webshellAiSending = false;
let webshellAiAbortController = null; // AbortController for current AI stream
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
@@ -266,6 +269,7 @@ function wsToggleRolePanel() {
var isOpen = panel.style.display === 'flex';
if (isOpen) { wsCloseRolePanel(); return; }
wsCloseAgentModePanel();
wsCloseProjectPanel();
panel.style.display = 'flex';
}
function wsCloseRolePanel() {
@@ -340,6 +344,7 @@ function wsToggleAgentModePanel() {
var isOpen = panel.style.display === 'flex';
if (isOpen) { wsCloseAgentModePanel(); return; }
wsCloseRolePanel();
wsCloseProjectPanel();
panel.style.display = 'flex';
}
function wsCloseAgentModePanel() {
@@ -347,10 +352,204 @@ function wsCloseAgentModePanel() {
if (panel) panel.style.display = 'none';
}
// ─── WebShell AI 项目选择器(与主「对话」页对齐) ───
function wsProjectT(key, fallback) {
if (typeof window.t === 'function') {
var v = window.t(key);
if (v && v !== key) return v;
}
return fallback;
}
function getWebshellAiConvId(conn) {
if (!conn || !conn.id) return '';
return webshellAiConvMap[conn.id] || '';
}
function getWebshellAiProjectSelection(conn) {
if (!conn || !conn.id) return '';
var convId = getWebshellAiConvId(conn);
if (convId) return webshellAiProjectByConvId[convId] || '';
return webshellAiDraftProjectByConn[conn.id] || '';
}
function wsSetWebshellAiProject(conn, projectId) {
if (!conn || !conn.id) return;
var pid = projectId || '';
var convId = getWebshellAiConvId(conn);
if (convId) {
if (pid) webshellAiProjectByConvId[convId] = pid;
else delete webshellAiProjectByConvId[convId];
} else if (pid) {
webshellAiDraftProjectByConn[conn.id] = pid;
} else {
delete webshellAiDraftProjectByConn[conn.id];
}
wsUpdateProjectButtonLabel();
}
function wsIsActiveProjectId(id) {
if (!id) return false;
var map = window.projectNameById || {};
return !!map[id];
}
function wsResolveWebshellAiProjectSelection(conn) {
var raw = getWebshellAiProjectSelection(conn);
if (!raw) return '';
return wsIsActiveProjectId(raw) ? raw : '';
}
function wsUpdateProjectButtonLabel() {
var textEl = document.getElementById('ws-project-text');
if (!textEl || !webshellCurrentConn) return;
var id = wsResolveWebshellAiProjectSelection(webshellCurrentConn);
var nameMap = window.projectNameById || {};
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
}
async function wsRenderProjectPanelList() {
var list = document.getElementById('ws-project-list');
if (!list || !webshellCurrentConn) return;
var conn = webshellCurrentConn;
var selected = wsResolveWebshellAiProjectSelection(conn);
var projects = [];
try {
if (typeof window.fetchAllProjects === 'function') {
projects = await window.fetchAllProjects(false);
}
} catch (e) {
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
return;
}
if (typeof window.rebuildProjectNameMap === 'function') {
window.rebuildProjectNameMap(projects);
}
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
list.innerHTML = '';
items.forEach(function (p) {
var isNone = !p.id;
var isSelected = isNone ? !selected : selected === p.id;
var desc = isNone
? (p.description || '')
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
btn.setAttribute('role', 'option');
btn.onclick = function () { wsSelectProject(p.id || ''); };
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
'<div class="role-selection-item-content-main">' +
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
list.appendChild(btn);
});
}
async function wsRenderProjectPanel() {
var list = document.getElementById('ws-project-list');
if (!list) return;
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
await wsRenderProjectPanelList();
}
function wsCloseProjectPanel() {
var panel = document.getElementById('ws-project-panel');
var btn = document.getElementById('ws-project-btn');
if (panel) panel.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
}
}
async function wsToggleProjectPanel() {
var panel = document.getElementById('ws-project-panel');
var btn = document.getElementById('ws-project-btn');
if (!panel) return;
var isHidden = panel.style.display === 'none' || !panel.style.display;
if (!isHidden) {
wsCloseProjectPanel();
return;
}
wsCloseRolePanel();
wsCloseAgentModePanel();
panel.style.display = 'flex';
if (btn) {
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
await wsRenderProjectPanel();
}
async function wsSelectProject(projectId) {
wsCloseProjectPanel();
await applyWebshellAiProjectSelection(projectId || '');
}
async function applyWebshellAiProjectSelection(projectId) {
var conn = webshellCurrentConn;
if (!conn || !conn.id) return;
var prev = getWebshellAiProjectSelection(conn);
if (projectId === prev) {
wsUpdateProjectButtonLabel();
return;
}
var convId = getWebshellAiConvId(conn);
if (convId) {
try {
var res = await apiFetch('/api/conversations/' + encodeURIComponent(convId) + '/project', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: projectId }),
});
if (!res.ok) {
var err = await res.json().catch(function () { return {}; });
throw new Error(err.error || res.statusText);
}
wsSetWebshellAiProject(conn, projectId);
if (typeof showNotification === 'function') {
showNotification(
projectId ? wsProjectT('projects.projectBound', '已绑定项目') : wsProjectT('projects.projectUnbound', '已解除项目绑定'),
'success'
);
}
} catch (e) {
console.error(e);
alert(wsProjectT('projects.updateProjectBindingFailed', '更新项目绑定失败') + ': ' + (e.message || e));
wsUpdateProjectButtonLabel();
return;
}
} else {
wsSetWebshellAiProject(conn, projectId);
}
wsUpdateProjectButtonLabel();
}
function showNewProjectModalFromWebshellAi() {
wsCloseProjectPanel();
if (webshellCurrentConn && webshellCurrentConn.id) {
window._projectModalFromWebshellConnId = webshellCurrentConn.id;
}
window._projectModalFromChat = false;
if (typeof showNewProjectModal === 'function') showNewProjectModal();
}
window.applyWebshellAiProjectSelection = applyWebshellAiProjectSelection;
window.showNewProjectModalFromWebshellAi = showNewProjectModalFromWebshellAi;
window.wsToggleProjectPanel = wsToggleProjectPanel;
window.wsCloseProjectPanel = wsCloseProjectPanel;
// ─── end WebShell AI 项目选择器 ───
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
function wsRefreshSelectors() {
wsUpdateRoleSelectorDisplay();
wsRenderRoleList();
wsUpdateProjectButtonLabel();
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
stored = 'eino_single';
@@ -370,6 +569,11 @@ document.addEventListener('click', function (e) {
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
wsCloseAgentModePanel();
}
var projectPanel = document.getElementById('ws-project-panel');
var projectBtn = document.getElementById('ws-project-btn');
if (projectPanel && projectPanel.style.display !== 'none' && projectBtn && !projectPanel.contains(e.target) && !projectBtn.contains(e.target)) {
wsCloseProjectPanel();
}
});
// ─── end WebShell AI 选择器 ───
@@ -1873,6 +2077,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (data) {
wsSetWebshellAiProject(conn, data.projectId || data.project_id || '');
messagesContainer.innerHTML = '';
var list = data.messages || [];
list.forEach(function (msg) {
@@ -1893,9 +2098,14 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
}
}
messagesContainer.appendChild(div);
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
if (block) messagesContainer.appendChild(block);
if (role === 'assistant') {
var wsMergedDetails = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
: (msg.processDetails || []);
if (wsMergedDetails.length > 0) {
var block = renderWebshellProcessDetailsBlock(wsMergedDetails, true);
if (block) messagesContainer.appendChild(block);
}
}
});
if (list.length === 0) {
@@ -2003,6 +2213,25 @@ function selectWebshell(id, stateReady) {
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
'<div class="webshell-ai-input-area">' +
'<div class="webshell-ai-selectors-row">' +
'<div class="ws-project-selector-wrapper project-selector-wrapper">' +
'<button type="button" id="ws-project-btn" class="role-selector-btn" onclick="wsToggleProjectPanel()" aria-label="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '选择项目')) + '" aria-haspopup="listbox" aria-expanded="false" title="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '绑定项目后共享事实黑板(跨对话)')) + '">' +
'<span class="role-selector-icon" aria-hidden="true">📁</span>' +
'<span id="ws-project-text" class="role-selector-text">' + escapeHtml(wsProjectT('projects.noProject', '无项目')) + '</span>' +
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
'</button>' +
'<div id="ws-project-panel" class="role-selection-panel chat-project-panel" style="display:none;" role="listbox">' +
'<div class="role-selection-panel-header">' +
'<h3 class="role-selection-panel-title">' + escapeHtml(wsProjectT('projects.selectProject', '选择项目')) + '</h3>' +
'<button type="button" class="role-selection-panel-close" onclick="wsCloseProjectPanel()" title="' + escapeHtml(wsProjectT('common.close', '关闭')) + '" aria-label="' + escapeHtml(wsProjectT('common.close', '关闭')) + '">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
'</div>' +
'<div class="chat-project-panel-body">' +
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
'<div class="chat-project-panel-footer">' +
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
'<span class="chat-project-panel-create-icon" aria-hidden="true">+</span>' +
'<span class="chat-project-panel-create-label">' + escapeHtml(wsProjectT('projects.newProject', '新建项目')) + '</span>' +
'</button></div></div></div></div>' +
'<div class="ws-role-selector-wrapper">' +
'<button type="button" class="role-selector-btn ws-role-selector-btn" id="ws-role-selector-btn" onclick="wsToggleRolePanel()">' +
'<span id="ws-role-selector-icon" class="role-selector-icon">\ud83d\udd35</span>' +
@@ -2174,9 +2403,11 @@ function selectWebshell(id, stateReady) {
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
// 初始化角色 + 模式选择器
// 初始化角色 + 模式 + 项目选择器
wsLoadRoles();
wsInitAgentMode();
if (typeof prefetchProjectsForChat === 'function') prefetchProjectsForChat();
wsUpdateProjectButtonLabel();
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
@@ -2225,6 +2456,8 @@ function selectWebshell(id, stateReady) {
if (aiNewConvBtn) {
aiNewConvBtn.addEventListener('click', function () {
delete webshellAiConvMap[conn.id];
delete webshellAiDraftProjectByConn[conn.id];
wsUpdateProjectButtonLabel();
if (aiMessages) {
aiMessages.innerHTML = '';
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
@@ -2767,7 +3000,15 @@ function loadWebshellAiHistory(conn, messagesContainer) {
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId;
if (data.conversationId) {
webshellAiConvMap[conn.id] = data.conversationId;
apiFetch('/api/conversations/' + encodeURIComponent(data.conversationId), { method: 'GET' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (conv) {
if (conv) wsSetWebshellAiProject(conn, conv.projectId || conv.project_id || '');
})
.catch(function () { /* ignore */ });
}
var list = Array.isArray(data.messages) ? data.messages : [];
list.forEach(function (msg) {
var role = (msg.role || '').toLowerCase();
@@ -2787,9 +3028,14 @@ function loadWebshellAiHistory(conn, messagesContainer) {
}
}
messagesContainer.appendChild(div);
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
if (block) messagesContainer.appendChild(block);
if (role === 'assistant') {
var wsHistMerged = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
: (msg.processDetails || []);
if (wsHistMerged.length > 0) {
var block = renderWebshellProcessDetailsBlock(wsHistMerged, true);
if (block) messagesContainer.appendChild(block);
}
}
});
if (list.length === 0) {
@@ -2922,6 +3168,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
conversationId: convId,
role: wsRole
};
if (!convId) {
var wsPid = getWebshellAiProjectSelection(conn);
if (wsPid) body.projectId = wsPid;
}
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
@@ -2970,6 +3220,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
if (_et === 'conversation' && _ed.conversationId) {
var convId = _ed.conversationId;
var prevDraft = webshellAiDraftProjectByConn[conn.id];
if (prevDraft) {
webshellAiProjectByConvId[convId] = prevDraft;
delete webshellAiDraftProjectByConn[conn.id];
}
webshellAiConvMap[conn.id] = convId;
var listEl = document.getElementById('webshell-ai-conv-list');
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
@@ -3136,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':
File diff suppressed because one or more lines are too long
+6696
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(function(){return(()=>{"use strict";var e={775:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0;var r=function(){function e(){}return e.prototype.activate=function(e){this._terminal=e},e.prototype.dispose=function(){},e.prototype.fit=function(){var e=this.proposeDimensions();if(e&&this._terminal){var t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}},e.prototype.proposeDimensions=function(){if(this._terminal&&this._terminal.element&&this._terminal.element.parentElement){var e=this._terminal._core;if(0!==e._renderService.dimensions.actualCellWidth&&0!==e._renderService.dimensions.actualCellHeight){var t=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(t.getPropertyValue("height")),i=Math.max(0,parseInt(t.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),o=r-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=i-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-e.viewport.scrollBarWidth;return{cols:Math.max(2,Math.floor(a/e._renderService.dimensions.actualCellWidth)),rows:Math.max(1,Math.floor(o/e._renderService.dimensions.actualCellHeight))}}}},e}();t.FitAddon=r}},t={};return function r(i){if(t[i])return t[i].exports;var n=t[i]={exports:{}};return e[i](n,n.exports,r),n.exports}(775)})()}));
//# sourceMappingURL=xterm-addon-fit.js.map
+190
View File
@@ -0,0 +1,190 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
opacity: 0.5;
}
.xterm-underline {
text-decoration: underline;
}
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-decoration-overview-ruler {
z-index: 7;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -934,7 +934,7 @@ Content-Type: application/json
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
<script src="/static/vendor/i18next.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/api-docs.js"></script>
</body>
+97 -42
View File
@@ -8,7 +8,7 @@
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js"></script>
</head>
<body>
@@ -2035,12 +2035,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 +2413,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 style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required style="flex: 1; min-width: 140px;" />
<select id="openai-model-select" class="model-pick-select" style="display: none; min-width: 160px; max-width: 240px;" title="">
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
</select>
<a href="javascript:void(0)" id="fetch-openai-models-btn" onclick="fetchModelList('openai')" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none; white-space: nowrap;" 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 +2499,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 style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
<input type="text" id="vision-model" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" style="flex: 1; min-width: 140px;" />
<select id="vision-model-select" class="model-pick-select" style="display: none; min-width: 160px; max-width: 240px;">
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
</select>
<a href="javascript:void(0)" id="fetch-vision-models-btn" onclick="fetchModelList('vision')" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none; white-space: nowrap;" 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 +2838,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 +3038,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 +3085,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 +3093,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>
@@ -3484,16 +3538,16 @@
</div>
</div>
<!-- Marked.js + DOMPurify:本地 vendor避免 CDN 不可用导致 Markdown 降级为纯文本 -->
<!-- Marked.js + DOMPurify + 其他前端依赖:本地 vendor内网/离线部署不依赖 CDN -->
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
<script src="/static/js/sanitize-markdown.js"></script>
<!-- Cytoscape.js for attack chain visualization -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
<script src="/static/vendor/cytoscape.min.js"></script>
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
<script src="https://cdn.jsdelivr.net/npm/elkjs@0.9.2/lib/elk.bundled.js"></script>
<script src="/static/vendor/elk.bundled.js"></script>
<!-- SheetJS for XLSX export (info-collect) -->
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script src="/static/vendor/xlsx.full.min.js"></script>
<script>
// 确保ELK对象全局可用
if (typeof ELK === 'undefined' && typeof elk !== 'undefined') {
@@ -4287,7 +4341,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
<script src="/static/vendor/i18next.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/builtin-tools.js"></script>
<script src="/static/js/auth.js"></script>
@@ -4301,10 +4355,11 @@
<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="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
<script src="/static/vendor/xterm.js"></script>
<script src="/static/vendor/xterm-addon-fit.js"></script>
<script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>