Compare commits

..

36 Commits

Author SHA1 Message Date
公明 997c4e7262 Add files via upload 2026-06-27 01:44:08 +08:00
公明 ac370b0ada Add files via upload 2026-06-27 01:42:44 +08:00
公明 017db2b9a8 Add files via upload 2026-06-27 01:41:36 +08:00
公明 86b4803683 Add files via upload 2026-06-27 01:40:12 +08:00
公明 4d98264fc3 Add files via upload 2026-06-27 01:38:02 +08:00
公明 fd1de4ea94 Add files via upload 2026-06-27 01:36:09 +08:00
公明 41ba3baca9 Add files via upload 2026-06-27 01:35:46 +08:00
公明 2e908daebb Add files via upload 2026-06-27 00:34:19 +08:00
公明 c1763e1b9a Add files via upload 2026-06-27 00:03:16 +08:00
公明 70e5d28619 Add files via upload 2026-06-26 23:54:29 +08:00
公明 49990ecb4f Add files via upload 2026-06-26 23:50:13 +08:00
公明 c91806c0c4 Add files via upload 2026-06-26 23:11:52 +08:00
公明 e537236bf3 Add files via upload 2026-06-26 23:10:11 +08:00
公明 7eeffb1933 Add files via upload 2026-06-26 18:16:30 +08:00
公明 0556b29d40 Add files via upload 2026-06-26 14:34:45 +08:00
公明 be3c0cfa64 Add files via upload 2026-06-26 14:31:47 +08:00
公明 8e5f40d226 Add files via upload 2026-06-26 14:30:00 +08:00
公明 4b6719a6f3 Add files via upload 2026-06-26 14:27:32 +08:00
公明 7c8f3228f8 Add files via upload 2026-06-26 14:25:14 +08:00
公明 537843b6b8 Add files via upload 2026-06-26 14:24:01 +08:00
公明 4a57574cf9 Add files via upload 2026-06-26 14:21:51 +08:00
公明 0168530084 Add files via upload 2026-06-26 10:57:59 +08:00
公明 4184a7b6f0 Add files via upload 2026-06-26 10:54:59 +08:00
公明 fb3b4dd6e5 Add files via upload 2026-06-26 01:22:30 +08:00
公明 7e4a8db7af Add files via upload 2026-06-26 01:01:49 +08:00
公明 6a72c95b9f Add files via upload 2026-06-26 00:58:29 +08:00
公明 447be050cd Add files via upload 2026-06-25 21:28:46 +08:00
公明 9b75c43f7b Add files via upload 2026-06-25 15:15:01 +08:00
公明 a443454753 Add files via upload 2026-06-25 14:56:56 +08:00
公明 08822ba5df Update config.yaml 2026-06-25 14:56:31 +08:00
公明 eda75fb98f Add files via upload 2026-06-25 14:55:10 +08:00
公明 e6978a7994 Add files via upload 2026-06-25 14:52:39 +08:00
公明 1db0f4740f Add files via upload 2026-06-25 14:50:28 +08:00
公明 6e4ff96dcd Add files via upload 2026-06-25 14:48:25 +08:00
公明 95470fefbc Add files via upload 2026-06-25 14:47:16 +08:00
公明 5e075bb198 Add files via upload 2026-06-25 14:45:43 +08:00
55 changed files with 3897 additions and 549 deletions
+2 -1
View File
@@ -114,7 +114,8 @@ multi_agent:
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高) batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。 # plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
plan_execute_loop_max_iterations: 0 plan_execute_loop_max_iterations: 0
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用 sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
user_verbatim_anchor_max_runes: 0 # 主代理 system 中逐轮保留用户原文(压缩后刷新);0=不截断(默认),>0=总字符上限,负数=禁用
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理 without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
without_write_todos: false without_write_todos: false
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认 orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 181 KiB

