mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-18 12:00:10 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed479d5e4d | |||
| a49f595231 | |||
| 82cf014a5e | |||
| 508de5fad0 | |||
| 6712344411 | |||
| 7eadccbff6 | |||
| 01b361e4a7 | |||
| f6ce31c961 | |||
| d5a0f93c6c | |||
| 56faefaaf9 | |||
| 16e9c5874a | |||
| 41b5cdde6b | |||
| cf1f8515d9 | |||
| 5e2b30c029 | |||
| 8c7c22369e | |||
| 9b1aba692b | |||
| db730b48c1 | |||
| dfb7dd7390 | |||
| 9f6eb33047 | |||
| 616d87f4cc |
@@ -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
@@ -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,减小档案体积。
|
||||
|
||||
**自定义工具的一般步骤**
|
||||
|
||||
@@ -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
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.38"
|
||||
version: "v1.6.39"
|
||||
# 服务器配置
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-25
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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_call(tool_result 由 ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)。
|
||||
type ToolInvokeNotifyHolder struct {
|
||||
mu sync.RWMutex
|
||||
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
||||
|
||||
@@ -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(
|
||||
@@ -2233,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1532,8 +1524,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -6354,35 +6350,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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 在迭代前 Set,MCP 桥 Fire 以补全 tool_result。
|
||||
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||
@@ -285,53 +286,63 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
executeStdoutDupMu.Unlock()
|
||||
}
|
||||
|
||||
var toolResultSent sync.Map // toolCallID -> struct{};与 ADK Tool 消息去重,避免 bridge 与事件流各推一次
|
||||
if args.ToolInvokeNotify != nil {
|
||||
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
removePendingByID(tid)
|
||||
if tid == "" || progress == nil {
|
||||
return
|
||||
var toolResultSent sync.Map // toolCallID -> struct{};ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
|
||||
tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) {
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
toolName = "unknown"
|
||||
}
|
||||
preview := content
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": !isErr,
|
||||
"isError": isErr,
|
||||
"result": content,
|
||||
"resultPreview": preview,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": agentName,
|
||||
"einoRole": einoRoleTag(agentName),
|
||||
"source": "eino",
|
||||
}
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
if tid == "" {
|
||||
if inferred, ok := popNextPendingForAgent(agentName); ok {
|
||||
tid = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||
tid = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||
tid = inferred.ToolCallID
|
||||
} else if inferred, ok := popAnyPending(); ok {
|
||||
tid = inferred.ToolCallID
|
||||
}
|
||||
}
|
||||
if tid != "" {
|
||||
removePendingByID(tid)
|
||||
if _, loaded := toolResultSent.LoadOrStore(tid, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
isErr := !success || invokeErr != nil
|
||||
body := content
|
||||
if invokeErr != nil {
|
||||
// 保留已流式累计的 stdout(如 execute 超时前的一半输出),避免 tool_result 只剩错误串、模型与 UI 丢失上下文
|
||||
tail := friendlyEinoExecuteInvokeTail(invokeErr)
|
||||
// execute 流式包装可能已把超时句写入 content(供 ADK tool 与流式 delta);勿重复拼接
|
||||
if tail != "" && strings.Contains(content, tail) {
|
||||
body = content
|
||||
} else if strings.TrimSpace(content) != "" {
|
||||
body = strings.TrimRight(content, "\n") + "\n\n" + tail
|
||||
} else {
|
||||
body = tail
|
||||
}
|
||||
isErr = true
|
||||
data["toolCallId"] = tid
|
||||
toolCallID = tid
|
||||
}
|
||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
||||
if args.FilesystemMonitorAgent != nil && args.MCPExecutionBinder != nil {
|
||||
if execID := args.MCPExecutionBinder.ExecutionID(toolCallID); execID != "" {
|
||||
args.FilesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
|
||||
}
|
||||
recordPendingExecuteStdoutDup(toolName, body, isErr)
|
||||
preview := body
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
agentTag := strings.TrimSpace(einoAgent)
|
||||
if agentTag == "" {
|
||||
agentTag = orchestratorName
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": !isErr,
|
||||
"isError": isErr,
|
||||
"result": body,
|
||||
"resultPreview": preview,
|
||||
"toolCallId": tid,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": agentTag,
|
||||
"einoRole": einoRoleTag(agentTag),
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
}
|
||||
if args.ToolInvokeNotify != nil {
|
||||
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
removePendingByID(strings.TrimSpace(toolCallID))
|
||||
// tool_result 仅由下方 ADK schema.Tool 事件推送,正文与送入模型的上下文一致(含 reduction 截断)。
|
||||
})
|
||||
}
|
||||
|
||||
@@ -632,6 +643,50 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
mv := ev.Output.MessageOutput
|
||||
|
||||
if mv.IsStreaming && mv.MessageStream != nil && mv.Role == schema.Tool {
|
||||
toolName := strings.TrimSpace(mv.ToolName)
|
||||
var toolBuf strings.Builder
|
||||
streamToolCallID := ""
|
||||
var toolStreamRecvErr error
|
||||
for {
|
||||
chunk, rerr := mv.MessageStream.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
toolStreamRecvErr = rerr
|
||||
break
|
||||
}
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
if chunk.Content != "" {
|
||||
toolBuf.WriteString(chunk.Content)
|
||||
}
|
||||
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
|
||||
streamToolCallID = tid
|
||||
}
|
||||
}
|
||||
content := toolBuf.String()
|
||||
isErr := false
|
||||
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
isErr = true
|
||||
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||
}
|
||||
if streamToolCallID != "" {
|
||||
opts := []schema.ToolMessageOption{schema.WithToolName(toolName)}
|
||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.ToolMessage(content, streamToolCallID, opts...))
|
||||
}
|
||||
tryEmitToolResultProgress(toolName, content, streamToolCallID, isErr, ev.AgentName)
|
||||
if toolStreamRecvErr != nil && logger != nil {
|
||||
logger.Warn("eino tool result stream recv error",
|
||||
zap.Error(toolStreamRecvErr),
|
||||
zap.String("agent", ev.AgentName),
|
||||
zap.String("tool", toolName))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if mv.IsStreaming && mv.MessageStream != nil {
|
||||
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||
streamHeaderSent := false
|
||||
@@ -973,7 +1028,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
}
|
||||
|
||||
if mv.Role == schema.Tool && progress != nil {
|
||||
if (mv.Role == schema.Tool || msg.Role == schema.Tool) && progress != nil {
|
||||
toolName := msg.ToolName
|
||||
if toolName == "" {
|
||||
toolName = mv.ToolName
|
||||
@@ -986,46 +1041,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||
}
|
||||
|
||||
preview := content
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": !isErr,
|
||||
"isError": isErr,
|
||||
"result": content,
|
||||
"resultPreview": preview,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"einoRole": einoRoleTag(ev.AgentName),
|
||||
"source": "eino",
|
||||
}
|
||||
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
||||
if toolCallID == "" {
|
||||
if inferred, ok := popNextPendingForAgent(ev.AgentName); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popAnyPending(); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
}
|
||||
}
|
||||
if toolCallID != "" {
|
||||
removePendingByID(toolCallID)
|
||||
if _, loaded := toolResultSent.LoadOrStore(toolCallID, struct{}{}); loaded {
|
||||
// ToolInvokeNotify 可能已推过 tool_result(如 execute 流式包装里 Fire 仅携带截断后的 stdout),
|
||||
// 此处仍应用 ADK Tool 消息中的完整内容刷新去重基准,避免模型复述全文时与截断串比对失败而重复展示「助手输出」。
|
||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||
continue
|
||||
}
|
||||
data["toolCallId"] = toolCallID
|
||||
}
|
||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
tryEmitToolResultProgress(toolName, content, toolCallID, isErr, ev.AgentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+11
-247
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ResultStorage 结果存储接口
|
||||
type ResultStorage interface {
|
||||
// SaveResult 保存工具执行结果
|
||||
SaveResult(executionID string, toolName string, result string) error
|
||||
|
||||
// GetResult 获取完整结果
|
||||
GetResult(executionID string) (string, error)
|
||||
|
||||
// GetResultPage 分页获取结果
|
||||
GetResultPage(executionID string, page int, limit int) (*ResultPage, error)
|
||||
|
||||
// SearchResult 搜索结果
|
||||
// useRegex: 如果为 true,将 keyword 作为正则表达式使用;如果为 false,使用简单的字符串包含匹配
|
||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
||||
|
||||
// FilterResult 过滤结果
|
||||
// useRegex: 如果为 true,将 filter 作为正则表达式使用;如果为 false,使用简单的字符串包含匹配
|
||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
||||
|
||||
// GetResultMetadata 获取结果元信息
|
||||
GetResultMetadata(executionID string) (*ResultMetadata, error)
|
||||
|
||||
// GetResultPath 获取结果文件路径
|
||||
GetResultPath(executionID string) string
|
||||
|
||||
// DeleteResult 删除结果
|
||||
DeleteResult(executionID string) error
|
||||
}
|
||||
|
||||
// ResultPage 分页结果
|
||||
type ResultPage struct {
|
||||
Lines []string `json:"lines"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// ResultMetadata 结果元信息
|
||||
type ResultMetadata struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
TotalSize int `json:"total_size"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FileResultStorage 基于文件的结果存储实现
|
||||
type FileResultStorage struct {
|
||||
baseDir string
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewFileResultStorage 创建新的文件结果存储
|
||||
func NewFileResultStorage(baseDir string, logger *zap.Logger) (*FileResultStorage, error) {
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建存储目录失败: %w", err)
|
||||
}
|
||||
|
||||
return &FileResultStorage{
|
||||
baseDir: baseDir,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getResultPath 获取结果文件路径
|
||||
func (s *FileResultStorage) getResultPath(executionID string) string {
|
||||
return filepath.Join(s.baseDir, executionID+".txt")
|
||||
}
|
||||
|
||||
// getMetadataPath 获取元数据文件路径
|
||||
func (s *FileResultStorage) getMetadataPath(executionID string) string {
|
||||
return filepath.Join(s.baseDir, executionID+".meta.json")
|
||||
}
|
||||
|
||||
// SaveResult 保存工具执行结果
|
||||
func (s *FileResultStorage) SaveResult(executionID string, toolName string, result string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 保存结果文件
|
||||
resultPath := s.getResultPath(executionID)
|
||||
if err := os.WriteFile(resultPath, []byte(result), 0644); err != nil {
|
||||
return fmt.Errorf("保存结果文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算统计信息
|
||||
lines := strings.Split(result, "\n")
|
||||
metadata := &ResultMetadata{
|
||||
ExecutionID: executionID,
|
||||
ToolName: toolName,
|
||||
TotalSize: len(result),
|
||||
TotalLines: len(lines),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 保存元数据
|
||||
metadataPath := s.getMetadataPath(executionID)
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化元数据失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(metadataPath, metadataJSON, 0644); err != nil {
|
||||
return fmt.Errorf("保存元数据文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("保存工具执行结果",
|
||||
zap.String("executionID", executionID),
|
||||
zap.String("toolName", toolName),
|
||||
zap.Int("size", len(result)),
|
||||
zap.Int("lines", len(lines)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetResult 获取完整结果
|
||||
func (s *FileResultStorage) GetResult(executionID string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
resultPath := s.getResultPath(executionID)
|
||||
data, err := os.ReadFile(resultPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("结果不存在: %s", executionID)
|
||||
}
|
||||
return "", fmt.Errorf("读取结果文件失败: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// GetResultMetadata 获取结果元信息
|
||||
func (s *FileResultStorage) GetResultMetadata(executionID string) (*ResultMetadata, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
metadataPath := s.getMetadataPath(executionID)
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("结果不存在: %s", executionID)
|
||||
}
|
||||
return nil, fmt.Errorf("读取元数据文件失败: %w", err)
|
||||
}
|
||||
|
||||
var metadata ResultMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("解析元数据失败: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// GetResultPage 分页获取结果
|
||||
func (s *FileResultStorage) GetResultPage(executionID string, page int, limit int) (*ResultPage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// 获取完整结果
|
||||
result, err := s.GetResult(executionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分割为行
|
||||
lines := strings.Split(result, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// 计算分页
|
||||
totalPages := (totalLines + limit - 1) / limit
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages && totalPages > 0 {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
// 计算起始和结束索引
|
||||
start := (page - 1) * limit
|
||||
end := start + limit
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
|
||||
// 提取指定页的行
|
||||
var pageLines []string
|
||||
if start < totalLines {
|
||||
pageLines = lines[start:end]
|
||||
} else {
|
||||
pageLines = []string{}
|
||||
}
|
||||
|
||||
return &ResultPage{
|
||||
Lines: pageLines,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
TotalLines: totalLines,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchResult 搜索结果
|
||||
func (s *FileResultStorage) SearchResult(executionID string, keyword string, useRegex bool) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// 获取完整结果
|
||||
result, err := s.GetResult(executionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果使用正则表达式,先编译正则
|
||||
var regex *regexp.Regexp
|
||||
if useRegex {
|
||||
compiledRegex, err := regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的正则表达式: %w", err)
|
||||
}
|
||||
regex = compiledRegex
|
||||
}
|
||||
|
||||
// 分割为行并搜索
|
||||
lines := strings.Split(result, "\n")
|
||||
var matchedLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
var matched bool
|
||||
if useRegex {
|
||||
matched = regex.MatchString(line)
|
||||
} else {
|
||||
matched = strings.Contains(line, keyword)
|
||||
}
|
||||
|
||||
if matched {
|
||||
matchedLines = append(matchedLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return matchedLines, nil
|
||||
}
|
||||
|
||||
// FilterResult 过滤结果
|
||||
func (s *FileResultStorage) FilterResult(executionID string, filter string, useRegex bool) ([]string, error) {
|
||||
// 过滤和搜索逻辑相同,都是查找包含关键词的行
|
||||
return s.SearchResult(executionID, filter, useRegex)
|
||||
}
|
||||
|
||||
// GetResultPath 获取结果文件路径
|
||||
func (s *FileResultStorage) GetResultPath(executionID string) string {
|
||||
return s.getResultPath(executionID)
|
||||
}
|
||||
|
||||
// DeleteResult 删除结果
|
||||
func (s *FileResultStorage) DeleteResult(executionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
resultPath := s.getResultPath(executionID)
|
||||
metadataPath := s.getMetadataPath(executionID)
|
||||
|
||||
// 删除结果文件
|
||||
if err := os.Remove(resultPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除结果文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 删除元数据文件
|
||||
if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除元数据文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("删除工具执行结果",
|
||||
zap.String("executionID", executionID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// setupTestStorage 创建测试用的存储实例
|
||||
func setupTestStorage(t *testing.T) (*FileResultStorage, string) {
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_result_storage_"+time.Now().Format("20060102_150405"))
|
||||
logger := zap.NewNop()
|
||||
|
||||
storage, err := NewFileResultStorage(tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试存储失败: %v", err)
|
||||
}
|
||||
|
||||
return storage, tmpDir
|
||||
}
|
||||
|
||||
// cleanupTestStorage 清理测试数据
|
||||
func cleanupTestStorage(t *testing.T, tmpDir string) {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
t.Logf("清理测试目录失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFileResultStorage(t *testing.T) {
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_new_storage_"+time.Now().Format("20060102_150405"))
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
logger := zap.NewNop()
|
||||
storage, err := NewFileResultStorage(tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("创建存储失败: %v", err)
|
||||
}
|
||||
|
||||
if storage == nil {
|
||||
t.Fatal("存储实例为nil")
|
||||
}
|
||||
|
||||
// 验证目录已创建
|
||||
if _, err := os.Stat(tmpDir); os.IsNotExist(err) {
|
||||
t.Fatal("存储目录未创建")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_SaveResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_001"
|
||||
toolName := "nmap_scan"
|
||||
result := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
||||
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证结果文件存在
|
||||
resultPath := filepath.Join(tmpDir, executionID+".txt")
|
||||
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
|
||||
t.Fatal("结果文件未创建")
|
||||
}
|
||||
|
||||
// 验证元数据文件存在
|
||||
metadataPath := filepath.Join(tmpDir, executionID+".meta.json")
|
||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||
t.Fatal("元数据文件未创建")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_GetResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_002"
|
||||
toolName := "test_tool"
|
||||
expectedResult := "Test result content\nLine 2\nLine 3"
|
||||
|
||||
// 先保存结果
|
||||
err := storage.SaveResult(executionID, toolName, expectedResult)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取结果
|
||||
result, err := storage.GetResult(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取结果失败: %v", err)
|
||||
}
|
||||
|
||||
if result != expectedResult {
|
||||
t.Errorf("结果不匹配。期望: %q, 实际: %q", expectedResult, result)
|
||||
}
|
||||
|
||||
// 测试不存在的执行ID
|
||||
_, err = storage.GetResult("nonexistent_id")
|
||||
if err == nil {
|
||||
t.Fatal("应该返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_GetResultMetadata(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_003"
|
||||
toolName := "test_tool"
|
||||
result := "Line 1\nLine 2\nLine 3"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取元数据
|
||||
metadata, err := storage.GetResultMetadata(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取元数据失败: %v", err)
|
||||
}
|
||||
|
||||
if metadata.ExecutionID != executionID {
|
||||
t.Errorf("执行ID不匹配。期望: %s, 实际: %s", executionID, metadata.ExecutionID)
|
||||
}
|
||||
|
||||
if metadata.ToolName != toolName {
|
||||
t.Errorf("工具名称不匹配。期望: %s, 实际: %s", toolName, metadata.ToolName)
|
||||
}
|
||||
|
||||
if metadata.TotalSize != len(result) {
|
||||
t.Errorf("总大小不匹配。期望: %d, 实际: %d", len(result), metadata.TotalSize)
|
||||
}
|
||||
|
||||
expectedLines := len(strings.Split(result, "\n"))
|
||||
if metadata.TotalLines != expectedLines {
|
||||
t.Errorf("总行数不匹配。期望: %d, 实际: %d", expectedLines, metadata.TotalLines)
|
||||
}
|
||||
|
||||
// 验证创建时间在合理范围内
|
||||
now := time.Now()
|
||||
if metadata.CreatedAt.After(now) || metadata.CreatedAt.Before(now.Add(-time.Second)) {
|
||||
t.Errorf("创建时间不在合理范围内: %v", metadata.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_GetResultPage(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_004"
|
||||
toolName := "test_tool"
|
||||
// 创建包含10行的结果
|
||||
lines := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
lines[i] = fmt.Sprintf("Line %d", i+1)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试第一页(每页3行)
|
||||
page, err := storage.GetResultPage(executionID, 1, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第一页失败: %v", err)
|
||||
}
|
||||
|
||||
if page.Page != 1 {
|
||||
t.Errorf("页码不匹配。期望: 1, 实际: %d", page.Page)
|
||||
}
|
||||
|
||||
if page.Limit != 3 {
|
||||
t.Errorf("每页行数不匹配。期望: 3, 实际: %d", page.Limit)
|
||||
}
|
||||
|
||||
if page.TotalLines != 10 {
|
||||
t.Errorf("总行数不匹配。期望: 10, 实际: %d", page.TotalLines)
|
||||
}
|
||||
|
||||
if page.TotalPages != 4 {
|
||||
t.Errorf("总页数不匹配。期望: 4, 实际: %d", page.TotalPages)
|
||||
}
|
||||
|
||||
if len(page.Lines) != 3 {
|
||||
t.Errorf("第一页行数不匹配。期望: 3, 实际: %d", len(page.Lines))
|
||||
}
|
||||
|
||||
if page.Lines[0] != "Line 1" {
|
||||
t.Errorf("第一行内容不匹配。期望: Line 1, 实际: %s", page.Lines[0])
|
||||
}
|
||||
|
||||
// 测试第二页
|
||||
page2, err := storage.GetResultPage(executionID, 2, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第二页失败: %v", err)
|
||||
}
|
||||
|
||||
if len(page2.Lines) != 3 {
|
||||
t.Errorf("第二页行数不匹配。期望: 3, 实际: %d", len(page2.Lines))
|
||||
}
|
||||
|
||||
if page2.Lines[0] != "Line 4" {
|
||||
t.Errorf("第二页第一行内容不匹配。期望: Line 4, 实际: %s", page2.Lines[0])
|
||||
}
|
||||
|
||||
// 测试最后一页(可能不满一页)
|
||||
page4, err := storage.GetResultPage(executionID, 4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第四页失败: %v", err)
|
||||
}
|
||||
|
||||
if len(page4.Lines) != 1 {
|
||||
t.Errorf("第四页行数不匹配。期望: 1, 实际: %d", len(page4.Lines))
|
||||
}
|
||||
|
||||
// 测试超出范围的页码(应该返回最后一页)
|
||||
page5, err := storage.GetResultPage(executionID, 5, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第五页失败: %v", err)
|
||||
}
|
||||
|
||||
// 超出范围的页码会被修正为最后一页,所以应该返回最后一页的内容
|
||||
if page5.Page != 4 {
|
||||
t.Errorf("超出范围的页码应该被修正为最后一页。期望: 4, 实际: %d", page5.Page)
|
||||
}
|
||||
|
||||
// 最后一页应该只有1行
|
||||
if len(page5.Lines) != 1 {
|
||||
t.Errorf("最后一页应该只有1行。实际: %d行", len(page5.Lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_SearchResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_005"
|
||||
toolName := "test_tool"
|
||||
result := "Line 1: error occurred\nLine 2: success\nLine 3: error again\nLine 4: ok"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 搜索包含"error"的行(简单字符串匹配)
|
||||
matchedLines, err := storage.SearchResult(executionID, "error", false)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if len(matchedLines) != 2 {
|
||||
t.Errorf("搜索结果数量不匹配。期望: 2, 实际: %d", len(matchedLines))
|
||||
}
|
||||
|
||||
// 验证搜索结果内容
|
||||
for i, line := range matchedLines {
|
||||
if !strings.Contains(line, "error") {
|
||||
t.Errorf("搜索结果第%d行不包含关键词: %s", i+1, line)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试搜索不存在的关键词
|
||||
noMatch, err := storage.SearchResult(executionID, "nonexistent", false)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if len(noMatch) != 0 {
|
||||
t.Errorf("搜索不存在的关键词应该返回空结果。实际: %d行", len(noMatch))
|
||||
}
|
||||
|
||||
// 测试正则表达式搜索
|
||||
regexMatched, err := storage.SearchResult(executionID, "error.*again", true)
|
||||
if err != nil {
|
||||
t.Fatalf("正则搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if len(regexMatched) != 1 {
|
||||
t.Errorf("正则搜索结果数量不匹配。期望: 1, 实际: %d", len(regexMatched))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_FilterResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_006"
|
||||
toolName := "test_tool"
|
||||
result := "Line 1: warning message\nLine 2: info message\nLine 3: warning again\nLine 4: debug message"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 过滤包含"warning"的行(简单字符串匹配)
|
||||
filteredLines, err := storage.FilterResult(executionID, "warning", false)
|
||||
if err != nil {
|
||||
t.Fatalf("过滤失败: %v", err)
|
||||
}
|
||||
|
||||
if len(filteredLines) != 2 {
|
||||
t.Errorf("过滤结果数量不匹配。期望: 2, 实际: %d", len(filteredLines))
|
||||
}
|
||||
|
||||
// 验证过滤结果内容
|
||||
for i, line := range filteredLines {
|
||||
if !strings.Contains(line, "warning") {
|
||||
t.Errorf("过滤结果第%d行不包含关键词: %s", i+1, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_DeleteResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_007"
|
||||
toolName := "test_tool"
|
||||
result := "Test result"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证文件存在
|
||||
resultPath := filepath.Join(tmpDir, executionID+".txt")
|
||||
metadataPath := filepath.Join(tmpDir, executionID+".meta.json")
|
||||
|
||||
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
|
||||
t.Fatal("结果文件不存在")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||
t.Fatal("元数据文件不存在")
|
||||
}
|
||||
|
||||
// 删除结果
|
||||
err = storage.DeleteResult(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("删除结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证文件已删除
|
||||
if _, err := os.Stat(resultPath); !os.IsNotExist(err) {
|
||||
t.Fatal("结果文件未被删除")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(metadataPath); !os.IsNotExist(err) {
|
||||
t.Fatal("元数据文件未被删除")
|
||||
}
|
||||
|
||||
// 测试删除不存在的执行ID(应该不报错)
|
||||
err = storage.DeleteResult("nonexistent_id")
|
||||
if err != nil {
|
||||
t.Errorf("删除不存在的执行ID不应该报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_ConcurrentAccess(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
// 并发保存多个结果
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
executionID := fmt.Sprintf("test_exec_%d", id)
|
||||
toolName := "test_tool"
|
||||
result := fmt.Sprintf("Result %d\nLine 2\nLine 3", id)
|
||||
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Errorf("并发保存失败 (ID: %s): %v", executionID, err)
|
||||
}
|
||||
|
||||
// 并发读取
|
||||
_, err = storage.GetResult(executionID)
|
||||
if err != nil {
|
||||
t.Errorf("并发读取失败 (ID: %s): %v", executionID, err)
|
||||
}
|
||||
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_LargeResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_large"
|
||||
toolName := "test_tool"
|
||||
|
||||
// 创建大结果(1000行)
|
||||
lines := make([]string, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
lines[i] = fmt.Sprintf("Line %d: This is a test line with some content", i+1)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
// 保存大结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存大结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证元数据
|
||||
metadata, err := storage.GetResultMetadata(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取元数据失败: %v", err)
|
||||
}
|
||||
|
||||
if metadata.TotalLines != 1000 {
|
||||
t.Errorf("总行数不匹配。期望: 1000, 实际: %d", metadata.TotalLines)
|
||||
}
|
||||
|
||||
// 测试分页查询大结果
|
||||
page, err := storage.GetResultPage(executionID, 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第一页失败: %v", err)
|
||||
}
|
||||
|
||||
if page.TotalPages != 10 {
|
||||
t.Errorf("总页数不匹配。期望: 10, 实际: %d", page.TotalPages)
|
||||
}
|
||||
|
||||
if len(page.Lines) != 100 {
|
||||
t.Errorf("第一页行数不匹配。期望: 100, 实际: %d", len(page.Lines))
|
||||
}
|
||||
}
|
||||
+801
-67
@@ -4588,46 +4588,199 @@ header {
|
||||
}
|
||||
|
||||
/* 系统设置 - 日志审计 */
|
||||
.audit-logs-toolbar {
|
||||
.audit-section-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.audit-logs-filters {
|
||||
.audit-section-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.audit-summary-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audit-logs-filters > .btn-secondary {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 0;
|
||||
.audit-summary-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary, rgba(0, 0, 0, 0.03));
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.audit-logs-filters label {
|
||||
.audit-summary-tag strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.audit-summary-tag--ok strong {
|
||||
color: #0d7a3e;
|
||||
}
|
||||
|
||||
.audit-summary-tag--warn strong {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.audit-summary-tag-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-filter-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.audit-filter-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.audit-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px 14px;
|
||||
}
|
||||
|
||||
.audit-field--event {
|
||||
flex: 0 1 360px;
|
||||
min-width: 200px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.audit-field--result {
|
||||
flex: 0 0 112px;
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.audit-filter-time-group {
|
||||
display: flex;
|
||||
flex: 1 1 360px;
|
||||
gap: 12px 14px;
|
||||
margin-left: auto;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.audit-filter-time-group .audit-field--time {
|
||||
flex: 1 1 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.audit-field:not(.audit-field--event):not(.audit-field--keyword):not(.audit-field--result):not(.audit-field--time) {
|
||||
flex: 0 1 150px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.audit-filter-bottom {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px 14px;
|
||||
}
|
||||
|
||||
.audit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audit-field > span {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.audit-field--keyword {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.audit-field select,
|
||||
.audit-field input[type="text"]:not(.audit-datetime-input) {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.audit-field .audit-datetime-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audit-logs-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.audit-logs-actions .btn-secondary,
|
||||
.audit-logs-actions .btn-primary {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* 事件类型:两个下拉与「结果」等控件同款边框,无外层套框 */
|
||||
.audit-filter-cascade {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audit-filter-cascade select {
|
||||
flex: 0 1 auto;
|
||||
min-width: 120px;
|
||||
max-width: 148px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.audit-filter-cascade select:disabled {
|
||||
@@ -4636,6 +4789,142 @@ header {
|
||||
background: var(--bg-secondary, #f5f6f8);
|
||||
}
|
||||
|
||||
.audit-native-select {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.audit-filter-cascade .audit-custom-select,
|
||||
.audit-field--result .audit-custom-select {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.audit-field--result .audit-custom-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.audit-custom-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.audit-custom-select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.audit-custom-select-caret {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audit-custom-select-trigger:hover:not(:disabled) {
|
||||
border-color: rgba(0, 102, 255, 0.45);
|
||||
}
|
||||
|
||||
.audit-custom-select.open .audit-custom-select-trigger {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
||||
}
|
||||
|
||||
.audit-custom-select.is-disabled .audit-custom-select-trigger {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-secondary, #f5f6f8);
|
||||
}
|
||||
|
||||
.audit-custom-select-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2000;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.audit-custom-select.open .audit-custom-select-dropdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.audit-custom-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.audit-custom-select-option:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.audit-custom-select-check {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--accent-color);
|
||||
opacity: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audit-custom-select-option.is-selected .audit-custom-select-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.audit-custom-select-option.is-selected {
|
||||
color: var(--accent-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.audit-custom-select-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-filter-cascade-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
@@ -4645,31 +4934,426 @@ header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audit-logs-filters select,
|
||||
.audit-logs-filters input[type="text"],
|
||||
.audit-logs-filters input[type="datetime-local"] {
|
||||
min-width: 140px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
.audit-time-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.audit-time-presets-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-time-preset-btn {
|
||||
padding: 0.25rem 0.65rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.audit-logs-actions {
|
||||
.audit-time-preset-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.audit-datetime-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.audit-datetime-field:focus-within {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
|
||||
}
|
||||
|
||||
.audit-datetime-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-datetime-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.audit-datetime-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-datetime-btn:hover {
|
||||
background: var(--bg-secondary, #f5f6f8);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.audit-datetime-clear-btn {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audit-dt-popover {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.audit-dt-popover-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.audit-dt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 列表 + 底部分页合并为一张卡片,避免双边框/底部分隔线 */
|
||||
#settings-section-audit .audit-log-list.c2-event-list {
|
||||
.audit-dt-month-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audit-dt-nav {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audit-dt-nav:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.audit-dt-body {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.audit-dt-calendar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audit-dt-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.audit-dt-weekdays span {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.audit-dt-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.audit-dt-day {
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-dt-day:hover {
|
||||
background: var(--bg-secondary, #f5f6f8);
|
||||
}
|
||||
|
||||
.audit-dt-day.is-other-month {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.audit-dt-day.is-selected {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.audit-dt-day.is-selected:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.audit-dt-time {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.audit-dt-time-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.audit-dt-time-label {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.audit-dt-time-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.audit-dt-time-item {
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-dt-time-item:hover {
|
||||
background: var(--bg-secondary, #f5f6f8);
|
||||
}
|
||||
|
||||
.audit-dt-time-item.is-selected {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.audit-dt-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.audit-dt-footer-btn {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audit-dt-footer-btn:hover {
|
||||
background: rgba(0, 102, 255, 0.08);
|
||||
}
|
||||
|
||||
.audit-dt-footer-btn--primary {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.audit-dt-footer-btn--primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
|
||||
#settings-section-audit .audit-log-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.audit-log-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.audit-log-table-wrap {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audit-log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
table-layout: auto;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.audit-log-table th,
|
||||
.audit-log-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.audit-log-table thead {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.audit-log-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-log-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.audit-log-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.audit-log-row:hover {
|
||||
background: rgba(0, 102, 255, 0.06);
|
||||
}
|
||||
|
||||
.audit-log-col-time {
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.audit-log-col-msg {
|
||||
max-width: 280px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-log-col-ip {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-log-col-resource {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.audit-log-cell-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.audit-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.audit-tag--ok {
|
||||
color: #0d7a3e;
|
||||
background: rgba(13, 122, 62, 0.1);
|
||||
border-color: rgba(13, 122, 62, 0.22);
|
||||
}
|
||||
|
||||
.audit-tag--fail {
|
||||
color: #c0392b;
|
||||
background: rgba(192, 57, 43, 0.1);
|
||||
border-color: rgba(192, 57, 43, 0.22);
|
||||
}
|
||||
|
||||
.audit-tag--cat {
|
||||
color: var(--accent-color);
|
||||
background: rgba(0, 102, 255, 0.08);
|
||||
border-color: rgba(0, 102, 255, 0.18);
|
||||
}
|
||||
|
||||
.audit-tag--act {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary, rgba(0, 0, 0, 0.03));
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.audit-tag--ip {
|
||||
color: #5c6b7a;
|
||||
background: rgba(92, 107, 122, 0.08);
|
||||
border-color: rgba(92, 107, 122, 0.18);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
#settings-section-audit .audit-logs-pagination {
|
||||
margin-top: 0;
|
||||
margin-top: 12px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
@@ -4679,11 +5363,7 @@ header {
|
||||
#settings-section-audit .audit-logs-pagination .monitor-pagination {
|
||||
margin-top: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.audit-log-item {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.audit-detail-pre {
|
||||
@@ -4696,41 +5376,41 @@ header {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.audit-summary-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 12px 0 16px;
|
||||
.audit-timezone-hint {
|
||||
margin: 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.audit-stat-card {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.audit-filter-bottom {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.audit-stat-card strong {
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.audit-filter-time-group {
|
||||
margin-left: 0;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audit-stat-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.audit-logs-actions {
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.audit-retention-hint {
|
||||
margin-top: 4px;
|
||||
opacity: 0.85;
|
||||
.audit-summary-tags {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.audit-export-trigger {
|
||||
@@ -7226,16 +7906,15 @@ header {
|
||||
}
|
||||
|
||||
.mcp-stats-timeline-dot {
|
||||
fill: #fff;
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 1.5;
|
||||
fill: #3b82f6;
|
||||
stroke: none;
|
||||
cursor: crosshair;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
transition: opacity 0.12s ease, r 0.12s ease;
|
||||
}
|
||||
|
||||
.mcp-stats-timeline-dot--peak {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mcp-stats-timeline__chart-wrap:hover .mcp-stats-timeline-dot,
|
||||
@@ -7244,9 +7923,7 @@ header {
|
||||
}
|
||||
|
||||
.mcp-stats-timeline-dot.is-active {
|
||||
fill: #2563eb;
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
fill: #1d4ed8;
|
||||
}
|
||||
|
||||
.mcp-stats-timeline-error {
|
||||
@@ -22057,6 +22734,58 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.robot-wechat-bound-flash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
border: 1px solid rgba(40, 167, 69, 0.35);
|
||||
color: var(--success-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.robot-wechat-bound-flash[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.robot-wechat-bound-flash.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.robot-wechat-bound-flash-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.robot-wechat-bound-summary {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.robot-wechat-bound-summary code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.robot-wechat-panel {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
@@ -22074,6 +22803,11 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* display:flex 会覆盖 [hidden] 默认 display:none,须显式隐藏 */
|
||||
.robot-wechat-bound-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.robot-wechat-bound-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
|
||||
+111
-1
@@ -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": {
|
||||
@@ -2086,14 +2087,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 +2188,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 +2353,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",
|
||||
|
||||
+111
-1
@@ -1071,6 +1071,7 @@
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
|
||||
"boundSuccess": "绑定成功,微信机器人已启用。",
|
||||
"alreadyBound": "该微信已绑定过,无需重复绑定。",
|
||||
"openLink": "无法显示二维码?点击用手机微信打开链接"
|
||||
},
|
||||
"wecom": {
|
||||
@@ -2074,14 +2075,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 +2176,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 +2341,8 @@
|
||||
"copyContent": "复制内容",
|
||||
"correctInfo": "正确信息",
|
||||
"errorInfo": "错误信息",
|
||||
"copyError": "复制错误"
|
||||
"copyError": "复制错误",
|
||||
"contentTruncated": "…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)"
|
||||
},
|
||||
"attackChainModal": {
|
||||
"title": "攻击链可视化",
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Audit log datetime picker — cross-browser, locale-aware (SLS-style calendar + time columns).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var registry = {};
|
||||
var popover = null;
|
||||
var activeFieldId = null;
|
||||
var draft = null;
|
||||
var viewYear = 0;
|
||||
var viewMonth = 0;
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pickerLocale() {
|
||||
if (typeof auditLocale === 'function') return auditLocale();
|
||||
if (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) return 'zh-CN';
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
function pickerT(key, fallback) {
|
||||
if (typeof auditT === 'function') return auditT(key, null, fallback);
|
||||
if (typeof t === 'function') {
|
||||
var v = t(key);
|
||||
if (v && v !== key) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function partsToStorage(p) {
|
||||
if (!p) return '';
|
||||
return p.y + '-' + pad2(p.m) + '-' + pad2(p.d) + 'T' + pad2(p.h) + ':' + pad2(p.mi);
|
||||
}
|
||||
|
||||
function parseStorage(value) {
|
||||
if (!value) return null;
|
||||
var m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(String(value).trim());
|
||||
if (!m) return null;
|
||||
return { y: +m[1], m: +m[2], d: +m[3], h: +m[4], mi: +m[5] };
|
||||
}
|
||||
|
||||
function formatDisplay(parts) {
|
||||
if (!parts) return '';
|
||||
var loc = pickerLocale();
|
||||
try {
|
||||
var d = new Date(parts.y, parts.m - 1, parts.d, parts.h, parts.mi, 0, 0);
|
||||
return d.toLocaleString(loc, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
} catch (_) {
|
||||
return partsToStorage(parts).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
function nowParts() {
|
||||
var n = new Date();
|
||||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: n.getHours(), mi: n.getMinutes() };
|
||||
}
|
||||
|
||||
function startOfTodayParts() {
|
||||
var n = new Date();
|
||||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: 0, mi: 0 };
|
||||
}
|
||||
|
||||
function monthTitle(year, month) {
|
||||
var loc = pickerLocale();
|
||||
if (loc.startsWith('zh')) {
|
||||
return year + '\u5e74' + pad2(month) + '\u6708';
|
||||
}
|
||||
try {
|
||||
return new Date(year, month - 1, 1).toLocaleString(loc, { month: 'long', year: 'numeric' });
|
||||
} catch (_) {
|
||||
return year + '-' + pad2(month);
|
||||
}
|
||||
}
|
||||
|
||||
function weekdayHeaders() {
|
||||
var loc = pickerLocale();
|
||||
if (loc.startsWith('zh')) {
|
||||
return ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
|
||||
}
|
||||
return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
}
|
||||
|
||||
function buildMonthGrid(year, month) {
|
||||
var first = new Date(year, month - 1, 1);
|
||||
var start = new Date(first);
|
||||
start.setDate(first.getDate() - first.getDay());
|
||||
var cells = [];
|
||||
var cursor = new Date(start);
|
||||
for (var i = 0; i < 42; i++) {
|
||||
cells.push({
|
||||
y: cursor.getFullYear(),
|
||||
m: cursor.getMonth() + 1,
|
||||
d: cursor.getDate(),
|
||||
inMonth: cursor.getMonth() === month - 1
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function ensurePopover() {
|
||||
if (popover) return popover;
|
||||
popover = document.createElement('div');
|
||||
popover.className = 'audit-dt-popover';
|
||||
popover.hidden = true;
|
||||
popover.setAttribute('role', 'dialog');
|
||||
popover.innerHTML =
|
||||
'<div class="audit-dt-popover-inner">' +
|
||||
'<div class="audit-dt-head">' +
|
||||
'<button type="button" class="audit-dt-nav" data-nav="prev" aria-label="prev">‹</button>' +
|
||||
'<span class="audit-dt-month-label"></span>' +
|
||||
'<button type="button" class="audit-dt-nav" data-nav="next" aria-label="next">›</button>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-body">' +
|
||||
'<div class="audit-dt-calendar"></div>' +
|
||||
'<div class="audit-dt-time">' +
|
||||
'<div class="audit-dt-time-col" data-part="hour">' +
|
||||
'<span class="audit-dt-time-label audit-dt-hour-label"></span>' +
|
||||
'<div class="audit-dt-time-list"></div>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-time-col" data-part="minute">' +
|
||||
'<span class="audit-dt-time-label audit-dt-minute-label"></span>' +
|
||||
'<div class="audit-dt-time-list"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-footer">' +
|
||||
'<button type="button" class="audit-dt-footer-btn" data-action="clear"></button>' +
|
||||
'<button type="button" class="audit-dt-footer-btn" data-action="today"></button>' +
|
||||
'<button type="button" class="audit-dt-footer-btn audit-dt-footer-btn--primary" data-action="confirm"></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(popover);
|
||||
|
||||
popover.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
var btn = ev.target.closest('[data-nav]');
|
||||
if (btn) {
|
||||
if (btn.getAttribute('data-nav') === 'prev') {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 1) { viewMonth = 12; viewYear -= 1; }
|
||||
} else {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 12) { viewMonth = 1; viewYear += 1; }
|
||||
}
|
||||
renderPopover();
|
||||
return;
|
||||
}
|
||||
var dayBtn = ev.target.closest('[data-day]');
|
||||
if (dayBtn && draft) {
|
||||
draft.y = +dayBtn.getAttribute('data-y');
|
||||
draft.m = +dayBtn.getAttribute('data-m');
|
||||
draft.d = +dayBtn.getAttribute('data-d');
|
||||
if (draft.y !== viewYear || draft.m !== viewMonth) {
|
||||
viewYear = draft.y;
|
||||
viewMonth = draft.m;
|
||||
renderCalendar();
|
||||
} else {
|
||||
updateDaySelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var timeBtn = ev.target.closest('[data-time]');
|
||||
if (timeBtn && draft) {
|
||||
var part = timeBtn.getAttribute('data-part');
|
||||
var val = +timeBtn.getAttribute('data-time');
|
||||
if (part === 'hour') draft.h = val;
|
||||
if (part === 'minute') draft.mi = val;
|
||||
updateTimeSelection();
|
||||
return;
|
||||
}
|
||||
var actionBtn = ev.target.closest('[data-action]');
|
||||
if (!actionBtn) return;
|
||||
var action = actionBtn.getAttribute('data-action');
|
||||
if (action === 'clear') {
|
||||
applyValue(activeFieldId, '');
|
||||
closePopover();
|
||||
} else if (action === 'today') {
|
||||
if (draft) {
|
||||
var t = nowParts();
|
||||
draft.y = t.y; draft.m = t.m; draft.d = t.d;
|
||||
viewYear = t.y; viewMonth = t.m;
|
||||
}
|
||||
renderPopover();
|
||||
} else if (action === 'confirm') {
|
||||
applyValue(activeFieldId, partsToStorage(draft));
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (!popover.hidden) renderPopover();
|
||||
refreshAllDisplays();
|
||||
});
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
function onDocumentClick(ev) {
|
||||
if (!popover || popover.hidden) return;
|
||||
if (popover.contains(ev.target)) return;
|
||||
if (activeFieldId && registry[activeFieldId] && registry[activeFieldId].wrap.contains(ev.target)) return;
|
||||
closePopover();
|
||||
}
|
||||
|
||||
function onDocumentKeydown(ev) {
|
||||
if (ev.key === 'Escape' && popover && !popover.hidden) {
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(fieldWrap) {
|
||||
var rect = fieldWrap.getBoundingClientRect();
|
||||
var width = 320;
|
||||
popover.style.width = width + 'px';
|
||||
var left = rect.left;
|
||||
if (left + width > window.innerWidth - 12) {
|
||||
left = Math.max(12, window.innerWidth - width - 12);
|
||||
}
|
||||
popover.style.left = left + 'px';
|
||||
var top = rect.bottom + 6;
|
||||
if (top + 340 > window.innerHeight - 12) {
|
||||
top = Math.max(12, rect.top - 340 - 6);
|
||||
}
|
||||
popover.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelector('.audit-dt-month-label').textContent = monthTitle(viewYear, viewMonth);
|
||||
var cal = popover.querySelector('.audit-dt-calendar');
|
||||
var headers = weekdayHeaders();
|
||||
var html = '<div class="audit-dt-weekdays">';
|
||||
headers.forEach(function (h) { html += '<span>' + h + '</span>'; });
|
||||
html += '</div><div class="audit-dt-days">';
|
||||
buildMonthGrid(viewYear, viewMonth).forEach(function (cell) {
|
||||
var cls = 'audit-dt-day';
|
||||
if (!cell.inMonth) cls += ' is-other-month';
|
||||
if (draft && cell.y === draft.y && cell.m === draft.m && cell.d === draft.d) cls += ' is-selected';
|
||||
html += '<button type="button" class="' + cls + '" data-day="1" data-y="' + cell.y +
|
||||
'" data-m="' + cell.m + '" data-d="' + cell.d + '">' + cell.d + '</button>';
|
||||
});
|
||||
html += '</div>';
|
||||
cal.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTimeLists() {
|
||||
if (!popover || !draft) return;
|
||||
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||
var hourHtml = '';
|
||||
var minuteHtml = '';
|
||||
var h;
|
||||
for (h = 0; h < 24; h++) {
|
||||
hourHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.h === h ? ' is-selected' : '') +
|
||||
'" data-part="hour" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||
}
|
||||
for (h = 0; h < 60; h++) {
|
||||
minuteHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.mi === h ? ' is-selected' : '') +
|
||||
'" data-part="minute" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||
}
|
||||
hourList.innerHTML = hourHtml;
|
||||
minuteList.innerHTML = minuteHtml;
|
||||
scrollTimeSelection(hourList, draft.h);
|
||||
scrollTimeSelection(minuteList, draft.mi);
|
||||
}
|
||||
|
||||
function updateDaySelection() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelectorAll('.audit-dt-day').forEach(function (btn) {
|
||||
var selected = +btn.getAttribute('data-y') === draft.y &&
|
||||
+btn.getAttribute('data-m') === draft.m &&
|
||||
+btn.getAttribute('data-d') === draft.d;
|
||||
btn.classList.toggle('is-selected', selected);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimeSelection() {
|
||||
if (!popover || !draft) return;
|
||||
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||
if (!hourList || !minuteList || !hourList.children.length) {
|
||||
renderTimeLists();
|
||||
return;
|
||||
}
|
||||
hourList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.h);
|
||||
});
|
||||
minuteList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.mi);
|
||||
});
|
||||
scrollTimeSelection(hourList, draft.h);
|
||||
scrollTimeSelection(minuteList, draft.mi);
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelector('.audit-dt-hour-label').textContent = pickerT('settingsAudit.pickerHour', 'Hour');
|
||||
popover.querySelector('.audit-dt-minute-label').textContent = pickerT('settingsAudit.pickerMinute', 'Min');
|
||||
popover.querySelector('[data-action="clear"]').textContent = pickerT('settingsAudit.pickerClear', 'Clear');
|
||||
popover.querySelector('[data-action="today"]').textContent = pickerT('settingsAudit.pickerToday', 'Today');
|
||||
popover.querySelector('[data-action="confirm"]').textContent = pickerT('settingsAudit.pickerConfirm', 'OK');
|
||||
renderCalendar();
|
||||
renderTimeLists();
|
||||
}
|
||||
|
||||
function scrollTimeSelection(listEl, value) {
|
||||
var sel = listEl.querySelector('.is-selected');
|
||||
if (sel && sel.scrollIntoView) {
|
||||
sel.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function openPopover(fieldId) {
|
||||
ensurePopover();
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
activeFieldId = fieldId;
|
||||
var stored = entry.wrap.dataset.value || '';
|
||||
draft = parseStorage(stored) || nowParts();
|
||||
viewYear = draft.y;
|
||||
viewMonth = draft.m;
|
||||
renderPopover();
|
||||
positionPopover(entry.wrap);
|
||||
popover.hidden = false;
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
if (!popover) return;
|
||||
popover.hidden = true;
|
||||
activeFieldId = null;
|
||||
draft = null;
|
||||
}
|
||||
|
||||
function refreshDisplay(fieldId) {
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
var parts = parseStorage(entry.wrap.dataset.value || '');
|
||||
entry.input.value = parts ? formatDisplay(parts) : '';
|
||||
entry.input.placeholder = pickerT('settingsAudit.datetimePlaceholder', 'Select date & time');
|
||||
entry.clearBtn.hidden = !parts;
|
||||
}
|
||||
|
||||
function refreshAllDisplays() {
|
||||
Object.keys(registry).forEach(refreshDisplay);
|
||||
}
|
||||
|
||||
function applyValue(fieldId, storageValue) {
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
entry.wrap.dataset.value = storageValue || '';
|
||||
refreshDisplay(fieldId);
|
||||
}
|
||||
|
||||
function bindField(fieldId) {
|
||||
var wrap = document.getElementById(fieldId);
|
||||
if (!wrap || wrap.dataset.auditDtBound === '1') return;
|
||||
var input = wrap.querySelector('.audit-datetime-input');
|
||||
var openBtn = wrap.querySelector('.audit-datetime-open-btn');
|
||||
var clearBtn = wrap.querySelector('.audit-datetime-clear-btn');
|
||||
if (!input || !openBtn || !clearBtn) return;
|
||||
|
||||
wrap.dataset.auditDtBound = '1';
|
||||
registry[fieldId] = { wrap: wrap, input: input, clearBtn: clearBtn };
|
||||
|
||||
openBtn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!popover || popover.hidden || activeFieldId !== fieldId) {
|
||||
openPopover(fieldId);
|
||||
} else {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
input.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
openPopover(fieldId);
|
||||
});
|
||||
clearBtn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
applyValue(fieldId, '');
|
||||
});
|
||||
refreshDisplay(fieldId);
|
||||
}
|
||||
|
||||
window.AuditDatetimePicker = {
|
||||
init: function () {
|
||||
bindField('audit-filter-since-field');
|
||||
bindField('audit-filter-until-field');
|
||||
refreshAllDisplays();
|
||||
},
|
||||
getValue: function (inputId) {
|
||||
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||
var entry = registry[fieldId];
|
||||
return entry ? (entry.wrap.dataset.value || '') : '';
|
||||
},
|
||||
setValue: function (inputId, dateObj) {
|
||||
if (!dateObj || Number.isNaN(dateObj.getTime())) return;
|
||||
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||
var p = {
|
||||
y: dateObj.getFullYear(),
|
||||
m: dateObj.getMonth() + 1,
|
||||
d: dateObj.getDate(),
|
||||
h: dateObj.getHours(),
|
||||
mi: dateObj.getMinutes()
|
||||
};
|
||||
applyValue(fieldId, partsToStorage(p));
|
||||
},
|
||||
clearAll: function () {
|
||||
applyValue('audit-filter-since-field', '');
|
||||
applyValue('audit-filter-until-field', '');
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
})();
|
||||
+352
-56
@@ -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');
|
||||
}
|
||||
|
||||
+61
-27
@@ -2639,6 +2639,57 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
|
||||
}
|
||||
|
||||
// 显示MCP调用详情
|
||||
const MCP_DETAIL_MAX_CHARS = 120000;
|
||||
|
||||
function extractMCPResultText(result) {
|
||||
if (!result) return '';
|
||||
const content = result.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map(item => (item && typeof item === 'object' && typeof item.text === 'string') ? item.text : '')
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
return content.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function truncateMCPDetailText(text, maxChars) {
|
||||
if (text == null) return '';
|
||||
const s = String(text);
|
||||
if (s.length <= maxChars) return s;
|
||||
const hint = typeof window.t === 'function'
|
||||
? window.t('mcpDetailModal.contentTruncated')
|
||||
: '…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)';
|
||||
return s.slice(0, maxChars) + '\n\n' + hint;
|
||||
}
|
||||
|
||||
/** 响应结果区 JSON 展示(过大时截断 content 内 text,避免 stringify 卡死页面) */
|
||||
function formatMCPResultJsonForDisplay(result, maxChars) {
|
||||
if (!result) return '{}';
|
||||
const payload = {
|
||||
content: result.content,
|
||||
isError: !!result.isError
|
||||
};
|
||||
let json = JSON.stringify(payload, null, 2);
|
||||
if (json.length <= maxChars) {
|
||||
return json;
|
||||
}
|
||||
const text = extractMCPResultText(result);
|
||||
const truncatedPayload = {
|
||||
content: [{ type: 'text', text: truncateMCPDetailText(text, Math.min(maxChars - 800, MCP_DETAIL_MAX_CHARS)) }],
|
||||
isError: !!result.isError
|
||||
};
|
||||
json = JSON.stringify(truncatedPayload, null, 2);
|
||||
if (json.length > maxChars) {
|
||||
return json.slice(0, maxChars) + '\n…';
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function showMCPDetail(executionId) {
|
||||
try {
|
||||
openAppModal('mcp-detail-modal', { focus: false });
|
||||
@@ -2700,42 +2751,22 @@ async function showMCPDetail(executionId) {
|
||||
}
|
||||
|
||||
if (exec.result) {
|
||||
const responseData = {
|
||||
content: exec.result.content,
|
||||
isError: exec.result.isError
|
||||
};
|
||||
responseElement.textContent = JSON.stringify(responseData, null, 2);
|
||||
const agentVisibleText = truncateMCPDetailText(extractMCPResultText(exec.result), MCP_DETAIL_MAX_CHARS);
|
||||
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
||||
|
||||
if (exec.result.isError) {
|
||||
// 错误场景:响应结果标红 + 错误信息区块
|
||||
responseElement.className = 'code-block error';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||
if (exec.error && errorSection && errorElement) {
|
||||
errorSection.style.display = 'block';
|
||||
errorElement.textContent = exec.error;
|
||||
}
|
||||
} else {
|
||||
// 成功场景:响应结果保持普通样式,正确信息单独拎出来
|
||||
responseElement.className = 'code-block';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||
if (successSection && successElement) {
|
||||
successSection.style.display = 'block';
|
||||
let successText = '';
|
||||
const content = exec.result.content;
|
||||
if (typeof content === 'string') {
|
||||
successText = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const texts = content
|
||||
.map(item => (item && typeof item === 'object' && typeof item.text === 'string') ? item.text : '')
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
successText = texts.join('\n\n');
|
||||
}
|
||||
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
successText = content.text;
|
||||
}
|
||||
if (!successText) {
|
||||
successText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
||||
}
|
||||
successElement.textContent = successText;
|
||||
successElement.textContent = agentVisibleText || emptyText;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -7468,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') : '删除失败';
|
||||
|
||||
@@ -2059,45 +2059,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result_delta': {
|
||||
const deltaInfo = event.data || {};
|
||||
const toolCallId = deltaInfo.toolCallId || null;
|
||||
if (!toolCallId) break;
|
||||
|
||||
const key = toolResultStreamKey(progressId, toolCallId);
|
||||
let state = toolResultStreamStateByKey.get(key);
|
||||
const deltaText = event.message || '';
|
||||
if (!deltaText) break;
|
||||
|
||||
if (!state) {
|
||||
const mapping = getToolCallMapping(progressId, toolCallId);
|
||||
let callItemId = mapping && mapping.itemId ? mapping.itemId : null;
|
||||
if (callItemId) {
|
||||
const callItem = document.getElementById(callItemId);
|
||||
if (callItem) {
|
||||
ensureToolCallResultSlot(callItem);
|
||||
const section = callItem.querySelector('.tool-result-section');
|
||||
if (section) {
|
||||
section.classList.remove('pending');
|
||||
section.className = 'tool-result-section success';
|
||||
}
|
||||
}
|
||||
}
|
||||
state = { itemId: callItemId, buffer: '', onCallItem: !!callItemId };
|
||||
toolResultStreamStateByKey.set(key, state);
|
||||
}
|
||||
|
||||
state.buffer += deltaText;
|
||||
const item = state.itemId ? document.getElementById(state.itemId) : null;
|
||||
if (item) {
|
||||
const pre = item.querySelector('pre.tool-result');
|
||||
if (pre) {
|
||||
pre.classList.remove('tool-result-pending');
|
||||
scheduleStreamPlainTextUpdate(pre, state.buffer);
|
||||
}
|
||||
}
|
||||
case 'tool_result_delta':
|
||||
// 工具执行过程不流式展示,仅等 tool_result 展示最终结果。
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
const resultInfo = event.data || {};
|
||||
@@ -3887,7 +3851,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
|
||||
const dotClass = 'mcp-stats-timeline-dot' + (isPeak ? ' mcp-stats-timeline-dot--peak' : '');
|
||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 3 : 2.5}"
|
||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
|
||||
data-time="${escapeHtml(tipTime)}"
|
||||
data-total="${c.p.total || 0}"
|
||||
data-failed="${c.p.failed || 0}" />`;
|
||||
@@ -3895,7 +3859,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
|
||||
const peakC = coords[peakIdx];
|
||||
const peakMarker = (peakC.p.total || 0) > 0
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="7" />`
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
|
||||
: '';
|
||||
|
||||
return `<svg class="mcp-stats-timeline__chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
|
||||
|
||||
@@ -3391,28 +3391,6 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
}
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
|
||||
// ─── Tool result delta (streaming output) ───
|
||||
} else if (_et === 'tool_result_delta' && _ed.toolCallId) {
|
||||
var trdKey = _ed.toolCallId;
|
||||
var trdDelta = _em || '';
|
||||
if (trdDelta) {
|
||||
var trdState = wsToolResultStreams.get(trdKey);
|
||||
if (!trdState) {
|
||||
var callEl = wsToolCallItems.get(trdKey);
|
||||
trdState = { el: callEl || null, buf: '', onCall: !!callEl };
|
||||
wsToolResultStreams.set(trdKey, trdState);
|
||||
}
|
||||
trdState.buf += trdDelta;
|
||||
if (trdState.el) {
|
||||
var trdPre = trdState.el.querySelector('pre.tool-result');
|
||||
if (trdPre) {
|
||||
trdPre.classList.remove('tool-result-pending');
|
||||
trdPre.textContent = trdState.buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
|
||||
// ─── Tool result (final) ───
|
||||
} else if (_et === 'tool_result' && _ed) {
|
||||
var success = _ed.success !== false;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 扫码绑定进行中 */
|
||||
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':
|
||||
|
||||
+65
-31
@@ -2817,6 +2817,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 +3017,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 +3064,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 +3072,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>×</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>×</button>
|
||||
<button type="button" class="audit-datetime-btn audit-datetime-open-btn" title="Open" aria-label="Open">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="audit-filter-bottom">
|
||||
<label class="audit-field audit-field--keyword">
|
||||
<span data-i18n="settingsAudit.filterQuery">关键词</span>
|
||||
<input type="text" id="audit-filter-q" data-i18n="settingsAudit.filterQueryPlaceholder" data-i18n-attr="placeholder" placeholder="消息 / 资源 ID / 操作名" />
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
|
||||
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
|
||||
</div>
|
||||
<div class="audit-logs-actions">
|
||||
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
|
||||
<div class="audit-export-dropdown">
|
||||
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-i18n="settingsAudit.exportBtn">导出</span>
|
||||
<span class="audit-export-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
|
||||
<div class="audit-logs-actions">
|
||||
<button type="button" class="btn-primary" onclick="filterAuditLogs()" data-i18n="settingsAudit.filterBtn">筛选</button>
|
||||
<button type="button" class="btn-secondary" onclick="resetAuditLogFilters()" data-i18n="settingsAudit.resetBtn">重置</button>
|
||||
<button type="button" class="btn-secondary" onclick="refreshAuditLogs()" data-i18n="common.refresh">刷新</button>
|
||||
<div class="audit-export-dropdown">
|
||||
<button type="button" class="btn-secondary audit-export-trigger" id="audit-export-trigger" onclick="toggleAuditExportMenu(event)" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-i18n="settingsAudit.exportBtn">导出</span>
|
||||
<span class="audit-export-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div id="audit-export-menu" class="audit-export-menu" role="menu" hidden>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('json')" data-i18n="settingsAudit.exportJson">导出 JSON</button>
|
||||
<button type="button" class="audit-export-menu-item" role="menuitem" onclick="runAuditExport('csv')" data-i18n="settingsAudit.exportCsv">导出 CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="audit-filter-timezone-hint" class="audit-timezone-hint" hidden></p>
|
||||
</div>
|
||||
<div id="audit-log-list" class="audit-log-list c2-event-list"></div>
|
||||
<div id="audit-log-list" class="audit-log-list"></div>
|
||||
<div id="audit-logs-pagination" class="pagination-container audit-logs-pagination"></div>
|
||||
</div>
|
||||
|
||||
@@ -4301,6 +4334,7 @@
|
||||
<script src="/static/js/chat.js"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
<script src="/static/js/settings.js"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
<script src="/static/js/audit.js"></script>
|
||||
<script src="/static/js/wechat-robot.js"></script>
|
||||
<script src="/static/vendor/xterm.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user