Compare commits

..

23 Commits

Author SHA1 Message Date
公明 7eeffb1933 Add files via upload 2026-06-26 18:16:30 +08:00
公明 0556b29d40 Add files via upload 2026-06-26 14:34:45 +08:00
公明 be3c0cfa64 Add files via upload 2026-06-26 14:31:47 +08:00
公明 8e5f40d226 Add files via upload 2026-06-26 14:30:00 +08:00
公明 4b6719a6f3 Add files via upload 2026-06-26 14:27:32 +08:00
公明 7c8f3228f8 Add files via upload 2026-06-26 14:25:14 +08:00
公明 537843b6b8 Add files via upload 2026-06-26 14:24:01 +08:00
公明 4a57574cf9 Add files via upload 2026-06-26 14:21:51 +08:00
公明 0168530084 Add files via upload 2026-06-26 10:57:59 +08:00
公明 4184a7b6f0 Add files via upload 2026-06-26 10:54:59 +08:00
公明 fb3b4dd6e5 Add files via upload 2026-06-26 01:22:30 +08:00
公明 7e4a8db7af Add files via upload 2026-06-26 01:01:49 +08:00
公明 6a72c95b9f Add files via upload 2026-06-26 00:58:29 +08:00
公明 447be050cd Add files via upload 2026-06-25 21:28:46 +08:00
公明 9b75c43f7b Add files via upload 2026-06-25 15:15:01 +08:00
公明 a443454753 Add files via upload 2026-06-25 14:56:56 +08:00
公明 08822ba5df Update config.yaml 2026-06-25 14:56:31 +08:00
公明 eda75fb98f Add files via upload 2026-06-25 14:55:10 +08:00
公明 e6978a7994 Add files via upload 2026-06-25 14:52:39 +08:00
公明 1db0f4740f Add files via upload 2026-06-25 14:50:28 +08:00
公明 6e4ff96dcd Add files via upload 2026-06-25 14:48:25 +08:00
公明 95470fefbc Add files via upload 2026-06-25 14:47:16 +08:00
公明 5e075bb198 Add files via upload 2026-06-25 14:45:43 +08:00
41 changed files with 3257 additions and 365 deletions
+2 -1
View File
@@ -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