+9
View File
@@ -26,6 +26,7 @@ import (
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/monitor" "cyberstrike-ai/internal/monitor"
"cyberstrike-ai/internal/multiagent"
"cyberstrike-ai/internal/robot" "cyberstrike-ai/internal/robot"
"cyberstrike-ai/internal/security" "cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/skillpackage" "cyberstrike-ai/internal/skillpackage"
@@ -67,6 +68,10 @@ type App struct {
// New 创建新应用 // New 创建新应用
func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) { func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
if err := multiagent.InitADK(); err != nil {
return nil, fmt.Errorf("初始化 Eino ADK: %w", err)
}
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
@@ -135,6 +140,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
externalMCPMgr.StartAllEnabled() externalMCPMgr.StartAllEnabled()
} }
execReconciler := monitor.NewExecutionReconciler(db, mcpServer, externalMCPMgr, log.Logger)
execReconciler.ReconcileOnStartup()
monitor.StartStaleRunningReconcileLoop(execReconciler, log.Logger)
// 创建Agent // 创建Agent
maxIterations := cfg.Agent.MaxIterations maxIterations := cfg.Agent.MaxIterations
if maxIterations <= 0 { if maxIterations <= 0 {
+15 -2
View File
@@ -96,9 +96,12 @@ type MultiAgentConfig struct {
// OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。 // OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。
OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"` OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"`
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"` SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
// SubAgentUserContextMaxRunes caps the user-context supplement appended to task descriptions for sub-agents. // SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
// 0 (default) uses the built-in default of 2000 runes; negative value disables injection entirely. // 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"` SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
// UserVerbatimAnchorMaxRunes injects all user turns verbatim into system prompt (survives summarization refresh).
// 0 (default) = no cap; >0 = total rune cap; negative disables anchor injection.
UserVerbatimAnchorMaxRunes int `yaml:"user_verbatim_anchor_max_runes,omitempty" json:"user_verbatim_anchor_max_runes,omitempty"`
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent. // EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"` EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras. // EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
@@ -107,6 +110,16 @@ type MultiAgentConfig struct {
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"` EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
} }
// UserVerbatimAnchorMaxRunesEffective returns max runes for user verbatim anchor; 0 = unlimited; negative = disabled.
func (c MultiAgentConfig) UserVerbatimAnchorMaxRunesEffective() int {
return c.UserVerbatimAnchorMaxRunes
}
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
return c.SubAgentUserContextMaxRunes
}
// MultiAgentEinoCallbacksConfig enables Eino unified callbacks on each ADK agent run (deep / plan_execute / supervisor / eino_single). // MultiAgentEinoCallbacksConfig enables Eino unified callbacks on each ADK agent run (deep / plan_execute / supervisor / eino_single).
// Modes: log_only (zap + optional OTel; no SSE to browser), sse (adds client SSE eino_trace_* when sse_trace_to_client), full (sse rules + stream callback copies closed). // Modes: log_only (zap + optional OTel; no SSE to browser), sse (adds client SSE eino_trace_* when sse_trace_to_client), full (sse rules + stream callback copies closed).
type MultiAgentEinoCallbacksConfig struct { type MultiAgentEinoCallbacksConfig struct {
+64 -20
View File
@@ -13,6 +13,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// ProjectFilterUnbound 列表 API 中 project_id=__none__ 表示仅未绑定项目的对话。
const ProjectFilterUnbound = "__none__"
// Conversation 对话 // Conversation 对话
type Conversation struct { type Conversation struct {
ID string `json:"id"` ID string `json:"id"`
@@ -361,20 +364,44 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
return &conv, nil return &conv, nil
} }
func conversationProjectIDColumn(alias string) string {
if alias != "" {
return alias + ".project_id"
}
return "project_id"
}
func appendConversationProjectFilter(where string, args []interface{}, projectID, alias string) (string, []interface{}) {
pid := strings.TrimSpace(projectID)
if pid == "" {
return where, args
}
col := conversationProjectIDColumn(alias)
if pid == ProjectFilterUnbound {
return where + fmt.Sprintf(" AND (%s IS NULL OR TRIM(COALESCE(%s, '')) = '')", col, col), args
}
return where + fmt.Sprintf(" AND %s = ?", col), append(args, pid)
}
// CountConversations 统计对话数量。 // CountConversations 统计对话数量。
func (db *DB) CountConversations(search string) (int, error) { func (db *DB) CountConversations(search, projectID string) (int, error) {
var count int var count int
var err error var err error
if search != "" { if search != "" {
searchPattern := "%" + search + "%" searchPattern := "%" + search + "%"
err = db.QueryRow( where := ` WHERE (c.title LIKE ?
`SELECT COUNT(*) FROM conversations c OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?))`
WHERE c.title LIKE ? args := []interface{}{searchPattern, searchPattern}
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)`, where, args = appendConversationProjectFilter(where, args, projectID, "c")
searchPattern, searchPattern, err = db.QueryRow(`SELECT COUNT(*) FROM conversations c`+where, args...).Scan(&count)
).Scan(&count)
} else { } else {
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`).Scan(&count) where := ""
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "")
if where != "" {
where = " WHERE" + strings.TrimPrefix(where, " AND")
}
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`+where, args...).Scan(&count)
} }
if err != nil { if err != nil {
return 0, fmt.Errorf("统计对话失败: %w", err) return 0, fmt.Errorf("统计对话失败: %w", err)
@@ -395,7 +422,7 @@ func conversationOrderClause(sortBy, tableAlias string) string {
} }
// ListConversations 列出所有对话 // ListConversations 列出所有对话
func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Conversation, error) { func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID string) ([]*Conversation, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
@@ -403,20 +430,30 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Co
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积 // 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
searchPattern := "%" + search + "%" searchPattern := "%" + search + "%"
orderClause := conversationOrderClause(sortBy, "c") orderClause := conversationOrderClause(sortBy, "c")
where := ` WHERE (c.title LIKE ?
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?))`
args := []interface{}{searchPattern, searchPattern}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err = db.Query( rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
FROM conversations c FROM conversations c`+where+`
WHERE c.title LIKE ?
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
`+orderClause+` `+orderClause+`
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
searchPattern, searchPattern, limit, offset, args...,
) )
} else { } else {
orderClause := conversationOrderClause(sortBy, "") orderClause := conversationOrderClause(sortBy, "")
where := ""
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "")
if where != "" {
where = " WHERE" + strings.TrimPrefix(where, " AND")
}
args = append(args, limit, offset)
rows, err = db.Query( rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?", "SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
limit, offset, args...,
) )
} }
@@ -472,23 +509,30 @@ const ungroupedConversationsSQL = `
)` )`
// CountUngroupedConversations 统计不在任何分组中的对话数量。 // CountUngroupedConversations 统计不在任何分组中的对话数量。
func (db *DB) CountUngroupedConversations() (int, error) { func (db *DB) CountUngroupedConversations(projectID string) (int, error) {
where := ungroupedConversationsSQL
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
var count int var count int
if err := db.QueryRow(`SELECT COUNT(*) ` + ungroupedConversationsSQL).Scan(&count); err != nil { if err := db.QueryRow(`SELECT COUNT(*) `+where, args...).Scan(&count); err != nil {
return 0, fmt.Errorf("统计未分组对话失败: %w", err) return 0, fmt.Errorf("统计未分组对话失败: %w", err)
} }
return count, nil return count, nil
} }
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。 // ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*Conversation, error) { func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID string) ([]*Conversation, error) {
orderClause := conversationOrderClause(sortBy, "c") orderClause := conversationOrderClause(sortBy, "c")
where := ungroupedConversationsSQL
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err := db.Query( rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+ `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
ungroupedConversationsSQL+` where+`
`+orderClause+` `+orderClause+`
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
limit, offset, args...,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("查询未分组对话失败: %w", err) return nil, fmt.Errorf("查询未分组对话失败: %w", err)
@@ -0,0 +1,60 @@
package database
import (
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestConversationProjectFilter(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "conversations.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
p, err := db.CreateProject(&Project{Name: "target-a", Status: "active"})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
convNone, err := db.CreateConversation("unbound", ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation unbound: %v", err)
}
convBound, err := db.CreateConversation("bound", ConversationCreateMeta{ProjectID: p.ID})
if err != nil {
t.Fatalf("CreateConversation bound: %v", err)
}
totalAll, err := db.CountConversations("", "")
if err != nil || totalAll < 2 {
t.Fatalf("CountConversations all: total=%d err=%v", totalAll, err)
}
totalBound, err := db.CountConversations("", p.ID)
if err != nil || totalBound != 1 {
t.Fatalf("CountConversations project: total=%d err=%v", totalBound, err)
}
totalUnbound, err := db.CountConversations("", ProjectFilterUnbound)
if err != nil || totalUnbound != 1 {
t.Fatalf("CountConversations unbound: total=%d err=%v", totalUnbound, err)
}
listBound, err := db.ListConversations(10, 0, "", "", p.ID)
if err != nil || len(listBound) != 1 || listBound[0].ID != convBound.ID {
t.Fatalf("ListConversations project: %+v err=%v", listBound, err)
}
listUnbound, err := db.ListConversations(10, 0, "", "", ProjectFilterUnbound)
if err != nil || len(listUnbound) != 1 || listUnbound[0].ID != convNone.ID {
t.Fatalf("ListConversations unbound: %+v err=%v", listUnbound, err)
}
_ = convNone
_ = convBound
}
+284 -22
View File
@@ -3,7 +3,6 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"sort"
"strings" "strings"
"time" "time"
@@ -227,6 +226,167 @@ func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolNa
return executions, nil return executions, nil
} }
func toolExecutionsFilterSQL(status, toolName string) (string, []interface{}) {
args := []interface{}{}
conditions := []string{}
if status != "" {
conditions = append(conditions, "status = ?")
args = append(args, status)
}
if toolName != "" {
conditions = append(conditions, "LOWER(tool_name) LIKE ?")
args = append(args, "%"+strings.ToLower(toolName)+"%")
}
if len(conditions) == 0 {
return "", args
}
return ` WHERE ` + strings.Join(conditions, ` AND `), args
}
// ToolStatsSummary 工具调用汇总(全量聚合,不含逐工具明细)
type ToolStatsSummary struct {
TotalCalls int
SuccessCalls int
FailedCalls int
LastCallTime *time.Time
ToolCount int
}
// ToolStatsSummaryResult 汇总 + Top N 工具排行
type ToolStatsSummaryResult struct {
Summary ToolStatsSummary
TopTools []*mcp.ToolStats
}
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
if topN <= 0 {
topN = 6
}
if topN > 100 {
topN = 100
}
result := &ToolStatsSummaryResult{
TopTools: make([]*mcp.ToolStats, 0, topN),
}
summaryQuery := `
SELECT COUNT(*),
COALESCE(SUM(total_calls), 0),
COALESCE(SUM(success_calls), 0),
COALESCE(SUM(failed_calls), 0),
MAX(last_call_time)
FROM tool_stats
`
var lastCallRaw sql.NullString
err := db.QueryRow(summaryQuery).Scan(
&result.Summary.ToolCount,
&result.Summary.TotalCalls,
&result.Summary.SuccessCalls,
&result.Summary.FailedCalls,
&lastCallRaw,
)
if err != nil {
return nil, err
}
if lastCallRaw.Valid && strings.TrimSpace(lastCallRaw.String) != "" {
if t, parseErr := time.Parse(time.RFC3339Nano, lastCallRaw.String); parseErr == nil {
result.Summary.LastCallTime = &t
} else if t, parseErr := time.Parse("2006-01-02 15:04:05.999999999-07:00", lastCallRaw.String); parseErr == nil {
result.Summary.LastCallTime = &t
} else if t, parseErr := time.Parse("2006-01-02 15:04:05", lastCallRaw.String); parseErr == nil {
result.Summary.LastCallTime = &t
}
}
topQuery := `
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time
FROM tool_stats
WHERE total_calls > 0
ORDER BY total_calls DESC, tool_name ASC
LIMIT ?
`
rows, err := db.Query(topQuery, topN)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var stat mcp.ToolStats
var lastCallTime sql.NullTime
if err := rows.Scan(
&stat.ToolName,
&stat.TotalCalls,
&stat.SuccessCalls,
&stat.FailedCalls,
&lastCallTime,
); err != nil {
db.logger.Warn("加载 Top 工具统计失败", zap.Error(err))
continue
}
if lastCallTime.Valid {
stat.LastCallTime = &lastCallTime.Time
}
result.TopTools = append(result.TopTools, &stat)
}
return result, nil
}
// LoadToolExecutionListPage 分页加载执行记录列表(不含 arguments/result,供监控列表使用)
func (db *DB) LoadToolExecutionListPage(offset, limit int, status, toolName string) ([]*mcp.ToolExecution, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
query := `
SELECT id, tool_name, status, start_time, end_time, duration_ms
FROM tool_executions
`
whereSQL, args := toolExecutionsFilterSQL(status, toolName)
query += whereSQL + ` ORDER BY start_time DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
executions := make([]*mcp.ToolExecution, 0, limit)
for rows.Next() {
var exec mcp.ToolExecution
var endTime sql.NullTime
var durationMs sql.NullInt64
if err := rows.Scan(
&exec.ID,
&exec.ToolName,
&exec.Status,
&exec.StartTime,
&endTime,
&durationMs,
); err != nil {
db.logger.Warn("加载执行记录列表失败", zap.Error(err))
continue
}
if endTime.Valid {
exec.EndTime = &endTime.Time
}
if durationMs.Valid {
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
}
executions = append(executions, &exec)
}
return executions, nil
}
// GetToolExecution 根据ID获取单条工具执行记录 // GetToolExecution 根据ID获取单条工具执行记录
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) { func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
query := ` query := `
@@ -288,6 +448,93 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
return &exec, nil return &exec, nil
} }
// CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 cancelled(如进程重启后无对应执行协程)。
func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg string) (int64, error) {
errMsg = strings.TrimSpace(errMsg)
if errMsg == "" {
errMsg = "执行已中断(服务重启或会话结束)"
}
query := `
UPDATE tool_executions
SET status = 'cancelled',
error = ?,
end_time = ?,
duration_ms = MAX(0, CAST((julianday(?) - julianday(start_time)) * 86400000 AS INTEGER))
WHERE status = 'running'
`
res, err := db.Exec(query, errMsg, endTime, endTime)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// FinalizeStaleRunningToolExecutions 将「非活跃且超过 minAge」的 running 记录标记为 cancelled。
// activeIDs 为当前进程内仍登记 cancel 的 executionId;不在集合内且已超时的视为孤儿记录。
func (db *DB) FinalizeStaleRunningToolExecutions(endTime time.Time, minAge time.Duration, activeIDs map[string]struct{}, errMsg string) (int64, error) {
errMsg = strings.TrimSpace(errMsg)
if errMsg == "" {
errMsg = "执行已中断(会话已结束)"
}
if minAge < 0 {
minAge = 0
}
cutoff := endTime.Add(-minAge)
rows, err := db.Query(`
SELECT id, start_time FROM tool_executions
WHERE status = 'running' AND start_time <= ?
`, cutoff)
if err != nil {
return 0, err
}
defer rows.Close()
type staleRow struct {
id string
startTime time.Time
}
var stale []staleRow
for rows.Next() {
var row staleRow
if err := rows.Scan(&row.id, &row.startTime); err != nil {
db.logger.Warn("读取 stale running 执行记录失败", zap.Error(err))
continue
}
if activeIDs != nil {
if _, active := activeIDs[row.id]; active {
continue
}
}
stale = append(stale, row)
}
if err := rows.Err(); err != nil {
return 0, err
}
if len(stale) == 0 {
return 0, nil
}
var affected int64
for _, row := range stale {
durationMs := endTime.Sub(row.startTime).Milliseconds()
if durationMs < 0 {
durationMs = 0
}
res, err := db.Exec(`
UPDATE tool_executions
SET status = 'cancelled', error = ?, end_time = ?, duration_ms = ?
WHERE id = ? AND status = 'running'
`, errMsg, endTime, durationMs, row.id)
if err != nil {
db.logger.Warn("更新 stale running 执行记录失败", zap.Error(err), zap.String("executionId", row.id))
continue
}
n, _ := res.RowsAffected()
affected += n
}
return affected, nil
}
// DeleteToolExecution 删除工具执行记录 // DeleteToolExecution 删除工具执行记录
func (db *DB) DeleteToolExecution(id string) error { func (db *DB) DeleteToolExecution(id string) error {
query := `DELETE FROM tool_executions WHERE id = ?` query := `DELETE FROM tool_executions WHERE id = ?`
@@ -600,13 +847,28 @@ func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界) // LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) { func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题) var query string
query := ` if dailyBuckets {
SELECT start_time, query = `
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed SELECT date(start_time, 'localtime') AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
FROM tool_executions FROM tool_executions
WHERE start_time >= ? WHERE start_time >= ?
GROUP BY bucket
ORDER BY bucket
` `
} else {
query = `
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
FROM tool_executions
WHERE start_time >= ?
GROUP BY bucket
ORDER BY bucket
`
}
rows, err := db.Query(query, since) rows, err := db.Query(query, since)
if err != nil { if err != nil {
@@ -614,35 +876,35 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
} }
defer rows.Close() defer rows.Close()
bucketMap := make(map[time.Time]struct{ total, failed int }) buckets := make([]CallsTimelineBucket, 0)
for rows.Next() { for rows.Next() {
var startTime time.Time var bucketStr string
var failed int var total, failed int
if err := rows.Scan(&startTime, &failed); err != nil { if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
db.logger.Warn("加载调用趋势失败", zap.Error(err)) db.logger.Warn("加载调用趋势失败", zap.Error(err))
continue continue
} }
key := truncateCallsTimelineBucket(startTime, dailyBuckets) bucketTime, err := parseCallsTimelineBucket(bucketStr, dailyBuckets)
entry := bucketMap[key] if err != nil {
entry.total++ db.logger.Warn("解析调用趋势时间桶失败", zap.Error(err), zap.String("bucket", bucketStr))
entry.failed += failed continue
bucketMap[key] = entry
} }
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
for bucketTime, counts := range bucketMap {
buckets = append(buckets, CallsTimelineBucket{ buckets = append(buckets, CallsTimelineBucket{
BucketTime: bucketTime, BucketTime: bucketTime,
Total: counts.total, Total: total,
Failed: counts.failed, Failed: failed,
}) })
} }
sort.Slice(buckets, func(i, j int) bool {
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
})
return buckets, nil return buckets, nil
} }
func parseCallsTimelineBucket(bucketStr string, dailyBuckets bool) (time.Time, error) {
if dailyBuckets {
return time.ParseInLocation("2006-01-02", bucketStr, time.Local)
}
return time.ParseInLocation("2006-01-02 15:04:05", bucketStr, time.Local)
}
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时) // DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
// 如果统计信息变为0,则删除该统计记录 // 如果统计信息变为0,则删除该统计记录
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error { func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
+102
View File
@@ -0,0 +1,102 @@
package database
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestCancelOrphanedRunningToolExecutions(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
start := time.Now().Add(-2 * time.Hour)
exec := &mcp.ToolExecution{
ID: "orphan-hydra",
ToolName: "hydra",
Arguments: map[string]interface{}{"target": "127.0.0.1"},
Status: "running",
StartTime: start,
}
if err := db.SaveToolExecution(exec); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
end := time.Now()
n, err := db.CancelOrphanedRunningToolExecutions(end, "执行已中断(服务重启)")
if err != nil {
t.Fatalf("CancelOrphanedRunningToolExecutions: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 row updated, got %d", n)
}
got, err := db.GetToolExecution("orphan-hydra")
if err != nil {
t.Fatalf("GetToolExecution: %v", err)
}
if got.Status != "cancelled" {
t.Fatalf("expected cancelled, got %s", got.Status)
}
if got.EndTime == nil {
t.Fatal("expected end_time to be set")
}
if got.Duration <= 0 {
t.Fatalf("expected positive duration, got %v", got.Duration)
}
}
func TestFinalizeStaleRunningToolExecutions_skipsActive(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
now := time.Now()
oldStart := now.Add(-5 * time.Minute)
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "stale", ToolName: "hydra", Status: "running", StartTime: oldStart,
}); err != nil {
t.Fatalf("SaveToolExecution stale: %v", err)
}
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "active", ToolName: "hydra", Status: "running", StartTime: oldStart,
}); err != nil {
t.Fatalf("SaveToolExecution active: %v", err)
}
active := map[string]struct{}{"active": {}}
n, err := db.FinalizeStaleRunningToolExecutions(now, time.Minute, active, "执行已中断(会话已结束)")
if err != nil {
t.Fatalf("FinalizeStaleRunningToolExecutions: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 stale row updated, got %d", n)
}
stale, err := db.GetToolExecution("stale")
if err != nil {
t.Fatalf("GetToolExecution stale: %v", err)
}
if stale.Status != "cancelled" {
t.Fatalf("stale expected cancelled, got %s", stale.Status)
}
activeExec, err := db.GetToolExecution("active")
if err != nil {
t.Fatalf("GetToolExecution active: %v", err)
}
if activeExec.Status != "running" {
t.Fatalf("active expected running, got %s", activeExec.Status)
}
}
+86
View File
@@ -0,0 +1,86 @@
package database
import (
"fmt"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor-summary.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
now := time.Now()
tools := []struct {
name string
calls int
ok int
fail int
result string
}{
{"alpha::run", 10, 9, 1, `{"content":[{"type":"text","text":"` + string(make([]byte, 64*1024)) + `"}]}`},
{"beta::scan", 5, 5, 0, `{"content":[{"type":"text","text":"ok"}]}`},
{"gamma::ping", 1, 1, 0, `{"content":[{"type":"text","text":"pong"}]}`},
}
for _, tool := range tools {
if err := db.UpdateToolStats(tool.name, tool.calls, tool.ok, tool.fail, &now); err != nil {
t.Fatalf("UpdateToolStats(%s): %v", tool.name, err)
}
for j := 0; j < tool.calls; j++ {
exec := &mcp.ToolExecution{
ID: fmt.Sprintf("%s-exec-%d", tool.name, j),
ToolName: tool.name,
Arguments: map[string]interface{}{"n": j},
Status: "completed",
StartTime: now.Add(-time.Duration(j) * time.Minute),
Result: &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: tool.result}}},
}
end := exec.StartTime.Add(time.Second)
exec.EndTime = &end
exec.Duration = time.Second
if err := db.SaveToolExecution(exec); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
}
}
summary, err := db.LoadToolStatsSummary(2)
if err != nil {
t.Fatalf("LoadToolStatsSummary: %v", err)
}
if summary.Summary.ToolCount != 3 {
t.Fatalf("toolCount = %d, want 3", summary.Summary.ToolCount)
}
if summary.Summary.TotalCalls != 16 {
t.Fatalf("totalCalls = %d, want 16", summary.Summary.TotalCalls)
}
if len(summary.TopTools) != 2 {
t.Fatalf("top tools = %d, want 2", len(summary.TopTools))
}
if summary.TopTools[0].ToolName != "alpha::run" {
t.Fatalf("top tool = %q, want alpha::run", summary.TopTools[0].ToolName)
}
list, err := db.LoadToolExecutionListPage(0, 5, "", "")
if err != nil {
t.Fatalf("LoadToolExecutionListPage: %v", err)
}
if len(list) != 5 {
t.Fatalf("list len = %d, want 5", len(list))
}
for _, exec := range list {
if exec.Arguments != nil || exec.Result != nil || exec.Error != "" {
t.Fatalf("expected lite execution row, got args/result/error on %s", exec.ID)
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@ package einomcp
import "sync" import "sync"
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire, // ToolInvokeNotifyHolder 由 Eino run loop 与 MCP/execute 桥共享;Fire 在工具原始返回时触发。
// 用于清除 pending tool_calltool_result ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)。 // UI 的 tool_result 须等 ADK schema.Tool 事件reduction 后正文),不在此 holder 的回调里推送
type ToolInvokeNotifyHolder struct { type ToolInvokeNotifyHolder struct {
mu sync.RWMutex mu sync.RWMutex
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
+16 -4
View File
@@ -200,9 +200,8 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
if h == nil || conversationID == "" || h.tasks == nil { if h == nil || conversationID == "" || h.tasks == nil {
return return
} }
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" { h.cancelActiveMCPToolForConversation(conversationID)
h.agent.CancelMCPToolExecutionWithNote(execID, "") h.tasks.AbortActiveEinoExecute(conversationID, "")
}
if ok, err := h.tasks.CancelTask(conversationID, ErrTaskCancelled); ok { if ok, err := h.tasks.CancelTask(conversationID, ErrTaskCancelled); ok {
h.logger.Info("已取消会话运行中任务", zap.String("conversationId", conversationID)) h.logger.Info("已取消会话运行中任务", zap.String("conversationId", conversationID))
} else if err != nil { } else if err != nil {
@@ -210,6 +209,15 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
} }
} }
func (h *AgentHandler) cancelActiveMCPToolForConversation(conversationID string) {
if h == nil || h.tasks == nil || h.agent == nil {
return
}
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" {
h.agent.CancelMCPToolExecutionWithNote(execID, "")
}
}
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘 // HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘
type HitlToolWhitelistSaver interface { type HitlToolWhitelistSaver interface {
MergeHitlToolWhitelistIntoConfig(add []string) error MergeHitlToolWhitelistIntoConfig(add []string) error
@@ -239,6 +247,7 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
hitlManager: NewHITLManager(db, logger), hitlManager: NewHITLManager(db, logger),
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor), batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
} }
tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
if err := handler.hitlManager.EnsureSchema(); err != nil { if err := handler.hitlManager.EnsureSchema(); err != nil {
logger.Warn("初始化 HITL 表失败", zap.Error(err)) logger.Warn("初始化 HITL 表失败", zap.Error(err))
} }
@@ -1352,6 +1361,7 @@ func (h *AgentHandler) cancelToolContinueAfter(conversationID, preferredExecID,
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) { func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
var req struct { var req struct {
ConversationID string `json:"conversationId" binding:"required"` ConversationID string `json:"conversationId" binding:"required"`
ExecutionID string `json:"executionId,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
ContinueAfter bool `json:"continueAfter,omitempty"` ContinueAfter bool `json:"continueAfter,omitempty"`
} }
@@ -1368,7 +1378,7 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
} }
note := strings.TrimSpace(req.Reason) note := strings.TrimSpace(req.Reason)
activeExec := strings.TrimSpace(h.tasks.ActiveMCPExecutionID(req.ConversationID)) activeExec := strings.TrimSpace(h.tasks.ActiveMCPExecutionID(req.ConversationID))
if ok, payload := h.cancelToolContinueAfter(req.ConversationID, "", note); ok { if ok, payload := h.cancelToolContinueAfter(req.ConversationID, strings.TrimSpace(req.ExecutionID), note); ok {
execID, _ := payload["executionId"].(string) execID, _ := payload["executionId"].(string)
h.logger.Info("对话页仅终止当前工具", h.logger.Info("对话页仅终止当前工具",
zap.String("conversationId", req.ConversationID), zap.String("conversationId", req.ConversationID),
@@ -1411,6 +1421,8 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
var cause error = ErrTaskCancelled var cause error = ErrTaskCancelled
msg := "已提交取消请求,任务将在当前步骤完成后停止。" msg := "已提交取消请求,任务将在当前步骤完成后停止。"
h.cancelActiveMCPToolForConversation(req.ConversationID)
h.tasks.AbortActiveEinoExecute(req.ConversationID, "")
ok, err := h.tasks.CancelTask(req.ConversationID, cause) ok, err := h.tasks.CancelTask(req.ConversationID, cause)
if err != nil { if err != nil {
h.logger.Error("取消任务失败", zap.Error(err)) h.logger.Error("取消任务失败", zap.Error(err))
+6 -5
View File
@@ -103,6 +103,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "50") limitStr := c.DefaultQuery("limit", "50")
offsetStr := c.DefaultQuery("offset", "0") offsetStr := c.DefaultQuery("offset", "0")
search := c.Query("search") // 获取搜索参数 search := c.Query("search") // 获取搜索参数
projectID := strings.TrimSpace(c.Query("project_id"))
limit, _ := strconv.Atoi(limitStr) limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr) offset, _ := strconv.Atoi(offsetStr)
@@ -114,7 +115,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
limit = 1000 limit = 1000
} }
excludeGrouped := strings.TrimSpace(search) == "" && excludeGrouped := strings.TrimSpace(search) == "" && projectID == "" &&
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1") (c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
sortBy := strings.TrimSpace(c.Query("sort_by")) sortBy := strings.TrimSpace(c.Query("sort_by"))
@@ -122,14 +123,14 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
var total int var total int
var err error var err error
if excludeGrouped { if excludeGrouped {
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy) conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy, projectID)
if err == nil { if err == nil {
total, err = h.db.CountUngroupedConversations() total, err = h.db.CountUngroupedConversations(projectID)
} }
} else { } else {
conversations, err = h.db.ListConversations(limit, offset, search, sortBy) conversations, err = h.db.ListConversations(limit, offset, search, sortBy, projectID)
if err == nil { if err == nil {
total, err = h.db.CountConversations(search) total, err = h.db.CountConversations(search, projectID)
} }
} }
if err != nil { if err != nil {
+219 -13
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -68,16 +69,34 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
h.agentHandler = ah h.agentHandler = ah
} }
const monitorPageTopTools = 6
// MonitorStatsSummary 工具调用汇总
type MonitorStatsSummary struct {
TotalCalls int `json:"totalCalls"`
SuccessCalls int `json:"successCalls"`
FailedCalls int `json:"failedCalls"`
LastCallTime *time.Time `json:"lastCallTime,omitempty"`
ToolCount int `json:"toolCount"`
}
// MonitorResponse 监控响应 // MonitorResponse 监控响应
type MonitorResponse struct { type MonitorResponse struct {
Executions []*mcp.ToolExecution `json:"executions"` Executions []*mcp.ToolExecution `json:"executions"`
Stats map[string]*mcp.ToolStats `json:"stats"` Summary *MonitorStatsSummary `json:"summary"`
TopTools []*mcp.ToolStats `json:"topTools"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Total int `json:"total,omitempty"` Total int `json:"total"`
Page int `json:"page,omitempty"` Page int `json:"page"`
PageSize int `json:"page_size,omitempty"` PageSize int `json:"pageSize"`
TotalPages int `json:"total_pages,omitempty"` TotalPages int `json:"totalPages"`
RetentionDays int `json:"retention_days,omitempty"` RetentionDays int `json:"retentionDays"`
}
// StatsResponse 统计信息响应(Dashboard 等)
type StatsResponse struct {
Summary *MonitorStatsSummary `json:"summary"`
TopTools []*mcp.ToolStats `json:"topTools"`
} }
// Monitor 获取监控信息 // Monitor 获取监控信息
@@ -101,9 +120,9 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool // 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool
toolName := normalizeToolNameFilter(c.Query("tool")) toolName := normalizeToolNameFilter(c.Query("tool"))
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName) executions, total := h.loadExecutionListWithPagination(page, pageSize, status, toolName)
h.enrichExecutionsConversationID(executions) h.enrichExecutionsConversationID(executions)
stats := h.loadStats() summary, topTools := h.loadStatsSummary(monitorPageTopTools)
totalPages := (total + pageSize - 1) / pageSize totalPages := (total + pageSize - 1) / pageSize
if totalPages == 0 { if totalPages == 0 {
@@ -112,7 +131,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
c.JSON(http.StatusOK, MonitorResponse{ c.JSON(http.StatusOK, MonitorResponse{
Executions: executions, Executions: executions,
Stats: stats, Summary: summary,
TopTools: topTools,
Timestamp: time.Now(), Timestamp: time.Now(),
Total: total, Total: total,
Page: page, Page: page,
@@ -134,6 +154,112 @@ func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
return executions return executions
} }
func (h *MonitorHandler) loadExecutionListWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
if h.db == nil {
allExecutions := h.mcpServer.GetAllExecutions()
if status != "" || toolName != "" {
filtered := make([]*mcp.ToolExecution, 0)
for _, exec := range allExecutions {
matchStatus := status == "" || exec.Status == status
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
if matchStatus && matchTool {
filtered = append(filtered, exec)
}
}
allExecutions = filtered
}
total := len(allExecutions)
offset := (page - 1) * pageSize
end := offset + pageSize
if end > total {
end = total
}
if offset >= total {
return []*mcp.ToolExecution{}, total
}
pageSlice := allExecutions[offset:end]
out := make([]*mcp.ToolExecution, 0, len(pageSlice))
for _, exec := range pageSlice {
if exec == nil {
continue
}
out = append(out, slimToolExecution(exec))
}
return out, total
}
offset := (page - 1) * pageSize
executions, err := h.db.LoadToolExecutionListPage(offset, pageSize, status, toolName)
if err != nil {
h.logger.Warn("从数据库加载执行记录列表失败,回退到内存数据", zap.Error(err))
return h.loadExecutionListWithPaginationFromMemory(page, pageSize, status, toolName)
}
total, err := h.db.CountToolExecutions(status, toolName)
if err != nil {
h.logger.Warn("获取执行记录总数失败", zap.Error(err))
total = offset + len(executions)
if len(executions) == pageSize {
total = offset + len(executions) + 1
}
}
return executions, total
}
func (h *MonitorHandler) loadExecutionListWithPaginationFromMemory(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
allExecutions := h.mcpServer.GetAllExecutions()
if status != "" || toolName != "" {
filtered := make([]*mcp.ToolExecution, 0)
for _, exec := range allExecutions {
matchStatus := status == "" || exec.Status == status
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
if matchStatus && matchTool {
filtered = append(filtered, exec)
}
}
allExecutions = filtered
}
total := len(allExecutions)
offset := (page - 1) * pageSize
end := offset + pageSize
if end > total {
end = total
}
if offset >= total {
return []*mcp.ToolExecution{}, total
}
pageSlice := allExecutions[offset:end]
out := make([]*mcp.ToolExecution, 0, len(pageSlice))
for _, exec := range pageSlice {
if exec == nil {
continue
}
out = append(out, slimToolExecution(exec))
}
return out, total
}
func slimToolExecution(exec *mcp.ToolExecution) *mcp.ToolExecution {
if exec == nil {
return nil
}
slim := &mcp.ToolExecution{
ID: exec.ID,
ToolName: exec.ToolName,
Status: exec.Status,
StartTime: exec.StartTime,
}
if exec.EndTime != nil {
end := *exec.EndTime
slim.EndTime = &end
}
if exec.Duration > 0 {
slim.Duration = exec.Duration
}
return slim
}
func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) { func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
if h.db == nil { if h.db == nil {
allExecutions := h.mcpServer.GetAllExecutions() allExecutions := h.mcpServer.GetAllExecutions()
@@ -206,7 +332,78 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
return executions, total return executions, total
} }
func (h *MonitorHandler) loadStats() map[string]*mcp.ToolStats { func (h *MonitorHandler) loadStatsSummary(topN int) (*MonitorStatsSummary, []*mcp.ToolStats) {
if topN <= 0 {
topN = monitorPageTopTools
}
if h.db != nil {
result, err := h.db.LoadToolStatsSummary(topN)
if err == nil {
return dbStatsSummaryToMonitor(result), result.TopTools
}
h.logger.Warn("从数据库加载统计汇总失败,回退到内存数据", zap.Error(err))
}
stats := h.loadStatsMap()
return summarizeToolStats(stats, topN)
}
func dbStatsSummaryToMonitor(result *database.ToolStatsSummaryResult) *MonitorStatsSummary {
if result == nil {
return &MonitorStatsSummary{}
}
summary := &MonitorStatsSummary{
TotalCalls: result.Summary.TotalCalls,
SuccessCalls: result.Summary.SuccessCalls,
FailedCalls: result.Summary.FailedCalls,
ToolCount: result.Summary.ToolCount,
}
if result.Summary.LastCallTime != nil {
t := *result.Summary.LastCallTime
summary.LastCallTime = &t
}
return summary
}
func summarizeToolStats(stats map[string]*mcp.ToolStats, topN int) (*MonitorStatsSummary, []*mcp.ToolStats) {
summary := &MonitorStatsSummary{}
if len(stats) == 0 {
return summary, nil
}
all := make([]*mcp.ToolStats, 0, len(stats))
for _, stat := range stats {
if stat == nil {
continue
}
summary.ToolCount++
summary.TotalCalls += stat.TotalCalls
summary.SuccessCalls += stat.SuccessCalls
summary.FailedCalls += stat.FailedCalls
if stat.LastCallTime != nil && (summary.LastCallTime == nil || stat.LastCallTime.After(*summary.LastCallTime)) {
t := *stat.LastCallTime
summary.LastCallTime = &t
}
if stat.TotalCalls > 0 {
statCopy := *stat
all = append(all, &statCopy)
}
}
sort.Slice(all, func(i, j int) bool {
if all[i].TotalCalls == all[j].TotalCalls {
return all[i].ToolName < all[j].ToolName
}
return all[i].TotalCalls > all[j].TotalCalls
})
if len(all) > topN {
all = all[:topN]
}
return summary, all
}
func (h *MonitorHandler) loadStatsMap() map[string]*mcp.ToolStats {
// 合并内部MCP服务器和外部MCP管理器的统计信息 // 合并内部MCP服务器和外部MCP管理器的统计信息
stats := make(map[string]*mcp.ToolStats) stats := make(map[string]*mcp.ToolStats)
@@ -334,7 +531,7 @@ func (h *MonitorHandler) CancelExecution(c *gin.Context) {
func (h *MonitorHandler) enrichExecutionsConversationID(executions []*mcp.ToolExecution) { func (h *MonitorHandler) enrichExecutionsConversationID(executions []*mcp.ToolExecution) {
for _, exec := range executions { for _, exec := range executions {
if exec == nil { if exec == nil || exec.Status != "running" {
continue continue
} }
exec.ConversationID = h.conversationIDForRunningExecution(exec.ID) exec.ConversationID = h.conversationIDForRunningExecution(exec.ID)
@@ -415,8 +612,17 @@ func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
// GetStats 获取统计信息 // GetStats 获取统计信息
func (h *MonitorHandler) GetStats(c *gin.Context) { func (h *MonitorHandler) GetStats(c *gin.Context) {
stats := h.loadStats() topN := 30
c.JSON(http.StatusOK, stats) if topStr := c.Query("top"); topStr != "" {
if t, err := strconv.Atoi(topStr); err == nil && t > 0 && t <= 100 {
topN = t
}
}
summary, topTools := h.loadStatsSummary(topN)
c.JSON(http.StatusOK, StatsResponse{
Summary: summary,
TopTools: topTools,
})
} }
// CallsTimelinePoint 调用趋势数据点 // CallsTimelinePoint 调用趋势数据点
+45 -6
View File
@@ -740,14 +740,21 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"executions": map[string]interface{}{ "executions": map[string]interface{}{
"type": "array", "type": "array",
"description": "执行记录列表", "description": "执行记录列表(轻量字段,不含 arguments/result",
"items": map[string]interface{}{ "items": map[string]interface{}{
"$ref": "#/components/schemas/ToolExecution", "$ref": "#/components/schemas/ToolExecution",
}, },
}, },
"stats": map[string]interface{}{ "summary": map[string]interface{}{
"type": "object", "type": "object",
"description": "统计信息", "description": "工具调用汇总",
},
"topTools": map[string]interface{}{
"type": "array",
"description": "调用量 Top N 工具",
"items": map[string]interface{}{
"type": "object",
},
}, },
"timestamp": map[string]interface{}{ "timestamp": map[string]interface{}{
"type": "string", "type": "string",
@@ -756,20 +763,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
"total": map[string]interface{}{ "total": map[string]interface{}{
"type": "integer", "type": "integer",
"description": "总数", "description": "执行记录总数",
}, },
"page": map[string]interface{}{ "page": map[string]interface{}{
"type": "integer", "type": "integer",
"description": "当前页", "description": "当前页",
}, },
"page_size": map[string]interface{}{ "pageSize": map[string]interface{}{
"type": "integer", "type": "integer",
"description": "每页数量", "description": "每页数量",
}, },
"total_pages": map[string]interface{}{ "totalPages": map[string]interface{}{
"type": "integer", "type": "integer",
"description": "总页数", "description": "总页数",
}, },
"retentionDays": map[string]interface{}{
"type": "integer",
"description": "执行记录保留天数",
},
}, },
}, },
"ConfigResponse": map[string]interface{}{ "ConfigResponse": map[string]interface{}{
@@ -1232,6 +1243,34 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string", "type": "string",
}, },
}, },
{
"name": "project_id",
"in": "query",
"required": false,
"description": "按项目筛选;传 __none__ 表示仅未绑定项目的对话",
"schema": map[string]interface{}{
"type": "string",
},
},
{
"name": "exclude_grouped",
"in": "query",
"required": false,
"description": "为 true 时排除已加入分组的对话(默认在未搜索且未按项目筛选时启用)",
"schema": map[string]interface{}{
"type": "boolean",
},
},
{
"name": "sort_by",
"in": "query",
"required": false,
"description": "排序字段:updated_at(默认)或 created_at",
"schema": map[string]interface{}{
"type": "string",
"enum": []string{"updated_at", "created_at"},
},
},
}, },
"responses": map[string]interface{}{ "responses": map[string]interface{}{
"200": map[string]interface{}{ "200": map[string]interface{}{
+27 -1
View File
@@ -7,7 +7,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// agentSessionContextBlock 注入会话工作目录项目黑板(用于 system prompt 追加块)。 // agentSessionContextBlock 注入会话工作目录项目黑板与用户原文锚点(用于 system prompt 追加块)。
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string { func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
var parts []string var parts []string
if ws := h.buildWorkspaceBlock(conversationID); ws != "" { if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
@@ -16,6 +16,9 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
if bb := h.projectBlackboardBlock(conversationID); bb != "" { if bb := h.projectBlackboardBlock(conversationID); bb != "" {
parts = append(parts, bb) parts = append(parts, bb)
} }
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
parts = append(parts, uv)
}
return strings.Join(parts, "\n\n") return strings.Join(parts, "\n\n")
} }
@@ -67,6 +70,29 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
return strings.TrimSpace(block) return strings.TrimSpace(block)
} }
// userVerbatimAnchorBlock 从 messages 表构建用户各轮原文锚点(压缩后仍由 summarization Finalize 刷新)。
func (h *AgentHandler) userVerbatimAnchorBlock(conversationID string) string {
if h == nil || h.db == nil || h.config == nil {
return ""
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return ""
}
maxRunes := h.config.MultiAgent.UserVerbatimAnchorMaxRunesEffective()
if maxRunes < 0 {
return ""
}
msgs, err := h.db.GetMessages(conversationID)
if err != nil {
if h.logger != nil {
h.logger.Warn("构建用户原文锚点失败", zap.String("conversationId", conversationID), zap.Error(err))
}
return ""
}
return project.BuildUserVerbatimAnchorBlockFromMessages(msgs, maxRunes)
}
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。 // conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
func (h *AgentHandler) conversationProjectID(conversationID string) string { func (h *AgentHandler) conversationProjectID(conversationID string) string {
if h == nil || h.db == nil { if h == nil || h.db == nil {
+1 -1
View File
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
} }
func (h *RobotHandler) cmdList() string { func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "", "") convs, err := h.db.ListConversations(50, 0, "", "", "")
if err != nil { if err != nil {
return "获取对话列表失败: " + err.Error() return "获取对话列表失败: " + err.Error()
} }
+18 -2
View File
@@ -247,6 +247,8 @@ type AgentTaskManager struct {
maxHistorySize int // 最大历史记录数 maxHistorySize int // 最大历史记录数
historyRetention time.Duration // 历史记录保留时间 historyRetention time.Duration // 历史记录保留时间
eventBus *TaskEventBus // 可选:任务结束时关闭镜像 SSE 订阅 eventBus *TaskEventBus // 可选:任务结束时关闭镜像 SSE 订阅
// toolCanceler 在用户整轮停止任务时终止当前 MCP 工具(非「中断并继续」)。
toolCanceler func(conversationID string)
} }
const ( const (
@@ -277,6 +279,13 @@ func (m *AgentTaskManager) SetTaskEventBus(b *TaskEventBus) {
m.eventBus = b m.eventBus = b
} }
// SetToolCanceler 设置整轮停止任务时终止当前 MCP 工具的回调(由 AgentHandler 注入)。
func (m *AgentTaskManager) SetToolCanceler(fn func(conversationID string)) {
m.mu.Lock()
defer m.mu.Unlock()
m.toolCanceler = fn
}
// GetTask 返回运行中任务(无则 nil)。 // GetTask 返回运行中任务(无则 nil)。
func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask { func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask {
m.mu.RLock() m.mu.RLock()
@@ -372,14 +381,21 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
task.InterruptContinueNote = "" task.InterruptContinueNote = ""
} }
cancel := task.cancel cancel := task.cancel
m.mu.Unlock()
if cause == nil { if cause == nil {
cause = ErrTaskCancelled cause = ErrTaskCancelled
} }
var toolCanceler func(string)
if errors.Is(cause, ErrTaskCancelled) {
toolCanceler = m.toolCanceler
}
m.mu.Unlock()
if cancel != nil { if cancel != nil {
cancel(cause) cancel(cause)
} }
if toolCanceler != nil {
toolCanceler(conversationID)
}
return true, nil return true, nil
} }
@@ -0,0 +1,80 @@
package handler
import (
"context"
"errors"
"testing"
"cyberstrike-ai/internal/multiagent"
)
func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
tm := NewAgentTaskManager()
called := false
tm.SetToolCanceler(func(conversationID string) {
if conversationID == "conv-1" {
called = true
}
})
_, cancel := context.WithCancelCause(context.Background())
_, err := tm.StartTask("conv-1", "hello", cancel)
if err != nil {
t.Fatalf("StartTask: %v", err)
}
ok, err := tm.CancelTask("conv-1", ErrTaskCancelled)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
if !called {
t.Fatal("expected tool canceler to be invoked on full task cancel")
}
}
func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
tm := NewAgentTaskManager()
called := false
tm.SetToolCanceler(func(conversationID string) {
called = true
})
_, cancel := context.WithCancelCause(context.Background())
_, err := tm.StartTask("conv-1", "hello", cancel)
if err != nil {
t.Fatalf("StartTask: %v", err)
}
ok, err := tm.CancelTask("conv-1", multiagent.ErrInterruptContinue)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
if called {
t.Fatal("tool canceler must not run for interrupt-continue")
}
}
func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
tm := NewAgentTaskManager()
var gotCause error
tm.SetToolCanceler(func(conversationID string) {
if conversationID == "conv-2" {
gotCause = ErrTaskCancelled
}
})
ctx, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-2", "hello", cancel); err != nil {
t.Fatalf("StartTask: %v", err)
}
if _, err := tm.CancelTask("conv-2", nil); err != nil {
t.Fatalf("CancelTask: %v", err)
}
if !errors.Is(context.Cause(ctx), ErrTaskCancelled) {
t.Fatalf("expected ErrTaskCancelled cause, got %v", context.Cause(ctx))
}
if gotCause != ErrTaskCancelled {
t.Fatalf("expected tool canceler path for default cancel cause")
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RunCommandWS 交互式 PTY 终端依赖 Unix PTY(见 terminal_ws_unix.go);Windows 暂不支持。
func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "Interactive WebSocket terminal is not supported on Windows; use POST /terminal/run or /terminal/run/stream instead.",
})
}
+17
View File
@@ -814,6 +814,23 @@ func (m *ExternalMCPManager) CancelToolExecution(id string) bool {
return m.CancelToolExecutionWithNote(id, "") return m.CancelToolExecutionWithNote(id, "")
} }
// ActiveRunningExecutionIDs 返回当前进程内仍登记 cancel 的外部 MCP executionId 快照。
func (m *ExternalMCPManager) ActiveRunningExecutionIDs() map[string]struct{} {
if m == nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if len(m.runningCancels) == 0 {
return nil
}
out := make(map[string]struct{}, len(m.runningCancels))
for id := range m.runningCancels {
out[id] = struct{}{}
}
return out
}
// updateStats 更新统计信息 // updateStats 更新统计信息
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) { func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
now := time.Now() now := time.Now()
+17
View File
@@ -1170,6 +1170,23 @@ func (s *Server) CancelToolExecution(id string) bool {
return s.CancelToolExecutionWithNote(id, "") return s.CancelToolExecutionWithNote(id, "")
} }
// ActiveRunningExecutionIDs 返回当前进程内仍登记 cancel 的 executionId 快照。
func (s *Server) ActiveRunningExecutionIDs() map[string]struct{} {
if s == nil {
return nil
}
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
if len(s.runningCancels) == 0 {
return nil
}
out := make(map[string]struct{}, len(s.runningCancels))
for id := range s.runningCancels {
out[id] = struct{}{}
}
return out
}
// initDefaultPrompts 初始化默认提示词模板 // initDefaultPrompts 初始化默认提示词模板
func (s *Server) initDefaultPrompts() { func (s *Server) initDefaultPrompts() {
s.mu.Lock() s.mu.Lock()
+101
View File
@@ -0,0 +1,101 @@
package monitor
import (
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
const (
staleRunningMinAge = 45 * time.Second
staleRunningReconcileGap = 2 * time.Minute
)
// ExecutionReconciler 在启动或运行期将无对应协程的 running 执行记录收尾为 cancelled。
type ExecutionReconciler struct {
db *database.DB
mcpServer *mcp.Server
externalMgr *mcp.ExternalMCPManager
logger *zap.Logger
}
// NewExecutionReconciler creates a reconciler for orphaned MCP tool executions.
func NewExecutionReconciler(db *database.DB, mcpServer *mcp.Server, externalMgr *mcp.ExternalMCPManager, logger *zap.Logger) *ExecutionReconciler {
return &ExecutionReconciler{
db: db,
mcpServer: mcpServer,
externalMgr: externalMgr,
logger: logger,
}
}
// ReconcileOnStartup marks every persisted running row as cancelled (safe right after process start).
func (r *ExecutionReconciler) ReconcileOnStartup() {
if r == nil || r.db == nil {
return
}
now := time.Now()
n, err := r.db.CancelOrphanedRunningToolExecutions(now, "执行已中断(服务重启)")
if err != nil {
if r.logger != nil {
r.logger.Warn("启动时清理孤儿 running 工具执行记录失败", zap.Error(err))
}
return
}
if n > 0 && r.logger != nil {
r.logger.Info("启动时已收尾孤儿 running 工具执行记录", zap.Int64("count", n))
}
}
func (r *ExecutionReconciler) activeExecutionIDs() map[string]struct{} {
ids := make(map[string]struct{})
if r.mcpServer != nil {
for id := range r.mcpServer.ActiveRunningExecutionIDs() {
ids[id] = struct{}{}
}
}
if r.externalMgr != nil {
for id := range r.externalMgr.ActiveRunningExecutionIDs() {
ids[id] = struct{}{}
}
}
return ids
}
// ReconcileStaleRunning finalizes running rows that are not tracked in-memory and older than staleRunningMinAge.
func (r *ExecutionReconciler) ReconcileStaleRunning() {
if r == nil || r.db == nil {
return
}
now := time.Now()
n, err := r.db.FinalizeStaleRunningToolExecutions(now, staleRunningMinAge, r.activeExecutionIDs(), "执行已中断(会话已结束)")
if err != nil {
if r.logger != nil {
r.logger.Warn("定期收尾 stale running 工具执行记录失败", zap.Error(err))
}
return
}
if n > 0 && r.logger != nil {
r.logger.Info("已收尾 stale running 工具执行记录", zap.Int64("count", n))
}
}
// StartStaleRunningReconcileLoop periodically reconciles orphaned running tool executions.
func StartStaleRunningReconcileLoop(r *ExecutionReconciler, logger *zap.Logger) {
if r == nil {
return
}
go func() {
ticker := time.NewTicker(staleRunningReconcileGap)
defer ticker.Stop()
for range ticker.C {
r.ReconcileStaleRunning()
if logger != nil {
logger.Debug("monitor stale running reconcile tick completed")
}
}
}()
}
+38
View File
@@ -0,0 +1,38 @@
package monitor
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestExecutionReconciler_ReconcileOnStartup(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor.db")
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "run-1", ToolName: "hydra", Status: "running", StartTime: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
r := NewExecutionReconciler(db, mcp.NewServer(zap.NewNop()), nil, zap.NewNop())
r.ReconcileOnStartup()
got, err := db.GetToolExecution("run-1")
if err != nil {
t.Fatalf("GetToolExecution: %v", err)
}
if got.Status != "cancelled" {
t.Fatalf("expected cancelled after startup reconcile, got %s", got.Status)
}
}
+16
View File
@@ -0,0 +1,16 @@
package multiagent
import (
"fmt"
"github.com/cloudwego/eino/adk"
)
// InitADK configures global Eino ADK settings. Call once at process startup before
// any ADK middleware or agents are created.
func InitADK() error {
if err := adk.SetLanguage(adk.LanguageChinese); err != nil {
return fmt.Errorf("adk set language: %w", err)
}
return nil
}
+3 -19
View File
@@ -299,6 +299,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
var toolResultSent sync.Map // toolCallID -> struct{}ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文) var toolResultSent sync.Map // toolCallID -> struct{}ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) { tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) {
// 仅由 ADK schema.Tool 事件调用;MCP/execute 桥在 reduction 前的 ToolInvokeNotify 不得推送 tool_result
// 否则全量输出会先占位并触发 toolResultSent 去重,导致 UI/监控展示与 agent 实际收到的截断正文不一致。
if progress == nil { if progress == nil {
return return
} }
@@ -316,6 +318,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"isError": isErr, "isError": isErr,
"result": content, "result": content,
"resultPreview": preview, "resultPreview": preview,
"agentFacing": true, // 与 reduction 后送入 ChatModel 的正文一致,供前端展示
"conversationId": conversationID, "conversationId": conversationID,
"einoAgent": agentName, "einoAgent": agentName,
"einoRole": einoRoleTag(agentName), "einoRole": einoRoleTag(agentName),
@@ -350,25 +353,6 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data) 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) {
// Eino execute / MCP 桥在工具返回时 Fire;若 ADK schema.Tool 事件迟迟不到,此处立即推送
// tool_result 解除 UI「执行中」。tryEmitToolResultProgress 经 toolResultSent 去重,ADK 晚到不重复。
isErr := !success || invokeErr != nil
body := einoToolResultBody(content)
if einoToolResultIsError(toolName, content) {
isErr = true
}
if tail := friendlyEinoExecuteInvokeTail(invokeErr); tail != "" {
if body == "" {
body = tail
} else if !strings.Contains(body, tail) {
body = strings.TrimSpace(body) + "\n\n" + tail
}
}
tryEmitToolResultProgress(toolName, body, toolCallID, isErr, einoAgent)
})
}
if args.EinoCallbacks != nil { if args.EinoCallbacks != nil {
ctx = einoobserve.AttachAgentRunCallbacks(ctx, args.EinoCallbacks, einoobserve.Params{ ctx = einoobserve.AttachAgentRunCallbacks(ctx, args.EinoCallbacks, einoobserve.Params{
@@ -84,7 +84,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround { if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
req.RunInBackendGround = true req.RunInBackendGround = true
} }
req.Command = security.PrepareNonInteractiveShellCommand(prependPythonUnbufferedEnv(req.Command)) req.Command = prependPythonUnbufferedEnv(req.Command)
convID := mcp.MCPConversationIDFromContext(ctx) convID := mcp.MCPConversationIDFromContext(ctx)
execReg := mcp.EinoExecuteRunRegistryFromContext(ctx) execReg := mcp.EinoExecuteRunRegistryFromContext(ctx)
@@ -61,12 +61,6 @@ func TestEinoStreamingShellWrap_PreparesNonInteractiveCommand(t *testing.T) {
t.Fatalf("recv: %v", rerr) t.Fatalf("recv: %v", rerr)
} }
} }
if !strings.Contains(inner.lastCommand, "exec </dev/null") {
t.Fatalf("missing stdin redirect in inner command: %q", inner.lastCommand)
}
if !strings.Contains(inner.lastCommand, "GIT_PAGER=cat") {
t.Fatalf("missing pager export in inner command: %q", inner.lastCommand)
}
if !strings.Contains(inner.lastCommand, "PYTHONUNBUFFERED=1") { if !strings.Contains(inner.lastCommand, "PYTHONUNBUFFERED=1") {
t.Fatalf("missing python unbuffer in inner command: %q", inner.lastCommand) t.Fatalf("missing python unbuffer in inner command: %q", inner.lastCommand)
} }
+31
View File
@@ -150,6 +150,7 @@ func newEinoSummarizationMiddleware(
} }
if appCfg != nil { if appCfg != nil {
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger) out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
} }
return out, nil return out, nil
}, },
@@ -413,6 +414,36 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
return nil return nil
} }
// refreshUserVerbatimAnchorInMessages 压缩后从 messages 表刷新 system 中的用户原文锚点。
func refreshUserVerbatimAnchorInMessages(msgs []adk.Message, db *database.DB, conversationID string, maxRunes int, logger *zap.Logger) []adk.Message {
if maxRunes < 0 || db == nil {
return msgs
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return msgs
}
rows, err := db.GetMessages(conversationID)
if err != nil {
if logger != nil {
logger.Warn("summarization: 刷新用户原文锚点失败",
zap.String("conversationId", conversationID),
zap.Error(err),
)
}
return msgs
}
block := project.BuildUserVerbatimAnchorBlockFromMessages(rows, maxRunes)
if block == "" {
return msgs
}
out := project.RefreshUserVerbatimAnchorInMessages(msgs, block)
if logger != nil {
logger.Info("summarization: 已刷新用户原文锚点", zap.String("conversationId", conversationID))
}
return out
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc { func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter() tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) { return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
+5 -5
View File
@@ -409,9 +409,9 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
"需要写入请使用 upsert_project_fact。", "需要写入请使用 upsert_project_fact。",
project.FactIndexSectionEndMarker, project.FactIndexSectionEndMarker,
"", "",
"# Skills System", transcriptSkillsSystemMarker,
"**How to Use Skills**", "**如何使用 Skill(技能)(渐进式展示):**",
"Remember: Skills make you more capable", "记住:Skill 让你更加强大和稳定",
}, "\n") }, "\n")
out := sanitizeSystemContentForTranscript(system) out := sanitizeSystemContentForTranscript(system)
@@ -421,7 +421,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
if strings.Contains(out, "- nmap") || strings.Contains(out, "高强度扫描要求") { if strings.Contains(out, "- nmap") || strings.Contains(out, "高强度扫描要求") {
t.Fatalf("static persona should be stripped: %q", out) t.Fatalf("static persona should be stripped: %q", out)
} }
if strings.Contains(out, "# Skills System") || strings.Contains(out, "How to Use Skills") { if strings.Contains(out, transcriptSkillsSystemMarker) || strings.Contains(out, "如何使用 Skill") {
t.Fatalf("skills boilerplate should be stripped: %q", out) t.Fatalf("skills boilerplate should be stripped: %q", out)
} }
if !strings.Contains(out, transcriptStaticSystemOmitNote) { if !strings.Contains(out, transcriptStaticSystemOmitNote) {
@@ -435,7 +435,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) { func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
t.Parallel() t.Parallel()
msgs := []adk.Message{ msgs := []adk.Message{
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n# Skills System\nboiler"), schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n" + transcriptSkillsSystemMarker + "\nboiler"),
schema.UserMessage("hello"), schema.UserMessage("hello"),
schema.AssistantMessage("reply", nil), schema.AssistantMessage("reply", nil),
} }
@@ -20,7 +20,9 @@ const (
transcriptStaticSystemOmitNote = "[static system prompt omitted — unchanged in live context after compaction]" transcriptStaticSystemOmitNote = "[static system prompt omitted — unchanged in live context after compaction]"
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引" transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
transcriptPersonaStartMarker = "你是CyberStrikeAI" transcriptPersonaStartMarker = "你是CyberStrikeAI"
transcriptSkillsSystemMarker = "# Skills System" // ADK LanguageChinese injects skill middleware prompt with this header (see eino adk/middlewares/skill/prompt.go).
transcriptSkillsSystemMarker = "# Skill 系统"
transcriptSkillsSystemMarkerEnglish = "# Skills System"
) )
type transcriptToolCall struct { type transcriptToolCall struct {
@@ -86,13 +88,23 @@ func stripToolNamesIndexFromSystem(s string) string {
} }
func stripSkillsSystemBoilerplate(s string) string { func stripSkillsSystemBoilerplate(s string) string {
idx := strings.Index(s, transcriptSkillsSystemMarker) idx := indexFirstSubstring(s, transcriptSkillsSystemMarker, transcriptSkillsSystemMarkerEnglish)
if idx < 0 { if idx < 0 {
return strings.TrimSpace(s) return strings.TrimSpace(s)
} }
return strings.TrimSpace(s[:idx]) return strings.TrimSpace(s[:idx])
} }
func indexFirstSubstring(s string, markers ...string) int {
first := -1
for _, m := range markers {
if i := strings.Index(s, m); i >= 0 && (first < 0 || i < first) {
first = i
}
}
return first
}
func extractProjectBlackboardSection(s string) string { func extractProjectBlackboardSection(s string) string {
start := strings.Index(s, project.FactIndexSectionStartMarker) start := strings.Index(s, project.FactIndexSectionStartMarker)
if start < 0 { if start < 0 {
+9 -2
View File
@@ -372,8 +372,15 @@ func RunDeepAgent(
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。 // noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()} deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
taskEnrichExtra := systemPromptExtra var taskBlackboardSupplement string
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunes, taskEnrichExtra); mw != nil { if appCfg.Project.Enabled && db != nil {
if pid := strings.TrimSpace(projectID); pid != "" {
if block, err := project.BuildFactIndexBlock(db, pid, appCfg.Project); err == nil {
taskBlackboardSupplement = strings.TrimSpace(block)
}
}
}
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunesEffective(), taskBlackboardSupplement); mw != nil {
deepHandlers = append(deepHandlers, mw) deepHandlers = append(deepHandlers, mw)
} }
if len(mainOrchestratorPre) > 0 { if len(mainOrchestratorPre) > 0 {
+12 -9
View File
@@ -3,6 +3,7 @@ package multiagent
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
@@ -11,7 +12,7 @@ import (
"github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/components/tool"
) )
const defaultSubAgentUserContextMaxRunes = 2000 const userContextSupplementHeader = "\n\n## 用户历史输入(原文,子代理必读)\n"
// taskContextEnrichMiddleware intercepts "task" tool calls on the orchestrator // taskContextEnrichMiddleware intercepts "task" tool calls on the orchestrator
// and appends the user's original conversation messages to the task description. // and appends the user's original conversation messages to the task description.
@@ -30,13 +31,14 @@ type taskContextEnrichMiddleware struct {
// newTaskContextEnrichMiddleware returns a middleware that enriches task // newTaskContextEnrichMiddleware returns a middleware that enriches task
// descriptions with user conversation context. Returns nil if disabled // descriptions with user conversation context. Returns nil if disabled
// (maxRunes < 0) or no user messages exist. // (maxRunes < 0) or no user messages exist.
// projectBlackboard 仅传项目黑板索引块(BuildFactIndexBlock);勿传完整 systemPromptExtra。
func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int, projectBlackboard string) adk.ChatModelAgentMiddleware { func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int, projectBlackboard string) adk.ChatModelAgentMiddleware {
supplement := buildUserContextSupplement(userMessage, history, maxRunes) supplement := buildUserContextSupplement(userMessage, history, maxRunes)
if bb := strings.TrimSpace(projectBlackboard); bb != "" { if bb := strings.TrimSpace(projectBlackboard); bb != "" {
if supplement != "" { if supplement != "" {
supplement += "\n\n## 项目黑板索引\n" + bb supplement += "\n\n" + bb
} else { } else {
supplement = "\n\n## 项目黑板索引\n" + bb supplement = "\n\n" + bb
} }
} }
if supplement == "" { if supplement == "" {
@@ -86,9 +88,6 @@ func buildUserContextSupplement(userMessage string, history []agent.ChatMessage,
if maxRunes < 0 { if maxRunes < 0 {
return "" return ""
} }
if maxRunes == 0 {
maxRunes = defaultSubAgentUserContextMaxRunes
}
var userMsgs []string var userMsgs []string
for _, h := range history { for _, h := range history {
@@ -107,12 +106,16 @@ func buildUserContextSupplement(userMessage string, history []agent.ChatMessage,
return "" return ""
} }
joined := strings.Join(userMsgs, "\n---\n") lines := make([]string, 0, len(userMsgs))
if len([]rune(joined)) > maxRunes { for i, msg := range userMsgs {
lines = append(lines, fmt.Sprintf("[第%d轮] %s", i+1, msg))
}
joined := strings.Join(lines, "\n")
if maxRunes > 0 && len([]rune(joined)) > maxRunes {
joined = truncateKeepFirstLast(userMsgs, maxRunes) joined = truncateKeepFirstLast(userMsgs, maxRunes)
} }
return "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n" + joined return userContextSupplementHeader + joined
} }
// truncateKeepFirstLast keeps the first and last user messages, giving each // truncateKeepFirstLast keeps the first and last user messages, giving each
@@ -74,7 +74,7 @@ func TestBuildUserContextSupplement_DisabledByNegative(t *testing.T) {
func TestBuildUserContextSupplement_CustomMaxRunes(t *testing.T) { func TestBuildUserContextSupplement_CustomMaxRunes(t *testing.T) {
msg := strings.Repeat("A", 200) msg := strings.Repeat("A", 200)
result := buildUserContextSupplement(msg, nil, 50) result := buildUserContextSupplement(msg, nil, 50)
header := "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n" header := userContextSupplementHeader
body := strings.TrimPrefix(result, header) body := strings.TrimPrefix(result, header)
if len([]rune(body)) > 50 { if len([]rune(body)) > 50 {
t.Errorf("body should be capped at 50 runes, got %d", len([]rune(body))) t.Errorf("body should be capped at 50 runes, got %d", len([]rune(body)))
@@ -89,7 +89,7 @@ func TestBuildUserContextSupplement_TruncateKeepsFirstAndLast(t *testing.T) {
history = append(history, agent.ChatMessage{Role: "user", Content: strings.Repeat("B", 500)}) history = append(history, agent.ChatMessage{Role: "user", Content: strings.Repeat("B", 500)})
} }
last := "最后一条指令" last := "最后一条指令"
result := buildUserContextSupplement(last, history, 0) result := buildUserContextSupplement(last, history, 800)
if !strings.Contains(result, "http://target.com") { if !strings.Contains(result, "http://target.com") {
t.Error("first message (target URL) should survive truncation") t.Error("first message (target URL) should survive truncation")
} }
+170
View File
@@ -0,0 +1,170 @@
package project
import (
"fmt"
"strings"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
const (
// UserVerbatimSectionHeading 用户原文锚点可读标题(块内保留,供 Agent 阅读)。
UserVerbatimSectionHeading = "## 用户历史输入(原文保留,勿省略或改写)"
// UserVerbatimSectionStartMarker / EndMarkerHTML 注释边界,供程序化替换;对模型无指令语义。
UserVerbatimSectionStartMarker = "<!-- user-verbatim-start -->"
UserVerbatimSectionEndMarker = "<!-- user-verbatim-end -->"
)
// ExtractUserContentsFromMessages 按时间顺序提取 user 角色消息的原文(跳过空白)。
func ExtractUserContentsFromMessages(msgs []database.Message) []string {
out := make([]string, 0, len(msgs))
for i := range msgs {
if !strings.EqualFold(strings.TrimSpace(msgs[i].Role), "user") {
continue
}
content := strings.TrimSpace(msgs[i].Content)
if content == "" {
continue
}
out = append(out, content)
}
return out
}
// BuildUserVerbatimAnchorBlockFromMessages 从 messages 表行构建用户原文锚点块。
// maxRunes: 0 = 不截断;>0 = 总 rune 上限(仍保留每一轮,仅对超长单条做尾部截断提示)。
func BuildUserVerbatimAnchorBlockFromMessages(msgs []database.Message, maxRunes int) string {
return BuildUserVerbatimAnchorBlock(ExtractUserContentsFromMessages(msgs), maxRunes)
}
// BuildUserVerbatimAnchorBlock 将各轮用户原文格式化为 system prompt 锚点块。
func BuildUserVerbatimAnchorBlock(userContents []string, maxRunes int) string {
if len(userContents) == 0 {
return ""
}
lines := make([]string, 0, len(userContents))
for _, content := range userContents {
content = strings.TrimSpace(content)
if content == "" {
continue
}
lines = append(lines, fmt.Sprintf("[第%d轮] %s", len(lines)+1, content))
}
if len(lines) == 0 {
return ""
}
body := strings.Join(lines, "\n")
if maxRunes > 0 {
body = capUserVerbatimBody(body, maxRunes)
}
return wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n" + body)
}
func capUserVerbatimBody(body string, maxRunes int) string {
rs := []rune(body)
if len(rs) <= maxRunes {
return body
}
suffix := "\n\n...(用户原文锚点已达配置上限,更早轮次可能被截断;完整原文见 messages 表)..."
suffixRunes := []rune(suffix)
keep := maxRunes - len(suffixRunes)
if keep <= 0 {
return string(rs[:maxRunes])
}
return string(rs[:keep]) + suffix
}
func wrapUserVerbatimBlock(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
return UserVerbatimSectionStartMarker + "\n" + content + "\n" + UserVerbatimSectionEndMarker + "\n"
}
// ReplaceUserVerbatimAnchorSection 用 freshBlock 替换 content 中已有的用户原文锚点段。
func ReplaceUserVerbatimAnchorSection(content, freshBlock string) (string, bool) {
content = strings.TrimSpace(content)
freshBlock = strings.TrimSpace(freshBlock)
if freshBlock == "" {
return content, false
}
start, ok := userVerbatimSectionStart(content)
if !ok {
return content, false
}
end, ok := userVerbatimSectionEnd(content, start)
if !ok {
return content, false
}
return strings.TrimSpace(content[:start] + freshBlock + content[end:]), true
}
func userVerbatimSectionStart(content string) (int, bool) {
idx := strings.Index(content, UserVerbatimSectionStartMarker)
if idx < 0 {
return 0, false
}
return idx, true
}
func userVerbatimSectionEnd(content string, start int) (int, bool) {
if start < 0 || start >= len(content) {
return 0, false
}
tail := content[start:]
idx := strings.LastIndex(tail, UserVerbatimSectionEndMarker)
if idx < 0 {
return 0, false
}
return start + idx + len(UserVerbatimSectionEndMarker), true
}
// RefreshUserVerbatimAnchorInMessages 在 summarization 等压缩后,用 freshBlock 刷新 system 中的用户原文锚点。
// 若尚无锚点段,则追加到首条 system 消息;若无 system 消息则在开头插入一条。
func RefreshUserVerbatimAnchorInMessages(msgs []adk.Message, freshBlock string) []adk.Message {
freshBlock = strings.TrimSpace(freshBlock)
if freshBlock == "" || len(msgs) == 0 {
return msgs
}
out := make([]adk.Message, len(msgs))
changed := false
for i, msg := range msgs {
if msg == nil || msg.Role != schema.System {
out[i] = msg
continue
}
newContent, ok := ReplaceUserVerbatimAnchorSection(msg.Content, freshBlock)
if !ok {
out[i] = msg
continue
}
cloned := *msg
cloned.Content = newContent
out[i] = &cloned
changed = true
}
if changed {
return out
}
for i, msg := range msgs {
if msg == nil || msg.Role != schema.System {
continue
}
cloned := *msg
cloned.Content = AppendSystemPromptBlock(cloned.Content, freshBlock)
out[i] = &cloned
return out
}
prefix := make([]adk.Message, 0, len(msgs)+1)
prefix = append(prefix, schema.SystemMessage(freshBlock))
return append(prefix, msgs...)
}
@@ -0,0 +1,96 @@
package project
import (
"strings"
"testing"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestBuildUserVerbatimAnchorBlock_MultiTurn(t *testing.T) {
msgs := []database.Message{
{Role: "user", Content: "目标 https://a.com 仅测 /api"},
{Role: "assistant", Content: "好的"},
{Role: "user", Content: "用 admin:test 登录"},
}
block := BuildUserVerbatimAnchorBlockFromMessages(msgs, 0)
if block == "" {
t.Fatal("expected non-empty block")
}
if !strings.Contains(block, UserVerbatimSectionStartMarker) {
t.Error("missing start marker")
}
if !strings.Contains(block, "[第1轮]") || !strings.Contains(block, "https://a.com") {
t.Error("missing first user turn")
}
if !strings.Contains(block, "[第2轮]") || !strings.Contains(block, "admin:test") {
t.Error("missing second user turn")
}
if strings.Contains(block, "好的") {
t.Error("assistant content should not appear")
}
}
func TestReplaceUserVerbatimAnchorSection(t *testing.T) {
old := "prefix\n\n" + wrapUserVerbatimBlock("## old\n\n[第1轮] a") + "\nsuffix"
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] b\n[第2轮] c")
out, ok := ReplaceUserVerbatimAnchorSection(old, newBlock)
if !ok {
t.Fatal("expected replace ok")
}
if !strings.Contains(out, "[第2轮] c") {
t.Errorf("expected new block, got %q", out)
}
if !strings.HasPrefix(strings.TrimSpace(out), "prefix") {
t.Error("prefix should remain")
}
if !strings.Contains(out, "suffix") {
t.Error("suffix should remain")
}
}
func TestRefreshUserVerbatimAnchorInMessages_ReplaceExisting(t *testing.T) {
oldBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] old")
msgs := []adk.Message{
schema.SystemMessage("instr\n\n" + oldBlock),
schema.UserMessage("hi"),
}
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] new")
out := RefreshUserVerbatimAnchorInMessages(msgs, newBlock)
if len(out) != 2 {
t.Fatalf("message count: got %d", len(out))
}
if !strings.Contains(out[0].Content, "[第1轮] new") {
t.Errorf("system content: %q", out[0].Content)
}
if strings.Contains(out[0].Content, "[第1轮] old") {
t.Error("old anchor should be replaced")
}
}
func TestRefreshUserVerbatimAnchorInMessages_InsertWhenMissing(t *testing.T) {
msgs := []adk.Message{
schema.SystemMessage("base instruction"),
schema.UserMessage("hi"),
}
block := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] anchor")
out := RefreshUserVerbatimAnchorInMessages(msgs, block)
if !strings.Contains(out[0].Content, "[第1轮] anchor") {
t.Errorf("expected appended anchor, got %q", out[0].Content)
}
}
func TestBuildUserVerbatimAnchorBlock_MaxRunes(t *testing.T) {
long := strings.Repeat("字", 200)
block := BuildUserVerbatimAnchorBlock([]string{long}, 50)
body := block
if idx := strings.Index(body, UserVerbatimSectionStartMarker); idx >= 0 {
body = strings.TrimPrefix(body[idx+len(UserVerbatimSectionStartMarker):], "\n")
}
if len([]rune(body)) > 120 {
t.Errorf("expected capped body, got %d runes", len([]rune(body)))
}
}
+82 -90
View File
@@ -162,9 +162,8 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
output, err = runCommandWithPTY(ctx, cmd2, cb) output, err = runCommandWithPTY(ctx, cmd2, cb)
} }
} else { } else {
outputBytes, err2 := cmd.CombinedOutput() // 非流式:内存缓冲 + ctx 取消杀进程组;行为对齐原 CombinedOutput,避免双流管道 fan-in 死锁。
output = string(outputBytes) output, err = combinedOutputCancellable(ctx, cmd)
err = err2
if err != nil && shouldRetryWithPTY(output) { if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试", e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
zap.String("tool", toolName), zap.String("tool", toolName),
@@ -692,83 +691,21 @@ func (e *Executor) formatParamValue(param config.ParameterConfig, value interfac
// IsBackgroundShellCommand 检测命令是否为完全后台命令(末尾有独立 &,且不在引号内)。 // IsBackgroundShellCommand 检测命令是否为完全后台命令(末尾有独立 &,且不在引号内)。
// command1 & command2 不算完全后台(command2 仍在前台执行)。 // command1 & command2 不算完全后台(command2 仍在前台执行)。
func IsBackgroundShellCommand(command string) bool { func IsBackgroundShellCommand(command string) bool {
// 移除首尾空格
command = strings.TrimSpace(command) command = strings.TrimSpace(command)
if command == "" { if command == "" {
return false return false
} }
positions := findStandaloneAmpersandPositions(command)
// 检查命令中所有不在引号内的 & 符号 if len(positions) == 0 {
// 找到最后一个 & 符号,检查它是否在命令末尾
inSingleQuote := false
inDoubleQuote := false
escaped := false
lastAmpersandPos := -1
for i, r := range command {
if escaped {
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == '\'' && !inDoubleQuote {
inSingleQuote = !inSingleQuote
continue
}
if r == '"' && !inSingleQuote {
inDoubleQuote = !inDoubleQuote
continue
}
if r == '&' && !inSingleQuote && !inDoubleQuote {
// 检查 & 前后是否有空格或换行(确保是独立的 &,而不是变量名的一部分)
isStandalone := false
// 检查前面:空格、制表符、换行符,或者是命令开头
if i == 0 {
isStandalone = true
} else {
prev := command[i-1]
if prev == ' ' || prev == '\t' || prev == '\n' || prev == '\r' {
isStandalone = true
}
}
// 检查后面:空格、制表符、换行符,或者是命令末尾
if isStandalone {
if i == len(command)-1 {
// 在末尾,肯定是独立的 &
lastAmpersandPos = i
} else {
next := command[i+1]
if next == ' ' || next == '\t' || next == '\n' || next == '\r' {
// 后面有空格,是独立的 &
lastAmpersandPos = i
}
}
}
}
}
// 如果没有找到 & 符号,不是后台命令
if lastAmpersandPos == -1 {
return false return false
} }
last := positions[len(positions)-1]
// 检查最后一个 & 后面是否还有非空内容 afterAmpersand := strings.TrimSpace(command[last+1:])
afterAmpersand := strings.TrimSpace(command[lastAmpersandPos+1:]) if afterAmpersand != "" {
if afterAmpersand == "" { return false
// & 在末尾或后面只有空白字符,这是完全后台命令 }
// 检查 & 前面是否有内容 beforeAmpersand := strings.TrimSpace(command[:last])
beforeAmpersand := strings.TrimSpace(command[:lastAmpersandPos])
return beforeAmpersand != "" return beforeAmpersand != ""
}
// 如果 & 后面还有非空内容,说明是 command1 & command2 的情况
// 这种情况下,command2会在前台执行,所以不算完全后台命令
return false
} }
// executeSystemCommand 执行系统命令 // executeSystemCommand 执行系统命令
@@ -804,7 +741,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
zap.String("command", command), zap.String("command", command),
) )
command = PrepareNonInteractiveShellCommand(command) command = PrepareShellCommandForExecute(command)
// 获取shell类型(可选,默认为sh) // 获取shell类型(可选,默认为sh)
shell := "sh" shell := "sh"
@@ -845,10 +782,8 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
commandWithoutAmpersand := strings.TrimSuffix(strings.TrimSpace(command), "&") commandWithoutAmpersand := strings.TrimSuffix(strings.TrimSpace(command), "&")
commandWithoutAmpersand = strings.TrimSpace(commandWithoutAmpersand) commandWithoutAmpersand = strings.TrimSpace(commandWithoutAmpersand)
// 构建新命令:将用户命令置于独立重定向的后台作业,再 echo $pid // 构建新命令:后台作业重定向标准流后 echo $pid(与 RedirectBackgroundJobStdio 一致)
// 若子进程与 echo 共享同一 stdout 管道,且长时间不向 stdout 写入换行, pidCommand := RedirectBackgroundJobStdio(commandWithoutAmpersand+" &") + " pid=$!; echo $pid"
// bufio.ReadString('\n') 会永久阻塞(例如 beacon 持续写二进制/单行日志)。
pidCommand := fmt.Sprintf("%s </dev/null >/dev/null 2>&1 & pid=$!; echo $pid", commandWithoutAmpersand)
// 创建新命令来获取PID // 创建新命令来获取PID
var pidCmd *exec.Cmd var pidCmd *exec.Cmd
@@ -981,9 +916,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
output, err = runCommandWithPTY(ctx, cmd2, cb) output, err = runCommandWithPTY(ctx, cmd2, cb)
} }
} else { } else {
outputBytes, err2 := cmd.CombinedOutput() output, err = combinedOutputCancellable(ctx, cmd)
output = string(outputBytes)
err = err2
if err != nil && shouldRetryWithPTY(output) { if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试") e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
cmd2 := exec.CommandContext(ctx, shell, "-c", command) cmd2 := exec.CommandContext(ctx, shell, "-c", command)
@@ -1027,12 +960,58 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
}, nil }, nil
} }
// combinedOutputCancellable 行为对齐 cmd.CombinedOutputstdout/stderr 写入内存缓冲),
// 但在 ctx 取消时 terminateCmdTree 终止整棵进程树。
// 非流式路径不使用双流管道 fan-in,避免 stderr 撑满管道缓冲区时与 stdout 互相阻塞导致死锁。
// 无输出空闲检测由上层 agent.tool_timeout_minutes 兜底,不改变原 CombinedOutput 语义。
func combinedOutputCancellable(ctx context.Context, cmd *exec.Cmd) (string, error) {
var stdoutBuf, stderrBuf strings.Builder
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
session, err := StartShellSession(cmd)
if err != nil {
return "", err
}
done := make(chan error, 1)
go func() {
done <- session.Wait()
}()
stopWatch := make(chan struct{})
go func() {
select {
case <-ctx.Done():
TerminateShellCmdSession(session)
case <-stopWatch:
}
}()
defer close(stopWatch)
var waitErr error
select {
case waitErr = <-done:
case <-ctx.Done():
waitErr = <-done
return joinCommandOutput(stdoutBuf.String(), stderrBuf.String()), ctx.Err()
}
return joinCommandOutput(stdoutBuf.String(), stderrBuf.String()), waitErr
}
func joinCommandOutput(stdout, stderr string) string {
if stderr == "" {
return stdout
}
if stdout == "" {
return stderr
}
return stdout + stderr
}
// streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。 // streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。
// 使用定长块读取,避免按行读取在无换行输出时永久阻塞;ctx 取消时终止进程树。 // 使用定长块读取,避免按行读取在无换行输出时永久阻塞;ctx 取消时终止进程树。
func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback, noOutputSec int) (string, error) { func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback, noOutputSec int) (string, error) {
if err := prepareShellCmdSession(cmd); err != nil {
return "", err
}
stdoutPipe, err := cmd.StdoutPipe() stdoutPipe, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return "", err return "", err
@@ -1042,7 +1021,8 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
_ = stdoutPipe.Close() _ = stdoutPipe.Close()
return "", err return "", err
} }
if err := cmd.Start(); err != nil { session, err := StartShellSession(cmd)
if err != nil {
_ = stdoutPipe.Close() _ = stdoutPipe.Close()
_ = stderrPipe.Close() _ = stderrPipe.Close()
return "", err return "", err
@@ -1052,7 +1032,7 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
go func() { go func() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
terminateCmdTree(cmd) TerminateShellCmdSession(session)
case <-stopWatch: case <-stopWatch:
} }
}() }()
@@ -1091,7 +1071,9 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
if deltaBuilder.Len() == 0 { if deltaBuilder.Len() == 0 {
return return
} }
if cb != nil {
cb(deltaBuilder.String()) cb(deltaBuilder.String())
}
deltaBuilder.Reset() deltaBuilder.Reset()
lastFlush = time.Now() lastFlush = time.Now()
} }
@@ -1102,13 +1084,13 @@ func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallba
} }
fireInactivity := func() { fireInactivity := func() {
terminateCmdTree(cmd) TerminateShellCmdSession(session)
msg := ShellNoOutputTimeoutMessage(idleWatch.Sec) msg := ShellNoOutputTimeoutMessage(idleWatch.Sec)
outBuilder.WriteString(msg) outBuilder.WriteString(msg)
if cb != nil { if cb != nil {
cb(msg) cb(msg)
} }
_ = cmd.Wait() _ = session.Wait()
} }
chunksLoop: chunksLoop:
@@ -1118,6 +1100,11 @@ chunksLoop:
idleCh = idleWatch.Expired idleCh = idleWatch.Expired
} }
select { select {
case <-ctx.Done():
TerminateShellCmdSession(session)
flush()
_ = session.Wait()
return outBuilder.String(), ctx.Err()
case <-idleCh: case <-idleCh:
fireInactivity() fireInactivity()
return outBuilder.String(), fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec) return outBuilder.String(), fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec)
@@ -1138,7 +1125,7 @@ chunksLoop:
flush() flush()
// 等待命令结束,返回最终退出状态 // 等待命令结束,返回最终退出状态
waitErr := cmd.Wait() waitErr := session.Wait()
return outBuilder.String(), waitErr return outBuilder.String(), waitErr
} }
@@ -1210,13 +1197,18 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
} }
defer func() { _ = ptmx.Close() }() defer func() { _ = ptmx.Close() }()
rootPID := 0
if cmd.Process != nil {
rootPID = cmd.Process.Pid
}
// ctx 取消时尽快终止子进程 // ctx 取消时尽快终止子进程
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
_ = ptmx.Close() // 触发读退出 _ = ptmx.Close() // 触发读退出
terminateCmdTree(cmd) terminateProcessGroup(rootPID, cmd)
case <-done: case <-done:
} }
}() }()
+32
View File
@@ -2,6 +2,8 @@ package security
import ( import (
"context" "context"
"os/exec"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -147,3 +149,33 @@ func indexOf(slice []string, s string) int {
} }
return -1 return -1
} }
// TestCombinedOutputCancellable_ContextCancelKillsTree 验证 ctx 取消时能在数秒内结束(杀进程组,非挂死)。
func TestCombinedOutputCancellable_ContextCancelKillsTree(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix process group kill")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 300")
ConfigureShellCmdForAgentExecute(cmd)
done := make(chan error, 1)
go func() {
_, err := combinedOutputCancellable(ctx, cmd)
done <- err
}()
time.Sleep(150 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("expected context cancel error")
}
case <-time.After(5 * time.Second):
t.Fatal("combinedOutputCancellable did not return within 5s after context cancel")
}
}
+14 -4
View File
@@ -19,13 +19,23 @@ func prepareShellCmdSession(cmd *exec.Cmd) error {
return nil return nil
} }
// terminateCmdTree 尽力终止 cmd 及其进程组(Unix 下 Setsid 后 PGID == 首进程 PID // terminateProcessGroup 对 rootPID 对应进程组发 SIGKILLrootPID 为 0 时回退到 cmd.Process.Pid
func terminateCmdTree(cmd *exec.Cmd) { func terminateProcessGroup(rootPID int, cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil { pid := rootPID
if pid <= 0 && cmd != nil && cmd.Process != nil {
pid = cmd.Process.Pid
}
if pid <= 0 {
return return
} }
pid := cmd.Process.Pid
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
} }
}
}
// terminateCmdTree 尽力终止 cmd 及其进程组(Unix 下 Setsid 后 PGID == 首进程 PID)。
func terminateCmdTree(cmd *exec.Cmd) {
terminateProcessGroup(0, cmd)
} }
+30 -4
View File
@@ -2,16 +2,42 @@
package security package security
import "os/exec" import (
"os/exec"
"strconv"
"syscall"
)
func prepareShellCmdSession(cmd *exec.Cmd) error { func prepareShellCmdSession(cmd *exec.Cmd) error {
_ = cmd if cmd == nil {
return nil
}
// 独立进程组,便于 taskkill /T 终止整棵子进程树。
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP
return nil return nil
} }
func terminateCmdTree(cmd *exec.Cmd) { // terminateProcessGroup 使用 taskkill /F /T 终止进程及其子进程;rootPID 为 0 时回退到 cmd.Process.Pid。
if cmd == nil || cmd.Process == nil { func terminateProcessGroup(rootPID int, cmd *exec.Cmd) {
pid := rootPID
if pid <= 0 && cmd != nil && cmd.Process != nil {
pid = cmd.Process.Pid
}
if pid <= 0 {
return return
} }
tk := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid))
if err := tk.Run(); err != nil {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
}
}
}
// terminateCmdTree 使用 taskkill /F /T 终止进程及其子进程(Windows 上 Process.Kill 无法保证杀掉 python 等孙进程)。
func terminateCmdTree(cmd *exec.Cmd) {
terminateProcessGroup(0, cmd)
} }
+111
View File
@@ -0,0 +1,111 @@
package security
import "strings"
const backgroundJobStdioRedirect = " </dev/null >/dev/null 2>&1"
// findStandaloneAmpersandPositions 返回不在引号内的独立 & 下标(排除 &&)。
func findStandaloneAmpersandPositions(command string) []int {
command = strings.TrimSpace(command)
if command == "" {
return nil
}
var positions []int
inSingleQuote := false
inDoubleQuote := false
escaped := false
for i := 0; i < len(command); i++ {
r := command[i]
if escaped {
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == '\'' && !inDoubleQuote {
inSingleQuote = !inSingleQuote
continue
}
if r == '"' && !inSingleQuote {
inDoubleQuote = !inDoubleQuote
continue
}
if r != '&' || inSingleQuote || inDoubleQuote {
continue
}
if i+1 < len(command) && command[i+1] == '&' {
continue
}
if i > 0 && command[i-1] == '&' {
continue
}
isStandalone := i == 0
if !isStandalone {
prev := command[i-1]
isStandalone = prev == ' ' || prev == '\t' || prev == '\n' || prev == '\r'
}
if !isStandalone {
continue
}
if i == len(command)-1 {
positions = append(positions, i)
continue
}
next := command[i+1]
if next == ' ' || next == '\t' || next == '\n' || next == '\r' {
positions = append(positions, i)
}
}
return positions
}
func segmentHasStdioRedirect(segment string) bool {
lower := strings.ToLower(strings.TrimSpace(segment))
if lower == "" {
return false
}
if strings.Contains(lower, ">/dev/null") || strings.Contains(lower, "2>/dev/null") {
return true
}
if strings.Contains(lower, "&>") || strings.Contains(lower, "&>>") {
return true
}
if strings.Contains(lower, "2>&1") && strings.Contains(lower, "/dev/null") {
return true
}
return false
}
// RedirectBackgroundJobStdio 为每个独立 & 前的后台段注入 </dev/null >/dev/null 2>&1
// 避免后台子进程占用 execute/exec 管道导致挂死。
func RedirectBackgroundJobStdio(command string) string {
positions := findStandaloneAmpersandPositions(command)
if len(positions) == 0 {
return command
}
out := command
for j := len(positions) - 1; j >= 0; j-- {
i := positions[j]
before := out[:i]
after := out[i:]
trimmed := strings.TrimRight(before, " \t\r\n")
if segmentHasStdioRedirect(trimmed) {
continue
}
trailing := before[len(trimmed):]
out = trimmed + backgroundJobStdioRedirect + trailing + after
}
return out
}
// PrepareShellCommandForExecute 组合 execute/exec 用的非交互包装与后台 IO 重定向。
// 须先注入 exec </dev/null,再改写 & 后台段,否则段内 </dev/null 会使 stdin 重定向被误判为已存在。
func PrepareShellCommandForExecute(shellCommand string) string {
return RedirectBackgroundJobStdio(PrepareNonInteractiveShellCommand(shellCommand))
}
@@ -0,0 +1,64 @@
package security
import (
"strings"
"testing"
)
func TestRedirectBackgroundJobStdio_mixedCommand(t *testing.T) {
in := "java -jar app.jar & JRMP_PID=$!; echo started"
out := RedirectBackgroundJobStdio(in)
if !strings.Contains(out, "java -jar app.jar </dev/null >/dev/null 2>&1 &") {
t.Fatalf("expected redirect before &: %q", out)
}
if !strings.Contains(out, "echo started") {
t.Fatalf("foreground tail preserved: %q", out)
}
}
func TestRedirectBackgroundJobStdio_trailingOnly(t *testing.T) {
in := "sleep 120 &"
out := RedirectBackgroundJobStdio(in)
want := "sleep 120 </dev/null >/dev/null 2>&1 &"
if strings.TrimSpace(out) != want {
t.Fatalf("got %q want %q", out, want)
}
}
func TestRedirectBackgroundJobStdio_skipsAlreadyRedirected(t *testing.T) {
in := "sleep 1 >/dev/null 2>&1 & echo ok"
out := RedirectBackgroundJobStdio(in)
if out != in {
t.Fatalf("should not double-redirect: %q", out)
}
}
func TestRedirectBackgroundJobStdio_skipsAndAnd(t *testing.T) {
in := "test -f /etc/passwd && echo ok"
out := RedirectBackgroundJobStdio(in)
if out != in {
t.Fatalf("&& must not be treated as background &: %q", out)
}
}
func TestPrepareShellCommandForExecute(t *testing.T) {
out := PrepareShellCommandForExecute("java -jar x & echo hi")
if !strings.Contains(out, "exec </dev/null") {
t.Fatalf("missing stdin redirect: %q", out)
}
if !strings.Contains(out, "GIT_PAGER=cat") {
t.Fatalf("missing pager export: %q", out)
}
if !strings.Contains(out, "java -jar x </dev/null >/dev/null 2>&1 &") {
t.Fatalf("missing background redirect: %q", out)
}
}
func TestIsBackgroundShellCommand_usesSharedParser(t *testing.T) {
if !IsBackgroundShellCommand("sleep 1 &") {
t.Fatal("trailing & should be background")
}
if IsBackgroundShellCommand("sleep 1 & echo hi") {
t.Fatal("mixed should not be fully background")
}
}
+20 -9
View File
@@ -27,6 +27,11 @@ func TerminateShellCmdTree(cmd *exec.Cmd) {
terminateCmdTree(cmd) terminateCmdTree(cmd)
} }
// TerminateShellCmdSession 使用 Start 时缓存的进程组 ID 终止(shell 已退出时仍有效)。
func TerminateShellCmdSession(session *ShellSession) {
TerminateShellSession(session)
}
// EinoStreamingShell 为 Eino ADK execute 工具提供流式 shell,行为与 exec 对齐: // EinoStreamingShell 为 Eino ADK execute 工具提供流式 shell,行为与 exec 对齐:
// 并发读取 stdout/stderr(定长块,非按行),避免官方 local.ExecuteStreaming 先排空 stdout // 并发读取 stdout/stderr(定长块,非按行),避免官方 local.ExecuteStreaming 先排空 stdout
// 导致 stderr 错误(如 sudo 密码提示)长时间不可见、UI 一直显示「执行中」。 // 导致 stderr 错误(如 sudo 密码提示)长时间不可见、UI 一直显示「执行中」。
@@ -55,8 +60,10 @@ func (s *EinoStreamingShell) ExecuteStreaming(ctx context.Context, input *filesy
func runShellInBackground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) { func runShellInBackground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
defer w.Close() defer w.Close()
command = PrepareShellCommandForExecute(command)
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
ConfigureShellCmdForAgentExecute(cmd) applyDefaultTerminalEnv(cmd)
attachNonInteractiveStdin(cmd)
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
_ = w.Send(nil, fmt.Errorf("failed to create stdout pipe: %w", err)) _ = w.Send(nil, fmt.Errorf("failed to create stdout pipe: %w", err))
@@ -68,7 +75,8 @@ func runShellInBackground(ctx context.Context, command string, w *schema.StreamW
_ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err)) _ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err))
return return
} }
if err := cmd.Start(); err != nil { session, err := StartShellSession(cmd)
if err != nil {
_ = stdout.Close() _ = stdout.Close()
_ = stderr.Close() _ = stderr.Close()
_ = w.Send(nil, fmt.Errorf("failed to start command: %w", err)) _ = w.Send(nil, fmt.Errorf("failed to start command: %w", err))
@@ -78,14 +86,14 @@ func runShellInBackground(ctx context.Context, command string, w *schema.StreamW
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
drainShellPipes(stdout, stderr) drainShellPipes(stdout, stderr)
_ = cmd.Wait() _ = session.Wait()
close(done) close(done)
}() }()
select { select {
case <-done: case <-done:
case <-ctx.Done(): case <-ctx.Done():
TerminateShellCmdTree(cmd) TerminateShellCmdSession(session)
} }
exitCode := 0 exitCode := 0
@@ -112,8 +120,10 @@ func drainShellPipes(stdout, stderr io.Reader) {
func streamShellForeground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) { func streamShellForeground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
defer w.Close() defer w.Close()
command = PrepareShellCommandForExecute(command)
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
ConfigureShellCmdForAgentExecute(cmd) applyDefaultTerminalEnv(cmd)
attachNonInteractiveStdin(cmd)
stdoutPipe, err := cmd.StdoutPipe() stdoutPipe, err := cmd.StdoutPipe()
if err != nil { if err != nil {
@@ -126,7 +136,8 @@ func streamShellForeground(ctx context.Context, command string, w *schema.Stream
_ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err)) _ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err))
return return
} }
if err := cmd.Start(); err != nil { session, err := StartShellSession(cmd)
if err != nil {
_ = stdoutPipe.Close() _ = stdoutPipe.Close()
_ = stderrPipe.Close() _ = stderrPipe.Close()
_ = w.Send(nil, fmt.Errorf("failed to start command: %w", err)) _ = w.Send(nil, fmt.Errorf("failed to start command: %w", err))
@@ -137,7 +148,7 @@ func streamShellForeground(ctx context.Context, command string, w *schema.Stream
go func() { go func() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
TerminateShellCmdTree(cmd) TerminateShellCmdSession(session)
case <-stopWatch: case <-stopWatch:
} }
}() }()
@@ -174,12 +185,12 @@ func streamShellForeground(ctx context.Context, command string, w *schema.Stream
} }
hadOutput = true hadOutput = true
if w.Send(&filesystem.ExecuteResponse{Output: chunk}, nil) { if w.Send(&filesystem.ExecuteResponse{Output: chunk}, nil) {
TerminateShellCmdTree(cmd) TerminateShellCmdSession(session)
return return
} }
} }
waitErr := cmd.Wait() waitErr := session.Wait()
if waitErr == nil { if waitErr == nil {
exitCode := 0 exitCode := 0
_ = w.Send(&filesystem.ExecuteResponse{ExitCode: &exitCode}, nil) _ = w.Send(&filesystem.ExecuteResponse{ExitCode: &exitCode}, nil)
@@ -115,3 +115,38 @@ func TestEinoStreamingShell_StderrWhileStdoutBlocks(t *testing.T) {
t.Fatalf("expected early stderr, got: %q", got.String()) t.Fatalf("expected early stderr, got: %q", got.String())
} }
} }
// TestEinoStreamingShell_BackgroundJobDoesNotHoldPipe 模拟 cmd & 后继续前台逻辑:重定向后应快速结束。
func TestEinoStreamingShell_BackgroundJobDoesNotHoldPipe(t *testing.T) {
if testing.Short() {
t.Skip("skipping shell integration in -short")
}
shell := NewEinoStreamingShell()
cmd := `(sh -c 'printf x; sleep 120') & echo started; sleep 0`
sr, err := shell.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: cmd})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
start := time.Now()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
if resp != nil && resp.Output != "" {
got.WriteString(resp.Output)
}
}
if time.Since(start) > 3*time.Second {
t.Fatalf("expected fast completion, took %v output=%q", time.Since(start), got.String())
}
if !strings.Contains(got.String(), "started") {
t.Fatalf("expected foreground echo, got: %q", got.String())
}
}
+47
View File
@@ -0,0 +1,47 @@
package security
import "os/exec"
// ShellSession 在 Start 时记录根 shell 的进程组 ID,取消/超时时可杀整组(即使 cmd.Process 已失效)。
type ShellSession struct {
Cmd *exec.Cmd
rootPID int
}
// StartShellSession 配置独立进程组并启动 shell,缓存 rootPIDUnix 下即 PGID)。
func StartShellSession(cmd *exec.Cmd) (*ShellSession, error) {
if err := prepareShellCmdSession(cmd); err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
pid := 0
if cmd.Process != nil {
pid = cmd.Process.Pid
}
return &ShellSession{Cmd: cmd, rootPID: pid}, nil
}
// Wait 等待 shell 退出。
func (s *ShellSession) Wait() error {
if s == nil || s.Cmd == nil {
return nil
}
return s.Cmd.Wait()
}
// Terminate 终止 shell 及其进程组。
func (s *ShellSession) Terminate() {
if s == nil {
return
}
terminateProcessGroup(s.rootPID, s.Cmd)
}
// TerminateShellSession 终止由 StartShellSession 启动的会话。
func TerminateShellSession(session *ShellSession) {
if session != nil {
session.Terminate()
}
}
+65
View File
@@ -0,0 +1,65 @@
package security
import (
"context"
"os/exec"
"runtime"
"testing"
"time"
)
func TestShellSession_TerminateUsesCachedRootPID(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix process group kill")
}
cmd := exec.Command("sh", "-c", "sleep 300")
ConfigureShellCmdForAgentExecute(cmd)
session, err := StartShellSession(cmd)
if err != nil {
t.Fatalf("StartShellSession: %v", err)
}
time.Sleep(100 * time.Millisecond)
session.Terminate()
done := make(chan error, 1)
go func() { done <- session.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("session did not finish within 5s after Terminate")
}
}
func TestShellSession_TerminateAfterContextCancel(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix process group kill")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 300")
ConfigureShellCmdForAgentExecute(cmd)
session, err := StartShellSession(cmd)
if err != nil {
t.Fatalf("StartShellSession: %v", err)
}
time.Sleep(100 * time.Millisecond)
cancel()
TerminateShellCmdSession(session)
done := make(chan error, 1)
go func() { done <- session.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("session did not finish within 5s after cancel+terminate")
}
}
+63 -28
View File
@@ -1,60 +1,95 @@
name: "hydra" name: "hydra"
command: "hydra" command: "hydra"
args: ["-I"]
enabled: true enabled: true
short_description: "密码暴力破解工具,支持多种协议和服务" short_description: "密码暴力破解工具,支持多种协议和服务"
description: | description: |
Hydra是一个快速的网络登录破解工具,支持多种协议和服务的密码暴力破解 Hydra 是网络登录口令爆破工具,支持 SSH、FTP、HTTP、SMB 等多种协议
**主要功能** **调用约定(必读)**
- 支持多种协议(SSH, FTP, HTTP, SMB等) - 必须提供 **用户名**`username`-l)或 `username_file`-L)至少其一
- 快速并行破解 - 必须提供 **口令**`password`-p)、`password_file`-P)或 `-C`(经 `additional_args`)至少其一
- 支持用户名和密码字典 - **先用小字典试跑**(几十~几百条),确认目标可达再扩大;禁止默认使用 rockyou 等超大字典
- 可恢复的会话 - 默认已启用:找到即停(-f)、并行 4(-t)、忽略 restore-I);长任务请设 `output_file`
**使用场景:** **CLI 顺序:** `hydra [选项] <target> <service>`(本工具已按此顺序组参,勿把 target 写在选项前)
- 密码强度测试
- 渗透测试 **使用场景:** 授权环境下的弱口令检测、密码强度评估
- 安全评估
- 弱密码检测 **注意:** 仅用于已授权目标;对无响应目标请减小 `wait_time` 或缩小字典,避免长时间挂起。
parameters: parameters:
- name: "target"
type: "string"
description: "目标IP或主机名"
required: true
position: 0
format: "positional"
- name: "service"
type: "string"
description: "服务类型(ssh, ftp, http等)"
required: true
position: 1
format: "positional"
- name: "username" - name: "username"
type: "string" type: "string"
description: "单个用户名" description: "单个用户名-l);与 username_file 二选一至少填一个"
required: false required: false
flag: "-l" flag: "-l"
format: "flag" format: "flag"
- name: "username_file" - name: "username_file"
type: "string" type: "string"
description: "用户名字典文件" description: "用户名字典文件-L"
required: false required: false
flag: "-L" flag: "-L"
format: "flag" format: "flag"
- name: "password" - name: "password"
type: "string" type: "string"
description: "单个密码" description: "单个密码-p"
required: false required: false
flag: "-p" flag: "-p"
format: "flag" format: "flag"
- name: "password_file" - name: "password_file"
type: "string" type: "string"
description: "密码字典文件" description: "密码字典文件-P);优先使用小字典试跑"
required: false required: false
flag: "-P" flag: "-P"
format: "flag" format: "flag"
- name: "stop_on_first"
type: "bool"
description: "找到一对有效账密后立即退出(-f,默认 true)"
required: false
flag: "-f"
format: "flag"
default: true
- name: "tasks"
type: "int"
description: "每目标并行连接数(-t);SSH 等建议 4,默认 4"
required: false
flag: "-t"
format: "flag"
default: 4
- name: "wait_time"
type: "int"
description: "单次连接等待响应秒数(-w),默认 16(低于 Hydra 默认 32,减少挂起感)"
required: false
flag: "-w"
format: "flag"
default: 16
- name: "wait_between"
type: "int"
description: "每线程连接间隔秒数(-W),默认 1"
required: false
flag: "-W"
format: "flag"
default: 1
- name: "output_file"
type: "string"
description: "将结果写入文件(-o),长任务建议指定"
required: false
flag: "-o"
format: "flag"
- name: "target"
type: "string"
description: "目标 IP、主机名或 CIDR(须在选项之后)"
required: true
position: 1
format: "positional"
- name: "service"
type: "string"
description: "服务类型(ssh、ftp、http-get、http-post-form、smb 等,见 hydra -h"
required: true
position: 2
format: "positional"
- name: "additional_args" - name: "additional_args"
type: "string" type: "string"
description: "额外的Hydra参数" description: "额外参数(如 -s 端口、-S SSL、-m 模块选项、-C login:pass 文件),追加在命令末尾"
required: false required: false
format: "positional" format: "positional"
+556 -6
View File
@@ -1615,9 +1615,34 @@ header {
.conversation-search-box { .conversation-search-box {
position: relative; position: relative;
margin-bottom: 10px;
}
.conversation-sidebar .sidebar-content {
padding: 10px 16px 16px;
}
.conversation-sidebar .conversation-search-box {
margin-top: 8px;
margin-bottom: 10px;
}
.conversation-sidebar .conversation-project-filter {
margin-bottom: 10px;
}
.conversation-sidebar .conversation-groups-section {
margin-bottom: 12px; margin-bottom: 12px;
} }
.conversation-sidebar .recent-conversations-section {
margin-bottom: 12px;
}
.conversation-sidebar .section-header {
margin-bottom: 8px;
}
.conversation-search-box input { .conversation-search-box input {
width: 100%; width: 100%;
padding: 8px 32px 8px 12px; padding: 8px 32px 8px 12px;
@@ -1668,6 +1693,170 @@ header {
height: 14px; height: 14px;
} }
.conversation-project-filter {
margin-bottom: 12px;
min-width: 0;
}
.conversation-project-filter-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 4px;
padding: 0 2px;
}
.conversation-project-filter-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.conversation-project-filter-ui {
position: relative;
width: 100%;
min-width: 0;
}
.conversation-project-filter-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
line-height: 1.25;
cursor: pointer;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.conversation-project-filter-trigger:hover:not(:disabled) {
border-color: var(--accent-color);
}
.conversation-project-filter-ui.open .conversation-project-filter-trigger {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
}
.conversation-project-filter-ui.open {
z-index: 120;
}
.conversation-project-filter-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.conversation-project-filter-caret {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform 0.15s ease;
}
.conversation-project-filter-ui.open .conversation-project-filter-caret {
transform: rotate(180deg);
}
.conversation-project-filter-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 200;
max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
padding: 4px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
}
.conversation-project-filter-ui.open .conversation-project-filter-dropdown {
display: block;
}
.conversation-project-filter-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font-size: 0.8125rem;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, color 0.12s ease;
}
.conversation-project-filter-option:hover {
background: var(--bg-secondary);
}
.conversation-project-filter-option.is-selected {
background: rgba(0, 102, 255, 0.08);
color: var(--accent-color);
font-weight: 500;
}
.conversation-project-filter-check {
width: 14px;
flex-shrink: 0;
opacity: 0;
font-size: 0.75rem;
line-height: 1;
color: var(--accent-color);
}
.conversation-project-filter-option.is-selected .conversation-project-filter-check {
opacity: 1;
}
.conversation-project-filter-option-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item-project-badge {
font-size: 0.6875rem;
color: var(--text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.conversations-list { .conversations-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -11196,6 +11385,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.conversation-groups-section, .conversation-groups-section,
.recent-conversations-section { .recent-conversations-section {
margin-bottom: 24px; margin-bottom: 24px;
min-width: 0;
} }
.conversation-groups-section:last-child, .conversation-groups-section:last-child,
@@ -11209,6 +11399,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
justify-content: space-between; justify-content: space-between;
margin-bottom: 12px; margin-bottom: 12px;
padding: 0 8px; padding: 0 8px;
min-width: 0;
gap: 8px;
} }
.section-header-actions { .section-header-actions {
@@ -11337,6 +11529,21 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.recent-conversations-section .section-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-conversations-section .section-title.section-title--filtered {
text-transform: none;
letter-spacing: normal;
font-size: 0.875rem;
color: var(--text-primary);
}
.add-group-btn, .add-group-btn,
.batch-manage-btn { .batch-manage-btn {
width: 24px; width: 24px;
@@ -11729,7 +11936,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
/* 批量管理模态框 */ /* 批量管理模态框 */
.batch-manage-modal-content { .batch-manage-modal-content {
max-width: 800px; max-width: 920px;
width: 90vw; width: 90vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -11739,7 +11946,23 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.batch-manage-header-actions { .batch-manage-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
min-width: 0;
}
.batch-manage-header-actions .conversation-project-filter-ui {
width: 148px;
min-width: 108px;
flex-shrink: 0;
}
.batch-manage-header-actions .conversation-project-filter-trigger {
font-size: 0.8125rem;
padding: 8px 10px;
}
.batch-manage-modal-content .conversation-project-filter-ui.open {
z-index: 400;
} }
.batch-search-box { .batch-search-box {
@@ -11783,8 +12006,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.batch-table-header { .batch-table-header {
display: grid; display: grid;
grid-template-columns: 40px 1fr 180px 80px; grid-template-columns: 40px minmax(0, 1.2fr) minmax(0, 0.9fr) 160px 72px;
gap: 16px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@@ -11802,8 +12025,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
.batch-conversation-row { .batch-conversation-row {
display: grid; display: grid;
grid-template-columns: 40px 1fr 180px 80px; grid-template-columns: 40px minmax(0, 1.2fr) minmax(0, 0.9fr) 160px 72px;
gap: 16px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
align-items: center; align-items: center;
@@ -11830,6 +12053,20 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
/* 完全依赖JavaScript截断,禁用CSS的ellipsis以避免在UTF-8多字节字符中间截断 */ /* 完全依赖JavaScript截断,禁用CSS的ellipsis以避免在UTF-8多字节字符中间截断 */
} }
.batch-table-col-project {
font-size: 0.8125rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.batch-table-col-project.is-unbound {
color: var(--text-muted);
font-style: italic;
opacity: 0.85;
}
.batch-table-col-time { .batch-table-col-time {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-muted); color: var(--text-muted);
@@ -19744,6 +19981,158 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
} }
.vuln-filter-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;
}
.vulnerability-filter-field--project .vuln-filter-select,
.vulnerability-filter-field--status .vuln-filter-select {
position: relative;
width: 100%;
min-width: 0;
}
.vulnerability-filter-field--project .vuln-filter-select {
min-width: 132px;
max-width: 180px;
}
.vulnerability-filter-field--status .vuln-filter-select {
min-width: 112px;
}
.vuln-filter-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
line-height: 1.25;
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.vuln-filter-select-trigger:hover:not(:disabled) {
border-color: rgba(59, 130, 246, 0.45);
}
.vuln-filter-select.open .vuln-filter-select-trigger {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
}
.vuln-filter-select.open {
z-index: 120;
}
.vuln-filter-select-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.vuln-filter-select-caret {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform 0.15s ease;
}
.vuln-filter-select.open .vuln-filter-select-caret {
transform: rotate(180deg);
}
.vuln-filter-select-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 200;
max-height: 280px;
overflow-y: auto;
padding: 4px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
}
.vuln-filter-select.open .vuln-filter-select-dropdown {
display: block;
}
.vuln-filter-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font-size: 0.8125rem;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, color 0.12s ease;
}
.vuln-filter-select-option:hover {
background: var(--bg-secondary);
}
.vuln-filter-select-option.is-selected {
background: rgba(59, 130, 246, 0.08);
color: #2563eb;
font-weight: 500;
}
.vuln-filter-select-check {
width: 14px;
flex-shrink: 0;
opacity: 0;
font-size: 0.75rem;
line-height: 1;
color: #2563eb;
}
.vuln-filter-select-option.is-selected .vuln-filter-select-check {
opacity: 1;
}
.vuln-filter-select-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vuln-filter-select.is-disabled .vuln-filter-select-trigger {
opacity: 0.55;
cursor: not-allowed;
}
.vulnerability-filter-clear-btn[hidden] { .vulnerability-filter-clear-btn[hidden] {
display: none !important; display: none !important;
} }
@@ -20044,6 +20433,167 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
color: #868e96; color: #868e96;
} }
.vuln-status-picker {
position: relative;
display: inline-flex;
vertical-align: middle;
z-index: 1;
}
.vuln-status-picker.open {
z-index: 120;
}
.vuln-status-picker-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
border: 1px solid transparent;
cursor: pointer;
font-family: inherit;
max-width: 148px;
transition: opacity 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
background: transparent;
color: inherit;
}
.vuln-status-picker-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vuln-status-picker-caret {
flex-shrink: 0;
opacity: 0.8;
transition: transform 0.15s ease;
}
.vuln-status-picker.open .vuln-status-picker-caret {
transform: rotate(180deg);
}
.vuln-status-picker.open .vuln-status-picker-trigger {
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
}
.vuln-status-picker.status-open .vuln-status-picker-trigger {
background: rgba(0, 102, 255, 0.1);
color: #0066ff;
border-color: rgba(0, 102, 255, 0.22);
}
.vuln-status-picker.status-confirmed .vuln-status-picker-trigger {
background: rgba(40, 167, 69, 0.1);
color: #28a745;
border-color: rgba(40, 167, 69, 0.22);
}
.vuln-status-picker.status-fixed .vuln-status-picker-trigger {
background: rgba(108, 117, 125, 0.1);
color: #6c757d;
border-color: rgba(108, 117, 125, 0.22);
}
.vuln-status-picker.status-false_positive .vuln-status-picker-trigger {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
border-color: rgba(220, 53, 69, 0.22);
}
.vuln-status-picker.status-ignored .vuln-status-picker-trigger {
background: rgba(108, 117, 125, 0.12);
color: #868e96;
border-color: rgba(108, 117, 125, 0.22);
}
.vuln-status-picker-trigger:hover:not(:disabled) {
filter: brightness(0.97);
}
.vuln-status-picker.is-disabled .vuln-status-picker-trigger {
opacity: 0.65;
cursor: wait;
pointer-events: none;
}
.vuln-status-picker-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 136px;
z-index: 200;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.vuln-status-picker-menu[hidden] {
display: none !important;
}
.vuln-status-picker-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font-size: 0.8125rem;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, color 0.12s ease;
}
.vuln-status-picker-option:hover {
background: var(--bg-secondary);
}
.vuln-status-picker-option.is-selected {
background: rgba(0, 102, 255, 0.08);
color: var(--accent-color);
font-weight: 500;
}
.vuln-status-picker-check {
width: 14px;
flex-shrink: 0;
opacity: 0;
font-size: 0.75rem;
line-height: 1;
color: var(--accent-color);
}
.vuln-status-picker-option.is-selected .vuln-status-picker-check {
opacity: 1;
}
.vuln-status-picker-label {
flex: 1;
min-width: 0;
}
.vulnerability-card--removing {
opacity: 0;
transform: scale(0.98);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.vulnerability-date { .vulnerability-date {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
+21
View File
@@ -91,10 +91,12 @@
"refresh": "Refresh", "refresh": "Refresh",
"refreshData": "Refresh data", "refreshData": "Refresh data",
"runningTasks": "Running tasks", "runningTasks": "Running tasks",
"runningConversations": "Running conversations",
"vulnTotal": "Total vulnerabilities", "vulnTotal": "Total vulnerabilities",
"toolCalls": "Tool invocations", "toolCalls": "Tool invocations",
"successRate": "Tool success rate", "successRate": "Tool success rate",
"clickToViewTasks": "Click to view tasks", "clickToViewTasks": "Click to view tasks",
"clickToViewChat": "Click to view conversations",
"clickToViewVuln": "Click to view vulnerabilities", "clickToViewVuln": "Click to view vulnerabilities",
"clickToViewMCP": "Click to view MCP monitor", "clickToViewMCP": "Click to view MCP monitor",
"accessOverviewTitle": "Access overview", "accessOverviewTitle": "Access overview",
@@ -499,6 +501,13 @@
"conversationGroups": "Conversation groups", "conversationGroups": "Conversation groups",
"addGroup": "New group", "addGroup": "New group",
"recentConversations": "Recent conversations", "recentConversations": "Recent conversations",
"filterByProject": "Filter by project",
"filterAllProjects": "All projects",
"filterUnboundProjects": "Unbound",
"projectConversationsTitle": "{{name}} · Conversations",
"unboundConversationsTitle": "Unbound conversations",
"noProjectConversations": "No conversations in this project",
"noUnboundConversations": "No unbound conversations",
"sortConversations": "Sort", "sortConversations": "Sort",
"sortByCreatedAt": "Created time", "sortByCreatedAt": "Created time",
"sortByUpdatedAt": "Updated time", "sortByUpdatedAt": "Updated time",
@@ -1667,6 +1676,7 @@
"timelineSummary": "{{total}} calls in range · peak {{peak}}", "timelineSummary": "{{total}} calls in range · peak {{peak}}",
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}", "timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
"timelineNoData": "No calls in this period", "timelineNoData": "No calls in this period",
"timelineLoading": "Loading trend…",
"timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks", "timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks",
"timelineLoadError": "Failed to load call trend", "timelineLoadError": "Failed to load call trend",
"timelineTotalLegend": "Total calls", "timelineTotalLegend": "Total calls",
@@ -1895,6 +1905,8 @@
"statusFixed": "Fixed", "statusFixed": "Fixed",
"statusFalsePositive": "False positive", "statusFalsePositive": "False positive",
"statusIgnored": "Ignored", "statusIgnored": "Ignored",
"statusChangeLabel": "Change status",
"statusUpdateFailed": "Failed to update status",
"searchVulnId": "Search vuln ID", "searchVulnId": "Search vuln ID",
"searchKeyword": "Search title, description, type, target…", "searchKeyword": "Search title, description, type, target…",
"searchKeywordShort": "Keyword", "searchKeywordShort": "Keyword",
@@ -1924,6 +1936,8 @@
"detailTarget": "Target", "detailTarget": "Target",
"detailProject": "Project", "detailProject": "Project",
"projectUnbound": "No project", "projectUnbound": "No project",
"allProjects": "All projects",
"filterByProject": "Filter by project",
"projectBindHint": "Once bound, agents can list this finding under the project scope.", "projectBindHint": "Once bound, agents can list this finding under the project scope.",
"projectBindFailed": "Failed to update project binding", "projectBindFailed": "Failed to update project binding",
"projectBindOk": "Project binding updated", "projectBindOk": "Project binding updated",
@@ -2004,6 +2018,10 @@
"settingsBasic": { "settingsBasic": {
"basicTitle": "Basic settings", "basicTitle": "Basic settings",
"openaiConfig": "OpenAI config", "openaiConfig": "OpenAI config",
"apiProvider": "API Provider",
"providerOpenAI": "OpenAI / OpenAI-compatible API",
"providerClaude": "Claude (Anthropic Messages API)",
"visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)",
"fofaConfig": "FOFA config", "fofaConfig": "FOFA config",
"agentConfig": "Agent config", "agentConfig": "Agent config",
"knowledgeConfig": "Knowledge base config", "knowledgeConfig": "Knowledge base config",
@@ -2522,6 +2540,9 @@
"title": "Manage conversations · {{count}} total", "title": "Manage conversations · {{count}} total",
"searchPlaceholder": "Search history", "searchPlaceholder": "Search history",
"conversationName": "Conversation name", "conversationName": "Conversation name",
"project": "Project",
"noProject": "No project",
"filterByProject": "Filter by project",
"lastTime": "Last activity", "lastTime": "Last activity",
"action": "Action", "action": "Action",
"selectAll": "Select all", "selectAll": "Select all",
+21
View File
@@ -91,10 +91,12 @@
"refresh": "刷新", "refresh": "刷新",
"refreshData": "刷新数据", "refreshData": "刷新数据",
"runningTasks": "运行中任务", "runningTasks": "运行中任务",
"runningConversations": "运行中对话",
"vulnTotal": "漏洞总数", "vulnTotal": "漏洞总数",
"toolCalls": "工具调用次数", "toolCalls": "工具调用次数",
"successRate": "工具执行成功率", "successRate": "工具执行成功率",
"clickToViewTasks": "点击查看任务管理", "clickToViewTasks": "点击查看任务管理",
"clickToViewChat": "点击查看对话",
"clickToViewVuln": "点击查看漏洞管理", "clickToViewVuln": "点击查看漏洞管理",
"clickToViewMCP": "点击查看 MCP 监控", "clickToViewMCP": "点击查看 MCP 监控",
"accessOverviewTitle": "接入概览", "accessOverviewTitle": "接入概览",
@@ -487,6 +489,13 @@
"conversationGroups": "对话分组", "conversationGroups": "对话分组",
"addGroup": "新建分组", "addGroup": "新建分组",
"recentConversations": "最近对话", "recentConversations": "最近对话",
"filterByProject": "按项目筛选",
"filterAllProjects": "全部项目",
"filterUnboundProjects": "未绑定项目",
"projectConversationsTitle": "{{name}} · 对话",
"unboundConversationsTitle": "未绑定项目",
"noProjectConversations": "该项目暂无对话",
"noUnboundConversations": "暂无未绑定项目的对话",
"sortConversations": "排序", "sortConversations": "排序",
"sortByCreatedAt": "创建时间", "sortByCreatedAt": "创建时间",
"sortByUpdatedAt": "更新时间", "sortByUpdatedAt": "更新时间",
@@ -1655,6 +1664,7 @@
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}", "timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}", "timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
"timelineNoData": "该时段暂无调用", "timelineNoData": "该时段暂无调用",
"timelineLoading": "趋势加载中…",
"timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具", "timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具",
"timelineLoadError": "无法加载调用趋势", "timelineLoadError": "无法加载调用趋势",
"timelineTotalLegend": "总调用", "timelineTotalLegend": "总调用",
@@ -1883,6 +1893,8 @@
"statusFixed": "已修复", "statusFixed": "已修复",
"statusFalsePositive": "误报", "statusFalsePositive": "误报",
"statusIgnored": "已忽略", "statusIgnored": "已忽略",
"statusChangeLabel": "更改状态",
"statusUpdateFailed": "更新状态失败",
"searchVulnId": "搜索漏洞 ID", "searchVulnId": "搜索漏洞 ID",
"searchKeyword": "搜索标题、描述、类型、目标…", "searchKeyword": "搜索标题、描述、类型、目标…",
"searchKeywordShort": "关键词", "searchKeywordShort": "关键词",
@@ -1912,6 +1924,8 @@
"detailTarget": "目标", "detailTarget": "目标",
"detailProject": "所属项目", "detailProject": "所属项目",
"projectUnbound": "未绑定项目", "projectUnbound": "未绑定项目",
"allProjects": "全部项目",
"filterByProject": "按项目筛选",
"projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞", "projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞",
"projectBindFailed": "绑定项目失败", "projectBindFailed": "绑定项目失败",
"projectBindOk": "已更新项目绑定", "projectBindOk": "已更新项目绑定",
@@ -1992,6 +2006,10 @@
"settingsBasic": { "settingsBasic": {
"basicTitle": "基本设置", "basicTitle": "基本设置",
"openaiConfig": "OpenAI 配置", "openaiConfig": "OpenAI 配置",
"apiProvider": "API 提供商",
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
"providerClaude": "Claude (Anthropic Messages API)",
"visionProviderReuseOpenAI": "OpenAI 配置(留空复用)",
"fofaConfig": "FOFA 配置", "fofaConfig": "FOFA 配置",
"agentConfig": "Agent 配置", "agentConfig": "Agent 配置",
"knowledgeConfig": "知识库配置", "knowledgeConfig": "知识库配置",
@@ -2510,6 +2528,9 @@
"title": "管理对话记录·共{{count}}条", "title": "管理对话记录·共{{count}}条",
"searchPlaceholder": "搜索历史记录", "searchPlaceholder": "搜索历史记录",
"conversationName": "对话名称", "conversationName": "对话名称",
"project": "项目",
"noProject": "无项目",
"filterByProject": "按项目筛选",
"lastTime": "最近一次对话时间", "lastTime": "最近一次对话时间",
"action": "操作", "action": "操作",
"selectAll": "全选", "selectAll": "全选",
+435 -36
View File
@@ -3119,7 +3119,7 @@ async function cancelMCPToolExecutionSubmit(executionId, userNote, options = {})
} }
try { try {
if (conversationId && typeof requestCancelWithContinue === 'function') { if (conversationId && typeof requestCancelWithContinue === 'function') {
await requestCancelWithContinue(conversationId, userNote || ''); await requestCancelWithContinue(conversationId, userNote || '', { executionId });
} else { } else {
const res = await apiFetch(`/api/monitor/execution/${encodeURIComponent(executionId)}/cancel`, { const res = await apiFetch(`/api/monitor/execution/${encodeURIComponent(executionId)}/cancel`, {
method: 'POST', method: 'POST',
@@ -3322,6 +3322,18 @@ function createConversationListItem(conversation) {
title.title = titleText; // 设置完整标题以便悬停查看 title.title = titleText; // 设置完整标题以便悬停查看
contentWrapper.appendChild(title); contentWrapper.appendChild(title);
if (!getConversationProjectFilter()) {
const pid = conversation.projectId || conversation.project_id || '';
const projectName = pid && window.projectNameById ? window.projectNameById[pid] : '';
if (projectName) {
const badge = document.createElement('div');
badge.className = 'conversation-item-project-badge';
badge.textContent = projectName;
badge.title = projectName;
contentWrapper.appendChild(badge);
}
}
const time = document.createElement('div'); const time = document.createElement('div');
time.className = 'conversation-time'; time.className = 'conversation-time';
time.textContent = conversation._timeText || formatConversationTimestamp(conversation._time || new Date()); time.textContent = conversation._timeText || formatConversationTimestamp(conversation._time || new Date());
@@ -3867,14 +3879,7 @@ async function deleteConversation(conversationId, skipConfirm = false) {
const batchModal = document.getElementById('batch-manage-modal'); const batchModal = document.getElementById('batch-manage-modal');
if (batchModal && isAppModalOpen('batch-manage-modal')) { if (batchModal && isAppModalOpen('batch-manage-modal')) {
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId); allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
updateBatchManageTitle(allConversationsForBatch.length); applyBatchConversationFilters();
const searchInput = document.getElementById('batch-search-input');
const query = searchInput ? searchInput.value : '';
if (query && query.trim()) {
filterBatchConversations(query);
} else {
renderBatchConversations();
}
} }
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致 // 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
@@ -6075,6 +6080,266 @@ let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端A
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染 let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size'; const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by'; const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by';
const CONVERSATIONS_PROJECT_FILTER_KEY = 'cyberstrike.conversations_project_filter';
const CONVERSATION_PROJECT_FILTER_NONE = '__none__';
const CONVERSATION_PROJECT_FILTER_SELECT_ID = 'conversation-project-filter';
const CONVERSATION_PROJECT_FILTER_CARET = '<svg class="conversation-project-filter-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const BATCH_PROJECT_FILTER_SELECT_ID = 'batch-project-filter';
const projectFilterCustomSelectRegistry = {};
let projectFilterCustomSelectDocBound = false;
function closeProjectFilterCustomSelect(selectId) {
const reg = projectFilterCustomSelectRegistry[selectId];
if (!reg || !reg.wrapper) return;
reg.wrapper.classList.remove('open');
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
}
function closeAllProjectFilterCustomSelects() {
Object.keys(projectFilterCustomSelectRegistry).forEach(closeProjectFilterCustomSelect);
}
function syncProjectFilterCustomSelect(selectId) {
const reg = projectFilterCustomSelectRegistry[selectId];
if (!reg) return;
const { select, dropdown, trigger } = reg;
const valueSpan = trigger.querySelector('.conversation-project-filter-value');
dropdown.innerHTML = '';
Array.prototype.forEach.call(select.options, (opt) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'conversation-project-filter-option';
item.setAttribute('role', 'option');
item.setAttribute('data-value', opt.value);
const labelText = opt.textContent || '';
item.title = labelText;
if (opt.value === select.value) {
item.classList.add('is-selected');
item.setAttribute('aria-selected', 'true');
} else {
item.setAttribute('aria-selected', 'false');
}
const check = document.createElement('span');
check.className = 'conversation-project-filter-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'conversation-project-filter-option-label';
label.textContent = labelText;
label.title = labelText;
item.appendChild(check);
item.appendChild(label);
dropdown.appendChild(item);
});
const selectedOpt = select.options[select.selectedIndex];
const selectedText = selectedOpt ? (selectedOpt.textContent || '') : '';
if (valueSpan) {
valueSpan.textContent = selectedText;
valueSpan.title = selectedText;
}
}
function initProjectFilterCustomSelect(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
if (select.dataset.projectCustomSelect === '1') {
syncProjectFilterCustomSelect(selectId);
return;
}
select.dataset.projectCustomSelect = '1';
select.classList.add('conversation-project-filter-native');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'conversation-project-filter-ui';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'conversation-project-filter-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const valueSpan = document.createElement('span');
valueSpan.className = 'conversation-project-filter-value';
trigger.appendChild(valueSpan);
trigger.insertAdjacentHTML('beforeend', CONVERSATION_PROJECT_FILTER_CARET);
const dropdown = document.createElement('div');
dropdown.className = 'conversation-project-filter-dropdown';
dropdown.setAttribute('role', 'listbox');
const parent = select.parentNode;
parent.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(dropdown);
wrapper.appendChild(select);
projectFilterCustomSelectRegistry[selectId] = { wrapper, trigger, dropdown, select };
trigger.addEventListener('click', (e) => {
e.stopPropagation();
const open = wrapper.classList.contains('open');
closeAllProjectFilterCustomSelects();
if (!open) {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
}
});
dropdown.addEventListener('click', (e) => {
const opt = e.target.closest('.conversation-project-filter-option');
if (!opt) return;
e.stopPropagation();
const val = opt.getAttribute('data-value');
if (val === null) return;
if (select.value !== val) {
select.value = val;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
closeProjectFilterCustomSelect(selectId);
syncProjectFilterCustomSelect(selectId);
});
if (!projectFilterCustomSelectDocBound) {
projectFilterCustomSelectDocBound = true;
document.addEventListener('click', closeAllProjectFilterCustomSelects);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllProjectFilterCustomSelects();
});
}
syncProjectFilterCustomSelect(selectId);
}
function syncConversationProjectCustomSelect() {
syncProjectFilterCustomSelect(CONVERSATION_PROJECT_FILTER_SELECT_ID);
}
function initConversationProjectCustomSelect() {
initProjectFilterCustomSelect(CONVERSATION_PROJECT_FILTER_SELECT_ID);
}
function getConversationProjectFilter() {
try {
return localStorage.getItem(CONVERSATIONS_PROJECT_FILTER_KEY) || '';
} catch (e) {
return '';
}
}
function setConversationProjectFilter(projectId) {
const value = (projectId || '').trim();
try {
if (value) localStorage.setItem(CONVERSATIONS_PROJECT_FILTER_KEY, value);
else localStorage.removeItem(CONVERSATIONS_PROJECT_FILTER_KEY);
} catch (e) { /* ignore */ }
const sel = document.getElementById('conversation-project-filter');
if (sel && sel.value !== value) sel.value = value;
syncConversationProjectCustomSelect();
updateConversationSidebarFilterUI();
}
function isValidConversationProjectFilter(projectId) {
if (!projectId) return true;
if (projectId === CONVERSATION_PROJECT_FILTER_NONE) return true;
const map = window.projectNameById;
if (!map || typeof map !== 'object') return true;
return Object.prototype.hasOwnProperty.call(map, projectId);
}
async function refreshConversationProjectFilter() {
const sel = document.getElementById('conversation-project-filter');
if (!sel) return;
const saved = getConversationProjectFilter();
let projects = [];
if (typeof window.ensureProjectsLoaded === 'function') {
try {
const list = await window.ensureProjectsLoaded();
projects = (list || []).filter((p) => p && p.id && p.status !== 'archived');
} catch (e) { /* ignore */ }
}
if (!projects.length) {
try {
const res = await apiFetch('/api/projects?status=active&limit=200');
if (res.ok) {
const data = await res.json();
const items = data.projects || data.items || (Array.isArray(data) ? data : []);
projects = items.filter((p) => p && p.id);
if (typeof window.rebuildProjectNameMap === 'function') {
window.rebuildProjectNameMap(items);
}
}
} catch (e) { /* ignore */ }
}
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
sel.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = allLabel;
allOpt.setAttribute('data-i18n', 'chat.filterAllProjects');
sel.appendChild(allOpt);
const unboundOpt = document.createElement('option');
unboundOpt.value = CONVERSATION_PROJECT_FILTER_NONE;
unboundOpt.textContent = unboundLabel;
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
sel.appendChild(unboundOpt);
projects
.slice()
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || '', undefined, { sensitivity: 'base' }))
.forEach((p) => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
sel.appendChild(opt);
});
const normalized = isValidConversationProjectFilter(saved) ? saved : '';
if (normalized !== saved) setConversationProjectFilter(normalized);
sel.value = normalized;
syncConversationProjectCustomSelect();
updateConversationSidebarFilterUI();
}
function onConversationProjectFilterChange(projectId) {
setConversationProjectFilter(projectId || '');
conversationsPagination.page = 1;
loadConversationsWithGroups(conversationsSearchQuery);
}
function updateConversationSidebarFilterUI() {
const groupsSection = document.querySelector('.conversation-groups-section');
const titleEl = document.querySelector('.recent-conversations-section .section-title');
const filter = getConversationProjectFilter();
const hasSearch = !!(conversationsSearchQuery && conversationsSearchQuery.trim());
if (groupsSection) {
groupsSection.hidden = !!filter || hasSearch;
}
if (!titleEl) return;
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
if (filter && filter !== CONVERSATION_PROJECT_FILTER_NONE) {
const name = (window.projectNameById && window.projectNameById[filter]) || filter;
const fullTitle = tFn ? tFn('chat.projectConversationsTitle', { name }) : `${name} · 对话`;
titleEl.textContent = fullTitle;
titleEl.title = fullTitle;
titleEl.classList.add('section-title--filtered');
titleEl.removeAttribute('data-i18n');
} else if (filter === CONVERSATION_PROJECT_FILTER_NONE) {
const fullTitle = tFn ? tFn('chat.unboundConversationsTitle') : '未绑定项目';
titleEl.textContent = fullTitle;
titleEl.title = fullTitle;
titleEl.classList.add('section-title--filtered');
titleEl.setAttribute('data-i18n', 'chat.unboundConversationsTitle');
} else {
titleEl.classList.remove('section-title--filtered');
titleEl.removeAttribute('title');
titleEl.setAttribute('data-i18n', 'chat.recentConversations');
if (tFn) titleEl.textContent = tFn('chat.recentConversations');
}
}
window.onConversationProjectBindingChanged = function onConversationProjectBindingChanged() {
loadConversationsWithGroups(conversationsSearchQuery);
};
function getConversationSortBy() { function getConversationSortBy() {
try { try {
@@ -6252,6 +6517,13 @@ async function fetchAllConversations(searchQuery) {
} }
function getConversationListEmptyHtml() { function getConversationListEmptyHtml() {
const filter = getConversationProjectFilter();
if (filter && filter !== CONVERSATION_PROJECT_FILTER_NONE) {
return '<div class="conversations-list-empty" data-i18n="chat.noProjectConversations"></div>';
}
if (filter === CONVERSATION_PROJECT_FILTER_NONE) {
return '<div class="conversations-list-empty" data-i18n="chat.noUnboundConversations"></div>';
}
return '<div class="conversations-list-empty" data-i18n="chat.noHistoryConversations"></div>'; return '<div class="conversations-list-empty" data-i18n="chat.noHistoryConversations"></div>';
} }
@@ -6428,11 +6700,16 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (conversationSortBy === 'created_at') { if (conversationSortBy === 'created_at') {
convParams.set('sort_by', 'created_at'); convParams.set('sort_by', 'created_at');
} }
const projectFilter = getConversationProjectFilter();
if (projectFilter) {
convParams.set('project_id', projectFilter);
}
if (searchQuery && searchQuery.trim()) { if (searchQuery && searchQuery.trim()) {
convParams.set('search', searchQuery.trim()); convParams.set('search', searchQuery.trim());
} else { } else if (!projectFilter) {
convParams.set('exclude_grouped', 'true'); convParams.set('exclude_grouped', 'true');
} }
updateConversationSidebarFilterUI();
const url = `/api/conversations?${convParams}`; const url = `/api/conversations?${convParams}`;
const [,, response] = await Promise.all([ const [,, response] = await Promise.all([
loadGroups(), loadGroups(),
@@ -6488,6 +6765,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
const pinnedConvs = []; const pinnedConvs = [];
const normalConvs = []; const normalConvs = [];
const hasSearchQuery = searchQuery && searchQuery.trim(); const hasSearchQuery = searchQuery && searchQuery.trim();
const hasProjectFilter = !!getConversationProjectFilter();
uniqueConversations.forEach(conv => { uniqueConversations.forEach(conv => {
// 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的) // 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的)
@@ -6501,6 +6779,16 @@ async function loadConversationsWithGroups(searchQuery = '') {
return; return;
} }
// 按项目筛选时展示该项目下全部对话(含分组内)
if (hasProjectFilter) {
if (conv.pinned) {
pinnedConvs.push(conv);
} else {
normalConvs.push(conv);
}
return;
}
// 如果没有搜索关键词,使用原有逻辑 // 如果没有搜索关键词,使用原有逻辑
// "最近对话"列表应该只显示不在任何分组中的对话 // "最近对话"列表应该只显示不在任何分组中的对话
// 无论是否在分组详情页,都不应该在"最近对话"中显示分组中的对话 // 无论是否在分组详情页,都不应该在"最近对话"中显示分组中的对话
@@ -7731,6 +8019,84 @@ function closeContextMenu() {
// 显示批量管理模态框 // 显示批量管理模态框
let allConversationsForBatch = []; let allConversationsForBatch = [];
function getConversationProjectId(conv) {
return (conv?.projectId || conv?.project_id || '').trim();
}
function getConversationProjectLabel(conv) {
const pid = getConversationProjectId(conv);
if (!pid) {
return typeof window.t === 'function' ? window.t('batchManageModal.noProject') : '无项目';
}
return (window.projectNameById && window.projectNameById[pid]) || pid;
}
async function refreshBatchProjectFilter() {
const sel = document.getElementById('batch-project-filter');
if (!sel) return;
const saved = sel.value || '';
if (typeof window.ensureProjectsLoaded === 'function') {
try {
await window.ensureProjectsLoaded();
} catch (e) { /* ignore */ }
}
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
sel.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = allLabel;
allOpt.setAttribute('data-i18n', 'chat.filterAllProjects');
sel.appendChild(allOpt);
const unboundOpt = document.createElement('option');
unboundOpt.value = CONVERSATION_PROJECT_FILTER_NONE;
unboundOpt.textContent = unboundLabel;
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
sel.appendChild(unboundOpt);
const source = window.projectNameById ? Object.keys(window.projectNameById) : [];
source
.sort((a, b) => {
const na = (window.projectNameById[a] || a).toLowerCase();
const nb = (window.projectNameById[b] || b).toLowerCase();
return na.localeCompare(nb);
})
.forEach((id) => {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = window.projectNameById[id] || id;
sel.appendChild(opt);
});
const valid = !saved || saved === CONVERSATION_PROJECT_FILTER_NONE || (window.projectNameById && window.projectNameById[saved]);
sel.value = valid ? saved : '';
syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
}
function getBatchFilteredConversations() {
const query = (document.getElementById('batch-search-input')?.value || '').trim().toLowerCase();
const projectFilter = (document.getElementById('batch-project-filter')?.value || '').trim();
return allConversationsForBatch.filter((conv) => {
const pid = getConversationProjectId(conv);
if (projectFilter) {
if (projectFilter === CONVERSATION_PROJECT_FILTER_NONE) {
if (pid) return false;
} else if (pid !== projectFilter) {
return false;
}
}
if (!query) return true;
const title = (conv.title || '').toLowerCase();
const projectName = getConversationProjectLabel(conv).toLowerCase();
return title.includes(query) || projectName.includes(query);
});
}
function applyBatchConversationFilters() {
const filtered = getBatchFilteredConversations();
updateBatchManageTitle(filtered.length);
renderBatchConversations(filtered);
}
// 更新批量管理模态框标题(含条数),支持 i18n;count 为当前条数 // 更新批量管理模态框标题(含条数),支持 i18n;count 为当前条数
function updateBatchManageTitle(count) { function updateBatchManageTitle(count) {
const titleEl = document.getElementById('batch-manage-title'); const titleEl = document.getElementById('batch-manage-title');
@@ -7742,19 +8108,27 @@ function updateBatchManageTitle(count) {
async function showBatchManageModal() { async function showBatchManageModal() {
try { try {
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
allConversationsForBatch = await fetchAllConversations(''); allConversationsForBatch = await fetchAllConversations('');
await refreshBatchProjectFilter();
const modal = document.getElementById('batch-manage-modal'); const sidebarFilter = getConversationProjectFilter();
updateBatchManageTitle(allConversationsForBatch.length); const batchSel = document.getElementById('batch-project-filter');
if (batchSel && sidebarFilter && (
renderBatchConversations(); sidebarFilter === CONVERSATION_PROJECT_FILTER_NONE ||
(window.projectNameById && window.projectNameById[sidebarFilter])
)) {
batchSel.value = sidebarFilter;
}
const searchInput = document.getElementById('batch-search-input');
if (searchInput) searchInput.value = '';
applyBatchConversationFilters();
openAppModal('batch-manage-modal', { focus: false }); openAppModal('batch-manage-modal', { focus: false });
} catch (error) { } catch (error) {
console.error('加载对话列表失败:', error); console.error('加载对话列表失败:', error);
// 错误时使用空数组,不显示错误提示(更友好的用户体验) initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
allConversationsForBatch = []; allConversationsForBatch = [];
updateBatchManageTitle(0); await refreshBatchProjectFilter();
renderBatchConversations(); applyBatchConversationFilters();
openAppModal('batch-manage-modal', { focus: false }); openAppModal('batch-manage-modal', { focus: false });
} }
} }
@@ -7817,15 +8191,27 @@ function renderBatchConversations(filtered = null) {
checkbox.dataset.conversationId = conv.id; checkbox.dataset.conversationId = conv.id;
checkbox.addEventListener('change', syncSelectAllBatchCheckbox); checkbox.addEventListener('change', syncSelectAllBatchCheckbox);
const checkboxCol = document.createElement('div');
checkboxCol.className = 'batch-table-col-checkbox';
checkboxCol.appendChild(checkbox);
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'batch-table-col-name'; name.className = 'batch-table-col-name';
const originalTitle = conv.title || (typeof window.t === 'function' ? window.t('batchManageModal.unnamedConversation') : '未命名对话'); const originalTitle = conv.title || (typeof window.t === 'function' ? window.t('batchManageModal.unnamedConversation') : '未命名对话');
// 使用安全截断函数,限制最大长度为45个字符(留出空间显示省略号) const truncatedTitle = safeTruncateText(originalTitle, 36);
const truncatedTitle = safeTruncateText(originalTitle, 45);
name.textContent = truncatedTitle; name.textContent = truncatedTitle;
// 设置title属性以显示完整文本(鼠标悬停时)
name.title = originalTitle; name.title = originalTitle;
const project = document.createElement('div');
project.className = 'batch-table-col-project';
const projectLabel = getConversationProjectLabel(conv);
const truncatedProject = safeTruncateText(projectLabel, 28);
project.textContent = truncatedProject;
project.title = projectLabel;
if (!getConversationProjectId(conv)) {
project.classList.add('is-unbound');
}
const time = document.createElement('div'); const time = document.createElement('div');
time.className = 'batch-table-col-time'; time.className = 'batch-table-col-time';
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
@@ -7858,8 +8244,9 @@ function renderBatchConversations(filtered = null) {
}; };
action.appendChild(deleteBtn); action.appendChild(deleteBtn);
row.appendChild(checkbox); row.appendChild(checkboxCol);
row.appendChild(name); row.appendChild(name);
row.appendChild(project);
row.appendChild(time); row.appendChild(time);
row.appendChild(action); row.appendChild(action);
@@ -7870,18 +8257,8 @@ function renderBatchConversations(filtered = null) {
} }
// 筛选批量管理对话 // 筛选批量管理对话
function filterBatchConversations(query) { function filterBatchConversations() {
if (!query || !query.trim()) { applyBatchConversationFilters();
renderBatchConversations();
return;
}
const filtered = allConversationsForBatch.filter(conv => {
const title = (conv.title || '').toLowerCase();
return title.includes(query.toLowerCase());
});
renderBatchConversations(filtered);
} }
// 全选/取消全选 // 全选/取消全选
@@ -7958,6 +8335,10 @@ function closeBatchManageModal() {
selectAll.checked = false; selectAll.checked = false;
selectAll.indeterminate = false; selectAll.indeterminate = false;
} }
const searchInput = document.getElementById('batch-search-input');
if (searchInput) searchInput.value = '';
const batchProj = document.getElementById('batch-project-filter');
if (batchProj) batchProj.value = '';
allConversationsForBatch = []; allConversationsForBatch = [];
} }
@@ -8028,9 +8409,16 @@ function refreshChatPanelI18n() {
document.addEventListener('languagechange', function () { document.addEventListener('languagechange', function () {
refreshSystemReadyMessageBubbles(); refreshSystemReadyMessageBubbles();
refreshChatPanelI18n(); refreshChatPanelI18n();
if (typeof refreshConversationProjectFilter === 'function') {
refreshConversationProjectFilter();
}
if (typeof refreshBatchProjectFilter === 'function') {
refreshBatchProjectFilter().then(() => {
const modal = document.getElementById('batch-manage-modal'); const modal = document.getElementById('batch-manage-modal');
if (isAppModalOpen('batch-manage-modal')) { if (modal && isAppModalOpen('batch-manage-modal') && typeof applyBatchConversationFilters === 'function') {
updateBatchManageTitle(allConversationsForBatch.length); applyBatchConversationFilters();
}
});
} }
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式 // 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
if (typeof loadConversationsWithGroups === 'function') { if (typeof loadConversationsWithGroups === 'function') {
@@ -8961,7 +9349,10 @@ function clearGroupSearch() {
// 初始化时加载分组 // 初始化时加载分组
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
if (window.i18nReady) await window.i18nReady;
updateConversationSortMenuUI(); updateConversationSortMenuUI();
initConversationProjectCustomSelect();
await refreshConversationProjectFilter();
await loadGroups(); await loadGroups();
await loadConversationsWithGroups(); await loadConversationsWithGroups();
@@ -9018,8 +9409,16 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
}); });
async function refreshAllProjectFilterSelects() {
await refreshConversationProjectFilter();
await refreshBatchProjectFilter();
}
// 顶层 async function 不会自动挂到 windowhitl 等脚本依赖 window.loadConversation // 顶层 async function 不会自动挂到 windowhitl 等脚本依赖 window.loadConversation
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.loadConversation = loadConversation; window.loadConversation = loadConversation;
window.startNewConversation = startNewConversation; window.startNewConversation = startNewConversation;
window.refreshConversationProjectFilter = refreshConversationProjectFilter;
window.refreshAllProjectFilterSelects = refreshAllProjectFilterSelects;
window.onConversationProjectFilterChange = onConversationProjectFilterChange;
} }
+29 -49
View File
@@ -1,4 +1,4 @@
// 仪表盘页面:拉取运行中任务、漏洞统计、批量任务、工具与 Skills 统计并渲染。 // 仪表盘页面:拉取运行中对话、漏洞统计、批量任务、工具与 Skills 统计并渲染。
// //
// 工程基础设施: // 工程基础设施:
// - dashboardState 集中保存运行时状态(in-flight controller / 自动轮询 timer / 上次更新时间 / // - dashboardState 集中保存运行时状态(in-flight controller / 自动轮询 timer / 上次更新时间 /
@@ -118,7 +118,7 @@ async function refreshDashboard() {
fetchJson('/api/agent-loop/tasks'), fetchJson('/api/agent-loop/tasks'),
fetchJson('/api/vulnerabilities/stats'), fetchJson('/api/vulnerabilities/stats'),
fetchJson('/api/batch-tasks?limit=500&page=1'), fetchJson('/api/batch-tasks?limit=500&page=1'),
fetchJson('/api/monitor/stats'), fetchJson('/api/monitor/stats?top=30'),
fetchJson('/api/knowledge/stats'), fetchJson('/api/knowledge/stats'),
fetchJson('/api/skills/stats'), fetchJson('/api/skills/stats'),
fetchJson('/api/vulnerabilities?limit=10&page=1'), fetchJson('/api/vulnerabilities?limit=10&page=1'),
@@ -150,36 +150,24 @@ async function refreshDashboard() {
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
if (signal && signal.aborted) return; if (signal && signal.aborted) return;
// 运行中任务:Agent 循环任务 + 批量队列「执行中」数量统一统计,避免顶部 KPI 与运行概览不一致 // 运行中对话:仅统计 Agent 循环任务批量队列见右侧「批量任务队列」
let agentRunningCount = null; let agentRunningCount = null;
if (tasksRes && Array.isArray(tasksRes.tasks)) { if (tasksRes && Array.isArray(tasksRes.tasks)) {
agentRunningCount = tasksRes.tasks.length; agentRunningCount = tasksRes.tasks.length;
} }
let batchRunningCount = 0; let batchRunningCount = 0;
let batchPendingCount = 0;
if (batchRes && Array.isArray(batchRes.queues)) { if (batchRes && Array.isArray(batchRes.queues)) {
batchRes.queues.forEach(q => { batchRes.queues.forEach(q => {
const s = (q.status || '').toLowerCase(); const s = (q.status || '').toLowerCase();
if (s === 'running') batchRunningCount++; if (s === 'running') batchRunningCount++;
else if (s === 'pending' || s === 'paused') batchPendingCount++;
}); });
} }
const totalRunning = (agentRunningCount || 0) + batchRunningCount; const runningConversations = agentRunningCount !== null ? agentRunningCount : 0;
if (runningEl) { if (runningEl) {
if (agentRunningCount !== null) { runningEl.textContent = agentRunningCount !== null ? String(agentRunningCount) : '-';
runningEl.textContent = String(totalRunning);
} else if (batchRes && Array.isArray(batchRes.queues)) {
runningEl.textContent = String(batchRunningCount);
} else {
runningEl.textContent = '-';
} }
} // KPI 副标:全部空闲 / 正在执行
// KPI 副标:N 待执行 / 全部空闲 if (runningConversations === 0) {
if (batchPendingCount > 0) {
setKpiSubBadge('dashboard-kpi-tasks-sub-text',
dt('dashboard.pendingCountLabel', { count: batchPendingCount }, batchPendingCount + ' 待执行'),
'pending');
} else if (totalRunning === 0) {
setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.allIdle', null, '系统空闲'), 'idle'); setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.allIdle', null, '系统空闲'), 'idle');
} else { } else {
setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.executingNow', null, '正在执行'), 'running'); setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.executingNow', null, '正在执行'), 'running');
@@ -301,36 +289,27 @@ async function refreshDashboard() {
updateProgressBar('dashboard-batch-progress-done', '0'); updateProgressBar('dashboard-batch-progress-done', '0');
} }
// 工具调用:monitor/stats 为 { toolName: { totalCalls, successCalls, failedCalls, ... } } // 工具调用:monitor/stats 为 { summary, topTools }
let toolsCount = 0, toolsTotalCalls = 0, toolsSuccessRate = -1, toolsFailedCount = 0; let toolsCount = 0, toolsTotalCalls = 0, toolsSuccessRate = -1, toolsFailedCount = 0;
if (monitorRes && typeof monitorRes === 'object') { if (monitorRes && monitorRes.summary) {
const names = Object.keys(monitorRes); const s = monitorRes.summary;
let totalCalls = 0, totalSuccess = 0, totalFailed = 0; toolsCount = s.toolCount || 0;
names.forEach(k => { toolsTotalCalls = s.totalCalls || 0;
const v = monitorRes[k]; toolsFailedCount = s.failedCalls || 0;
const n = v && (v.totalCalls ?? v.TotalCalls); const totalSuccess = s.successCalls || 0;
if (typeof n === 'number') totalCalls += n; setEl('dashboard-kpi-tools-calls', formatNumber(toolsTotalCalls));
const s = v && (v.successCalls ?? v.SuccessCalls);
if (typeof s === 'number') totalSuccess += s;
const f = v && (v.failedCalls ?? v.FailedCalls);
if (typeof f === 'number') totalFailed += f;
});
toolsCount = names.length;
toolsTotalCalls = totalCalls;
toolsFailedCount = totalFailed;
setEl('dashboard-kpi-tools-calls', formatNumber(totalCalls));
setKpiSubText('dashboard-kpi-tools-sub-text', setKpiSubText('dashboard-kpi-tools-sub-text',
dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具')); dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具'));
if (totalCalls > 0) { if (toolsTotalCalls > 0) {
toolsSuccessRate = (totalSuccess / totalCalls) * 100; toolsSuccessRate = (totalSuccess / toolsTotalCalls) * 100;
const rateStr = toolsSuccessRate.toFixed(1) + '%'; const rateStr = toolsSuccessRate.toFixed(1) + '%';
setEl('dashboard-kpi-success-rate', rateStr); setEl('dashboard-kpi-success-rate', rateStr);
setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, totalFailed); setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, toolsFailedCount);
} else { } else {
setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-success-rate', '-');
setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用')); setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用'));
} }
renderDashboardToolsBar(monitorRes); renderDashboardToolsBar(monitorRes.topTools);
} else { } else {
setEl('dashboard-kpi-tools-calls', '-'); setEl('dashboard-kpi-tools-calls', '-');
setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-success-rate', '-');
@@ -414,7 +393,7 @@ async function refreshDashboard() {
var toolsConfiguredCount = (toolsConfigRes && typeof toolsConfigRes.total === 'number') var toolsConfiguredCount = (toolsConfigRes && typeof toolsConfigRes.total === 'number')
? toolsConfigRes.total : 0; ? toolsConfigRes.total : 0;
updateSmartCTA({ updateSmartCTA({
totalRunning: totalRunning, totalRunning: runningConversations + batchRunningCount,
totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0, totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0,
totalCalls: toolsTotalCalls, totalCalls: toolsTotalCalls,
toolsConfigured: toolsConfiguredCount, toolsConfigured: toolsConfiguredCount,
@@ -430,7 +409,7 @@ async function refreshDashboard() {
failedTools: toolsFailedCount, failedTools: toolsFailedCount,
toolsConfigured: toolsConfiguredCount, toolsConfigured: toolsConfiguredCount,
totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0, totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0,
totalRunning: totalRunning totalRunning: runningConversations + batchRunningCount
}); });
// 更新「上次更新」时间 // 更新「上次更新」时间
@@ -1615,12 +1594,12 @@ function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
} }
} }
function renderDashboardToolsBar(monitorRes) { function renderDashboardToolsBar(topTools) {
const placeholder = document.getElementById('dashboard-tools-pie-placeholder'); const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
const barChartEl = document.getElementById('dashboard-tools-bar-chart'); const barChartEl = document.getElementById('dashboard-tools-bar-chart');
if (!placeholder || !barChartEl) return; if (!placeholder || !barChartEl) return;
if (!monitorRes || typeof monitorRes !== 'object') { if (!Array.isArray(topTools) || topTools.length === 0) {
placeholder.style.removeProperty('display'); placeholder.style.removeProperty('display');
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
barChartEl.style.display = 'none'; barChartEl.style.display = 'none';
@@ -1628,11 +1607,12 @@ function renderDashboardToolsBar(monitorRes) {
return; return;
} }
const entries = Object.keys(monitorRes).map(function (k) { const entries = topTools.map(function (t) {
const v = monitorRes[k]; return {
const totalCalls = v && (v.totalCalls ?? v.TotalCalls); name: t.toolName || '',
return { name: k, totalCalls: typeof totalCalls === 'number' ? totalCalls : 0 }; totalCalls: typeof t.totalCalls === 'number' ? t.totalCalls : 0,
}).filter(function (e) { return e.totalCalls > 0; }) };
}).filter(function (e) { return e.name && e.totalCalls > 0; })
.sort(function (a, b) { return b.totalCalls - a.totalCalls; }) .sort(function (a, b) { return b.totalCalls - a.totalCalls; })
.slice(0, 30); .slice(0, 30);
+201 -152
View File
@@ -970,17 +970,22 @@ async function requestCancel(conversationId) {
} }
/** 与 MCP 监控一致:仅终止当前进行中的工具调用,工具返回后本轮推理继续(可选 reason 合并进工具结果) */ /** 与 MCP 监控一致:仅终止当前进行中的工具调用,工具返回后本轮推理继续(可选 reason 合并进工具结果) */
async function requestCancelWithContinue(conversationId, reason) { async function requestCancelWithContinue(conversationId, reason, options = {}) {
const executionId = options && options.executionId ? String(options.executionId).trim() : '';
const body = {
conversationId,
reason: reason || '',
continueAfter: true,
};
if (executionId) {
body.executionId = executionId;
}
const response = await apiFetch('/api/agent-loop/cancel', { const response = await apiFetch('/api/agent-loop/cancel', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify(body),
conversationId,
reason: reason || '',
continueAfter: true,
}),
}); });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
@@ -1021,7 +1026,9 @@ async function submitUserInterruptContinue() {
stopBtn.disabled = true; stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...'; stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...';
} }
await requestCancelWithContinue(conversationId, reason); await requestCancelWithContinue(conversationId, reason, {
executionId: monitorCtx && monitorCtx.executionId ? monitorCtx.executionId : '',
});
if (monitorCtx && monitorCtx.executionId && typeof refreshMonitorPanel === 'function') { if (monitorCtx && monitorCtx.executionId && typeof refreshMonitorPanel === 'function') {
const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page) const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page)
? monitorState.pagination.page ? monitorState.pagination.page
@@ -3118,6 +3125,12 @@ function attachToolResultToCall(progressId, toolCallId, data, options) {
if (!item && mapping && mapping.timeline) { if (!item && mapping && mapping.timeline) {
item = findToolCallItemById(mapping.timeline, toolCallId); item = findToolCallItemById(mapping.timeline, toolCallId);
} }
if (!item && progressId) {
const progressRoot = document.getElementById(String(progressId));
if (progressRoot) {
item = findToolCallItemById(progressRoot, toolCallId);
}
}
if (!item) return false; if (!item) return false;
mergeToolResultIntoCallItem(item, data, options); mergeToolResultIntoCallItem(item, data, options);
return true; return true;
@@ -3168,6 +3181,12 @@ function coalesceProcessDetailsToolPairs(details) {
} }
} }
if (target) { if (target) {
// agentFacing 或较新的 tool_result 覆盖旧合并(历史数据可能含 reduction 前全量正文)
const prev = target.data._mergedResult;
if (prev && data.agentFacing !== true && prev.agentFacing === true) {
out.push(detail);
continue;
}
absorbResult(target, detail); absorbResult(target, detail);
continue; continue;
} }
@@ -3527,12 +3546,15 @@ let monitorPanelFetchSeq = 0;
// 监控面板状态 // 监控面板状态
const monitorState = { const monitorState = {
executions: [], executions: [],
stats: {}, summary: null,
topTools: [],
timeline: null, timeline: null,
timelineRange: null, timelineRange: null,
timelineError: null, timelineError: null,
timelineLoading: false,
lastFetchedAt: null, lastFetchedAt: null,
retentionDays: 0, retentionDays: 0,
selectedExecutions: new Set(),
pagination: { pagination: {
page: 1, page: 1,
pageSize: (() => { pageSize: (() => {
@@ -3626,17 +3648,14 @@ async function refreshMonitorPanel(page = null) {
try { try {
const mySeq = ++monitorPanelFetchSeq; const mySeq = ++monitorPanelFetchSeq;
// 如果指定了页码,使用指定页码,否则使用当前页码
const currentPage = page !== null ? page : monitorState.pagination.page; const currentPage = page !== null ? page : monitorState.pagination.page;
const pageSize = monitorState.pagination.pageSize; const pageSize = monitorState.pagination.pageSize;
// 获取当前的筛选条件
const statusFilter = document.getElementById('monitor-status-filter'); const statusFilter = document.getElementById('monitor-status-filter');
const toolFilter = document.getElementById('monitor-tool-filter'); const toolFilter = document.getElementById('monitor-tool-filter');
const currentStatusFilter = statusFilter ? statusFilter.value : 'all'; const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all'; const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
// 构建请求 URL
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`; let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
if (currentStatusFilter && currentStatusFilter !== 'all') { if (currentStatusFilter && currentStatusFilter !== 'all') {
url += `&status=${encodeURIComponent(currentStatusFilter)}`; url += `&status=${encodeURIComponent(currentStatusFilter)}`;
@@ -3645,36 +3664,33 @@ async function refreshMonitorPanel(page = null) {
url += `&tool=${encodeURIComponent(currentToolFilter)}`; url += `&tool=${encodeURIComponent(currentToolFilter)}`;
} }
const { result, timeline, timelineError } = await fetchMonitorAndTimeline(url); const range = getMcpMonitorTimelineRange();
monitorState.timelineLoading = true;
const timelinePromise = fetchMonitorTimeline(range);
const monitorResp = await apiFetch(url, { method: 'GET' });
const result = await monitorResp.json().catch(() => ({}));
if (!monitorResp.ok) {
throw new Error(result.error || '获取监控数据失败');
}
if (mySeq !== monitorPanelFetchSeq) { if (mySeq !== monitorPanelFetchSeq) {
return; return;
} }
monitorState.executions = Array.isArray(result.executions) ? result.executions : []; applyMonitorPayload(result, currentStatusFilter);
monitorState.stats = result.stats || {};
const { timeline, timelineError } = await timelinePromise;
if (mySeq !== monitorPanelFetchSeq) {
return;
}
monitorState.timeline = timeline; monitorState.timeline = timeline;
monitorState.timelineError = timelineError; monitorState.timelineError = timelineError;
monitorState.lastFetchedAt = new Date(); monitorState.timelineLoading = false;
monitorState.retentionDays = typeof result.retention_days === 'number' ? result.retention_days : 0; updateMonitorTimelineSection();
// 更新分页信息
if (result.total !== undefined) {
monitorState.pagination = {
page: result.page || currentPage,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalPages: result.total_pages || 1
};
}
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, currentStatusFilter);
renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize(); initializeMonitorPageSize();
} catch (error) { } catch (error) {
console.error('刷新监控面板失败:', error); console.error('刷新监控面板失败:', error);
monitorState.timelineLoading = false;
if (statsContainer) { if (statsContainer) {
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`; statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`;
} }
@@ -3717,10 +3733,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
try { try {
const mySeq = ++monitorPanelFetchSeq; const mySeq = ++monitorPanelFetchSeq;
const currentPage = 1; // 筛选时重置到第一页 const currentPage = 1;
const pageSize = monitorState.pagination.pageSize; const pageSize = monitorState.pagination.pageSize;
// 构建请求 URL
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`; let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
if (statusFilter && statusFilter !== 'all') { if (statusFilter && statusFilter !== 'all') {
url += `&status=${encodeURIComponent(statusFilter)}`; url += `&status=${encodeURIComponent(statusFilter)}`;
@@ -3729,36 +3744,33 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
url += `&tool=${encodeURIComponent(toolFilter)}`; url += `&tool=${encodeURIComponent(toolFilter)}`;
} }
const { result, timeline, timelineError } = await fetchMonitorAndTimeline(url); const range = getMcpMonitorTimelineRange();
monitorState.timelineLoading = true;
const timelinePromise = fetchMonitorTimeline(range);
const monitorResp = await apiFetch(url, { method: 'GET' });
const result = await monitorResp.json().catch(() => ({}));
if (!monitorResp.ok) {
throw new Error(result.error || '获取监控数据失败');
}
if (mySeq !== monitorPanelFetchSeq) { if (mySeq !== monitorPanelFetchSeq) {
return; return;
} }
monitorState.executions = Array.isArray(result.executions) ? result.executions : []; applyMonitorPayload(result, statusFilter);
monitorState.stats = result.stats || {};
const { timeline, timelineError } = await timelinePromise;
if (mySeq !== monitorPanelFetchSeq) {
return;
}
monitorState.timeline = timeline; monitorState.timeline = timeline;
monitorState.timelineError = timelineError; monitorState.timelineError = timelineError;
monitorState.lastFetchedAt = new Date(); monitorState.timelineLoading = false;
monitorState.retentionDays = typeof result.retention_days === 'number' ? result.retention_days : 0; updateMonitorTimelineSection();
// 更新分页信息
if (result.total !== undefined) {
monitorState.pagination = {
page: result.page || currentPage,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalPages: result.total_pages || 1
};
}
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, statusFilter);
renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize(); initializeMonitorPageSize();
} catch (error) { } catch (error) {
console.error('刷新监控面板失败:', error); console.error('刷新监控面板失败:', error);
monitorState.timelineLoading = false;
if (statsContainer) { if (statsContainer) {
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`; statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`;
} }
@@ -3768,6 +3780,63 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
} }
} }
function applyMonitorPayload(result, statusFilter) {
const currentPage = monitorState.pagination.page;
const pageSize = monitorState.pagination.pageSize;
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
monitorState.summary = result.summary || null;
monitorState.topTools = Array.isArray(result.topTools) ? result.topTools : [];
monitorState.lastFetchedAt = new Date();
monitorState.retentionDays = typeof result.retentionDays === 'number' ? result.retentionDays : 0;
if (result.total !== undefined) {
monitorState.pagination = {
page: result.page || currentPage,
pageSize: result.pageSize || pageSize,
total: result.total || 0,
totalPages: result.totalPages || 1
};
}
renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, statusFilter);
renderMonitorPagination();
}
async function fetchMonitorTimeline(range) {
try {
const timelineResp = await apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' });
const timelineJson = await timelineResp.json().catch(() => ({}));
if (!timelineResp.ok) {
return { timeline: null, timelineError: timelineJson.error || 'timeline failed' };
}
return { timeline: timelineJson, timelineError: null };
} catch (err) {
return { timeline: null, timelineError: err && err.message ? err.message : 'timeline failed' };
}
}
function updateMonitorTimelineSection() {
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
if (timelineInner) {
const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(
monitorState.timeline,
monitorState.timelineError,
compactEmpty,
monitorState.timelineLoading
);
bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI();
return;
}
if (monitorState.summary) {
renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
}
}
const MCP_STATS_TOP_N = 6; const MCP_STATS_TOP_N = 6;
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d']; const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
@@ -3782,29 +3851,14 @@ function getMcpMonitorTimelineRange() {
return range; return range;
} }
async function fetchMonitorAndTimeline(monitorUrl) { function buildMonitorTotals(summary) {
const range = getMcpMonitorTimelineRange(); const s = summary && typeof summary === 'object' ? summary : {};
const [monitorResp, timelineResp] = await Promise.all([ return {
apiFetch(monitorUrl, { method: 'GET' }), total: s.totalCalls || 0,
apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' }) success: s.successCalls || 0,
]); failed: s.failedCalls || 0,
const result = await monitorResp.json().catch(() => ({})); lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
if (!monitorResp.ok) { };
throw new Error(result.error || '获取监控数据失败');
}
let timeline = null;
let timelineError = null;
try {
const timelineJson = await timelineResp.json().catch(() => ({}));
if (timelineResp.ok) {
timeline = timelineJson;
} else {
timelineError = timelineJson.error || 'timeline failed';
}
} catch (err) {
timelineError = err && err.message ? err.message : 'timeline failed';
}
return { result, timeline, timelineError };
} }
function formatMcpTimelineLabel(isoOrDate, rangeKey, locale) { function formatMcpTimelineLabel(isoOrDate, rangeKey, locale) {
@@ -4028,34 +4082,19 @@ async function setMcpMonitorTimelineRange(range) {
localStorage.setItem('mcpMonitorTimelineRange', range); localStorage.setItem('mcpMonitorTimelineRange', range);
monitorState.timelineRange = range; monitorState.timelineRange = range;
monitorState.timelineError = null; monitorState.timelineError = null;
monitorState.timelineLoading = true;
syncMcpMonitorTimelineRangeUI(range); syncMcpMonitorTimelineRangeUI(range);
updateMonitorTimelineSection();
try { try {
const timelineResp = await apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' }); const { timeline, timelineError } = await fetchMonitorTimeline(range);
const timelineJson = await timelineResp.json().catch(() => ({})); monitorState.timeline = timeline;
if (!timelineResp.ok) { monitorState.timelineError = timelineError;
throw new Error(timelineJson.error || '加载趋势失败'); monitorState.timelineLoading = false;
} updateMonitorTimelineSection();
monitorState.timeline = timelineJson;
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
if (timelineInner) {
const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI(range);
} else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
}
} catch (err) { } catch (err) {
monitorState.timelineError = err.message || 'error'; monitorState.timelineError = err.message || 'error';
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner'); monitorState.timelineLoading = false;
if (timelineInner) { updateMonitorTimelineSection();
const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI(range);
}
} }
} }
window.setMcpMonitorTimelineRange = setMcpMonitorTimelineRange; window.setMcpMonitorTimelineRange = setMcpMonitorTimelineRange;
@@ -4084,7 +4123,12 @@ function renderMcpStatsTimelineEmptyState(compact) {
</div>`; </div>`;
} }
function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty) { function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loading) {
if (loading) {
const loadingText = mcpMonitorT('timelineLoading') || monitorFallback('趋势加载中…', 'Loading trend…');
return `<div class="monitor-empty monitor-empty--inline">${escapeHtml(loadingText)}</div>`;
}
const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined'); const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined');
if (timelineError) { if (timelineError) {
@@ -4152,7 +4196,7 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
const timelineCol = showTimeline const timelineCol = showTimeline
? `<div class="mcp-stats-combined__timeline"> ? `<div class="mcp-stats-combined__timeline">
<p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p> <p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p>
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools)}</div> <div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools, monitorState.timelineLoading)}</div>
</div>` </div>`
: ''; : '';
@@ -4207,20 +4251,11 @@ function refreshMonitorPanelFromState() {
if (!monitorState.lastFetchedAt) return; if (!monitorState.lastFetchedAt) return;
const statusFilter = document.getElementById('monitor-status-filter'); const statusFilter = document.getElementById('monitor-status-filter');
const currentStatusFilter = statusFilter ? statusFilter.value : 'all'; const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
renderMonitorStats(monitorState.stats || {}, monitorState.lastFetchedAt); renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions || [], currentStatusFilter); renderMonitorExecutions(monitorState.executions || [], currentStatusFilter);
renderMonitorPagination(); renderMonitorPagination();
} }
function normalizeMonitorStatsEntries(statsMap) {
if (!statsMap || typeof statsMap !== 'object') return [];
return Object.entries(statsMap).map(([key, item]) => {
const stat = item && typeof item === 'object' ? { ...item } : {};
if (!stat.toolName) stat.toolName = key;
return stat;
});
}
const MCP_STATS_TOOL_CHEVRON = '<svg class="mcp-stats-tool-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>'; const MCP_STATS_TOOL_CHEVRON = '<svg class="mcp-stats-tool-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
function getMcpStatsRateTone(rateNum) { function getMcpStatsRateTone(rateNum) {
@@ -4915,15 +4950,19 @@ function renderMcpStatsToolRanking(topTools, totals, activeToolFilter = '', opti
return renderMcpStatsDetailSection(topTools, totals, activeToolFilter); return renderMcpStatsDetailSection(topTools, totals, activeToolFilter);
} }
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) { function renderMonitorStats(summary = null, topTools = [], lastFetchedAt = null) {
const container = document.getElementById('monitor-stats'); const container = document.getElementById('monitor-stats');
if (!container) { if (!container) {
return; return;
} }
const entries = normalizeMonitorStatsEntries(statsMap); const tools = Array.isArray(topTools) ? topTools : [];
const showTimeline = monitorState.timeline != null || !!monitorState.timelineError; const totals = buildMonitorTotals(summary);
if (entries.length === 0 && !showTimeline) { const toolCount = summary && typeof summary.toolCount === 'number' ? summary.toolCount : tools.length;
const showTimeline = monitorState.timelineLoading || monitorState.timeline != null || !!monitorState.timelineError;
const hasSummaryData = toolCount > 0 || totals.total > 0;
if (!hasSummaryData && !showTimeline) {
const noStats = mcpMonitorT('noStatsData') || monitorFallback('暂无统计数据', 'No statistical data'); const noStats = mcpMonitorT('noStatsData') || monitorFallback('暂无统计数据', 'No statistical data');
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>'; container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
const subtitle = document.getElementById('monitor-stats-subtitle'); const subtitle = document.getElementById('monitor-stats-subtitle');
@@ -4931,20 +4970,6 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
return; return;
} }
const totals = entries.reduce(
(acc, item) => {
acc.total += item.totalCalls || 0;
acc.success += item.successCalls || 0;
acc.failed += item.failedCalls || 0;
const lastCall = item.lastCallTime ? new Date(item.lastCallTime) : null;
if (lastCall && (!acc.lastCallTime || lastCall > acc.lastCallTime)) {
acc.lastCallTime = lastCall;
}
return acc;
},
{ total: 0, success: 0, failed: 0, lastCallTime: null }
);
const hasCalls = totals.total > 0; const hasCalls = totals.total > 0;
const successRateNum = hasCalls ? (totals.success / totals.total) * 100 : 0; const successRateNum = hasCalls ? (totals.success / totals.total) * 100 : 0;
const successRate = hasCalls ? successRateNum.toFixed(1) : '-'; const successRate = hasCalls ? successRateNum.toFixed(1) : '-';
@@ -4965,19 +4990,13 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
const toolFilterEl = document.getElementById('monitor-tool-filter'); const toolFilterEl = document.getElementById('monitor-tool-filter');
const activeToolFilter = toolFilterEl ? toolFilterEl.value.trim() : ''; const activeToolFilter = toolFilterEl ? toolFilterEl.value.trim() : '';
const topTools = entries
.filter(tool => (tool.totalCalls || 0) > 0)
.slice()
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
.slice(0, MCP_STATS_TOP_N);
const hasAnyCalls = totals.total > 0; const hasAnyCalls = totals.total > 0;
const showCombined = hasAnyCalls && (topTools.length > 0 || showTimeline); const showCombined = hasAnyCalls && (tools.length > 0 || showTimeline);
const html = ` const html = `
<div class="mcp-exec-stats"> <div class="mcp-exec-stats">
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)} ${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
${showCombined ? renderMcpStatsCombinedSection( ${showCombined ? renderMcpStatsCombinedSection(
topTools, tools,
totals, totals,
activeToolFilter, activeToolFilter,
monitorState.timeline, monitorState.timeline,
@@ -4995,7 +5014,7 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
} else if (toolFilterEl) { } else if (toolFilterEl) {
toolFilterEl.classList.remove('is-filter-active'); toolFilterEl.classList.remove('is-filter-active');
} }
updateMonitorStatsSubtitle(lastFetchedAt, entries.length, monitorState.retentionDays); updateMonitorStatsSubtitle(lastFetchedAt, toolCount, monitorState.retentionDays);
} }
function renderMonitorExecutions(executions = [], statusFilter = 'all') { function renderMonitorExecutions(executions = [], statusFilter = 'all') {
@@ -5052,10 +5071,12 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const terminateBtn = status === 'running' const terminateBtn = status === 'running'
? `<button type="button" class="btn-secondary btn-monitor-abort" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>` ? `<button type="button" class="btn-secondary btn-monitor-abort" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>`
: ''; : '';
const jsExecId = rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isSelected = monitorState.selectedExecutions.has(rawExecId);
return ` return `
<tr> <tr>
<td> <td>
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" /> <input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" />
</td> </td>
<td>${toolName}</td> <td>${toolName}</td>
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td> <td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
@@ -5201,6 +5222,8 @@ async function deleteExecution(executionId) {
throw new Error(error.error || deleteFailedMsg); throw new Error(error.error || deleteFailedMsg);
} }
monitorState.selectedExecutions.delete(executionId);
// 删除成功后刷新当前页面 // 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page; const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage); await refreshMonitorPanel(currentPage);
@@ -5214,10 +5237,22 @@ async function deleteExecution(executionId) {
} }
} }
// 切换单条执行记录选中状态(持久化到 monitorState,避免轮询刷新后丢失)
function toggleExecutionSelection(executionId, selected) {
if (!executionId) {
return;
}
if (selected) {
monitorState.selectedExecutions.add(executionId);
} else {
monitorState.selectedExecutions.delete(executionId);
}
updateBatchActionsState();
}
// 更新批量操作状态 // 更新批量操作状态
function updateBatchActionsState() { function updateBatchActionsState() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked'); const selectedCount = monitorState.selectedExecutions.size;
const selectedCount = checkboxes.length;
const batchActions = document.getElementById('monitor-batch-actions'); const batchActions = document.getElementById('monitor-batch-actions');
const selectedCountSpan = document.getElementById('monitor-selected-count'); const selectedCountSpan = document.getElementById('monitor-selected-count');
@@ -5234,13 +5269,18 @@ function updateBatchActionsState() {
selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项'; selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项';
} }
// 更新全选复选框状态 // 更新全选复选框状态(仅反映当前页)
const selectAllCheckbox = document.getElementById('monitor-select-all'); const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) { if (selectAllCheckbox) {
const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox'); const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); if (allCheckboxes.length === 0) {
selectAllCheckbox.checked = allChecked; selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length; selectAllCheckbox.indeterminate = false;
} else {
const checkedOnPage = Array.from(allCheckboxes).filter(cb => monitorState.selectedExecutions.has(cb.value)).length;
selectAllCheckbox.checked = checkedOnPage === allCheckboxes.length;
selectAllCheckbox.indeterminate = checkedOnPage > 0 && checkedOnPage < allCheckboxes.length;
}
} }
} }
@@ -5249,6 +5289,11 @@ function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox'); const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
cb.checked = checkbox.checked; cb.checked = checkbox.checked;
if (checkbox.checked) {
monitorState.selectedExecutions.add(cb.value);
} else {
monitorState.selectedExecutions.delete(cb.value);
}
}); });
updateBatchActionsState(); updateBatchActionsState();
} }
@@ -5258,6 +5303,7 @@ function selectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox'); const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
cb.checked = true; cb.checked = true;
monitorState.selectedExecutions.add(cb.value);
}); });
const selectAllCheckbox = document.getElementById('monitor-select-all'); const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) { if (selectAllCheckbox) {
@@ -5273,6 +5319,7 @@ function deselectAllExecutions() {
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
cb.checked = false; cb.checked = false;
}); });
monitorState.selectedExecutions.clear();
const selectAllCheckbox = document.getElementById('monitor-select-all'); const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) { if (selectAllCheckbox) {
selectAllCheckbox.checked = false; selectAllCheckbox.checked = false;
@@ -5283,14 +5330,12 @@ function deselectAllExecutions() {
// 批量删除执行记录 // 批量删除执行记录
async function batchDeleteExecutions() { async function batchDeleteExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked'); const ids = Array.from(monitorState.selectedExecutions);
if (checkboxes.length === 0) { if (ids.length === 0) {
const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录'; const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录';
alert(selectFirstMsg); alert(selectFirstMsg);
return; return;
} }
const ids = Array.from(checkboxes).map(cb => cb.value);
const count = ids.length; const count = ids.length;
const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`; const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`;
if (!confirm(batchConfirmMsg)) { if (!confirm(batchConfirmMsg)) {
@@ -5315,6 +5360,10 @@ async function batchDeleteExecutions() {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
const deletedCount = result.deleted || count; const deletedCount = result.deleted || count;
ids.forEach(function (id) {
monitorState.selectedExecutions.delete(id);
});
// 删除成功后刷新当前页面 // 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page; const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage); await refreshMonitorPanel(currentPage);
+10
View File
@@ -293,6 +293,9 @@ async function ensureProjectsLoaded(force) {
projectsCacheAll = list; projectsCacheAll = list;
rebuildProjectNameMap(projectsCacheAll); rebuildProjectNameMap(projectsCacheAll);
_projectsListReady = true; _projectsListReady = true;
if (typeof window.refreshConversationProjectFilter === 'function') {
window.refreshConversationProjectFilter();
}
return projectsCacheAll; return projectsCacheAll;
}) })
.catch((e) => { .catch((e) => {
@@ -371,6 +374,9 @@ async function loadProjectsList() {
if (typeof refreshVulnerabilityProjectFilter === 'function') { if (typeof refreshVulnerabilityProjectFilter === 'function') {
refreshVulnerabilityProjectFilter(); refreshVulnerabilityProjectFilter();
} }
if (typeof window.refreshAllProjectFilterSelects === 'function') {
await window.refreshAllProjectFilterSelects();
}
} }
function projectInitial(name) { function projectInitial(name) {
@@ -2198,6 +2204,9 @@ async function applyChatProjectSelection(projectId) {
setActiveProjectId(projectId); setActiveProjectId(projectId);
} }
updateChatProjectButtonLabel(); updateChatProjectButtonLabel();
if (typeof window.onConversationProjectBindingChanged === 'function') {
window.onConversationProjectBindingChanged(projectId);
}
} }
/** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */ /** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */
@@ -2326,3 +2335,4 @@ window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode; window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
window.rebuildProjectNameMap = rebuildProjectNameMap; window.rebuildProjectNameMap = rebuildProjectNameMap;
window.projectNameById = projectNameById; window.projectNameById = projectNameById;
window.ensureProjectsLoaded = ensureProjectsLoaded;
+431 -14
View File
@@ -39,6 +39,220 @@ function vulnStatusLabel(code) {
return m[code] ? vulnT(m[code]) : code; return m[code] ? vulnT(m[code]) : code;
} }
const VULN_STATUS_CODES = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'];
const VULNERABILITY_REMOVE_ANIM_MS = 200;
function getVulnerabilityScrollContainer() {
const page = document.getElementById('page-vulnerabilities');
return page ? page.querySelector('.page-content') : null;
}
function getExpandedVulnerabilityIds() {
const ids = [];
document.querySelectorAll('#vulnerabilities-list .vulnerability-content').forEach(function (el) {
if (el.style.display !== 'none') {
const id = (el.id || '').replace(/^content-/, '');
if (id) ids.push(id);
}
});
return ids;
}
function restoreExpandedVulnerabilityDetails(expandedIds) {
if (!expandedIds || !expandedIds.length) return;
expandedIds.forEach(function (id) {
const content = document.getElementById('content-' + id);
const icon = document.getElementById('expand-icon-' + id);
if (!content || content.style.display !== 'none') return;
content.style.display = 'block';
if (icon) icon.style.transform = 'rotate(90deg)';
loadVulnerabilityRelatedFacts(id).catch(function (e) { console.warn(e); });
});
}
function buildVulnerabilityStatusPicker(vuln) {
const current = vuln.status || 'open';
const id = escapeHtml(vuln.id);
const label = escapeHtml(vulnT('vulnerabilityPage.statusChangeLabel'));
const caretSvg = '<svg class="vuln-status-picker-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const options = VULN_STATUS_CODES.map(function (code) {
const selected = code === current;
const selCls = selected ? ' is-selected' : '';
const ariaSel = selected ? ' aria-selected="true"' : ' aria-selected="false"';
return '<button type="button" class="vuln-status-picker-option' + selCls + '" role="option" data-value="' + code + '"' + ariaSel + '>' +
'<span class="vuln-status-picker-check" aria-hidden="true">✓</span>' +
'<span class="vuln-status-picker-label">' + escapeHtml(vulnStatusLabel(code)) + '</span>' +
'</button>';
}).join('');
return '<div class="vuln-status-picker status-' + escapeHtml(current) + '" data-vuln-id="' + id + '" data-prev-status="' + escapeHtml(current) + '">' +
'<button type="button" class="vuln-status-picker-trigger" aria-label="' + label + '" aria-haspopup="listbox" aria-expanded="false">' +
'<span class="vuln-status-picker-value">' + escapeHtml(vulnStatusLabel(current)) + '</span>' +
caretSvg +
'</button>' +
'<div class="vuln-status-picker-menu" role="listbox" hidden>' + options + '</div>' +
'</div>';
}
const VULN_STATUS_PICKER_STATUS_CLASSES = VULN_STATUS_CODES.map(function (code) {
return 'status-' + code;
});
function setVulnerabilityStatusPickerDisabled(pickerEl, disabled) {
if (!pickerEl) return;
pickerEl.classList.toggle('is-disabled', !!disabled);
const trigger = pickerEl.querySelector('.vuln-status-picker-trigger');
if (trigger) trigger.disabled = !!disabled;
}
function updateVulnerabilityStatusPicker(pickerEl, status) {
if (!pickerEl) return;
const code = status || 'open';
VULN_STATUS_PICKER_STATUS_CLASSES.forEach(function (cls) {
pickerEl.classList.remove(cls);
});
pickerEl.classList.add('status-' + code);
pickerEl.dataset.prevStatus = code;
const valueEl = pickerEl.querySelector('.vuln-status-picker-value');
if (valueEl) valueEl.textContent = vulnStatusLabel(code);
pickerEl.querySelectorAll('.vuln-status-picker-option').forEach(function (opt) {
const isSel = opt.getAttribute('data-value') === code;
opt.classList.toggle('is-selected', isSel);
opt.setAttribute('aria-selected', isSel ? 'true' : 'false');
});
}
let vulnerabilityStatusPickerDocBound = false;
function closeAllVulnerabilityStatusPickers() {
document.querySelectorAll('.vuln-status-picker.open').forEach(function (picker) {
picker.classList.remove('open');
const menu = picker.querySelector('.vuln-status-picker-menu');
const trigger = picker.querySelector('.vuln-status-picker-trigger');
if (menu) menu.hidden = true;
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
}
function initVulnerabilityStatusPickers(root) {
if (!vulnerabilityStatusPickerDocBound) {
document.addEventListener('click', closeAllVulnerabilityStatusPickers);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllVulnerabilityStatusPickers();
});
vulnerabilityStatusPickerDocBound = true;
}
const scope = root || document.getElementById('vulnerabilities-list');
if (!scope) return;
scope.querySelectorAll('.vuln-status-picker').forEach(function (picker) {
if (picker.dataset.bound === '1') return;
picker.dataset.bound = '1';
picker.addEventListener('click', function (e) { e.stopPropagation(); });
picker.addEventListener('keydown', function (e) { e.stopPropagation(); });
const trigger = picker.querySelector('.vuln-status-picker-trigger');
const menu = picker.querySelector('.vuln-status-picker-menu');
if (!trigger || !menu) return;
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (picker.classList.contains('is-disabled')) return;
const wasOpen = picker.classList.contains('open');
closeAllVulnerabilityStatusPickers();
if (!wasOpen) {
picker.classList.add('open');
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
}
});
menu.addEventListener('click', function (e) {
e.stopPropagation();
const opt = e.target.closest('.vuln-status-picker-option');
if (!opt || picker.classList.contains('is-disabled')) return;
const newStatus = opt.getAttribute('data-value');
const vulnId = picker.dataset.vulnId;
closeAllVulnerabilityStatusPickers();
changeVulnerabilityStatus(vulnId, newStatus, picker);
});
});
}
function vulnerabilityStatusMatchesFilter(status) {
const filterStatus = (vulnerabilityFilters.status || '').trim();
return !filterStatus || filterStatus === status;
}
function removeVulnerabilityCard(vulnId, options) {
const opts = options || {};
const card = document.getElementById('vulnerability-card-' + vulnId) ||
document.querySelector('.vulnerability-card[data-vuln-id="' + vulnId + '"]');
if (!card) return;
const nextCard = card.nextElementSibling;
card.classList.add('vulnerability-card--removing');
setTimeout(function () {
card.remove();
if (opts.decrementTotal !== false) {
vulnerabilityPagination.total = Math.max(0, (vulnerabilityPagination.total || 0) - 1);
vulnerabilityPagination.totalPages = Math.max(
1,
Math.ceil(vulnerabilityPagination.total / vulnerabilityPagination.pageSize)
);
renderVulnerabilityPagination();
}
const list = document.getElementById('vulnerabilities-list');
const remaining = list ? list.querySelectorAll('.vulnerability-card').length : 0;
if (remaining === 0) {
if (vulnerabilityPagination.currentPage > 1) {
vulnerabilityPagination.currentPage--;
}
loadVulnerabilities();
return;
}
if (opts.focusNext !== false && nextCard && nextCard.classList.contains('vulnerability-card')) {
nextCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, VULNERABILITY_REMOVE_ANIM_MS);
}
async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
if (!vulnId || !pickerEl) return;
const prevStatus = pickerEl.dataset.prevStatus || newStatus;
if (newStatus === prevStatus) return;
setVulnerabilityStatusPickerDisabled(pickerEl, true);
try {
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(vulnId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!response.ok) {
const err = await response.json().catch(function () { return {}; });
throw new Error(err.error || vulnT('vulnerabilityPage.statusUpdateFailed'));
}
updateVulnerabilityStatusPicker(pickerEl, newStatus);
loadVulnerabilityStats();
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
removeVulnerabilityCard(vulnId, { decrementTotal: true, focusNext: true });
}
} catch (error) {
console.error('更新漏洞状态失败:', error);
updateVulnerabilityStatusPicker(pickerEl, prevStatus);
alert(vulnT('vulnerabilityPage.statusUpdateFailed') + ': ' + error.message);
} finally {
setVulnerabilityStatusPickerDisabled(pickerEl, false);
}
}
// 从localStorage读取每页显示数量,默认为20 // 从localStorage读取每页显示数量,默认为20
const getVulnerabilityPageSize = () => { const getVulnerabilityPageSize = () => {
const saved = localStorage.getItem('vulnerabilityPageSize'); const saved = localStorage.getItem('vulnerabilityPageSize');
@@ -175,6 +389,7 @@ function syncVulnerabilityFiltersFromLocationHash() {
syncVulnerabilityStatCardActiveState(); syncVulnerabilityStatCardActiveState();
updateVulnerabilityFilterPanelState(); updateVulnerabilityFilterPanelState();
renderVulnerabilityFilterChips(); renderVulnerabilityFilterChips();
syncAllVulnFilterCustomSelects();
} }
// 初始化漏洞管理页面 // 初始化漏洞管理页面
@@ -387,6 +602,7 @@ function initVulnerabilityFilterPanel() {
if (vulnerabilityFilterPanelBound) { if (vulnerabilityFilterPanelBound) {
updateVulnerabilityFilterPanelState(); updateVulnerabilityFilterPanelState();
syncAllVulnFilterCustomSelects();
return; return;
} }
vulnerabilityFilterPanelBound = true; vulnerabilityFilterPanelBound = true;
@@ -448,6 +664,146 @@ function initVulnerabilityFilterPanel() {
}); });
bindVulnerabilityFilterTypeaheads(); bindVulnerabilityFilterTypeaheads();
initVulnerabilityFilterSelects();
}
const VULN_FILTER_CUSTOM_SELECT_IDS = ['vulnerability-project-filter', 'vulnerability-status-filter'];
const vulnFilterCustomSelectMap = {};
let vulnFilterCustomSelectDocBound = false;
const VULN_FILTER_SELECT_CARET = '<svg class="vuln-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function closeAllVulnFilterCustomSelects() {
Object.keys(vulnFilterCustomSelectMap).forEach(function (id) {
const reg = vulnFilterCustomSelectMap[id];
if (!reg || !reg.wrapper) return;
reg.wrapper.classList.remove('open');
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
});
}
function syncVulnFilterCustomSelect(selectId) {
const reg = vulnFilterCustomSelectMap[selectId];
if (!reg) return;
const select = reg.select;
const dropdown = reg.dropdown;
const trigger = reg.trigger;
const valueSpan = trigger.querySelector('.vuln-filter-select-value');
dropdown.innerHTML = '';
Array.prototype.forEach.call(select.options, function (opt) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'vuln-filter-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');
} else {
item.setAttribute('aria-selected', 'false');
}
const check = document.createElement('span');
check.className = 'vuln-filter-select-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'vuln-filter-select-label';
label.textContent = opt.textContent;
item.appendChild(check);
item.appendChild(label);
dropdown.appendChild(item);
});
const selectedOpt = select.options[select.selectedIndex];
if (valueSpan) {
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
}
trigger.disabled = !!select.disabled;
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
}
function syncAllVulnFilterCustomSelects() {
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(syncVulnFilterCustomSelect);
}
function enhanceVulnFilterCustomSelect(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
if (select.dataset.vulnCustomSelect === '1') {
syncVulnFilterCustomSelect(selectId);
return;
}
select.dataset.vulnCustomSelect = '1';
select.classList.add('vuln-filter-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'vuln-filter-select';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'vuln-filter-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const valueSpan = document.createElement('span');
valueSpan.className = 'vuln-filter-select-value';
trigger.appendChild(valueSpan);
trigger.insertAdjacentHTML('beforeend', VULN_FILTER_SELECT_CARET);
const dropdown = document.createElement('div');
dropdown.className = 'vuln-filter-select-dropdown';
dropdown.setAttribute('role', 'listbox');
const parent = select.parentNode;
parent.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(dropdown);
wrapper.appendChild(select);
vulnFilterCustomSelectMap[selectId] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (select.disabled) return;
if (typeof closeAllVulnerabilityStatusPickers === 'function') {
closeAllVulnerabilityStatusPickers();
}
const open = wrapper.classList.contains('open');
closeAllVulnFilterCustomSelects();
if (!open) {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
}
});
dropdown.addEventListener('click', function (e) {
const opt = e.target.closest('.vuln-filter-select-option');
if (!opt) return;
e.stopPropagation();
const val = opt.getAttribute('data-value');
if (val === null) return;
if (select.value !== val) {
select.value = val;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
wrapper.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
syncVulnFilterCustomSelect(selectId);
});
}
function initVulnerabilityFilterSelects() {
if (!vulnFilterCustomSelectDocBound) {
document.addEventListener('click', closeAllVulnFilterCustomSelects);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllVulnFilterCustomSelects();
});
vulnFilterCustomSelectDocBound = true;
}
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(enhanceVulnFilterCustomSelect);
syncAllVulnFilterCustomSelects();
} }
function countVulnerabilityAdvancedFiltersActive() { function countVulnerabilityAdvancedFiltersActive() {
@@ -559,6 +915,9 @@ function removeVulnerabilityFilterByKey(key) {
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) { if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
vulnerabilityFilters[key] = ''; vulnerabilityFilters[key] = '';
} }
if (key === 'project_id' || key === 'status') {
syncAllVulnFilterCustomSelects();
}
applyVulnerabilityFilters(); applyVulnerabilityFilters();
} }
@@ -779,9 +1138,22 @@ function updateVulnerabilityStats(stats) {
} }
// 加载漏洞列表 // 加载漏洞列表
async function loadVulnerabilities(page = null) { async function loadVulnerabilities(page = null, options = {}) {
const opts = options && typeof options === 'object' ? options : {};
const preserveScroll = !!opts.preserveScroll;
const silent = !!opts.silent;
let expandedIds = opts.expandedIds;
const scrollEl = preserveScroll ? getVulnerabilityScrollContainer() : null;
const scrollTop = scrollEl ? scrollEl.scrollTop : 0;
if (expandedIds === undefined && preserveScroll) {
expandedIds = getExpandedVulnerabilityIds();
}
const listContainer = document.getElementById('vulnerabilities-list'); const listContainer = document.getElementById('vulnerabilities-list');
if (!silent) {
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`; listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
}
try { try {
// 检查apiFetch是否可用 // 检查apiFetch是否可用
@@ -830,8 +1202,14 @@ async function loadVulnerabilities(page = null) {
console.error('未知的响应格式:', data); console.error('未知的响应格式:', data);
} }
renderVulnerabilities(vulnerabilities); renderVulnerabilities(vulnerabilities, { expandedIds: expandedIds || [] });
renderVulnerabilityPagination(); renderVulnerabilityPagination();
if (preserveScroll && scrollEl) {
requestAnimationFrame(function () {
scrollEl.scrollTop = scrollTop;
});
}
} catch (error) { } catch (error) {
console.error('加载漏洞列表失败:', error); console.error('加载漏洞列表失败:', error);
listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`; listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`;
@@ -839,7 +1217,8 @@ async function loadVulnerabilities(page = null) {
} }
// 渲染漏洞列表 // 渲染漏洞列表
function renderVulnerabilities(vulnerabilities) { function renderVulnerabilities(vulnerabilities, renderOptions) {
const opts = renderOptions && typeof renderOptions === 'object' ? renderOptions : {};
const listContainer = document.getElementById('vulnerabilities-list'); const listContainer = document.getElementById('vulnerabilities-list');
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新) // 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
@@ -862,7 +1241,6 @@ function renderVulnerabilities(vulnerabilities) {
const html = vulnerabilities.map(vuln => { const html = vulnerabilities.map(vuln => {
const severityClass = `severity-${vuln.severity}`; const severityClass = `severity-${vuln.severity}`;
const severityText = vulnSeverityLabel(vuln.severity); const severityText = vulnSeverityLabel(vuln.severity);
const statusText = vulnStatusLabel(vuln.status);
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale()); const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
const projectLabel = vuln.project_id const projectLabel = vuln.project_id
? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id) ? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id)
@@ -875,7 +1253,7 @@ function renderVulnerabilities(vulnerabilities) {
const deleteTitle = escapeHtml(vulnT('common.delete')); const deleteTitle = escapeHtml(vulnT('common.delete'));
return ` return `
<div class="vulnerability-card ${severityClass}"> <div class="vulnerability-card ${severityClass}" id="vulnerability-card-${vuln.id}" data-vuln-id="${escapeHtml(vuln.id)}">
<div class="vulnerability-header" onclick="toggleVulnerabilityDetails('${vuln.id}')" style="cursor: pointer;"> <div class="vulnerability-header" onclick="toggleVulnerabilityDetails('${vuln.id}')" style="cursor: pointer;">
<div class="vulnerability-title-section"> <div class="vulnerability-title-section">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
@@ -886,7 +1264,7 @@ function renderVulnerabilities(vulnerabilities) {
</div> </div>
<div class="vulnerability-meta"> <div class="vulnerability-meta">
<span class="severity-badge ${severityClass}">${severityText}</span> <span class="severity-badge ${severityClass}">${severityText}</span>
<span class="status-badge status-${vuln.status}">${statusText}</span> ${buildVulnerabilityStatusPicker(vuln)}
${projectBadge} ${projectBadge}
<span class="vulnerability-date">${createdDate}</span> <span class="vulnerability-date">${createdDate}</span>
</div> </div>
@@ -935,10 +1313,13 @@ function renderVulnerabilities(vulnerabilities) {
}).join(''); }).join('');
listContainer.innerHTML = html; listContainer.innerHTML = html;
initVulnerabilityStatusPickers(listContainer);
if (typeof window.applyTranslations === 'function') { if (typeof window.applyTranslations === 'function') {
window.applyTranslations(listContainer); window.applyTranslations(listContainer);
} }
restoreExpandedVulnerabilityDetails(opts.expandedIds);
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验) // 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) { if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
setTimeout(() => { setTimeout(() => {
@@ -1191,11 +1572,27 @@ async function saveVulnerability() {
throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed')); throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed'));
} }
const updated = await response.json();
const editedId = currentVulnerabilityId;
const isEdit = !!editedId;
const expandedIds = isEdit ? getExpandedVulnerabilityIds() : [];
closeVulnerabilityModal(); closeVulnerabilityModal();
loadVulnerabilityStats(); loadVulnerabilityStats();
// 保存/更新后,重置到第一页
if (!isEdit) {
vulnerabilityPagination.currentPage = 1; vulnerabilityPagination.currentPage = 1;
loadVulnerabilities(); loadVulnerabilities();
return;
}
const newStatus = (updated && updated.status) || data.status;
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
removeVulnerabilityCard(editedId, { decrementTotal: true, focusNext: true });
return;
}
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
} catch (error) { } catch (error) {
console.error('保存漏洞失败:', error); console.error('保存漏洞失败:', error);
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message); alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
@@ -1216,14 +1613,20 @@ async function deleteVulnerability(id) {
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed')); if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
loadVulnerabilityStats(); loadVulnerabilityStats();
// 删除后,如果当前页没有数据了,回到上一页 const card = document.getElementById('vulnerability-card-' + id) ||
document.querySelector('.vulnerability-card[data-vuln-id="' + id + '"]');
if (card) {
removeVulnerabilityCard(id, { decrementTotal: true, focusNext: true });
return;
}
if (vulnerabilityPagination.currentPage > 1 && vulnerabilityPagination.total > 0) { if (vulnerabilityPagination.currentPage > 1 && vulnerabilityPagination.total > 0) {
const itemsOnCurrentPage = vulnerabilityPagination.total - (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize; const itemsOnCurrentPage = vulnerabilityPagination.total - (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
if (itemsOnCurrentPage <= 1) { if (itemsOnCurrentPage <= 1) {
vulnerabilityPagination.currentPage--; vulnerabilityPagination.currentPage--;
} }
} }
loadVulnerabilities(); await loadVulnerabilities(null, { preserveScroll: true });
} catch (error) { } catch (error) {
console.error('删除漏洞失败:', error); console.error('删除漏洞失败:', error);
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message); alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
@@ -1263,6 +1666,7 @@ function clearVulnerabilityFilters() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.value = ''; if (el) el.value = '';
}); });
syncAllVulnFilterCustomSelects();
vulnerabilityFilters = { vulnerabilityFilters = {
q: '', q: '',
@@ -1685,10 +2089,16 @@ window.onclick = function(event) {
} }
}; };
document.addEventListener('languagechange', function () { document.addEventListener('languagechange', async function () {
const page = document.getElementById('page-vulnerabilities'); const page = document.getElementById('page-vulnerabilities');
if (page && page.classList.contains('active')) { if (page && page.classList.contains('active')) {
const panel = document.getElementById('vulnerability-filter-panel');
if (panel && typeof window.applyTranslations === 'function') {
window.applyTranslations(panel);
}
renderVulnerabilityFilterChips(); renderVulnerabilityFilterChips();
await refreshVulnerabilityProjectFilter();
syncAllVulnFilterCustomSelects();
loadVulnerabilities(); loadVulnerabilities();
} }
}); });
@@ -1709,11 +2119,15 @@ async function bindVulnerabilityProject(vulnId, projectId, silent) {
alert(vulnT('vulnerabilityPage.projectBindOk')); alert(vulnT('vulnerabilityPage.projectBindOk'));
} }
loadVulnerabilityStats(); loadVulnerabilityStats();
loadVulnerabilities(); const expandedIds = getExpandedVulnerabilityIds();
if (!expandedIds.includes(vulnId)) {
expandedIds.push(vulnId);
}
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
} catch (error) { } catch (error) {
console.error('绑定项目失败:', error); console.error('绑定项目失败:', error);
alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message); alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message);
loadVulnerabilities(); await loadVulnerabilities(null, { preserveScroll: true });
} }
} }
@@ -1738,15 +2152,16 @@ async function refreshVulnerabilityProjectFilter() {
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; }); list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
} }
const cur = vulnerabilityFilters.project_id || sel.value || ''; const cur = vulnerabilityFilters.project_id || sel.value || '';
let html = '<option value="">全部项目</option>'; let html = '<option value="">' + escapeHtml(vulnT('vulnerabilityPage.allProjects')) + '</option>';
(list || []).forEach((p) => { (list || []).forEach((p) => {
if (!p.id) return; if (!p.id) return;
const selected = p.id === cur ? ' selected' : ''; const selected = p.id === cur ? ' selected' : '';
const arch = p.status === 'archived' ? ' [归档]' : ''; const arch = p.status === 'archived' ? ' [' + vulnT('projects.archived') + ']' : '';
html += `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`; html += `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
}); });
sel.innerHTML = html; sel.innerHTML = html;
if (cur) sel.value = cur; if (cur) sel.value = cur;
syncVulnFilterCustomSelect('vulnerability-project-filter');
const modalSel = document.getElementById('vulnerability-project-id'); const modalSel = document.getElementById('vulnerability-project-id');
if (modalSel && isAppModalOpen('vulnerability-modal')) { if (modalSel && isAppModalOpen('vulnerability-modal')) {
const modalCur = modalSel.value || ''; const modalCur = modalSel.value || '';
@@ -1762,6 +2177,7 @@ function setVulnerabilityProjectFilter(projectId) {
vulnerabilityFilters.project_id = projectId || ''; vulnerabilityFilters.project_id = projectId || '';
const sel = document.getElementById('vulnerability-project-filter'); const sel = document.getElementById('vulnerability-project-filter');
if (sel) sel.value = projectId || ''; if (sel) sel.value = projectId || '';
syncVulnFilterCustomSelect('vulnerability-project-filter');
applyVulnerabilityFilters(); applyVulnerabilityFilters();
} }
@@ -1777,4 +2193,5 @@ window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter;
window.setVulnerabilityIdFilter = setVulnerabilityIdFilter; window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
window.bindVulnerabilityProject = bindVulnerabilityProject; window.bindVulnerabilityProject = bindVulnerabilityProject;
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml; window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
window.changeVulnerabilityStatus = changeVulnerabilityStatus;
+27 -13
View File
@@ -377,9 +377,9 @@
</div> </div>
<!-- 第一行:核心 KPI(关键指标置顶 + 副标徽章承载次级信息) --> <!-- 第一行:核心 KPI(关键指标置顶 + 副标徽章承载次级信息) -->
<div class="dashboard-kpi-row" id="dashboard-cards"> <div class="dashboard-kpi-row" id="dashboard-cards">
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }" data-i18n="dashboard.clickToViewTasks" data-i18n-attr="title" title="点击查看任务管理"> <div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('chat')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('chat'); }" data-i18n="dashboard.clickToViewChat" data-i18n-attr="title" title="点击查看对话">
<div class="dashboard-kpi-head"> <div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.runningTasks">运行中任务</div> <div class="dashboard-kpi-label" data-i18n="dashboard.runningConversations">运行中对话</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-tasks" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></span> <span class="dashboard-kpi-icon dashboard-kpi-icon-tasks" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></span>
</div> </div>
<div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div> <div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div>
@@ -778,7 +778,7 @@
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">
<!-- 全局搜索 --> <!-- 全局搜索 -->
<div class="conversation-search-box" style="margin-bottom: 16px; margin-top: 16px;"> <div class="conversation-search-box">
<input type="text" id="conversation-search-input" data-i18n="chat.searchHistory" data-i18n-attr="placeholder" placeholder="搜索历史记录..." <input type="text" id="conversation-search-input" data-i18n="chat.searchHistory" data-i18n-attr="placeholder" placeholder="搜索历史记录..."
oninput="handleConversationSearch(this.value)" oninput="handleConversationSearch(this.value)"
onkeypress="if(event.key === 'Enter') handleConversationSearch(this.value)" /> onkeypress="if(event.key === 'Enter') handleConversationSearch(this.value)" />
@@ -791,6 +791,15 @@
</button> </button>
</div> </div>
<!-- 按项目筛选对话 -->
<div class="conversation-project-filter">
<label class="conversation-project-filter-label" for="conversation-project-filter" data-i18n="chat.filterByProject">按项目筛选</label>
<select id="conversation-project-filter" class="conversation-project-filter-native" onchange="onConversationProjectFilterChange(this.value)" data-i18n="chat.filterByProject" data-i18n-attr="title" title="按项目筛选">
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
<option value="__none__" data-i18n="chat.filterUnboundProjects">未绑定项目</option>
</select>
</div>
<!-- 对话分组 --> <!-- 对话分组 -->
<div class="conversation-groups-section"> <div class="conversation-groups-section">
<div class="section-header"> <div class="section-header">
@@ -1929,14 +1938,14 @@
data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" /> data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />
</label> </label>
<label class="vulnerability-filter-field vulnerability-filter-field--project"> <label class="vulnerability-filter-field vulnerability-filter-field--project">
<span class="sr-only">项目</span> <span class="sr-only" data-i18n="vulnerabilityPage.detailProject">项目</span>
<select id="vulnerability-project-filter" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)"> <select id="vulnerability-project-filter" data-i18n="vulnerabilityPage.filterByProject" data-i18n-attr="title" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
<option value="">全部项目</option> <option value="" data-i18n="vulnerabilityPage.allProjects">全部项目</option>
</select> </select>
</label> </label>
<label class="vulnerability-filter-field vulnerability-filter-field--status"> <label class="vulnerability-filter-field vulnerability-filter-field--status">
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span> <span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态"> <select id="vulnerability-status-filter" data-i18n="vulnerabilityPage.status" data-i18n-attr="title" title="状态">
<option value="" data-i18n="knowledgePage.all">全部状态</option> <option value="" data-i18n="knowledgePage.all">全部状态</option>
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option> <option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option> <option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
@@ -2523,10 +2532,10 @@
<h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4> <h4 data-i18n="settingsBasic.openaiConfig">OpenAI 配置</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-group"> <div class="form-group">
<label for="openai-provider">API 提供商</label> <label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;"> <select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
<option value="openai">OpenAI / 兼容 OpenAI 协议</option> <option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
<option value="claude">Claude (Anthropic Messages API)</option> <option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -2610,9 +2619,9 @@
<div class="form-group"> <div class="form-group">
<label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label> <label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label>
<select id="vision-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;"> <select id="vision-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
<option value="">OpenAI 配置(留空复用)</option> <option value="" data-i18n="settingsBasic.visionProviderReuseOpenAI">OpenAI 配置(留空复用)</option>
<option value="openai">OpenAI / 兼容 OpenAI 协议</option> <option value="openai" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
<option value="claude">Claude (Anthropic Messages API)</option> <option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -3764,6 +3773,10 @@
<div class="modal-header"> <div class="modal-header">
<h2 id="batch-manage-title">管理对话记录·共<span id="batch-manage-count">0</span></h2> <h2 id="batch-manage-title">管理对话记录·共<span id="batch-manage-count">0</span></h2>
<div class="batch-manage-header-actions"> <div class="batch-manage-header-actions">
<select id="batch-project-filter" class="conversation-project-filter-native" onchange="applyBatchConversationFilters()" data-i18n="batchManageModal.filterByProject" data-i18n-attr="title" title="按项目筛选">
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
<option value="__none__" data-i18n="chat.filterUnboundProjects">未绑定项目</option>
</select>
<div class="batch-search-box"> <div class="batch-search-box">
<input type="text" id="batch-search-input" data-i18n="batchManageModal.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索历史记录" oninput="filterBatchConversations(this.value)" /> <input type="text" id="batch-search-input" data-i18n="batchManageModal.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索历史记录" oninput="filterBatchConversations(this.value)" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -3781,6 +3794,7 @@
<input type="checkbox" id="batch-select-all" onchange="toggleSelectAllBatch()" data-i18n="batchManageModal.selectAll" data-i18n-attr="title" title="全选" /> <input type="checkbox" id="batch-select-all" onchange="toggleSelectAllBatch()" data-i18n="batchManageModal.selectAll" data-i18n-attr="title" title="全选" />
</div> </div>
<div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div> <div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div>
<div class="batch-table-col-project" data-i18n="batchManageModal.project">项目</div>
<div class="batch-table-col-time" data-i18n="batchManageModal.lastTime">最近一次对话时间</div> <div class="batch-table-col-time" data-i18n="batchManageModal.lastTime">最近一次对话时间</div>
<div class="batch-table-col-action" data-i18n="batchManageModal.action">操作</div> <div class="batch-table-col-action" data-i18n="batchManageModal.action">操作</div>
</div> </div>