mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-26 16:00:06 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eeffb1933 | |||
| 0556b29d40 | |||
| be3c0cfa64 | |||
| 8e5f40d226 | |||
| 4b6719a6f3 | |||
| 7c8f3228f8 | |||
| 537843b6b8 | |||
| 4a57574cf9 | |||
| 0168530084 | |||
| 4184a7b6f0 | |||
| fb3b4dd6e5 | |||
| 7e4a8db7af | |||
| 6a72c95b9f | |||
| 447be050cd | |||
| 9b75c43f7b | |||
| a443454753 | |||
| 08822ba5df | |||
| eda75fb98f | |||
| e6978a7994 | |||
| 1db0f4740f | |||
| 6e4ff96dcd | |||
| 95470fefbc | |||
| 5e075bb198 |
+2
-1
@@ -114,7 +114,8 @@ multi_agent:
|
||||
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||
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_write_todos: false
|
||||
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 |
@@ -26,6 +26,7 @@ import (
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/monitor"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
"cyberstrike-ai/internal/robot"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/skillpackage"
|
||||
@@ -67,6 +68,10 @@ type App struct {
|
||||
|
||||
// New 创建新应用
|
||||
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)
|
||||
router := gin.Default()
|
||||
|
||||
@@ -135,6 +140,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
externalMCPMgr.StartAllEnabled()
|
||||
}
|
||||
|
||||
execReconciler := monitor.NewExecutionReconciler(db, mcpServer, externalMCPMgr, log.Logger)
|
||||
execReconciler.ReconcileOnStartup()
|
||||
monitor.StartStaleRunningReconcileLoop(execReconciler, log.Logger)
|
||||
|
||||
// 创建Agent
|
||||
maxIterations := cfg.Agent.MaxIterations
|
||||
if maxIterations <= 0 {
|
||||
|
||||
@@ -96,9 +96,12 @@ type MultiAgentConfig struct {
|
||||
// OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。
|
||||
OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"`
|
||||
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
|
||||
// SubAgentUserContextMaxRunes caps the user-context supplement appended to task descriptions for sub-agents.
|
||||
// 0 (default) uses the built-in default of 2000 runes; negative value disables injection entirely.
|
||||
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
||||
// 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"`
|
||||
// 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 MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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 {
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProjectFilterUnbound 列表 API 中 project_id=__none__ 表示仅未绑定项目的对话。
|
||||
const ProjectFilterUnbound = "__none__"
|
||||
|
||||
// Conversation 对话
|
||||
type Conversation struct {
|
||||
ID string `json:"id"`
|
||||
@@ -361,20 +364,44 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
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 统计对话数量。
|
||||
func (db *DB) CountConversations(search string) (int, error) {
|
||||
func (db *DB) CountConversations(search, projectID string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if search != "" {
|
||||
searchPattern := "%" + search + "%"
|
||||
err = db.QueryRow(
|
||||
`SELECT COUNT(*) FROM conversations c
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)`,
|
||||
searchPattern, searchPattern,
|
||||
).Scan(&count)
|
||||
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")
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM conversations c`+where, args...).Scan(&count)
|
||||
} 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 {
|
||||
return 0, fmt.Errorf("统计对话失败: %w", err)
|
||||
@@ -395,7 +422,7 @@ func conversationOrderClause(sortBy, tableAlias string) string {
|
||||
}
|
||||
|
||||
// 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 err error
|
||||
|
||||
@@ -403,20 +430,30 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Co
|
||||
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
||||
searchPattern := "%" + search + "%"
|
||||
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(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
||||
FROM conversations c
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
||||
FROM conversations c`+where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
searchPattern, searchPattern, limit, offset,
|
||||
args...,
|
||||
)
|
||||
} else {
|
||||
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(
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?",
|
||||
limit, offset,
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||
args...,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,23 +509,30 @@ const ungroupedConversationsSQL = `
|
||||
)`
|
||||
|
||||
// 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
|
||||
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 count, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
where := ungroupedConversationsSQL
|
||||
args := []interface{}{}
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||
ungroupedConversationsSQL+`
|
||||
where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
limit, offset,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
+288
-26
@@ -3,7 +3,6 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -227,6 +226,167 @@ func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolNa
|
||||
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获取单条工具执行记录
|
||||
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
query := `
|
||||
@@ -288,6 +448,93 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
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 删除工具执行记录
|
||||
func (db *DB) DeleteToolExecution(id string) error {
|
||||
query := `DELETE FROM tool_executions WHERE id = ?`
|
||||
@@ -600,13 +847,28 @@ func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
|
||||
|
||||
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
|
||||
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
|
||||
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
|
||||
query := `
|
||||
SELECT start_time,
|
||||
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
`
|
||||
var query string
|
||||
if dailyBuckets {
|
||||
query = `
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -614,35 +876,35 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||
buckets := make([]CallsTimelineBucket, 0)
|
||||
for rows.Next() {
|
||||
var startTime time.Time
|
||||
var failed int
|
||||
if err := rows.Scan(&startTime, &failed); err != nil {
|
||||
var bucketStr string
|
||||
var total, failed int
|
||||
if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
|
||||
db.logger.Warn("加载调用趋势失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
key := truncateCallsTimelineBucket(startTime, dailyBuckets)
|
||||
entry := bucketMap[key]
|
||||
entry.total++
|
||||
entry.failed += failed
|
||||
bucketMap[key] = entry
|
||||
}
|
||||
|
||||
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
|
||||
for bucketTime, counts := range bucketMap {
|
||||
bucketTime, err := parseCallsTimelineBucket(bucketStr, dailyBuckets)
|
||||
if err != nil {
|
||||
db.logger.Warn("解析调用趋势时间桶失败", zap.Error(err), zap.String("bucket", bucketStr))
|
||||
continue
|
||||
}
|
||||
buckets = append(buckets, CallsTimelineBucket{
|
||||
BucketTime: bucketTime,
|
||||
Total: counts.total,
|
||||
Failed: counts.failed,
|
||||
Total: total,
|
||||
Failed: failed,
|
||||
})
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
|
||||
})
|
||||
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 减少工具统计信息(用于删除执行记录时)
|
||||
// 如果统计信息变为0,则删除该统计记录
|
||||
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,8 +2,8 @@ package einomcp
|
||||
|
||||
import "sync"
|
||||
|
||||
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire,
|
||||
// 用于清除 pending tool_call(tool_result 由 ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)。
|
||||
// ToolInvokeNotifyHolder 由 Eino run loop 与 MCP/execute 桥共享;Fire 在工具原始返回时触发。
|
||||
// UI 的 tool_result 须等 ADK schema.Tool 事件(reduction 后正文),不在此 holder 的回调里推送。
|
||||
type ToolInvokeNotifyHolder struct {
|
||||
mu sync.RWMutex
|
||||
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
||||
|
||||
@@ -200,9 +200,7 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
if h == nil || conversationID == "" || h.tasks == nil {
|
||||
return
|
||||
}
|
||||
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" {
|
||||
h.agent.CancelMCPToolExecutionWithNote(execID, "")
|
||||
}
|
||||
h.cancelActiveMCPToolForConversation(conversationID)
|
||||
if ok, err := h.tasks.CancelTask(conversationID, ErrTaskCancelled); ok {
|
||||
h.logger.Info("已取消会话运行中任务", zap.String("conversationId", conversationID))
|
||||
} else if err != nil {
|
||||
@@ -210,6 +208,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 免审批工具到全局配置并落盘
|
||||
type HitlToolWhitelistSaver interface {
|
||||
MergeHitlToolWhitelistIntoConfig(add []string) error
|
||||
@@ -239,6 +246,7 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
||||
hitlManager: NewHITLManager(db, logger),
|
||||
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 {
|
||||
logger.Warn("初始化 HITL 表失败", zap.Error(err))
|
||||
}
|
||||
@@ -1411,6 +1419,7 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
|
||||
var cause error = ErrTaskCancelled
|
||||
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
|
||||
h.cancelActiveMCPToolForConversation(req.ConversationID)
|
||||
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
|
||||
if err != nil {
|
||||
h.logger.Error("取消任务失败", zap.Error(err))
|
||||
|
||||
@@ -103,6 +103,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
search := c.Query("search") // 获取搜索参数
|
||||
projectID := strings.TrimSpace(c.Query("project_id"))
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
@@ -114,7 +115,7 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
excludeGrouped := strings.TrimSpace(search) == "" &&
|
||||
excludeGrouped := strings.TrimSpace(search) == "" && projectID == "" &&
|
||||
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
||||
sortBy := strings.TrimSpace(c.Query("sort_by"))
|
||||
|
||||
@@ -122,14 +123,14 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
var total int
|
||||
var err error
|
||||
if excludeGrouped {
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy)
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy, projectID)
|
||||
if err == nil {
|
||||
total, err = h.db.CountUngroupedConversations()
|
||||
total, err = h.db.CountUngroupedConversations(projectID)
|
||||
}
|
||||
} else {
|
||||
conversations, err = h.db.ListConversations(limit, offset, search, sortBy)
|
||||
conversations, err = h.db.ListConversations(limit, offset, search, sortBy, projectID)
|
||||
if err == nil {
|
||||
total, err = h.db.CountConversations(search)
|
||||
total, err = h.db.CountConversations(search, projectID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
+221
-15
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -68,16 +69,34 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
||||
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 监控响应
|
||||
type MonitorResponse struct {
|
||||
Executions []*mcp.ToolExecution `json:"executions"`
|
||||
Stats map[string]*mcp.ToolStats `json:"stats"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Total int `json:"total,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
TotalPages int `json:"total_pages,omitempty"`
|
||||
RetentionDays int `json:"retention_days,omitempty"`
|
||||
Executions []*mcp.ToolExecution `json:"executions"`
|
||||
Summary *MonitorStatsSummary `json:"summary"`
|
||||
TopTools []*mcp.ToolStats `json:"topTools"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
}
|
||||
|
||||
// StatsResponse 统计信息响应(Dashboard 等)
|
||||
type StatsResponse struct {
|
||||
Summary *MonitorStatsSummary `json:"summary"`
|
||||
TopTools []*mcp.ToolStats `json:"topTools"`
|
||||
}
|
||||
|
||||
// Monitor 获取监控信息
|
||||
@@ -101,9 +120,9 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
|
||||
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::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)
|
||||
stats := h.loadStats()
|
||||
summary, topTools := h.loadStatsSummary(monitorPageTopTools)
|
||||
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages == 0 {
|
||||
@@ -112,7 +131,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, MonitorResponse{
|
||||
Executions: executions,
|
||||
Stats: stats,
|
||||
Summary: summary,
|
||||
TopTools: topTools,
|
||||
Timestamp: time.Now(),
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -134,6 +154,112 @@ func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
|
||||
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) {
|
||||
if h.db == nil {
|
||||
allExecutions := h.mcpServer.GetAllExecutions()
|
||||
@@ -206,7 +332,78 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
|
||||
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管理器的统计信息
|
||||
stats := make(map[string]*mcp.ToolStats)
|
||||
|
||||
@@ -334,7 +531,7 @@ func (h *MonitorHandler) CancelExecution(c *gin.Context) {
|
||||
|
||||
func (h *MonitorHandler) enrichExecutionsConversationID(executions []*mcp.ToolExecution) {
|
||||
for _, exec := range executions {
|
||||
if exec == nil {
|
||||
if exec == nil || exec.Status != "running" {
|
||||
continue
|
||||
}
|
||||
exec.ConversationID = h.conversationIDForRunningExecution(exec.ID)
|
||||
@@ -415,8 +612,17 @@ func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func (h *MonitorHandler) GetStats(c *gin.Context) {
|
||||
stats := h.loadStats()
|
||||
c.JSON(http.StatusOK, stats)
|
||||
topN := 30
|
||||
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 调用趋势数据点
|
||||
|
||||
@@ -740,14 +740,21 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"properties": map[string]interface{}{
|
||||
"executions": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "执行记录列表",
|
||||
"description": "执行记录列表(轻量字段,不含 arguments/result)",
|
||||
"items": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/ToolExecution",
|
||||
},
|
||||
},
|
||||
"stats": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "统计信息",
|
||||
"description": "工具调用汇总",
|
||||
},
|
||||
"topTools": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "调用量 Top N 工具",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"timestamp": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -756,20 +763,24 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"total": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "总数",
|
||||
"description": "执行记录总数",
|
||||
},
|
||||
"page": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "当前页",
|
||||
},
|
||||
"page_size": map[string]interface{}{
|
||||
"pageSize": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "每页数量",
|
||||
},
|
||||
"total_pages": map[string]interface{}{
|
||||
"totalPages": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "总页数",
|
||||
},
|
||||
"retentionDays": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "执行记录保留天数",
|
||||
},
|
||||
},
|
||||
},
|
||||
"ConfigResponse": map[string]interface{}{
|
||||
@@ -1232,6 +1243,34 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"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{}{
|
||||
"200": map[string]interface{}{
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// agentSessionContextBlock 注入会话工作目录与项目黑板(用于 system prompt 追加块)。
|
||||
// agentSessionContextBlock 注入会话工作目录、项目黑板与用户原文锚点(用于 system prompt 追加块)。
|
||||
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
var parts []string
|
||||
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
||||
@@ -16,6 +16,9 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
||||
parts = append(parts, bb)
|
||||
}
|
||||
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
|
||||
parts = append(parts, uv)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
@@ -67,6 +70,29 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
||||
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;未绑定或查询失败时返回空字符串。
|
||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||
if h == nil || h.db == nil {
|
||||
|
||||
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
|
||||
}
|
||||
|
||||
func (h *RobotHandler) cmdList() string {
|
||||
convs, err := h.db.ListConversations(50, 0, "", "")
|
||||
convs, err := h.db.ListConversations(50, 0, "", "", "")
|
||||
if err != nil {
|
||||
return "获取对话列表失败: " + err.Error()
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ type AgentTaskManager struct {
|
||||
maxHistorySize int // 最大历史记录数
|
||||
historyRetention time.Duration // 历史记录保留时间
|
||||
eventBus *TaskEventBus // 可选:任务结束时关闭镜像 SSE 订阅
|
||||
// toolCanceler 在用户整轮停止任务时终止当前 MCP 工具(非「中断并继续」)。
|
||||
toolCanceler func(conversationID string)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -277,6 +279,13 @@ func (m *AgentTaskManager) SetTaskEventBus(b *TaskEventBus) {
|
||||
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)。
|
||||
func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask {
|
||||
m.mu.RLock()
|
||||
@@ -372,14 +381,21 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
||||
task.InterruptContinueNote = ""
|
||||
}
|
||||
cancel := task.cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
if cause == nil {
|
||||
cause = ErrTaskCancelled
|
||||
}
|
||||
var toolCanceler func(string)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
toolCanceler = m.toolCanceler
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel(cause)
|
||||
}
|
||||
if toolCanceler != nil {
|
||||
toolCanceler(conversationID)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -814,6 +814,23 @@ func (m *ExternalMCPManager) CancelToolExecution(id string) bool {
|
||||
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 更新统计信息
|
||||
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -1170,6 +1170,23 @@ func (s *Server) CancelToolExecution(id string) bool {
|
||||
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 初始化默认提示词模板
|
||||
func (s *Server) initDefaultPrompts() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -299,6 +299,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
|
||||
var toolResultSent sync.Map // toolCallID -> struct{};ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -316,6 +318,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"isError": isErr,
|
||||
"result": content,
|
||||
"resultPreview": preview,
|
||||
"agentFacing": true, // 与 reduction 后送入 ChatModel 的正文一致,供前端展示
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": agentName,
|
||||
"einoRole": einoRoleTag(agentName),
|
||||
@@ -350,25 +353,6 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
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 {
|
||||
ctx = einoobserve.AttachAgentRunCallbacks(ctx, args.EinoCallbacks, einoobserve.Params{
|
||||
|
||||
@@ -150,6 +150,7 @@ func newEinoSummarizationMiddleware(
|
||||
}
|
||||
if appCfg != nil {
|
||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
@@ -413,6 +414,36 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
|
||||
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 {
|
||||
tc := agent.NewTikTokenCounter()
|
||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
||||
|
||||
@@ -409,9 +409,9 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
"需要写入请使用 upsert_project_fact。",
|
||||
project.FactIndexSectionEndMarker,
|
||||
"",
|
||||
"# Skills System",
|
||||
"**How to Use Skills**",
|
||||
"Remember: Skills make you more capable",
|
||||
transcriptSkillsSystemMarker,
|
||||
"**如何使用 Skill(技能)(渐进式展示):**",
|
||||
"记住:Skill 让你更加强大和稳定",
|
||||
}, "\n")
|
||||
|
||||
out := sanitizeSystemContentForTranscript(system)
|
||||
@@ -421,7 +421,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
if strings.Contains(out, "- nmap") || strings.Contains(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)
|
||||
}
|
||||
if !strings.Contains(out, transcriptStaticSystemOmitNote) {
|
||||
@@ -435,7 +435,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.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.AssistantMessage("reply", nil),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ const (
|
||||
transcriptStaticSystemOmitNote = "[static system prompt omitted — unchanged in live context after compaction]"
|
||||
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
|
||||
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 {
|
||||
@@ -86,13 +88,23 @@ func stripToolNamesIndexFromSystem(s string) string {
|
||||
}
|
||||
|
||||
func stripSkillsSystemBoilerplate(s string) string {
|
||||
idx := strings.Index(s, transcriptSkillsSystemMarker)
|
||||
idx := indexFirstSubstring(s, transcriptSkillsSystemMarker, transcriptSkillsSystemMarkerEnglish)
|
||||
if idx < 0 {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
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 {
|
||||
start := strings.Index(s, project.FactIndexSectionStartMarker)
|
||||
if start < 0 {
|
||||
|
||||
@@ -372,8 +372,15 @@ func RunDeepAgent(
|
||||
|
||||
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
|
||||
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
|
||||
taskEnrichExtra := systemPromptExtra
|
||||
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunes, taskEnrichExtra); mw != nil {
|
||||
var taskBlackboardSupplement string
|
||||
if appCfg != nil && 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)
|
||||
}
|
||||
if len(mainOrchestratorPre) > 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
const defaultSubAgentUserContextMaxRunes = 2000
|
||||
const userContextSupplementHeader = "\n\n## 用户历史输入(原文,子代理必读)\n"
|
||||
|
||||
// taskContextEnrichMiddleware intercepts "task" tool calls on the orchestrator
|
||||
// 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
|
||||
// descriptions with user conversation context. Returns nil if disabled
|
||||
// (maxRunes < 0) or no user messages exist.
|
||||
// projectBlackboard 仅传项目黑板索引块(BuildFactIndexBlock);勿传完整 systemPromptExtra。
|
||||
func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int, projectBlackboard string) adk.ChatModelAgentMiddleware {
|
||||
supplement := buildUserContextSupplement(userMessage, history, maxRunes)
|
||||
if bb := strings.TrimSpace(projectBlackboard); bb != "" {
|
||||
if supplement != "" {
|
||||
supplement += "\n\n## 项目黑板索引\n" + bb
|
||||
supplement += "\n\n" + bb
|
||||
} else {
|
||||
supplement = "\n\n## 项目黑板索引\n" + bb
|
||||
supplement = "\n\n" + bb
|
||||
}
|
||||
}
|
||||
if supplement == "" {
|
||||
@@ -86,9 +88,6 @@ func buildUserContextSupplement(userMessage string, history []agent.ChatMessage,
|
||||
if maxRunes < 0 {
|
||||
return ""
|
||||
}
|
||||
if maxRunes == 0 {
|
||||
maxRunes = defaultSubAgentUserContextMaxRunes
|
||||
}
|
||||
|
||||
var userMsgs []string
|
||||
for _, h := range history {
|
||||
@@ -107,12 +106,16 @@ func buildUserContextSupplement(userMessage string, history []agent.ChatMessage,
|
||||
return ""
|
||||
}
|
||||
|
||||
joined := strings.Join(userMsgs, "\n---\n")
|
||||
if len([]rune(joined)) > maxRunes {
|
||||
lines := make([]string, 0, len(userMsgs))
|
||||
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)
|
||||
}
|
||||
|
||||
return "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n" + joined
|
||||
return userContextSupplementHeader + joined
|
||||
}
|
||||
|
||||
// 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) {
|
||||
msg := strings.Repeat("A", 200)
|
||||
result := buildUserContextSupplement(msg, nil, 50)
|
||||
header := "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n"
|
||||
header := userContextSupplementHeader
|
||||
body := strings.TrimPrefix(result, header)
|
||||
if len([]rune(body)) > 50 {
|
||||
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)})
|
||||
}
|
||||
last := "最后一条指令"
|
||||
result := buildUserContextSupplement(last, history, 0)
|
||||
result := buildUserContextSupplement(last, history, 800)
|
||||
if !strings.Contains(result, "http://target.com") {
|
||||
t.Error("first message (target URL) should survive truncation")
|
||||
}
|
||||
|
||||
@@ -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 / EndMarker:HTML 注释边界,供程序化替换;对模型无指令语义。
|
||||
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)))
|
||||
}
|
||||
}
|
||||
+556
-6
@@ -1615,9 +1615,34 @@ header {
|
||||
|
||||
.conversation-search-box {
|
||||
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;
|
||||
}
|
||||
|
||||
.conversation-sidebar .recent-conversations-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.conversation-sidebar .section-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.conversation-search-box input {
|
||||
width: 100%;
|
||||
padding: 8px 32px 8px 12px;
|
||||
@@ -1668,6 +1693,170 @@ header {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -11196,6 +11385,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
.conversation-groups-section,
|
||||
.recent-conversations-section {
|
||||
margin-bottom: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-groups-section:last-child,
|
||||
@@ -11209,6 +11399,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 8px;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-header-actions {
|
||||
@@ -11337,6 +11529,21 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
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,
|
||||
.batch-manage-btn {
|
||||
width: 24px;
|
||||
@@ -11729,7 +11936,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
|
||||
/* 批量管理模态框 */
|
||||
.batch-manage-modal-content {
|
||||
max-width: 800px;
|
||||
max-width: 920px;
|
||||
width: 90vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -11739,7 +11946,23 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
.batch-manage-header-actions {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -11783,8 +12006,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
|
||||
.batch-table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 180px 80px;
|
||||
gap: 16px;
|
||||
grid-template-columns: 40px minmax(0, 1.2fr) minmax(0, 0.9fr) 160px 72px;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@@ -11802,8 +12025,8 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
|
||||
.batch-conversation-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 180px 80px;
|
||||
gap: 16px;
|
||||
grid-template-columns: 40px minmax(0, 1.2fr) minmax(0, 0.9fr) 160px 72px;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
@@ -11830,6 +12053,20 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
/* 完全依赖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 {
|
||||
font-size: 0.875rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.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] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -20044,6 +20433,167 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -499,6 +499,13 @@
|
||||
"conversationGroups": "Conversation groups",
|
||||
"addGroup": "New group",
|
||||
"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",
|
||||
"sortByCreatedAt": "Created time",
|
||||
"sortByUpdatedAt": "Updated time",
|
||||
@@ -1667,6 +1674,7 @@
|
||||
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
|
||||
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
|
||||
"timelineNoData": "No calls in this period",
|
||||
"timelineLoading": "Loading trend…",
|
||||
"timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks",
|
||||
"timelineLoadError": "Failed to load call trend",
|
||||
"timelineTotalLegend": "Total calls",
|
||||
@@ -1895,6 +1903,8 @@
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"statusChangeLabel": "Change status",
|
||||
"statusUpdateFailed": "Failed to update status",
|
||||
"searchVulnId": "Search vuln ID",
|
||||
"searchKeyword": "Search title, description, type, target…",
|
||||
"searchKeywordShort": "Keyword",
|
||||
@@ -1924,6 +1934,8 @@
|
||||
"detailTarget": "Target",
|
||||
"detailProject": "Project",
|
||||
"projectUnbound": "No project",
|
||||
"allProjects": "All projects",
|
||||
"filterByProject": "Filter by project",
|
||||
"projectBindHint": "Once bound, agents can list this finding under the project scope.",
|
||||
"projectBindFailed": "Failed to update project binding",
|
||||
"projectBindOk": "Project binding updated",
|
||||
@@ -2522,6 +2534,9 @@
|
||||
"title": "Manage conversations · {{count}} total",
|
||||
"searchPlaceholder": "Search history",
|
||||
"conversationName": "Conversation name",
|
||||
"project": "Project",
|
||||
"noProject": "No project",
|
||||
"filterByProject": "Filter by project",
|
||||
"lastTime": "Last activity",
|
||||
"action": "Action",
|
||||
"selectAll": "Select all",
|
||||
|
||||
@@ -487,6 +487,13 @@
|
||||
"conversationGroups": "对话分组",
|
||||
"addGroup": "新建分组",
|
||||
"recentConversations": "最近对话",
|
||||
"filterByProject": "按项目筛选",
|
||||
"filterAllProjects": "全部项目",
|
||||
"filterUnboundProjects": "未绑定项目",
|
||||
"projectConversationsTitle": "{{name}} · 对话",
|
||||
"unboundConversationsTitle": "未绑定项目",
|
||||
"noProjectConversations": "该项目暂无对话",
|
||||
"noUnboundConversations": "暂无未绑定项目的对话",
|
||||
"sortConversations": "排序",
|
||||
"sortByCreatedAt": "创建时间",
|
||||
"sortByUpdatedAt": "更新时间",
|
||||
@@ -1655,6 +1662,7 @@
|
||||
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
|
||||
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
|
||||
"timelineNoData": "该时段暂无调用",
|
||||
"timelineLoading": "趋势加载中…",
|
||||
"timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具",
|
||||
"timelineLoadError": "无法加载调用趋势",
|
||||
"timelineTotalLegend": "总调用",
|
||||
@@ -1883,6 +1891,8 @@
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"statusChangeLabel": "更改状态",
|
||||
"statusUpdateFailed": "更新状态失败",
|
||||
"searchVulnId": "搜索漏洞 ID",
|
||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||
"searchKeywordShort": "关键词",
|
||||
@@ -1912,6 +1922,8 @@
|
||||
"detailTarget": "目标",
|
||||
"detailProject": "所属项目",
|
||||
"projectUnbound": "未绑定项目",
|
||||
"allProjects": "全部项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞",
|
||||
"projectBindFailed": "绑定项目失败",
|
||||
"projectBindOk": "已更新项目绑定",
|
||||
@@ -2510,6 +2522,9 @@
|
||||
"title": "管理对话记录·共{{count}}条",
|
||||
"searchPlaceholder": "搜索历史记录",
|
||||
"conversationName": "对话名称",
|
||||
"project": "项目",
|
||||
"noProject": "无项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"lastTime": "最近一次对话时间",
|
||||
"action": "操作",
|
||||
"selectAll": "全选",
|
||||
|
||||
+425
-34
@@ -3322,6 +3322,18 @@ function createConversationListItem(conversation) {
|
||||
title.title = titleText; // 设置完整标题以便悬停查看
|
||||
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');
|
||||
time.className = 'conversation-time';
|
||||
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');
|
||||
if (batchModal && isAppModalOpen('batch-manage-modal')) {
|
||||
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
|
||||
updateBatchManageTitle(allConversationsForBatch.length);
|
||||
const searchInput = document.getElementById('batch-search-input');
|
||||
const query = searchInput ? searchInput.value : '';
|
||||
if (query && query.trim()) {
|
||||
filterBatchConversations(query);
|
||||
} else {
|
||||
renderBatchConversations();
|
||||
}
|
||||
applyBatchConversationFilters();
|
||||
}
|
||||
|
||||
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
|
||||
@@ -6075,6 +6080,266 @@ let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端A
|
||||
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
||||
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
|
||||
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() {
|
||||
try {
|
||||
@@ -6252,6 +6517,13 @@ async function fetchAllConversations(searchQuery) {
|
||||
}
|
||||
|
||||
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>';
|
||||
}
|
||||
|
||||
@@ -6428,11 +6700,16 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
if (conversationSortBy === 'created_at') {
|
||||
convParams.set('sort_by', 'created_at');
|
||||
}
|
||||
const projectFilter = getConversationProjectFilter();
|
||||
if (projectFilter) {
|
||||
convParams.set('project_id', projectFilter);
|
||||
}
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
convParams.set('search', searchQuery.trim());
|
||||
} else {
|
||||
} else if (!projectFilter) {
|
||||
convParams.set('exclude_grouped', 'true');
|
||||
}
|
||||
updateConversationSidebarFilterUI();
|
||||
const url = `/api/conversations?${convParams}`;
|
||||
const [,, response] = await Promise.all([
|
||||
loadGroups(),
|
||||
@@ -6488,6 +6765,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
const pinnedConvs = [];
|
||||
const normalConvs = [];
|
||||
const hasSearchQuery = searchQuery && searchQuery.trim();
|
||||
const hasProjectFilter = !!getConversationProjectFilter();
|
||||
|
||||
uniqueConversations.forEach(conv => {
|
||||
// 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的)
|
||||
@@ -6501,6 +6779,16 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按项目筛选时展示该项目下全部对话(含分组内)
|
||||
if (hasProjectFilter) {
|
||||
if (conv.pinned) {
|
||||
pinnedConvs.push(conv);
|
||||
} else {
|
||||
normalConvs.push(conv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有搜索关键词,使用原有逻辑
|
||||
// "最近对话"列表应该只显示不在任何分组中的对话
|
||||
// 无论是否在分组详情页,都不应该在"最近对话"中显示分组中的对话
|
||||
@@ -7731,6 +8019,84 @@ function closeContextMenu() {
|
||||
// 显示批量管理模态框
|
||||
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 为当前条数
|
||||
function updateBatchManageTitle(count) {
|
||||
const titleEl = document.getElementById('batch-manage-title');
|
||||
@@ -7742,19 +8108,27 @@ function updateBatchManageTitle(count) {
|
||||
|
||||
async function showBatchManageModal() {
|
||||
try {
|
||||
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||
allConversationsForBatch = await fetchAllConversations('');
|
||||
|
||||
const modal = document.getElementById('batch-manage-modal');
|
||||
updateBatchManageTitle(allConversationsForBatch.length);
|
||||
|
||||
renderBatchConversations();
|
||||
await refreshBatchProjectFilter();
|
||||
const sidebarFilter = getConversationProjectFilter();
|
||||
const batchSel = document.getElementById('batch-project-filter');
|
||||
if (batchSel && sidebarFilter && (
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('加载对话列表失败:', error);
|
||||
// 错误时使用空数组,不显示错误提示(更友好的用户体验)
|
||||
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||
allConversationsForBatch = [];
|
||||
updateBatchManageTitle(0);
|
||||
renderBatchConversations();
|
||||
await refreshBatchProjectFilter();
|
||||
applyBatchConversationFilters();
|
||||
openAppModal('batch-manage-modal', { focus: false });
|
||||
}
|
||||
}
|
||||
@@ -7817,15 +8191,27 @@ function renderBatchConversations(filtered = null) {
|
||||
checkbox.dataset.conversationId = conv.id;
|
||||
checkbox.addEventListener('change', syncSelectAllBatchCheckbox);
|
||||
|
||||
const checkboxCol = document.createElement('div');
|
||||
checkboxCol.className = 'batch-table-col-checkbox';
|
||||
checkboxCol.appendChild(checkbox);
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'batch-table-col-name';
|
||||
const originalTitle = conv.title || (typeof window.t === 'function' ? window.t('batchManageModal.unnamedConversation') : '未命名对话');
|
||||
// 使用安全截断函数,限制最大长度为45个字符(留出空间显示省略号)
|
||||
const truncatedTitle = safeTruncateText(originalTitle, 45);
|
||||
const truncatedTitle = safeTruncateText(originalTitle, 36);
|
||||
name.textContent = truncatedTitle;
|
||||
// 设置title属性以显示完整文本(鼠标悬停时)
|
||||
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');
|
||||
time.className = 'batch-table-col-time';
|
||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
||||
@@ -7858,8 +8244,9 @@ function renderBatchConversations(filtered = null) {
|
||||
};
|
||||
action.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(checkbox);
|
||||
row.appendChild(checkboxCol);
|
||||
row.appendChild(name);
|
||||
row.appendChild(project);
|
||||
row.appendChild(time);
|
||||
row.appendChild(action);
|
||||
|
||||
@@ -7870,18 +8257,8 @@ function renderBatchConversations(filtered = null) {
|
||||
}
|
||||
|
||||
// 筛选批量管理对话
|
||||
function filterBatchConversations(query) {
|
||||
if (!query || !query.trim()) {
|
||||
renderBatchConversations();
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allConversationsForBatch.filter(conv => {
|
||||
const title = (conv.title || '').toLowerCase();
|
||||
return title.includes(query.toLowerCase());
|
||||
});
|
||||
|
||||
renderBatchConversations(filtered);
|
||||
function filterBatchConversations() {
|
||||
applyBatchConversationFilters();
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
@@ -7958,6 +8335,10 @@ function closeBatchManageModal() {
|
||||
selectAll.checked = 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 = [];
|
||||
}
|
||||
|
||||
@@ -8030,7 +8411,7 @@ document.addEventListener('languagechange', function () {
|
||||
refreshChatPanelI18n();
|
||||
const modal = document.getElementById('batch-manage-modal');
|
||||
if (isAppModalOpen('batch-manage-modal')) {
|
||||
updateBatchManageTitle(allConversationsForBatch.length);
|
||||
refreshBatchProjectFilter().then(() => applyBatchConversationFilters());
|
||||
}
|
||||
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
|
||||
if (typeof loadConversationsWithGroups === 'function') {
|
||||
@@ -8962,6 +9343,8 @@ function clearGroupSearch() {
|
||||
// 初始化时加载分组
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
updateConversationSortMenuUI();
|
||||
initConversationProjectCustomSelect();
|
||||
await refreshConversationProjectFilter();
|
||||
await loadGroups();
|
||||
await loadConversationsWithGroups();
|
||||
|
||||
@@ -9018,8 +9401,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function refreshAllProjectFilterSelects() {
|
||||
await refreshConversationProjectFilter();
|
||||
await refreshBatchProjectFilter();
|
||||
}
|
||||
|
||||
// 顶层 async function 不会自动挂到 window,hitl 等脚本依赖 window.loadConversation
|
||||
if (typeof window !== 'undefined') {
|
||||
window.loadConversation = loadConversation;
|
||||
window.startNewConversation = startNewConversation;
|
||||
window.refreshConversationProjectFilter = refreshConversationProjectFilter;
|
||||
window.refreshAllProjectFilterSelects = refreshAllProjectFilterSelects;
|
||||
window.onConversationProjectFilterChange = onConversationProjectFilterChange;
|
||||
}
|
||||
|
||||
+21
-29
@@ -118,7 +118,7 @@ async function refreshDashboard() {
|
||||
fetchJson('/api/agent-loop/tasks'),
|
||||
fetchJson('/api/vulnerabilities/stats'),
|
||||
fetchJson('/api/batch-tasks?limit=500&page=1'),
|
||||
fetchJson('/api/monitor/stats'),
|
||||
fetchJson('/api/monitor/stats?top=30'),
|
||||
fetchJson('/api/knowledge/stats'),
|
||||
fetchJson('/api/skills/stats'),
|
||||
fetchJson('/api/vulnerabilities?limit=10&page=1'),
|
||||
@@ -301,36 +301,27 @@ async function refreshDashboard() {
|
||||
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;
|
||||
if (monitorRes && typeof monitorRes === 'object') {
|
||||
const names = Object.keys(monitorRes);
|
||||
let totalCalls = 0, totalSuccess = 0, totalFailed = 0;
|
||||
names.forEach(k => {
|
||||
const v = monitorRes[k];
|
||||
const n = v && (v.totalCalls ?? v.TotalCalls);
|
||||
if (typeof n === 'number') totalCalls += n;
|
||||
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));
|
||||
if (monitorRes && monitorRes.summary) {
|
||||
const s = monitorRes.summary;
|
||||
toolsCount = s.toolCount || 0;
|
||||
toolsTotalCalls = s.totalCalls || 0;
|
||||
toolsFailedCount = s.failedCalls || 0;
|
||||
const totalSuccess = s.successCalls || 0;
|
||||
setEl('dashboard-kpi-tools-calls', formatNumber(toolsTotalCalls));
|
||||
setKpiSubText('dashboard-kpi-tools-sub-text',
|
||||
dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具'));
|
||||
if (totalCalls > 0) {
|
||||
toolsSuccessRate = (totalSuccess / totalCalls) * 100;
|
||||
if (toolsTotalCalls > 0) {
|
||||
toolsSuccessRate = (totalSuccess / toolsTotalCalls) * 100;
|
||||
const rateStr = toolsSuccessRate.toFixed(1) + '%';
|
||||
setEl('dashboard-kpi-success-rate', rateStr);
|
||||
setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, totalFailed);
|
||||
setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, toolsFailedCount);
|
||||
} else {
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用'));
|
||||
}
|
||||
renderDashboardToolsBar(monitorRes);
|
||||
renderDashboardToolsBar(monitorRes.topTools);
|
||||
} else {
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
@@ -1615,12 +1606,12 @@ function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardToolsBar(monitorRes) {
|
||||
function renderDashboardToolsBar(topTools) {
|
||||
const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
const barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||
if (!placeholder || !barChartEl) return;
|
||||
|
||||
if (!monitorRes || typeof monitorRes !== 'object') {
|
||||
if (!Array.isArray(topTools) || topTools.length === 0) {
|
||||
placeholder.style.removeProperty('display');
|
||||
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
|
||||
barChartEl.style.display = 'none';
|
||||
@@ -1628,11 +1619,12 @@ function renderDashboardToolsBar(monitorRes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.keys(monitorRes).map(function (k) {
|
||||
const v = monitorRes[k];
|
||||
const totalCalls = v && (v.totalCalls ?? v.TotalCalls);
|
||||
return { name: k, totalCalls: typeof totalCalls === 'number' ? totalCalls : 0 };
|
||||
}).filter(function (e) { return e.totalCalls > 0; })
|
||||
const entries = topTools.map(function (t) {
|
||||
return {
|
||||
name: t.toolName || '',
|
||||
totalCalls: typeof t.totalCalls === 'number' ? t.totalCalls : 0,
|
||||
};
|
||||
}).filter(function (e) { return e.name && e.totalCalls > 0; })
|
||||
.sort(function (a, b) { return b.totalCalls - a.totalCalls; })
|
||||
.slice(0, 30);
|
||||
|
||||
|
||||
+193
-151
@@ -3118,6 +3118,12 @@ function attachToolResultToCall(progressId, toolCallId, data, options) {
|
||||
if (!item && mapping && mapping.timeline) {
|
||||
item = findToolCallItemById(mapping.timeline, toolCallId);
|
||||
}
|
||||
if (!item && progressId) {
|
||||
const progressRoot = document.getElementById(String(progressId));
|
||||
if (progressRoot) {
|
||||
item = findToolCallItemById(progressRoot, toolCallId);
|
||||
}
|
||||
}
|
||||
if (!item) return false;
|
||||
mergeToolResultIntoCallItem(item, data, options);
|
||||
return true;
|
||||
@@ -3154,7 +3160,7 @@ function coalesceProcessDetailsToolPairs(details) {
|
||||
if (id) callsById.set(id, copy);
|
||||
fifoCalls.push(copy);
|
||||
out.push(copy);
|
||||
} else if (et === 'tool_result') {
|
||||
} else if (et === 'tool_result') {
|
||||
let target = null;
|
||||
if (id && callsById.has(id)) {
|
||||
target = callsById.get(id);
|
||||
@@ -3168,6 +3174,12 @@ function coalesceProcessDetailsToolPairs(details) {
|
||||
}
|
||||
}
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
@@ -3527,12 +3539,15 @@ let monitorPanelFetchSeq = 0;
|
||||
// 监控面板状态
|
||||
const monitorState = {
|
||||
executions: [],
|
||||
stats: {},
|
||||
summary: null,
|
||||
topTools: [],
|
||||
timeline: null,
|
||||
timelineRange: null,
|
||||
timelineError: null,
|
||||
timelineLoading: false,
|
||||
lastFetchedAt: null,
|
||||
retentionDays: 0,
|
||||
selectedExecutions: new Set(),
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: (() => {
|
||||
@@ -3626,17 +3641,14 @@ async function refreshMonitorPanel(page = null) {
|
||||
|
||||
try {
|
||||
const mySeq = ++monitorPanelFetchSeq;
|
||||
// 如果指定了页码,使用指定页码,否则使用当前页码
|
||||
const currentPage = page !== null ? page : monitorState.pagination.page;
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
|
||||
// 获取当前的筛选条件
|
||||
|
||||
const statusFilter = document.getElementById('monitor-status-filter');
|
||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||
const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
|
||||
const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
||||
|
||||
// 构建请求 URL
|
||||
|
||||
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
|
||||
if (currentStatusFilter && currentStatusFilter !== 'all') {
|
||||
url += `&status=${encodeURIComponent(currentStatusFilter)}`;
|
||||
@@ -3644,37 +3656,34 @@ async function refreshMonitorPanel(page = null) {
|
||||
if (currentToolFilter && currentToolFilter !== 'all') {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.stats = result.stats || {};
|
||||
applyMonitorPayload(result, currentStatusFilter);
|
||||
|
||||
const { timeline, timelineError } = await timelinePromise;
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
monitorState.timeline = timeline;
|
||||
monitorState.timelineError = timelineError;
|
||||
monitorState.lastFetchedAt = new Date();
|
||||
monitorState.retentionDays = typeof result.retention_days === 'number' ? result.retention_days : 0;
|
||||
|
||||
// 更新分页信息
|
||||
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();
|
||||
|
||||
// 初始化每页显示数量选择器
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
initializeMonitorPageSize();
|
||||
} catch (error) {
|
||||
console.error('刷新监控面板失败:', error);
|
||||
monitorState.timelineLoading = false;
|
||||
if (statsContainer) {
|
||||
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
@@ -3717,10 +3726,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
|
||||
try {
|
||||
const mySeq = ++monitorPanelFetchSeq;
|
||||
const currentPage = 1; // 筛选时重置到第一页
|
||||
const currentPage = 1;
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
|
||||
// 构建请求 URL
|
||||
|
||||
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
url += `&status=${encodeURIComponent(statusFilter)}`;
|
||||
@@ -3728,37 +3736,34 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
if (toolFilter && toolFilter !== 'all') {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.stats = result.stats || {};
|
||||
applyMonitorPayload(result, statusFilter);
|
||||
|
||||
const { timeline, timelineError } = await timelinePromise;
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
monitorState.timeline = timeline;
|
||||
monitorState.timelineError = timelineError;
|
||||
monitorState.lastFetchedAt = new Date();
|
||||
monitorState.retentionDays = typeof result.retention_days === 'number' ? result.retention_days : 0;
|
||||
|
||||
// 更新分页信息
|
||||
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();
|
||||
|
||||
// 初始化每页显示数量选择器
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
initializeMonitorPageSize();
|
||||
} catch (error) {
|
||||
console.error('刷新监控面板失败:', error);
|
||||
monitorState.timelineLoading = false;
|
||||
if (statsContainer) {
|
||||
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
@@ -3768,6 +3773,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_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
||||
@@ -3782,29 +3844,14 @@ function getMcpMonitorTimelineRange() {
|
||||
return range;
|
||||
}
|
||||
|
||||
async function fetchMonitorAndTimeline(monitorUrl) {
|
||||
const range = getMcpMonitorTimelineRange();
|
||||
const [monitorResp, timelineResp] = await Promise.all([
|
||||
apiFetch(monitorUrl, { method: 'GET' }),
|
||||
apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' })
|
||||
]);
|
||||
const result = await monitorResp.json().catch(() => ({}));
|
||||
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 buildMonitorTotals(summary) {
|
||||
const s = summary && typeof summary === 'object' ? summary : {};
|
||||
return {
|
||||
total: s.totalCalls || 0,
|
||||
success: s.successCalls || 0,
|
||||
failed: s.failedCalls || 0,
|
||||
lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMcpTimelineLabel(isoOrDate, rangeKey, locale) {
|
||||
@@ -4028,34 +4075,19 @@ async function setMcpMonitorTimelineRange(range) {
|
||||
localStorage.setItem('mcpMonitorTimelineRange', range);
|
||||
monitorState.timelineRange = range;
|
||||
monitorState.timelineError = null;
|
||||
monitorState.timelineLoading = true;
|
||||
syncMcpMonitorTimelineRangeUI(range);
|
||||
updateMonitorTimelineSection();
|
||||
try {
|
||||
const timelineResp = await apiFetch(`/api/monitor/calls-timeline?range=${encodeURIComponent(range)}`, { method: 'GET' });
|
||||
const timelineJson = await timelineResp.json().catch(() => ({}));
|
||||
if (!timelineResp.ok) {
|
||||
throw new Error(timelineJson.error || '加载趋势失败');
|
||||
}
|
||||
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);
|
||||
}
|
||||
const { timeline, timelineError } = await fetchMonitorTimeline(range);
|
||||
monitorState.timeline = timeline;
|
||||
monitorState.timelineError = timelineError;
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
} catch (err) {
|
||||
monitorState.timelineError = err.message || 'error';
|
||||
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);
|
||||
}
|
||||
monitorState.timelineLoading = false;
|
||||
updateMonitorTimelineSection();
|
||||
}
|
||||
}
|
||||
window.setMcpMonitorTimelineRange = setMcpMonitorTimelineRange;
|
||||
@@ -4084,7 +4116,12 @@ function renderMcpStatsTimelineEmptyState(compact) {
|
||||
</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');
|
||||
|
||||
if (timelineError) {
|
||||
@@ -4152,7 +4189,7 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
|
||||
const timelineCol = showTimeline
|
||||
? `<div class="mcp-stats-combined__timeline">
|
||||
<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>`
|
||||
: '';
|
||||
|
||||
@@ -4207,20 +4244,11 @@ function refreshMonitorPanelFromState() {
|
||||
if (!monitorState.lastFetchedAt) return;
|
||||
const statusFilter = document.getElementById('monitor-status-filter');
|
||||
const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
|
||||
renderMonitorStats(monitorState.stats || {}, monitorState.lastFetchedAt);
|
||||
renderMonitorStats(monitorState.summary, monitorState.topTools, monitorState.lastFetchedAt);
|
||||
renderMonitorExecutions(monitorState.executions || [], currentStatusFilter);
|
||||
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>';
|
||||
|
||||
function getMcpStatsRateTone(rateNum) {
|
||||
@@ -4915,15 +4943,19 @@ function renderMcpStatsToolRanking(topTools, totals, activeToolFilter = '', opti
|
||||
return renderMcpStatsDetailSection(topTools, totals, activeToolFilter);
|
||||
}
|
||||
|
||||
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
function renderMonitorStats(summary = null, topTools = [], lastFetchedAt = null) {
|
||||
const container = document.getElementById('monitor-stats');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = normalizeMonitorStatsEntries(statsMap);
|
||||
const showTimeline = monitorState.timeline != null || !!monitorState.timelineError;
|
||||
if (entries.length === 0 && !showTimeline) {
|
||||
const tools = Array.isArray(topTools) ? topTools : [];
|
||||
const totals = buildMonitorTotals(summary);
|
||||
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');
|
||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
||||
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||
@@ -4931,20 +4963,6 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
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 successRateNum = hasCalls ? (totals.success / totals.total) * 100 : 0;
|
||||
const successRate = hasCalls ? successRateNum.toFixed(1) : '-';
|
||||
@@ -4965,19 +4983,13 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
const toolFilterEl = document.getElementById('monitor-tool-filter');
|
||||
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 showCombined = hasAnyCalls && (topTools.length > 0 || showTimeline);
|
||||
const showCombined = hasAnyCalls && (tools.length > 0 || showTimeline);
|
||||
const html = `
|
||||
<div class="mcp-exec-stats">
|
||||
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
|
||||
${showCombined ? renderMcpStatsCombinedSection(
|
||||
topTools,
|
||||
tools,
|
||||
totals,
|
||||
activeToolFilter,
|
||||
monitorState.timeline,
|
||||
@@ -4995,7 +5007,7 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
} else if (toolFilterEl) {
|
||||
toolFilterEl.classList.remove('is-filter-active');
|
||||
}
|
||||
updateMonitorStatsSubtitle(lastFetchedAt, entries.length, monitorState.retentionDays);
|
||||
updateMonitorStatsSubtitle(lastFetchedAt, toolCount, monitorState.retentionDays);
|
||||
}
|
||||
|
||||
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
@@ -5052,10 +5064,12 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
const terminateBtn = status === 'running'
|
||||
? `<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 `
|
||||
<tr>
|
||||
<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>${toolName}</td>
|
||||
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
|
||||
@@ -5201,6 +5215,8 @@ async function deleteExecution(executionId) {
|
||||
throw new Error(error.error || deleteFailedMsg);
|
||||
}
|
||||
|
||||
monitorState.selectedExecutions.delete(executionId);
|
||||
|
||||
// 删除成功后刷新当前页面
|
||||
const currentPage = monitorState.pagination.page;
|
||||
await refreshMonitorPanel(currentPage);
|
||||
@@ -5214,10 +5230,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() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
|
||||
const selectedCount = checkboxes.length;
|
||||
const selectedCount = monitorState.selectedExecutions.size;
|
||||
const batchActions = document.getElementById('monitor-batch-actions');
|
||||
const selectedCountSpan = document.getElementById('monitor-selected-count');
|
||||
|
||||
@@ -5234,13 +5262,18 @@ function updateBatchActionsState() {
|
||||
selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项';
|
||||
}
|
||||
|
||||
// 更新全选复选框状态
|
||||
// 更新全选复选框状态(仅反映当前页)
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
if (selectAllCheckbox) {
|
||||
const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox');
|
||||
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
|
||||
selectAllCheckbox.checked = allChecked;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
|
||||
if (allCheckboxes.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
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 +5282,11 @@ function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
if (checkbox.checked) {
|
||||
monitorState.selectedExecutions.add(cb.value);
|
||||
} else {
|
||||
monitorState.selectedExecutions.delete(cb.value);
|
||||
}
|
||||
});
|
||||
updateBatchActionsState();
|
||||
}
|
||||
@@ -5258,6 +5296,7 @@ function selectAllExecutions() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = true;
|
||||
monitorState.selectedExecutions.add(cb.value);
|
||||
});
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
if (selectAllCheckbox) {
|
||||
@@ -5273,6 +5312,7 @@ function deselectAllExecutions() {
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
monitorState.selectedExecutions.clear();
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
@@ -5283,14 +5323,12 @@ function deselectAllExecutions() {
|
||||
|
||||
// 批量删除执行记录
|
||||
async function batchDeleteExecutions() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
const ids = Array.from(monitorState.selectedExecutions);
|
||||
if (ids.length === 0) {
|
||||
const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录';
|
||||
alert(selectFirstMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = Array.from(checkboxes).map(cb => cb.value);
|
||||
const count = ids.length;
|
||||
const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`;
|
||||
if (!confirm(batchConfirmMsg)) {
|
||||
@@ -5314,6 +5352,10 @@ async function batchDeleteExecutions() {
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
const deletedCount = result.deleted || count;
|
||||
|
||||
ids.forEach(function (id) {
|
||||
monitorState.selectedExecutions.delete(id);
|
||||
});
|
||||
|
||||
// 删除成功后刷新当前页面
|
||||
const currentPage = monitorState.pagination.page;
|
||||
|
||||
@@ -293,6 +293,9 @@ async function ensureProjectsLoaded(force) {
|
||||
projectsCacheAll = list;
|
||||
rebuildProjectNameMap(projectsCacheAll);
|
||||
_projectsListReady = true;
|
||||
if (typeof window.refreshConversationProjectFilter === 'function') {
|
||||
window.refreshConversationProjectFilter();
|
||||
}
|
||||
return projectsCacheAll;
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -371,6 +374,9 @@ async function loadProjectsList() {
|
||||
if (typeof refreshVulnerabilityProjectFilter === 'function') {
|
||||
refreshVulnerabilityProjectFilter();
|
||||
}
|
||||
if (typeof window.refreshAllProjectFilterSelects === 'function') {
|
||||
await window.refreshAllProjectFilterSelects();
|
||||
}
|
||||
}
|
||||
|
||||
function projectInitial(name) {
|
||||
@@ -2198,6 +2204,9 @@ async function applyChatProjectSelection(projectId) {
|
||||
setActiveProjectId(projectId);
|
||||
}
|
||||
updateChatProjectButtonLabel();
|
||||
if (typeof window.onConversationProjectBindingChanged === 'function') {
|
||||
window.onConversationProjectBindingChanged(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */
|
||||
@@ -2326,3 +2335,4 @@ window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||
window.projectNameById = projectNameById;
|
||||
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
||||
|
||||
+434
-17
@@ -39,6 +39,220 @@ function vulnStatusLabel(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
|
||||
const getVulnerabilityPageSize = () => {
|
||||
const saved = localStorage.getItem('vulnerabilityPageSize');
|
||||
@@ -175,6 +389,7 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
syncVulnerabilityStatCardActiveState();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
|
||||
// 初始化漏洞管理页面
|
||||
@@ -387,6 +602,7 @@ function initVulnerabilityFilterPanel() {
|
||||
|
||||
if (vulnerabilityFilterPanelBound) {
|
||||
updateVulnerabilityFilterPanelState();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
return;
|
||||
}
|
||||
vulnerabilityFilterPanelBound = true;
|
||||
@@ -448,6 +664,146 @@ function initVulnerabilityFilterPanel() {
|
||||
});
|
||||
|
||||
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() {
|
||||
@@ -559,6 +915,9 @@ function removeVulnerabilityFilterByKey(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
|
||||
vulnerabilityFilters[key] = '';
|
||||
}
|
||||
if (key === 'project_id' || key === 'status') {
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
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');
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
if (!silent) {
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查apiFetch是否可用
|
||||
@@ -830,8 +1202,14 @@ async function loadVulnerabilities(page = null) {
|
||||
console.error('未知的响应格式:', data);
|
||||
}
|
||||
|
||||
renderVulnerabilities(vulnerabilities);
|
||||
renderVulnerabilities(vulnerabilities, { expandedIds: expandedIds || [] });
|
||||
renderVulnerabilityPagination();
|
||||
|
||||
if (preserveScroll && scrollEl) {
|
||||
requestAnimationFrame(function () {
|
||||
scrollEl.scrollTop = scrollTop;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载漏洞列表失败:', error);
|
||||
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');
|
||||
|
||||
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
|
||||
@@ -862,7 +1241,6 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
const html = vulnerabilities.map(vuln => {
|
||||
const severityClass = `severity-${vuln.severity}`;
|
||||
const severityText = vulnSeverityLabel(vuln.severity);
|
||||
const statusText = vulnStatusLabel(vuln.status);
|
||||
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
|
||||
const projectLabel = 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'));
|
||||
|
||||
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-title-section">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
@@ -886,7 +1264,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
</div>
|
||||
<div class="vulnerability-meta">
|
||||
<span class="severity-badge ${severityClass}">${severityText}</span>
|
||||
<span class="status-badge status-${vuln.status}">${statusText}</span>
|
||||
${buildVulnerabilityStatusPicker(vuln)}
|
||||
${projectBadge}
|
||||
<span class="vulnerability-date">${createdDate}</span>
|
||||
</div>
|
||||
@@ -935,10 +1313,13 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
}).join('');
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
initVulnerabilityStatusPickers(listContainer);
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
|
||||
restoreExpandedVulnerabilityDetails(opts.expandedIds);
|
||||
|
||||
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
|
||||
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
|
||||
setTimeout(() => {
|
||||
@@ -1191,11 +1572,27 @@ async function saveVulnerability() {
|
||||
throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed'));
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
const editedId = currentVulnerabilityId;
|
||||
const isEdit = !!editedId;
|
||||
const expandedIds = isEdit ? getExpandedVulnerabilityIds() : [];
|
||||
|
||||
closeVulnerabilityModal();
|
||||
loadVulnerabilityStats();
|
||||
// 保存/更新后,重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
loadVulnerabilities();
|
||||
|
||||
if (!isEdit) {
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
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) {
|
||||
console.error('保存漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
|
||||
@@ -1216,14 +1613,20 @@ async function deleteVulnerability(id) {
|
||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||||
|
||||
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) {
|
||||
const itemsOnCurrentPage = vulnerabilityPagination.total - (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
|
||||
if (itemsOnCurrentPage <= 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
}
|
||||
loadVulnerabilities();
|
||||
await loadVulnerabilities(null, { preserveScroll: true });
|
||||
} catch (error) {
|
||||
console.error('删除漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
|
||||
@@ -1263,6 +1666,7 @@ function clearVulnerabilityFilters() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
syncAllVulnFilterCustomSelects();
|
||||
|
||||
vulnerabilityFilters = {
|
||||
q: '',
|
||||
@@ -1685,10 +2089,16 @@ window.onclick = function(event) {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
document.addEventListener('languagechange', async function () {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
if (page && page.classList.contains('active')) {
|
||||
const panel = document.getElementById('vulnerability-filter-panel');
|
||||
if (panel && typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(panel);
|
||||
}
|
||||
renderVulnerabilityFilterChips();
|
||||
await refreshVulnerabilityProjectFilter();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
loadVulnerabilities();
|
||||
}
|
||||
});
|
||||
@@ -1709,11 +2119,15 @@ async function bindVulnerabilityProject(vulnId, projectId, silent) {
|
||||
alert(vulnT('vulnerabilityPage.projectBindOk'));
|
||||
}
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
const expandedIds = getExpandedVulnerabilityIds();
|
||||
if (!expandedIds.includes(vulnId)) {
|
||||
expandedIds.push(vulnId);
|
||||
}
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
} catch (error) {
|
||||
console.error('绑定项目失败:', error);
|
||||
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; });
|
||||
}
|
||||
const cur = vulnerabilityFilters.project_id || sel.value || '';
|
||||
let html = '<option value="">全部项目</option>';
|
||||
let html = '<option value="">' + escapeHtml(vulnT('vulnerabilityPage.allProjects')) + '</option>';
|
||||
(list || []).forEach((p) => {
|
||||
if (!p.id) return;
|
||||
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>`;
|
||||
});
|
||||
sel.innerHTML = html;
|
||||
if (cur) sel.value = cur;
|
||||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||||
const modalSel = document.getElementById('vulnerability-project-id');
|
||||
if (modalSel && isAppModalOpen('vulnerability-modal')) {
|
||||
const modalCur = modalSel.value || '';
|
||||
@@ -1762,6 +2177,7 @@ function setVulnerabilityProjectFilter(projectId) {
|
||||
vulnerabilityFilters.project_id = projectId || '';
|
||||
const sel = document.getElementById('vulnerability-project-filter');
|
||||
if (sel) sel.value = projectId || '';
|
||||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
@@ -1777,4 +2193,5 @@ window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter;
|
||||
window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
|
||||
window.bindVulnerabilityProject = bindVulnerabilityProject;
|
||||
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
|
||||
window.changeVulnerabilityStatus = changeVulnerabilityStatus;
|
||||
|
||||
|
||||
@@ -778,7 +778,7 @@
|
||||
</div>
|
||||
<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="搜索历史记录..."
|
||||
oninput="handleConversationSearch(this.value)"
|
||||
onkeypress="if(event.key === 'Enter') handleConversationSearch(this.value)" />
|
||||
@@ -790,6 +790,15 @@
|
||||
</svg>
|
||||
</button>
|
||||
</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">
|
||||
@@ -1929,14 +1938,14 @@
|
||||
data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--project">
|
||||
<span class="sr-only">项目</span>
|
||||
<select id="vulnerability-project-filter" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
|
||||
<option value="">全部项目</option>
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.detailProject">项目</span>
|
||||
<select id="vulnerability-project-filter" data-i18n="vulnerabilityPage.filterByProject" data-i18n-attr="title" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
|
||||
<option value="" data-i18n="vulnerabilityPage.allProjects">全部项目</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||
<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="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
|
||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||
@@ -3764,6 +3773,10 @@
|
||||
<div class="modal-header">
|
||||
<h2 id="batch-manage-title">管理对话记录·共<span id="batch-manage-count">0</span>条</h2>
|
||||
<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">
|
||||
<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">
|
||||
@@ -3781,6 +3794,7 @@
|
||||
<input type="checkbox" id="batch-select-all" onchange="toggleSelectAllBatch()" data-i18n="batchManageModal.selectAll" data-i18n-attr="title" title="全选" />
|
||||
</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-action" data-i18n="batchManageModal.action">操作</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user