+9
View File
@@ -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 {
+15 -2
View File
@@ -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 {
+64 -20
View File
@@ -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
View File
@@ -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 {
+102
View File
@@ -0,0 +1,102 @@
package database
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestCancelOrphanedRunningToolExecutions(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
start := time.Now().Add(-2 * time.Hour)
exec := &mcp.ToolExecution{
ID: "orphan-hydra",
ToolName: "hydra",
Arguments: map[string]interface{}{"target": "127.0.0.1"},
Status: "running",
StartTime: start,
}
if err := db.SaveToolExecution(exec); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
end := time.Now()
n, err := db.CancelOrphanedRunningToolExecutions(end, "执行已中断(服务重启)")
if err != nil {
t.Fatalf("CancelOrphanedRunningToolExecutions: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 row updated, got %d", n)
}
got, err := db.GetToolExecution("orphan-hydra")
if err != nil {
t.Fatalf("GetToolExecution: %v", err)
}
if got.Status != "cancelled" {
t.Fatalf("expected cancelled, got %s", got.Status)
}
if got.EndTime == nil {
t.Fatal("expected end_time to be set")
}
if got.Duration <= 0 {
t.Fatalf("expected positive duration, got %v", got.Duration)
}
}
func TestFinalizeStaleRunningToolExecutions_skipsActive(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
now := time.Now()
oldStart := now.Add(-5 * time.Minute)
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "stale", ToolName: "hydra", Status: "running", StartTime: oldStart,
}); err != nil {
t.Fatalf("SaveToolExecution stale: %v", err)
}
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "active", ToolName: "hydra", Status: "running", StartTime: oldStart,
}); err != nil {
t.Fatalf("SaveToolExecution active: %v", err)
}
active := map[string]struct{}{"active": {}}
n, err := db.FinalizeStaleRunningToolExecutions(now, time.Minute, active, "执行已中断(会话已结束)")
if err != nil {
t.Fatalf("FinalizeStaleRunningToolExecutions: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 stale row updated, got %d", n)
}
stale, err := db.GetToolExecution("stale")
if err != nil {
t.Fatalf("GetToolExecution stale: %v", err)
}
if stale.Status != "cancelled" {
t.Fatalf("stale expected cancelled, got %s", stale.Status)
}
activeExec, err := db.GetToolExecution("active")
if err != nil {
t.Fatalf("GetToolExecution active: %v", err)
}
if activeExec.Status != "running" {
t.Fatalf("active expected running, got %s", activeExec.Status)
}
}
+86
View File
@@ -0,0 +1,86 @@
package database
import (
"fmt"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor-summary.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
now := time.Now()
tools := []struct {
name string
calls int
ok int
fail int
result string
}{
{"alpha::run", 10, 9, 1, `{"content":[{"type":"text","text":"` + string(make([]byte, 64*1024)) + `"}]}`},
{"beta::scan", 5, 5, 0, `{"content":[{"type":"text","text":"ok"}]}`},
{"gamma::ping", 1, 1, 0, `{"content":[{"type":"text","text":"pong"}]}`},
}
for _, tool := range tools {
if err := db.UpdateToolStats(tool.name, tool.calls, tool.ok, tool.fail, &now); err != nil {
t.Fatalf("UpdateToolStats(%s): %v", tool.name, err)
}
for j := 0; j < tool.calls; j++ {
exec := &mcp.ToolExecution{
ID: fmt.Sprintf("%s-exec-%d", tool.name, j),
ToolName: tool.name,
Arguments: map[string]interface{}{"n": j},
Status: "completed",
StartTime: now.Add(-time.Duration(j) * time.Minute),
Result: &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: tool.result}}},
}
end := exec.StartTime.Add(time.Second)
exec.EndTime = &end
exec.Duration = time.Second
if err := db.SaveToolExecution(exec); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
}
}
summary, err := db.LoadToolStatsSummary(2)
if err != nil {
t.Fatalf("LoadToolStatsSummary: %v", err)
}
if summary.Summary.ToolCount != 3 {
t.Fatalf("toolCount = %d, want 3", summary.Summary.ToolCount)
}
if summary.Summary.TotalCalls != 16 {
t.Fatalf("totalCalls = %d, want 16", summary.Summary.TotalCalls)
}
if len(summary.TopTools) != 2 {
t.Fatalf("top tools = %d, want 2", len(summary.TopTools))
}
if summary.TopTools[0].ToolName != "alpha::run" {
t.Fatalf("top tool = %q, want alpha::run", summary.TopTools[0].ToolName)
}
list, err := db.LoadToolExecutionListPage(0, 5, "", "")
if err != nil {
t.Fatalf("LoadToolExecutionListPage: %v", err)
}
if len(list) != 5 {
t.Fatalf("list len = %d, want 5", len(list))
}
for _, exec := range list {
if exec.Arguments != nil || exec.Result != nil || exec.Error != "" {
t.Fatalf("expected lite execution row, got args/result/error on %s", exec.ID)
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@ package einomcp
import "sync"
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire,
// 用于清除 pending tool_calltool_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)
+12 -3
View File
@@ -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))
+6 -5
View File
@@ -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
View File
@@ -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 调用趋势数据点
+45 -6
View File
@@ -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{}{
+27 -1
View File
@@ -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 {
+1 -1
View File
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
}
func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "", "")
convs, err := h.db.ListConversations(50, 0, "", "", "")
if err != nil {
return "获取对话列表失败: " + err.Error()
}
+18 -2
View File
@@ -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")
}
}
+17
View File
@@ -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()
+17
View File
@@ -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()
+101
View File
@@ -0,0 +1,101 @@
package monitor
import (
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
const (
staleRunningMinAge = 45 * time.Second
staleRunningReconcileGap = 2 * time.Minute
)
// ExecutionReconciler 在启动或运行期将无对应协程的 running 执行记录收尾为 cancelled。
type ExecutionReconciler struct {
db *database.DB
mcpServer *mcp.Server
externalMgr *mcp.ExternalMCPManager
logger *zap.Logger
}
// NewExecutionReconciler creates a reconciler for orphaned MCP tool executions.
func NewExecutionReconciler(db *database.DB, mcpServer *mcp.Server, externalMgr *mcp.ExternalMCPManager, logger *zap.Logger) *ExecutionReconciler {
return &ExecutionReconciler{
db: db,
mcpServer: mcpServer,
externalMgr: externalMgr,
logger: logger,
}
}
// ReconcileOnStartup marks every persisted running row as cancelled (safe right after process start).
func (r *ExecutionReconciler) ReconcileOnStartup() {
if r == nil || r.db == nil {
return
}
now := time.Now()
n, err := r.db.CancelOrphanedRunningToolExecutions(now, "执行已中断(服务重启)")
if err != nil {
if r.logger != nil {
r.logger.Warn("启动时清理孤儿 running 工具执行记录失败", zap.Error(err))
}
return
}
if n > 0 && r.logger != nil {
r.logger.Info("启动时已收尾孤儿 running 工具执行记录", zap.Int64("count", n))
}
}
func (r *ExecutionReconciler) activeExecutionIDs() map[string]struct{} {
ids := make(map[string]struct{})
if r.mcpServer != nil {
for id := range r.mcpServer.ActiveRunningExecutionIDs() {
ids[id] = struct{}{}
}
}
if r.externalMgr != nil {
for id := range r.externalMgr.ActiveRunningExecutionIDs() {
ids[id] = struct{}{}
}
}
return ids
}
// ReconcileStaleRunning finalizes running rows that are not tracked in-memory and older than staleRunningMinAge.
func (r *ExecutionReconciler) ReconcileStaleRunning() {
if r == nil || r.db == nil {
return
}
now := time.Now()
n, err := r.db.FinalizeStaleRunningToolExecutions(now, staleRunningMinAge, r.activeExecutionIDs(), "执行已中断(会话已结束)")
if err != nil {
if r.logger != nil {
r.logger.Warn("定期收尾 stale running 工具执行记录失败", zap.Error(err))
}
return
}
if n > 0 && r.logger != nil {
r.logger.Info("已收尾 stale running 工具执行记录", zap.Int64("count", n))
}
}
// StartStaleRunningReconcileLoop periodically reconciles orphaned running tool executions.
func StartStaleRunningReconcileLoop(r *ExecutionReconciler, logger *zap.Logger) {
if r == nil {
return
}
go func() {
ticker := time.NewTicker(staleRunningReconcileGap)
defer ticker.Stop()
for range ticker.C {
r.ReconcileStaleRunning()
if logger != nil {
logger.Debug("monitor stale running reconcile tick completed")
}
}
}()
}
+38
View File
@@ -0,0 +1,38 @@
package monitor
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestExecutionReconciler_ReconcileOnStartup(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor.db")
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "run-1", ToolName: "hydra", Status: "running", StartTime: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
r := NewExecutionReconciler(db, mcp.NewServer(zap.NewNop()), nil, zap.NewNop())
r.ReconcileOnStartup()
got, err := db.GetToolExecution("run-1")
if err != nil {
t.Fatalf("GetToolExecution: %v", err)
}
if got.Status != "cancelled" {
t.Fatalf("expected cancelled after startup reconcile, got %s", got.Status)
}
}
+16
View File
@@ -0,0 +1,16 @@
package multiagent
import (
"fmt"
"github.com/cloudwego/eino/adk"
)
// InitADK configures global Eino ADK settings. Call once at process startup before
// any ADK middleware or agents are created.
func InitADK() error {
if err := adk.SetLanguage(adk.LanguageChinese); err != nil {
return fmt.Errorf("adk set language: %w", err)
}
return nil
}
+3 -19
View File
@@ -299,6 +299,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
var toolResultSent sync.Map // toolCallID -> struct{}ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
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{
+31
View File
@@ -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) {
+5 -5
View File
@@ -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 {
+9 -2
View File
@@ -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 {
+12 -9
View File
@@ -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")
}
+170
View File
@@ -0,0 +1,170 @@
package project
import (
"fmt"
"strings"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
const (
// UserVerbatimSectionHeading 用户原文锚点可读标题(块内保留,供 Agent 阅读)。
UserVerbatimSectionHeading = "## 用户历史输入(原文保留,勿省略或改写)"
// UserVerbatimSectionStartMarker / EndMarkerHTML 注释边界,供程序化替换;对模型无指令语义。
UserVerbatimSectionStartMarker = "<!-- user-verbatim-start -->"
UserVerbatimSectionEndMarker = "<!-- user-verbatim-end -->"
)
// ExtractUserContentsFromMessages 按时间顺序提取 user 角色消息的原文(跳过空白)。
func ExtractUserContentsFromMessages(msgs []database.Message) []string {
out := make([]string, 0, len(msgs))
for i := range msgs {
if !strings.EqualFold(strings.TrimSpace(msgs[i].Role), "user") {
continue
}
content := strings.TrimSpace(msgs[i].Content)
if content == "" {
continue
}
out = append(out, content)
}
return out
}
// BuildUserVerbatimAnchorBlockFromMessages 从 messages 表行构建用户原文锚点块。
// maxRunes: 0 = 不截断;>0 = 总 rune 上限(仍保留每一轮,仅对超长单条做尾部截断提示)。
func BuildUserVerbatimAnchorBlockFromMessages(msgs []database.Message, maxRunes int) string {
return BuildUserVerbatimAnchorBlock(ExtractUserContentsFromMessages(msgs), maxRunes)
}
// BuildUserVerbatimAnchorBlock 将各轮用户原文格式化为 system prompt 锚点块。
func BuildUserVerbatimAnchorBlock(userContents []string, maxRunes int) string {
if len(userContents) == 0 {
return ""
}
lines := make([]string, 0, len(userContents))
for _, content := range userContents {
content = strings.TrimSpace(content)
if content == "" {
continue
}
lines = append(lines, fmt.Sprintf("[第%d轮] %s", len(lines)+1, content))
}
if len(lines) == 0 {
return ""
}
body := strings.Join(lines, "\n")
if maxRunes > 0 {
body = capUserVerbatimBody(body, maxRunes)
}
return wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n" + body)
}
func capUserVerbatimBody(body string, maxRunes int) string {
rs := []rune(body)
if len(rs) <= maxRunes {
return body
}
suffix := "\n\n...(用户原文锚点已达配置上限,更早轮次可能被截断;完整原文见 messages 表)..."
suffixRunes := []rune(suffix)
keep := maxRunes - len(suffixRunes)
if keep <= 0 {
return string(rs[:maxRunes])
}
return string(rs[:keep]) + suffix
}
func wrapUserVerbatimBlock(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
return UserVerbatimSectionStartMarker + "\n" + content + "\n" + UserVerbatimSectionEndMarker + "\n"
}
// ReplaceUserVerbatimAnchorSection 用 freshBlock 替换 content 中已有的用户原文锚点段。
func ReplaceUserVerbatimAnchorSection(content, freshBlock string) (string, bool) {
content = strings.TrimSpace(content)
freshBlock = strings.TrimSpace(freshBlock)
if freshBlock == "" {
return content, false
}
start, ok := userVerbatimSectionStart(content)
if !ok {
return content, false
}
end, ok := userVerbatimSectionEnd(content, start)
if !ok {
return content, false
}
return strings.TrimSpace(content[:start] + freshBlock + content[end:]), true
}
func userVerbatimSectionStart(content string) (int, bool) {
idx := strings.Index(content, UserVerbatimSectionStartMarker)
if idx < 0 {
return 0, false
}
return idx, true
}
func userVerbatimSectionEnd(content string, start int) (int, bool) {
if start < 0 || start >= len(content) {
return 0, false
}
tail := content[start:]
idx := strings.LastIndex(tail, UserVerbatimSectionEndMarker)
if idx < 0 {
return 0, false
}
return start + idx + len(UserVerbatimSectionEndMarker), true
}
// RefreshUserVerbatimAnchorInMessages 在 summarization 等压缩后,用 freshBlock 刷新 system 中的用户原文锚点。
// 若尚无锚点段,则追加到首条 system 消息;若无 system 消息则在开头插入一条。
func RefreshUserVerbatimAnchorInMessages(msgs []adk.Message, freshBlock string) []adk.Message {
freshBlock = strings.TrimSpace(freshBlock)
if freshBlock == "" || len(msgs) == 0 {
return msgs
}
out := make([]adk.Message, len(msgs))
changed := false
for i, msg := range msgs {
if msg == nil || msg.Role != schema.System {
out[i] = msg
continue
}
newContent, ok := ReplaceUserVerbatimAnchorSection(msg.Content, freshBlock)
if !ok {
out[i] = msg
continue
}
cloned := *msg
cloned.Content = newContent
out[i] = &cloned
changed = true
}
if changed {
return out
}
for i, msg := range msgs {
if msg == nil || msg.Role != schema.System {
continue
}
cloned := *msg
cloned.Content = AppendSystemPromptBlock(cloned.Content, freshBlock)
out[i] = &cloned
return out
}
prefix := make([]adk.Message, 0, len(msgs)+1)
prefix = append(prefix, schema.SystemMessage(freshBlock))
return append(prefix, msgs...)
}
@@ -0,0 +1,96 @@
package project
import (
"strings"
"testing"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestBuildUserVerbatimAnchorBlock_MultiTurn(t *testing.T) {
msgs := []database.Message{
{Role: "user", Content: "目标 https://a.com 仅测 /api"},
{Role: "assistant", Content: "好的"},
{Role: "user", Content: "用 admin:test 登录"},
}
block := BuildUserVerbatimAnchorBlockFromMessages(msgs, 0)
if block == "" {
t.Fatal("expected non-empty block")
}
if !strings.Contains(block, UserVerbatimSectionStartMarker) {
t.Error("missing start marker")
}
if !strings.Contains(block, "[第1轮]") || !strings.Contains(block, "https://a.com") {
t.Error("missing first user turn")
}
if !strings.Contains(block, "[第2轮]") || !strings.Contains(block, "admin:test") {
t.Error("missing second user turn")
}
if strings.Contains(block, "好的") {
t.Error("assistant content should not appear")
}
}
func TestReplaceUserVerbatimAnchorSection(t *testing.T) {
old := "prefix\n\n" + wrapUserVerbatimBlock("## old\n\n[第1轮] a") + "\nsuffix"
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] b\n[第2轮] c")
out, ok := ReplaceUserVerbatimAnchorSection(old, newBlock)
if !ok {
t.Fatal("expected replace ok")
}
if !strings.Contains(out, "[第2轮] c") {
t.Errorf("expected new block, got %q", out)
}
if !strings.HasPrefix(strings.TrimSpace(out), "prefix") {
t.Error("prefix should remain")
}
if !strings.Contains(out, "suffix") {
t.Error("suffix should remain")
}
}
func TestRefreshUserVerbatimAnchorInMessages_ReplaceExisting(t *testing.T) {
oldBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] old")
msgs := []adk.Message{
schema.SystemMessage("instr\n\n" + oldBlock),
schema.UserMessage("hi"),
}
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] new")
out := RefreshUserVerbatimAnchorInMessages(msgs, newBlock)
if len(out) != 2 {
t.Fatalf("message count: got %d", len(out))
}
if !strings.Contains(out[0].Content, "[第1轮] new") {
t.Errorf("system content: %q", out[0].Content)
}
if strings.Contains(out[0].Content, "[第1轮] old") {
t.Error("old anchor should be replaced")
}
}
func TestRefreshUserVerbatimAnchorInMessages_InsertWhenMissing(t *testing.T) {
msgs := []adk.Message{
schema.SystemMessage("base instruction"),
schema.UserMessage("hi"),
}
block := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] anchor")
out := RefreshUserVerbatimAnchorInMessages(msgs, block)
if !strings.Contains(out[0].Content, "[第1轮] anchor") {
t.Errorf("expected appended anchor, got %q", out[0].Content)
}
}
func TestBuildUserVerbatimAnchorBlock_MaxRunes(t *testing.T) {
long := strings.Repeat("字", 200)
block := BuildUserVerbatimAnchorBlock([]string{long}, 50)
body := block
if idx := strings.Index(body, UserVerbatimSectionStartMarker); idx >= 0 {
body = strings.TrimPrefix(body[idx+len(UserVerbatimSectionStartMarker):], "\n")
}
if len([]rune(body)) > 120 {
t.Errorf("expected capped body, got %d runes", len([]rune(body)))
}
}
+556 -6
View File
@@ -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);
+15
View File
@@ -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",
+15
View File
@@ -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
View File
@@ -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 不会自动挂到 windowhitl 等脚本依赖 window.loadConversation
if (typeof window !== 'undefined') {
window.loadConversation = loadConversation;
window.startNewConversation = startNewConversation;
window.refreshConversationProjectFilter = refreshConversationProjectFilter;
window.refreshAllProjectFilterSelects = refreshAllProjectFilterSelects;
window.onConversationProjectFilterChange = onConversationProjectFilterChange;
}
+21 -29
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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
View File
@@ -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;
+19 -5
View File
@@ -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>