Compare commits

..

46 Commits

Author SHA1 Message Date
公明 b8ebf023a0 Update config.yaml 2026-06-02 12:19:14 +08:00
公明 604ce34d5e Merge pull request #136 from Opr4Mp3r/fix/sse-mcp-session-context
fix(mcp): keep SSE client session alive after connect
2026-06-02 11:37:23 +08:00
opr4 b29b36bfd5 fix(mcp): keep SSE client session alive after connect 2026-06-01 21:36:42 +08:00
公明 11bab83fc5 Update config.yaml 2026-06-01 19:07:09 +08:00
公明 dc750e3680 Add files via upload 2026-06-01 19:06:25 +08:00
公明 0236d1c155 Add files via upload 2026-06-01 19:04:14 +08:00
公明 be59ddcab6 Add files via upload 2026-06-01 17:35:41 +08:00
公明 25464a68e6 Add files via upload 2026-05-31 19:07:26 +08:00
公明 eabfed09c9 Add files via upload 2026-05-31 13:33:32 +08:00
公明 cbcbd414cd Add files via upload 2026-05-29 17:59:19 +08:00
公明 0933f9365b Update config.yaml 2026-05-29 17:18:05 +08:00
公明 e792891ff3 Add files via upload 2026-05-29 17:17:01 +08:00
公明 e14e5f15d3 Update config.yaml 2026-05-29 16:26:29 +08:00
公明 4d5e0c5f21 Add files via upload 2026-05-29 15:12:43 +08:00
公明 b3238304ce Add files via upload 2026-05-29 14:22:56 +08:00
公明 665e2ec73a Add files via upload 2026-05-29 14:22:32 +08:00
公明 d63d9c25b8 Add files via upload 2026-05-29 14:21:26 +08:00
公明 d1c63d0ba7 Add files via upload 2026-05-29 14:19:08 +08:00
公明 55d6d449cd Add files via upload 2026-05-29 14:16:09 +08:00
公明 d4bc9646d9 Add files via upload 2026-05-29 14:12:21 +08:00
公明 b941f5a8d9 Add files via upload 2026-05-29 11:17:05 +08:00
公明 97e2c0fd43 Add files via upload 2026-05-29 11:14:04 +08:00
公明 bd3e48c2d0 Add files via upload 2026-05-29 10:58:15 +08:00
公明 8b0b91fddc Add files via upload 2026-05-29 10:56:18 +08:00
公明 2b38595b42 Add files via upload 2026-05-29 10:54:39 +08:00
公明 5c795439ee Update config.yaml 2026-05-28 15:49:18 +08:00
公明 df531910cf Add files via upload 2026-05-28 14:34:14 +08:00
公明 8a089a826c Add files via upload 2026-05-28 14:15:41 +08:00
公明 60b32ffc69 Add files via upload 2026-05-28 14:14:48 +08:00
公明 21c36fcce8 Add files via upload 2026-05-28 14:12:44 +08:00
公明 4d048f6da0 Add files via upload 2026-05-28 14:11:05 +08:00
公明 03a2707b83 Add files via upload 2026-05-28 14:09:17 +08:00
公明 9941f51b3e Add files via upload 2026-05-28 13:00:01 +08:00
公明 1553e896c5 Add files via upload 2026-05-28 12:58:27 +08:00
公明 ea2184773e Add files via upload 2026-05-28 11:53:33 +08:00
公明 764d8110ec Add files via upload 2026-05-28 11:21:07 +08:00
公明 e037f383f5 Add files via upload 2026-05-28 11:20:14 +08:00
公明 e40f7cb468 Add files via upload 2026-05-28 10:56:33 +08:00
公明 72aca69204 Add files via upload 2026-05-28 10:52:18 +08:00
公明 133da1c640 Add files via upload 2026-05-28 10:49:13 +08:00
公明 af78b47517 Add files via upload 2026-05-28 10:15:12 +08:00
公明 f5fabc05a4 Add files via upload 2026-05-27 21:15:58 +08:00
公明 5cc53b1076 Add files via upload 2026-05-27 21:14:37 +08:00
公明 f1be2064db Add files via upload 2026-05-27 19:58:02 +08:00
公明 0c9c2ec606 Add files via upload 2026-05-27 19:56:08 +08:00
公明 cf09dd36d8 Add files via upload 2026-05-27 19:01:30 +08:00
27 changed files with 2760 additions and 426 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.25" version: "v1.6.29"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -77,7 +77,7 @@ fofa:
# Agent 配置 # Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果 # 达到最大迭代次数时,AI 会自动总结测试结果
agent: agent:
max_iterations: 1200 # 最大迭代次数,AI 代理最多执行多少轮工具调用 max_iterations: 12000 # 最大迭代次数,AI 代理最多执行多少轮工具调用
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储 large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下 result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起) tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+120 -4
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"strings" "strings"
"time" "time"
@@ -12,19 +13,106 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
const (
// SQLite 在 WAL 模式下建议使用较保守的连接数,降低长读快照导致 checkpoint 饥饿的概率。
sqliteMaxOpenConns = 25
sqliteMaxIdleConns = 5
// 以页为单位的自动 checkpoint 触发阈值(默认 1000 页,约 4MB @ 4KB/page)。
sqliteWALAutoCheckpointPages = 1000
// 控制 WAL 目标上限,避免异常场景持续膨胀(256MB)。
sqliteJournalSizeLimitBytes = 256 * 1024 * 1024
// 定时执行 PASSIVE checkpoint,平滑推进 WAL 回收。
sqlitePassiveCheckpointInterval = 300 * time.Second
)
// configureDBPool 设置 SQLite 连接池参数,提升并发稳定性 // configureDBPool 设置 SQLite 连接池参数,提升并发稳定性
func configureDBPool(db *sql.DB) { func configureDBPool(db *sql.DB) {
// SQLite 同一时间只允许一个写入者,限制连接数避免 "database is locked" 错误 // SQLite 同一时间只允许一个写入者;过高连接数会放大锁竞争和 WAL 回收延迟。
db.SetMaxOpenConns(25) db.SetMaxOpenConns(sqliteMaxOpenConns)
db.SetMaxIdleConns(5) db.SetMaxIdleConns(sqliteMaxIdleConns)
db.SetConnMaxLifetime(30 * time.Minute) db.SetConnMaxLifetime(30 * time.Minute)
} }
// configureSQLitePragmas 调整 WAL 回收行为,降低 -wal 文件长期膨胀风险。
func configureSQLitePragmas(db *sql.DB) error {
if _, err := db.Exec(fmt.Sprintf("PRAGMA wal_autocheckpoint=%d", sqliteWALAutoCheckpointPages)); err != nil {
return fmt.Errorf("设置 wal_autocheckpoint 失败: %w", err)
}
if _, err := db.Exec(fmt.Sprintf("PRAGMA journal_size_limit=%d", sqliteJournalSizeLimitBytes)); err != nil {
return fmt.Errorf("设置 journal_size_limit 失败: %w", err)
}
return nil
}
// DB 数据库连接 // DB 数据库连接
type DB struct { type DB struct {
*sql.DB *sql.DB
logger *zap.Logger logger *zap.Logger
conversationArtifactsDir string conversationArtifactsDir string
checkpointLoopName string
checkpointStop chan struct{}
checkpointDone chan struct{}
closeOnce sync.Once
closeErr error
}
// startPassiveCheckpointLoop 启动后台 PASSIVE checkpoint 循环。
func (db *DB) startPassiveCheckpointLoop(name string) {
if sqlitePassiveCheckpointInterval <= 0 || db == nil || db.DB == nil {
return
}
db.checkpointLoopName = strings.TrimSpace(name)
db.checkpointStop = make(chan struct{})
db.checkpointDone = make(chan struct{})
go func() {
defer close(db.checkpointDone)
ticker := time.NewTicker(sqlitePassiveCheckpointInterval)
defer ticker.Stop()
// 启动后先尝试一次,尽快回收已有 WAL 堆积。
db.runPassiveCheckpoint("startup")
for {
select {
case <-db.checkpointStop:
return
case <-ticker.C:
db.runPassiveCheckpoint("ticker")
}
}
}()
}
// runPassiveCheckpoint 执行一次 PRAGMA wal_checkpoint(PASSIVE)。
func (db *DB) runPassiveCheckpoint(trigger string) {
if db == nil || db.DB == nil {
return
}
startAt := time.Now()
var busy, logFrames, checkpointed int
err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointed)
if db.logger == nil {
return
}
fields := []zap.Field{
zap.String("db", db.checkpointLoopName),
zap.String("trigger", trigger),
zap.Int("busy", busy),
zap.Int("log_frames", logFrames),
zap.Int("checkpointed_frames", checkpointed),
zap.Int64("elapsed_ms", time.Since(startAt).Milliseconds()),
}
if err != nil {
db.logger.Warn("SQLite PASSIVE checkpoint 完成(失败)",
append(fields, zap.Error(err))...,
)
return
}
if busy > 0 {
db.logger.Info("SQLite PASSIVE checkpoint 完成(部分推进)", fields...)
return
}
db.logger.Info("SQLite PASSIVE checkpoint 完成(成功)", fields...)
} }
// NewDB 创建数据库连接 // NewDB 创建数据库连接
@@ -37,8 +125,13 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
configureDBPool(db) configureDBPool(db)
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("连接数据库失败: %w", err) return nil, fmt.Errorf("连接数据库失败: %w", err)
} }
if err := configureSQLitePragmas(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("配置数据库 PRAGMA 失败: %w", err)
}
database := &DB{ database := &DB{
DB: db, DB: db,
@@ -54,8 +147,10 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
// 初始化表 // 初始化表
if err := database.initTables(); err != nil { if err := database.initTables(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("初始化表失败: %w", err) return nil, fmt.Errorf("初始化表失败: %w", err)
} }
database.startPassiveCheckpointLoop("conversations")
return database, nil return database, nil
} }
@@ -1159,8 +1254,13 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
configureDBPool(sqlDB) configureDBPool(sqlDB)
if err := sqlDB.Ping(); err != nil { if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("连接知识库数据库失败: %w", err) return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
} }
if err := configureSQLitePragmas(sqlDB); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("配置知识库数据库 PRAGMA 失败: %w", err)
}
database := &DB{ database := &DB{
DB: sqlDB, DB: sqlDB,
@@ -1169,8 +1269,10 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
// 初始化知识库表 // 初始化知识库表
if err := database.initKnowledgeTables(); err != nil { if err := database.initKnowledgeTables(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("初始化知识库表失败: %w", err) return nil, fmt.Errorf("初始化知识库表失败: %w", err)
} }
database.startPassiveCheckpointLoop("knowledge")
return database, nil return database, nil
} }
@@ -1284,5 +1386,19 @@ func (db *DB) migrateKnowledgeEmbeddingsColumns() error {
// Close 关闭数据库连接 // Close 关闭数据库连接
func (db *DB) Close() error { func (db *DB) Close() error {
return db.DB.Close() if db == nil {
return nil
}
db.closeOnce.Do(func() {
if db.checkpointStop != nil {
close(db.checkpointStop)
if db.checkpointDone != nil {
<-db.checkpointDone
}
}
if db.DB != nil {
db.closeErr = db.DB.Close()
}
})
return db.closeErr
} }
+13 -1
View File
@@ -265,10 +265,22 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
// DeleteVulnerability 删除漏洞 // DeleteVulnerability 删除漏洞
func (db *DB) DeleteVulnerability(id string) error { func (db *DB) DeleteVulnerability(id string) error {
_, err := db.Exec("DELETE FROM vulnerabilities WHERE id = ?", id) tx, err := db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
return fmt.Errorf("清理事实漏洞关联失败: %w", err)
}
if _, err := tx.Exec("DELETE FROM vulnerabilities WHERE id = ?", id); err != nil {
return fmt.Errorf("删除漏洞失败: %w", err) return fmt.Errorf("删除漏洞失败: %w", err)
} }
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务失败: %w", err)
}
return nil return nil
} }
+41 -25
View File
@@ -96,6 +96,17 @@ type runHandler struct {
seq atomic.Uint64 seq atomic.Uint64
} }
func safeRunInfo(info *callbacks.RunInfo) callbacks.RunInfo {
if info == nil {
return callbacks.RunInfo{
Name: "unknown",
Type: "unknown",
Component: components.Component("unknown"),
}
}
return *info
}
func (h *runHandler) genSpanID() string { func (h *runHandler) genSpanID() string {
return fmt.Sprintf("%s-%d", h.runID, h.seq.Add(1)) return fmt.Sprintf("%s-%d", h.runID, h.seq.Add(1))
} }
@@ -134,6 +145,7 @@ func (h *runHandler) popMatching(want string) string {
} }
func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context { func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
ri := safeRunInfo(info)
var parentID string var parentID string
h.mu.Lock() h.mu.Lock()
if len(h.spanStack) > 0 { if len(h.spanStack) > 0 {
@@ -151,9 +163,9 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
ctx, sp = tracer.Start(ctx, spanName, ctx, sp = tracer.Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindInternal), trace.WithSpanKind(trace.SpanKindInternal),
trace.WithAttributes( trace.WithAttributes(
attribute.String("eino.component", string(info.Component)), attribute.String("eino.component", string(ri.Component)),
attribute.String("eino.name", info.Name), attribute.String("eino.name", ri.Name),
attribute.String("eino.type", info.Type), attribute.String("eino.type", ri.Type),
attribute.String("cyberstrike.run_id", h.runID), attribute.String("cyberstrike.run_id", h.runID),
attribute.String("cyberstrike.conversation_id", strings.TrimSpace(h.params.ConversationID)), attribute.String("cyberstrike.conversation_id", strings.TrimSpace(h.params.ConversationID)),
attribute.String("cyberstrike.orchestration", strings.TrimSpace(h.params.OrchMode)), attribute.String("cyberstrike.orchestration", strings.TrimSpace(h.params.OrchMode)),
@@ -169,9 +181,9 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
zap.String("runId", h.runID), zap.String("runId", h.runID),
zap.String("spanId", spanID), zap.String("spanId", spanID),
zap.String("parentSpanId", parentID), zap.String("parentSpanId", parentID),
zap.String("component", string(info.Component)), zap.String("component", string(ri.Component)),
zap.String("name", info.Name), zap.String("name", ri.Name),
zap.String("type", info.Type), zap.String("type", ri.Type),
zap.String("phase", "start"), zap.String("phase", "start"),
} }
if sp, ok := ctx.Value(ctxOtelSpanKey{}).(trace.Span); ok && sp != nil { if sp, ok := ctx.Value(ctxOtelSpanKey{}).(trace.Span); ok && sp != nil {
@@ -195,9 +207,9 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
"parentSpanId": parentID, "parentSpanId": parentID,
"conversationId": strings.TrimSpace(h.params.ConversationID), "conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode), "orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(info.Component), "component": string(ri.Component),
"name": info.Name, "name": ri.Name,
"type": info.Type, "type": ri.Type,
"ts": time.Now().UTC().Format(time.RFC3339Nano), "ts": time.Now().UTC().Format(time.RFC3339Nano),
"inputSummary": inSum, "inputSummary": inSum,
"source": "eino_callbacks", "source": "eino_callbacks",
@@ -208,6 +220,7 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
} }
func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context { func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
ri := safeRunInfo(info)
spanID, _ := ctx.Value(ctxSpanKey{}).(string) spanID, _ := ctx.Value(ctxSpanKey{}).(string)
if spanID == "" { if spanID == "" {
spanID = h.popSpan() spanID = h.popSpan()
@@ -226,9 +239,9 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
fields := []zap.Field{ fields := []zap.Field{
zap.String("runId", h.runID), zap.String("runId", h.runID),
zap.String("spanId", spanID), zap.String("spanId", spanID),
zap.String("component", string(info.Component)), zap.String("component", string(ri.Component)),
zap.String("name", info.Name), zap.String("name", ri.Name),
zap.String("type", info.Type), zap.String("type", ri.Type),
zap.String("phase", "end"), zap.String("phase", "end"),
} }
if h.cfg.ZapVerbose { if h.cfg.ZapVerbose {
@@ -243,9 +256,9 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
"spanId": spanID, "spanId": spanID,
"conversationId": strings.TrimSpace(h.params.ConversationID), "conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode), "orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(info.Component), "component": string(ri.Component),
"name": info.Name, "name": ri.Name,
"type": info.Type, "type": ri.Type,
"ts": time.Now().UTC().Format(time.RFC3339Nano), "ts": time.Now().UTC().Format(time.RFC3339Nano),
"outputSummary": outSum, "outputSummary": outSum,
"source": "eino_callbacks", "source": "eino_callbacks",
@@ -255,6 +268,7 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
} }
func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err error) context.Context { func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err error) context.Context {
ri := safeRunInfo(info)
spanID, _ := ctx.Value(ctxSpanKey{}).(string) spanID, _ := ctx.Value(ctxSpanKey{}).(string)
if spanID == "" { if spanID == "" {
spanID = h.popSpan() spanID = h.popSpan()
@@ -276,9 +290,9 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
h.params.Logger.Warn("eino_callback_error", h.params.Logger.Warn("eino_callback_error",
zap.String("runId", h.runID), zap.String("runId", h.runID),
zap.String("spanId", spanID), zap.String("spanId", spanID),
zap.String("component", string(info.Component)), zap.String("component", string(ri.Component)),
zap.String("name", info.Name), zap.String("name", ri.Name),
zap.String("type", info.Type), zap.String("type", ri.Type),
zap.Error(err), zap.Error(err),
) )
} }
@@ -288,9 +302,9 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
"spanId": spanID, "spanId": spanID,
"conversationId": strings.TrimSpace(h.params.ConversationID), "conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode), "orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(info.Component), "component": string(ri.Component),
"name": info.Name, "name": ri.Name,
"type": info.Type, "type": ri.Type,
"ts": time.Now().UTC().Format(time.RFC3339Nano), "ts": time.Now().UTC().Format(time.RFC3339Nano),
"error": msg, "error": msg,
"source": "eino_callbacks", "source": "eino_callbacks",
@@ -300,28 +314,30 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
} }
func (h *runHandler) onStartStreamIn(ctx context.Context, info *callbacks.RunInfo, input *schema.StreamReader[callbacks.CallbackInput]) context.Context { func (h *runHandler) onStartStreamIn(ctx context.Context, info *callbacks.RunInfo, input *schema.StreamReader[callbacks.CallbackInput]) context.Context {
ri := safeRunInfo(info)
if input != nil { if input != nil {
input.Close() input.Close()
} }
if h.params.Logger != nil { if h.params.Logger != nil {
h.params.Logger.Debug("eino_callback_stream_in", h.params.Logger.Debug("eino_callback_stream_in",
zap.String("runId", h.runID), zap.String("runId", h.runID),
zap.String("component", string(info.Component)), zap.String("component", string(ri.Component)),
zap.String("name", info.Name), zap.String("name", ri.Name),
) )
} }
return ctx return ctx
} }
func (h *runHandler) onEndStreamOut(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context { func (h *runHandler) onEndStreamOut(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
ri := safeRunInfo(info)
if output != nil { if output != nil {
output.Close() output.Close()
} }
if h.params.Logger != nil { if h.params.Logger != nil {
h.params.Logger.Debug("eino_callback_stream_out", h.params.Logger.Debug("eino_callback_stream_out",
zap.String("runId", h.runID), zap.String("runId", h.runID),
zap.String("component", string(info.Component)), zap.String("component", string(ri.Component)),
zap.String("name", info.Name), zap.String("name", ri.Name),
) )
} }
return ctx return ctx
+28 -3
View File
@@ -1013,6 +1013,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} }
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
flushedThinking := make(map[string]bool) // streamId -> flushed flushedThinking := make(map[string]bool) // streamId -> flushed
seenToolCallSigs := make(map[string]string) // toolCallId -> payload signature
seenToolResultSigs := make(map[string]string) // toolCallId -> payload signature
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta // response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。 // 聚合为一条 planning 写入 process_details,刷新后与线上一致。
@@ -1075,6 +1077,29 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} }
return func(eventType, message string, data interface{}) { return func(eventType, message string, data interface{}) {
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
if dataMap, ok := data.(map[string]interface{}); ok {
toolCallID := strings.TrimSpace(fmt.Sprint(dataMap["toolCallId"]))
if toolCallID != "" && toolCallID != "<nil>" {
payloadJSON, _ := json.Marshal(dataMap)
sig := eventType + "|" + message + "|" + string(payloadJSON)
seen := seenToolCallSigs
if eventType == "tool_result" {
seen = seenToolResultSigs
}
if prev, exists := seen[toolCallID]; exists && prev == sig {
h.logger.Debug("跳过重复工具进度事件",
zap.String("eventType", eventType),
zap.String("toolCallId", toolCallID))
return
}
seen[toolCallID] = sig
}
}
}
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅 // 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅
if sendEventFunc != nil { if sendEventFunc != nil {
sendEventFunc(eventType, message, data) sendEventFunc(eventType, message, data)
@@ -1420,7 +1445,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
var req ChatRequest var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
// 对于流式请求,也发送SSE格式的错误 // 对于流式请求,也发送SSE格式的错误
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
event := StreamEvent{ event := StreamEvent{
@@ -1442,7 +1467,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
) )
// 设置SSE响应头 // 设置SSE响应头
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // 禁用nginx缓冲 c.Header("X-Accel-Buffering", "no") // 禁用nginx缓冲
@@ -2023,7 +2048,7 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
return return
} }
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// EinoSingleAgentLoopStream Eino ADK 单代理(ChatModelAgent + Runner)流式对话;不依赖 multi_agent.enabled。 // EinoSingleAgentLoopStream Eino ADK 单代理(ChatModelAgent + Runner)流式对话;不依赖 multi_agent.enabled。
func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) { func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。 // MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) { func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
if h.config == nil || !h.config.MultiAgent.Enabled { if h.config == nil || !h.config.MultiAgent.Enabled {
+212 -19
View File
@@ -40,8 +40,13 @@ const (
robotCmdRoles = "角色" robotCmdRoles = "角色"
robotCmdRolesList = "角色列表" robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色" robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除" robotCmdDelete = "删除"
robotCmdVersion = "版本" robotCmdVersion = "版本"
robotCmdProjects = "项目"
robotCmdProjectsList = "项目列表"
robotCmdBindProject = "绑定项目"
robotCmdNewProject = "新建项目"
robotCmdUnbindProject = "解除项目"
) )
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理 // RobotHandler 企业微信/钉钉/飞书等机器人回调处理
@@ -234,7 +239,7 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
_ = h.db.UpdateConversationTitle(convID, newTitle) _ = h.db.UpdateConversationTitle(convID, newTitle)
} }
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), h.robotMessageTimeout())
sk := h.sessionKey(platform, userID) sk := h.sessionKey(platform, userID)
h.cancelMu.Lock() h.cancelMu.Lock()
h.runningCancels[sk] = cancel h.runningCancels[sk] = cancel
@@ -252,6 +257,9 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return "任务已取消。" return "任务已取消。"
} }
if errors.Is(err, context.DeadlineExceeded) {
return "任务执行超时,请稍后重试或精简本次请求范围。"
}
return "处理失败: " + err.Error() return "处理失败: " + err.Error()
} }
if newConvID != convID { if newConvID != convID {
@@ -260,22 +268,182 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
return resp return resp
} }
func (h *RobotHandler) robotMessageTimeout() time.Duration {
// 机器人整次消息处理超时(与单次工具超时 agent.tool_timeout_minutes 解耦)。
return 10 * time.Hour
}
func (h *RobotHandler) cmdHelp() string { func (h *RobotHandler) cmdHelp() string {
return "**【CyberStrikeAI 机器人命令】**\n\n" + var b strings.Builder
"- `帮助` `help` — 显示本帮助 | Show this help\n" + b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
"- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" + b.WriteString("【通用 General】\n")
"- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" + b.WriteString("· 帮助 / help — 显示本帮助\n")
"- `新对话` `new` — 开启新对话 | Start new conversation\n" + b.WriteString("· 版本 / version — 显示当前版本号\n")
"- `清空` `clear` — 清空当前上下文 | Clear context\n" + b.WriteString("\n【对话 Conversation】\n")
"- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" + b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n")
"- `停止` `stop` — 中断当前任务 | Stop running task\n" + b.WriteString("· 切换 <ID> / switch <ID> — 指定对话继续\n")
"- `角色` `roles` — 列出所有可用角色 | List roles\n" + b.WriteString("· 新对话 / new — 开启新对话\n")
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" + b.WriteString("· 清空 / clear — 清空当前上下文\n")
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" + b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n")
"- `版本` `version` — 显示当前版本号 | Show version\n\n" + b.WriteString("· 停止 / stop — 中断当前任务\n")
"---\n" + b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话\n")
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" + b.WriteString("\n【角色 Role】\n")
"Otherwise, send any text for AI penetration testing / security analysis." b.WriteString("· 角色 / roles — 列出所有可用角色\n")
b.WriteString("· 角色 <名> / role <name> — 切换当前角色\n")
if h.projectsEnabled() {
b.WriteString("\n【项目 Project】\n")
b.WriteString("· 项目 / projects — 列出所有项目\n")
b.WriteString("· 新建项目 <名称> / new project <name> — 创建并绑定当前对话\n")
b.WriteString("· 绑定项目 <ID或名称> / bind project <ID|name> — 绑定到已有项目\n")
b.WriteString("· 解除项目 / unbind project — 解除项目绑定\n")
}
b.WriteString("\n──────────────\n")
b.WriteString("除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。")
return b.String()
}
func (h *RobotHandler) projectsEnabled() bool {
return h.config != nil && h.config.Project.Enabled
}
func (h *RobotHandler) resolveProjectByIDOrName(idOrName string) (*database.Project, string) {
idOrName = strings.TrimSpace(idOrName)
if idOrName == "" {
return nil, "请指定项目 ID 或名称,例如:绑定项目 xxx-xxx"
}
if p, err := h.db.GetProject(idOrName); err == nil {
return p, ""
}
list, err := h.db.ListProjects("", 200, 0)
if err != nil {
return nil, "查询项目失败: " + err.Error()
}
var matches []*database.Project
for _, p := range list {
if p.Name == idOrName {
matches = append(matches, p)
}
}
switch len(matches) {
case 0:
return nil, fmt.Sprintf("项目「%s」不存在。发送「项目」查看列表。", idOrName)
case 1:
return matches[0], ""
default:
var b strings.Builder
b.WriteString(fmt.Sprintf("名称「%s」匹配到多个项目,请使用 ID 绑定:\n", idOrName))
for _, p := range matches {
b.WriteString(fmt.Sprintf("· %s\n ID: %s\n", p.Name, p.ID))
}
return nil, strings.TrimSuffix(b.String(), "\n")
}
}
func (h *RobotHandler) formatProjectLabel(projectID string) string {
if strings.TrimSpace(projectID) == "" {
return "未绑定"
}
if p, err := h.db.GetProject(projectID); err == nil {
return fmt.Sprintf("「%s」 (%s)", p.Name, p.ID)
}
return projectID
}
func (h *RobotHandler) cmdProjects() string {
if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。"
}
list, err := h.db.ListProjects("", 50, 0)
if err != nil {
return "获取项目列表失败: " + err.Error()
}
if len(list) == 0 {
return "暂无项目。发送「新建项目 <名称>」创建并绑定到当前对话。"
}
var b strings.Builder
b.WriteString("【项目列表】\n")
for i, p := range list {
if i >= 20 {
b.WriteString("… 仅显示前 20 条\n")
break
}
status := p.Status
if status == "" {
status = "active"
}
b.WriteString(fmt.Sprintf("· %s [%s]\n ID: %s\n", p.Name, status, p.ID))
}
return strings.TrimSuffix(b.String(), "\n")
}
func (h *RobotHandler) cmdBindProject(platform, userID, idOrName string) string {
if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。"
}
p, errMsg := h.resolveProjectByIDOrName(idOrName)
if p == nil {
return errMsg
}
convID, _ := h.getOrCreateConversation(platform, userID, "")
if convID == "" {
return "无法获取当前对话,请稍后再试。"
}
if err := h.db.SetConversationProjectID(convID, p.ID); err != nil {
return "绑定失败: " + err.Error()
}
return fmt.Sprintf("已将当前对话绑定到项目:「%s」\nID: %s", p.Name, p.ID)
}
func (h *RobotHandler) cmdNewProject(platform, userID, name string) string {
if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。"
}
name = strings.TrimSpace(name)
if name == "" {
return "请指定项目名称,例如:新建项目 某目标渗透"
}
p := &database.Project{Name: name, Status: "active"}
created, err := h.db.CreateProject(p)
if err != nil {
return "创建项目失败: " + err.Error()
}
convID, _ := h.getOrCreateConversation(platform, userID, name)
if convID == "" {
return fmt.Sprintf("项目已创建:「%s」\nID: %s\n(绑定当前对话失败,请手动发送「绑定项目 %s」)", created.Name, created.ID, created.ID)
}
if err := h.db.SetConversationProjectID(convID, created.ID); err != nil {
return fmt.Sprintf("项目已创建:「%s」\nID: %s\n绑定失败: %s", created.Name, created.ID, err.Error())
}
return fmt.Sprintf("已创建项目并绑定当前对话:「%s」\nID: %s", created.Name, created.ID)
}
func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。"
}
sk := h.sessionKey(platform, userID)
h.mu.RLock()
convID := h.sessions[sk]
h.mu.RUnlock()
if convID == "" {
if persistedConvID, _ := h.loadSessionBinding(sk); persistedConvID != "" {
convID = persistedConvID
}
}
if convID == "" {
return "当前没有进行中的对话,无需解除绑定。"
}
projectID, err := h.db.GetConversationProjectID(convID)
if err != nil {
return "获取对话项目失败: " + err.Error()
}
if strings.TrimSpace(projectID) == "" {
return "当前对话未绑定项目。"
}
if err := h.db.SetConversationProjectID(convID, ""); err != nil {
return "解除绑定失败: " + err.Error()
}
return "已解除当前对话的项目绑定。"
} }
func (h *RobotHandler) cmdList() string { func (h *RobotHandler) cmdList() string {
@@ -349,7 +517,12 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
return "当前对话 ID: " + convID + "(获取标题失败)" return "当前对话 ID: " + convID + "(获取标题失败)"
} }
role := h.getRole(platform, userID) role := h.getRole(platform, userID)
return fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role) reply := fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role)
if h.projectsEnabled() {
projectID, _ := h.db.GetConversationProjectID(conv.ID)
reply += "\n当前项目: " + h.formatProjectLabel(projectID)
}
return reply
} }
func (h *RobotHandler) cmdRoles() string { func (h *RobotHandler) cmdRoles() string {
@@ -486,6 +659,26 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
return h.cmdDelete(platform, userID, convID), true return h.cmdDelete(platform, userID, convID), true
case text == robotCmdVersion || text == "version": case text == robotCmdVersion || text == "version":
return h.cmdVersion(), true return h.cmdVersion(), true
case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects":
return h.cmdProjects(), true
case text == robotCmdUnbindProject || text == "unbind project":
return h.cmdUnbindProject(platform, userID), true
case strings.HasPrefix(text, robotCmdNewProject+" ") || strings.HasPrefix(text, "new project "):
var name string
if strings.HasPrefix(text, robotCmdNewProject+" ") {
name = strings.TrimSpace(text[len(robotCmdNewProject)+1:])
} else {
name = strings.TrimSpace(text[len("new project "):])
}
return h.cmdNewProject(platform, userID, name), true
case strings.HasPrefix(text, robotCmdBindProject+" ") || strings.HasPrefix(text, "bind project "):
var idOrName string
if strings.HasPrefix(text, robotCmdBindProject+" ") {
idOrName = strings.TrimSpace(text[len(robotCmdBindProject)+1:])
} else {
idOrName = strings.TrimSpace(text[len("bind project "):])
}
return h.cmdBindProject(platform, userID, idOrName), true
default: default:
return "", false return "", false
} }
+19
View File
@@ -134,6 +134,16 @@ func quoteCmdPath(p string) string {
return "\"" + strings.ReplaceAll(p, "\"", "\"\"") + "\"" return "\"" + strings.ReplaceAll(p, "\"", "\"\"") + "\""
} }
// normalizeWindowsCmdPath 把前端统一的 "/" 路径转换为 cmd 更稳定识别的 "\"。
// 仅用于 Windows 命令构造,不改变语义(例如 "." / ".." 会保持不变)。
func normalizeWindowsCmdPath(p string) string {
s := strings.TrimSpace(p)
if s == "" {
return s
}
return strings.ReplaceAll(s, "/", "\\")
}
// quotePsSingle 把字符串按 PowerShell 单引号字符串规则转义(内部 ' → '')。 // quotePsSingle 把字符串按 PowerShell 单引号字符串规则转义(内部 ' → '')。
// 供 PowerShell 脚本参数使用,全脚本只用单引号,外层 cmd 再用双引号包裹即可安全传递。 // 供 PowerShell 脚本参数使用,全脚本只用单引号,外层 cmd 再用双引号包裹即可安全传递。
func quotePsSingle(s string) string { func quotePsSingle(s string) string {
@@ -198,6 +208,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
p = "." p = "."
} }
if targetOS == "windows" { if targetOS == "windows" {
p = normalizeWindowsCmdPath(p)
return "dir /a " + quoteCmdPath(p), nil return "dir /a " + quoteCmdPath(p), nil
} }
return "ls -la " + quoteShellSinglePosix(p), nil return "ls -la " + quoteShellSinglePosix(p), nil
@@ -207,6 +218,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
return "", errFileOpPathRequired return "", errFileOpPathRequired
} }
if targetOS == "windows" { if targetOS == "windows" {
path = normalizeWindowsCmdPath(path)
return "type " + quoteCmdPath(path), nil return "type " + quoteCmdPath(path), nil
} }
return "cat " + quoteShellSinglePosix(path), nil return "cat " + quoteShellSinglePosix(path), nil
@@ -216,6 +228,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
return "", errFileOpPathRequired return "", errFileOpPathRequired
} }
if targetOS == "windows" { if targetOS == "windows" {
path = normalizeWindowsCmdPath(path)
return "del /q /f " + quoteCmdPath(path), nil return "del /q /f " + quoteCmdPath(path), nil
} }
return "rm -f " + quoteShellSinglePosix(path), nil return "rm -f " + quoteShellSinglePosix(path), nil
@@ -225,6 +238,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
return "", errFileOpPathRequired return "", errFileOpPathRequired
} }
if targetOS == "windows" { if targetOS == "windows" {
path = normalizeWindowsCmdPath(path)
// cmd 的 md 默认会自动创建中间目录(等价于 Linux 的 mkdir -p // cmd 的 md 默认会自动创建中间目录(等价于 Linux 的 mkdir -p
return "md " + quoteCmdPath(path), nil return "md " + quoteCmdPath(path), nil
} }
@@ -237,6 +251,8 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
return "", errFileOpRenameNeedsBothPaths return "", errFileOpRenameNeedsBothPaths
} }
if targetOS == "windows" { if targetOS == "windows" {
oldPath = normalizeWindowsCmdPath(oldPath)
newPath = normalizeWindowsCmdPath(newPath)
return "move /y " + quoteCmdPath(oldPath) + " " + quoteCmdPath(newPath), nil return "move /y " + quoteCmdPath(oldPath) + " " + quoteCmdPath(newPath), nil
} }
return "mv -f " + quoteShellSinglePosix(oldPath) + " " + quoteShellSinglePosix(newPath), nil return "mv -f " + quoteShellSinglePosix(oldPath) + " " + quoteShellSinglePosix(newPath), nil
@@ -249,6 +265,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
// 这样既能写入任意二进制/含引号的文本,又避免各家 shell 的转义地狱。 // 这样既能写入任意二进制/含引号的文本,又避免各家 shell 的转义地狱。
b64 := base64.StdEncoding.EncodeToString([]byte(in.Content)) b64 := base64.StdEncoding.EncodeToString([]byte(in.Content))
if targetOS == "windows" { if targetOS == "windows" {
path = normalizeWindowsCmdPath(path)
return buildWindowsPowerShellWrite(path, b64), nil return buildWindowsPowerShellWrite(path, b64), nil
} }
return "echo '" + b64 + "' | base64 -d > " + quoteShellSinglePosix(path), nil return "echo '" + b64 + "' | base64 -d > " + quoteShellSinglePosix(path), nil
@@ -261,6 +278,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
return "", errFileOpUploadTooLarge return "", errFileOpUploadTooLarge
} }
if targetOS == "windows" { if targetOS == "windows" {
path = normalizeWindowsCmdPath(path)
return buildWindowsPowerShellWrite(path, in.Content), nil return buildWindowsPowerShellWrite(path, in.Content), nil
} }
return "echo '" + in.Content + "' | base64 -d > " + quoteShellSinglePosix(path), nil return "echo '" + in.Content + "' | base64 -d > " + quoteShellSinglePosix(path), nil
@@ -270,6 +288,7 @@ func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error)
return "", errFileOpPathRequired return "", errFileOpPathRequired
} }
if targetOS == "windows" { if targetOS == "windows" {
path = normalizeWindowsCmdPath(path)
if in.ChunkIndex == 0 { if in.ChunkIndex == 0 {
return buildWindowsPowerShellWrite(path, in.Content), nil return buildWindowsPowerShellWrite(path, in.Content), nil
} }
+61 -8
View File
@@ -44,11 +44,12 @@ func newSDKClientFromSession(session *mcp.ClientSession, client *mcp.Client, log
// lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient // lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient
type lazySDKClient struct { type lazySDKClient struct {
serverCfg config.ExternalMCPServerConfig serverCfg config.ExternalMCPServerConfig
logger *zap.Logger logger *zap.Logger
inner ExternalMCPClient // 连接成功后为 *sdkClient sessionCancel context.CancelFunc
mu sync.RWMutex inner ExternalMCPClient // connected SDK client
status string mu sync.RWMutex
status string
} }
func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient { func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient {
@@ -92,14 +93,61 @@ func (c *lazySDKClient) Initialize(ctx context.Context) error {
} }
c.mu.Unlock() c.mu.Unlock()
inner, err := createSDKClient(ctx, c.serverCfg, c.logger) sessionCtx, sessionCancel := context.WithCancel(context.Background())
if err != nil { type connectResult struct {
inner ExternalMCPClient
err error
}
resultCh := make(chan connectResult)
abandoned := make(chan struct{})
go func() {
inner, err := createSDKClient(sessionCtx, c.serverCfg, c.logger)
select {
case resultCh <- connectResult{inner: inner, err: err}:
case <-abandoned:
if inner != nil {
_ = inner.Close()
}
sessionCancel()
}
}()
var result connectResult
select {
case result = <-resultCh:
case <-ctx.Done():
close(abandoned)
sessionCancel()
c.setStatus("error")
return ctx.Err()
}
if err := ctx.Err(); err != nil {
sessionCancel()
if result.inner != nil {
_ = result.inner.Close()
}
c.setStatus("error") c.setStatus("error")
return err return err
} }
if result.err != nil {
sessionCancel()
c.setStatus("error")
return result.err
}
c.mu.Lock() c.mu.Lock()
c.inner = inner if c.inner != nil {
c.mu.Unlock()
sessionCancel()
if result.inner != nil {
_ = result.inner.Close()
}
return nil
}
c.inner = result.inner
c.sessionCancel = sessionCancel
c.mu.Unlock() c.mu.Unlock()
c.setStatus("connected") c.setStatus("connected")
return nil return nil
@@ -128,9 +176,14 @@ func (c *lazySDKClient) CallTool(ctx context.Context, name string, args map[stri
func (c *lazySDKClient) Close() error { func (c *lazySDKClient) Close() error {
c.mu.Lock() c.mu.Lock()
inner := c.inner inner := c.inner
sessionCancel := c.sessionCancel
c.inner = nil c.inner = nil
c.sessionCancel = nil
c.mu.Unlock() c.mu.Unlock()
c.setStatus("disconnected") c.setStatus("disconnected")
if sessionCancel != nil {
sessionCancel()
}
if inner != nil { if inner != nil {
return inner.Close() return inner.Close()
} }
+37 -14
View File
@@ -184,14 +184,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
mainAgentToolStep := make(map[string]int) mainAgentToolStep := make(map[string]int)
pendingByID := make(map[string]toolCallPendingInfo) pendingByID := make(map[string]toolCallPendingInfo)
pendingQueueByAgent := make(map[string][]string) pendingQueueByAgent := make(map[string][]string)
var pendingMu sync.Mutex
markPending := func(tc toolCallPendingInfo) { markPending := func(tc toolCallPendingInfo) {
if tc.ToolCallID == "" { if tc.ToolCallID == "" {
return return
} }
pendingMu.Lock()
defer pendingMu.Unlock()
pendingByID[tc.ToolCallID] = tc pendingByID[tc.ToolCallID] = tc
pendingQueueByAgent[tc.EinoAgent] = append(pendingQueueByAgent[tc.EinoAgent], tc.ToolCallID) pendingQueueByAgent[tc.EinoAgent] = append(pendingQueueByAgent[tc.EinoAgent], tc.ToolCallID)
} }
popNextPendingForAgent := func(agentName string) (toolCallPendingInfo, bool) { popNextPendingForAgent := func(agentName string) (toolCallPendingInfo, bool) {
pendingMu.Lock()
defer pendingMu.Unlock()
q := pendingQueueByAgent[agentName] q := pendingQueueByAgent[agentName]
for len(q) > 0 { for len(q) > 0 {
id := q[0] id := q[0]
@@ -208,19 +213,42 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
if toolCallID == "" { if toolCallID == "" {
return return
} }
pendingMu.Lock()
defer pendingMu.Unlock()
delete(pendingByID, toolCallID) delete(pendingByID, toolCallID)
} }
popAnyPending := func() (toolCallPendingInfo, bool) {
pendingMu.Lock()
defer pendingMu.Unlock()
for id, tc := range pendingByID {
delete(pendingByID, id)
return tc, true
}
return toolCallPendingInfo{}, false
}
pendingCount := func() int {
pendingMu.Lock()
defer pendingMu.Unlock()
return len(pendingByID)
}
flushAllPendingAsFailed := func(err error) { flushAllPendingAsFailed := func(err error) {
pendingMu.Lock()
pendingSnapshot := make([]toolCallPendingInfo, 0, len(pendingByID))
for _, tc := range pendingByID {
pendingSnapshot = append(pendingSnapshot, tc)
}
pendingByID = make(map[string]toolCallPendingInfo)
pendingQueueByAgent = make(map[string][]string)
pendingMu.Unlock()
if progress == nil { if progress == nil {
pendingByID = make(map[string]toolCallPendingInfo)
pendingQueueByAgent = make(map[string][]string)
return return
} }
msg := "" msg := ""
if err != nil { if err != nil {
msg = err.Error() msg = err.Error()
} }
for _, tc := range pendingByID { for _, tc := range pendingSnapshot {
toolName := tc.ToolName toolName := tc.ToolName
if strings.TrimSpace(toolName) == "" { if strings.TrimSpace(toolName) == "" {
toolName = "unknown" toolName = "unknown"
@@ -238,8 +266,6 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"source": "eino", "source": "eino",
}) })
} }
pendingByID = make(map[string]toolCallPendingInfo)
pendingQueueByAgent = make(map[string][]string)
} }
// 最近一次成功的 Eino filesystem execute 的标准输出(trim):用于抑制模型紧接着复述同一字符串时的重复「助手输出」时间线。 // 最近一次成功的 Eino filesystem execute 的标准输出(trim):用于抑制模型紧接着复述同一字符串时的重复「助手输出」时间线。
@@ -319,7 +345,9 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
runnerCfg := adk.RunnerConfig{ runnerCfg := adk.RunnerConfig{
Agent: da, Agent: da,
// 启用 ADK 流式事件:plan_execute 也需要输出 reasoning/response 流,
// 与 deep/supervisor/eino_single 的前端体验保持一致。
EnableStreaming: true, EnableStreaming: true,
} }
var cpStore *fileCheckPointStore var cpStore *fileCheckPointStore
@@ -519,8 +547,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
return takePartial(ctxErr) return takePartial(ctxErr)
} }
if len(pendingByID) > 0 { if orphanCount := pendingCount(); orphanCount > 0 {
orphanCount := len(pendingByID)
flushAllPendingAsFailed(errors.New("pending tool call missing result before run completion")) flushAllPendingAsFailed(errors.New("pending tool call missing result before run completion"))
if progress != nil { if progress != nil {
progress("eino_pending_orphaned", "pending tool calls were force-closed at run end", map[string]interface{}{ progress("eino_pending_orphaned", "pending tool calls were force-closed at run end", map[string]interface{}{
@@ -957,12 +984,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
toolCallID = inferred.ToolCallID toolCallID = inferred.ToolCallID
} else if inferred, ok := popNextPendingForAgent(""); ok { } else if inferred, ok := popNextPendingForAgent(""); ok {
toolCallID = inferred.ToolCallID toolCallID = inferred.ToolCallID
} else { } else if inferred, ok := popAnyPending(); ok {
for id := range pendingByID { toolCallID = inferred.ToolCallID
toolCallID = id
delete(pendingByID, id)
break
}
} }
} }
if toolCallID != "" { if toolCallID != "" {
+45 -10
View File
@@ -59,6 +59,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
} }
plannerCfg := &planexecute.PlannerConfig{ plannerCfg := &planexecute.PlannerConfig{
ToolCallingChatModel: tcm, ToolCallingChatModel: tcm,
NewPlan: newLenientPlan,
} }
if fn := planExecutePlannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers); fn != nil { if fn := planExecutePlannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers); fn != nil {
plannerCfg.GenInputFn = fn plannerCfg.GenInputFn = fn
@@ -70,6 +71,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{ replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
ChatModel: tcm, ChatModel: tcm,
GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers), GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID, a.PlannerReplannerRewriteHandlers),
NewPlan: newLenientPlan,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("plan_execute replanner: %w", err) return nil, fmt.Errorf("plan_execute replanner: %w", err)
@@ -146,14 +148,12 @@ func planExecutePlannerGenInput(
} }
return func(ctx context.Context, userInput []adk.Message) ([]adk.Message, error) { return func(ctx context.Context, userInput []adk.Message) ([]adk.Message, error) {
userInput = capPlanExecuteUserInputMessages(userInput, appCfg, mwCfg) userInput = capPlanExecuteUserInputMessages(userInput, appCfg, mwCfg)
msgs := make([]adk.Message, 0, 1+len(userInput)) msgs := make([]adk.Message, 0, len(userInput))
if oi != "" {
msgs = append(msgs, schema.SystemMessage(oi))
}
msgs = append(msgs, userInput...) msgs = append(msgs, userInput...)
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 { if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
msgs = rewritten msgs = rewritten
} }
msgs = normalizeSingleLeadingSystemMessage(msgs, oi)
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_planner", msgs) logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_planner", msgs)
return msgs, nil return msgs, nil
} }
@@ -182,9 +182,7 @@ func planExecuteExecutorGenInput(
if err != nil { if err != nil {
return nil, err return nil, err
} }
if oi != "" { userMsgs = normalizeSingleLeadingSystemMessage(userMsgs, oi)
userMsgs = append([]adk.Message{schema.SystemMessage(oi)}, userMsgs...)
}
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_executor_gen_input", userMsgs) logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_executor_gen_input", userMsgs)
return userMsgs, nil return userMsgs, nil
} }
@@ -231,17 +229,54 @@ func planExecuteReplannerGenInput(
if err != nil { if err != nil {
return nil, err return nil, err
} }
if oi != "" {
msgs = append([]adk.Message{schema.SystemMessage(oi)}, msgs...)
}
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 { if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
msgs = rewritten msgs = rewritten
} }
msgs = normalizeSingleLeadingSystemMessage(msgs, oi)
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_replanner", msgs) logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_replanner", msgs)
return msgs, nil return msgs, nil
} }
} }
// normalizeSingleLeadingSystemMessage enforces a provider-friendly message shape:
// exactly one system message at index 0 (when any system context exists).
// For strict OpenAI-compatible backends (e.g. qwen/vllm templates), this avoids
// "System message must be at the beginning" caused by multiple/disordered system messages.
func normalizeSingleLeadingSystemMessage(msgs []adk.Message, extraSystem string) []adk.Message {
extraSystem = strings.TrimSpace(extraSystem)
if len(msgs) == 0 {
if extraSystem == "" {
return msgs
}
return []adk.Message{schema.SystemMessage(extraSystem)}
}
systemParts := make([]string, 0, 2)
if extraSystem != "" {
systemParts = append(systemParts, extraSystem)
}
nonSystem := make([]adk.Message, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
if msg.Role == schema.System {
if s := strings.TrimSpace(msg.Content); s != "" {
systemParts = append(systemParts, s)
}
continue
}
nonSystem = append(nonSystem, msg)
}
if len(systemParts) == 0 {
return nonSystem
}
out := make([]adk.Message, 0, len(nonSystem)+1)
out = append(out, schema.SystemMessage(strings.Join(systemParts, "\n\n")))
out = append(out, nonSystem...)
return out
}
func capPlanExecuteUserInputMessages(input []adk.Message, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message { func capPlanExecuteUserInputMessages(input []adk.Message, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
if len(input) == 0 { if len(input) == 0 {
return input return input
@@ -0,0 +1,45 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestNormalizeSingleLeadingSystemMessage_MergesMultipleSystems(t *testing.T) {
in := []adk.Message{
schema.SystemMessage("sys-1"),
schema.UserMessage("u1"),
schema.SystemMessage("sys-2"),
schema.AssistantMessage("a1", nil),
}
out := normalizeSingleLeadingSystemMessage(in, "orch")
if len(out) != 3 {
t.Fatalf("unexpected output length: got %d want 3", len(out))
}
if out[0].Role != schema.System {
t.Fatalf("first message role must be system, got %s", out[0].Role)
}
if got := out[0].Content; got != "orch\n\nsys-1\n\nsys-2" {
t.Fatalf("unexpected merged system content: %q", got)
}
if out[1].Role != schema.User || out[2].Role != schema.Assistant {
t.Fatalf("non-system message order changed unexpectedly")
}
}
func TestNormalizeSingleLeadingSystemMessage_NoSystemKeepsFlow(t *testing.T) {
in := []adk.Message{
schema.UserMessage("u1"),
schema.AssistantMessage("a1", nil),
}
out := normalizeSingleLeadingSystemMessage(in, "")
if len(out) != 2 {
t.Fatalf("unexpected output length: got %d want 2", len(out))
}
if out[0].Role != schema.User || out[1].Role != schema.Assistant {
t.Fatalf("message order changed unexpectedly")
}
}
+5 -1
View File
@@ -3,6 +3,7 @@ package multiagent
import ( import (
"context" "context"
"errors" "errors"
"io"
"strings" "strings"
"time" "time"
@@ -23,6 +24,10 @@ func isEinoTransientRunError(err error) bool {
if err == nil { if err == nil {
return false return false
} }
// io.EOF 常见于流式正常收尾,不应触发分段重试。
if errors.Is(err, io.EOF) {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false return false
} }
@@ -55,7 +60,6 @@ func isEinoTransientRunError(err error) bool {
"no such host", "no such host",
"network is unreachable", "network is unreachable",
"broken pipe", "broken pipe",
"eof",
"read tcp", "read tcp",
"write tcp", "write tcp",
"dial tcp", "dial tcp",
@@ -3,6 +3,7 @@ package multiagent
import ( import (
"context" "context"
"errors" "errors"
"io"
"testing" "testing"
"time" "time"
@@ -18,9 +19,12 @@ func TestIsEinoTransientRunError(t *testing.T) {
want bool want bool
}{ }{
{"nil", nil, false}, {"nil", nil, false},
{"io eof", io.EOF, false},
{"plain eof text", errors.New("EOF"), false},
{"429", errors.New("HTTP 429 Too Many Requests"), true}, {"429", errors.New("HTTP 429 Too Many Requests"), true},
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true}, {"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true}, {"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"unexpected eof", errors.New("unexpected EOF"), true},
{"503", errors.New("upstream returned 503"), true}, {"503", errors.New("upstream returned 503"), true},
{"iteration limit", errors.New("max iteration reached"), false}, {"iteration limit", errors.New("max iteration reached"), false},
{"canceled", context.Canceled, false}, {"canceled", context.Canceled, false},
@@ -0,0 +1,157 @@
package multiagent
import (
"context"
"encoding/json"
"strings"
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
)
// lenientPlan keeps plan_execute running even when model tool arguments contain minor JSON defects.
// It first tries strict JSON, then falls back to lightweight step extraction heuristics.
type lenientPlan struct {
Steps []string `json:"steps"`
}
func newLenientPlan(context.Context) planexecute.Plan {
return &lenientPlan{}
}
func (p *lenientPlan) FirstStep() string {
if p == nil || len(p.Steps) == 0 {
return ""
}
return p.Steps[0]
}
func (p *lenientPlan) MarshalJSON() ([]byte, error) {
type alias lenientPlan
return json.Marshal((*alias)(p))
}
func (p *lenientPlan) UnmarshalJSON(b []byte) error {
type alias lenientPlan
var strict alias
if err := json.Unmarshal(b, &strict); err == nil {
strict.Steps = normalizePlanSteps(strict.Steps)
if len(strict.Steps) > 0 {
*p = lenientPlan(strict)
return nil
}
}
steps := extractPlanStepsLenient(string(b))
if len(steps) == 0 {
steps = []string{"继续按当前目标执行下一步,并输出可验证证据。"}
}
p.Steps = steps
return nil
}
func extractPlanStepsLenient(raw string) []string {
s := strings.TrimSpace(stripCodeFence(raw))
if s == "" {
return nil
}
if extracted, ok := sliceByStepsArray(s); ok {
var arr []string
if err := json.Unmarshal([]byte(extracted), &arr); err == nil {
arr = normalizePlanSteps(arr)
if len(arr) > 0 {
return arr
}
}
if arr := splitStepsHeuristically(strings.Trim(extracted, "[]")); len(arr) > 0 {
return arr
}
}
// Last-resort: treat plaintext body as one actionable step.
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return []string{s}
}
func sliceByStepsArray(s string) (string, bool) {
lower := strings.ToLower(s)
key := `"steps"`
i := strings.Index(lower, key)
if i < 0 {
return "", false
}
start := strings.Index(s[i:], "[")
if start < 0 {
return "", false
}
start += i
depth := 0
for j := start; j < len(s); j++ {
switch s[j] {
case '[':
depth++
case ']':
depth--
if depth == 0 {
return s[start : j+1], true
}
}
}
return "", false
}
func splitStepsHeuristically(body string) []string {
body = strings.ReplaceAll(body, "\r\n", "\n")
body = strings.ReplaceAll(body, "\\n", "\n")
var parts []string
if strings.Contains(body, "\n") {
for _, line := range strings.Split(body, "\n") {
parts = append(parts, line)
}
} else {
for _, seg := range strings.Split(body, ",") {
parts = append(parts, seg)
}
}
out := make([]string, 0, len(parts))
for _, part := range parts {
t := strings.TrimSpace(part)
t = strings.Trim(t, "\"'`")
t = strings.TrimLeft(t, "-*0123456789.、 \t")
t = strings.TrimSpace(strings.ReplaceAll(t, `\"`, `"`))
if t == "" {
continue
}
out = append(out, t)
}
return normalizePlanSteps(out)
}
func normalizePlanSteps(in []string) []string {
out := make([]string, 0, len(in))
for _, step := range in {
t := strings.TrimSpace(step)
if t == "" {
continue
}
out = append(out, t)
}
return out
}
func stripCodeFence(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```JSON")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
return strings.TrimSpace(s)
}
+288
View File
@@ -0,0 +1,288 @@
name: "fscan"
command: "fscan"
enabled: false
short_description: "内网综合扫描工具,支持存活探测、端口扫描、服务识别、爆破、POC检测"
description: |
Fscan是一款内网综合扫描工具,支持主机发现、端口扫描、服务识别、
密码爆破、Web指纹识别和漏洞POC检测。
**主要功能:**
- 主机存活探测(ICMP/TCP/Ping
- 端口扫描(默认1000常用端口)
- 服务版本识别与指纹匹配
- 弱口令暴力破解(SSH/SMB/Mysql/Redis等)
- Web应用漏洞POC扫描
- DNS探测与域名枚举
- Redis未授权利用(写入/WebShell/反弹Shell
- 持久化后门生成(Linux ELF / Windows PE
**使用场景:**
- 内网资产快速梳理
- 弱口令批量检测
- 常见服务漏洞验证
- 渗透测试信息收集
- 红队内网横向
parameters:
- name: "target"
type: "string"
description: "目标主机:IP地址、IP段(如192.168.1.0/24)、IP文件或域名"
required: true
flag: "-h"
format: "flag"
- name: "ports"
type: "string"
description: |
扫描端口列表,逗号分隔。默认覆盖1000个常用端口。
示例: "22,80,443,3306,6379" 或 "1-1000"
required: false
flag: "-p"
format: "flag"
default: "21,22,23,25,53,80,81,88,110,111,135,139,143,161,389,443,445,465,502,512,513,514,515,548,554,587,623,636,873,902,993,995,1080,1099,1194,1433,1434,1521,1522,1525,1723,1883,2049,2121,2181,2200,2222,2375,2376,2379,2380,3000,3128,3268,3269,3306,3389,3690,4369,4444,4848,5000,5005,5044,5060,5432,5601,5631,5632,5671,5672,5900,5984,5985,5986,6000,6379,6380,6443,6666,6667,7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999,10000,10051,10250,10255,11211,15672,22222,26379,27017,27018,50000,50070,50075,61613,61614,61616"
- name: "mode"
type: "string"
description: |
扫描模式:
- all:全功能扫描(默认)
- icmp:仅存活探测
- 或指定插件名称(如 ssh, smb, mysql, redis 等)
required: false
flag: "-m"
format: "flag"
default: "all"
- name: "output_file"
type: "string"
description: "结果输出文件路径(默认 result.txt"
required: false
flag: "-o"
format: "flag"
default: "result.txt"
- name: "output_format"
type: "string"
description: "输出格式:txt(默认), json, csv"
required: false
flag: "-f"
format: "flag"
default: "txt"
- name: "threads"
type: "int"
description: "端口扫描线程数"
required: false
flag: "-t"
format: "flag"
default: 600
- name: "module_threads"
type: "int"
description: "模块并发线程数"
required: false
flag: "-mt"
format: "flag"
default: 20
- name: "poc_num"
type: "int"
description: "POC扫描并发数"
required: false
flag: "-num"
format: "flag"
default: 20
- name: "timeout"
type: "int"
description: "端口扫描超时时间(秒)"
required: false
flag: "-time"
format: "flag"
default: 3
- name: "web_timeout"
type: "int"
description: "Web请求超时时间(秒)"
required: false
flag: "-wt"
format: "flag"
default: 5
- name: "global_timeout"
type: "int"
description: "全局超时时间(秒)"
required: false
flag: "-gt"
format: "flag"
default: 180
- name: "url"
type: "string"
description: "目标URL(用于Web扫描模式)"
required: false
flag: "-u"
format: "flag"
- name: "proxy"
type: "string"
description: "HTTP代理地址(如: http://127.0.0.1:8080"
required: false
flag: "-proxy"
format: "flag"
- name: "socks5"
type: "string"
description: "SOCKS5代理地址(如: 127.0.0.1:1080"
required: false
flag: "-socks5"
format: "flag"
- name: "cookie"
type: "string"
description: "HTTP Cookie值"
required: false
flag: "-cookie"
format: "flag"
- name: "domain"
type: "string"
description: "目标域名"
required: false
flag: "-domain"
format: "flag"
- name: "username"
type: "string"
description: "暴力破解用户名"
required: false
flag: "-user"
format: "flag"
- name: "password"
type: "string"
description: "暴力破解密码"
required: false
flag: "-pwd"
format: "flag"
- name: "user_file"
type: "string"
description: "用户名字典文件路径"
required: false
flag: "-userf"
format: "flag"
- name: "pass_file"
type: "string"
description: "密码字典文件路径"
required: false
flag: "-pwdf"
format: "flag"
- name: "host_file"
type: "string"
description: "目标主机文件路径(每行一个IP)"
required: false
flag: "-hf"
format: "flag"
- name: "port_file"
type: "string"
description: "自定义端口文件路径"
required: false
flag: "-pf"
format: "flag"
- name: "url_file"
type: "string"
description: "目标URL文件路径"
required: false
flag: "-uf"
format: "flag"
- name: "pocname"
type: "string"
description: "指定POC名称进行单点扫描"
required: false
flag: "-pocname"
format: "flag"
- name: "pocpath"
type: "string"
description: "自定义POC脚本路径"
required: false
flag: "-pocpath"
format: "flag"
- name: "iface"
type: "string"
description: "指定本地网卡IP地址(VPN场景使用)"
required: false
flag: "-iface"
format: "flag"
- name: "exclude_host"
type: "string"
description: "排除的主机IP"
required: false
flag: "-eh"
format: "flag"
- name: "exclude_port"
type: "string"
description: "排除的端口"
required: false
flag: "-ep"
format: "flag"
- name: "retry"
type: "int"
description: "最大重试次数"
required: false
flag: "-retry"
format: "flag"
default: 3
- name: "rate_limit"
type: "int"
description: "每分钟最大发包次数(0表示不限制)"
required: false
flag: "-rate"
format: "flag"
- name: "max_redirect"
type: "int"
description: "HTTP最大重定向次数"
required: false
flag: "-max-redirect"
format: "flag"
default: 10
- name: "lang"
type: "string"
description: "输出语言:zh(默认中文), en(英文)"
required: false
flag: "-lang"
format: "flag"
default: "zh"
- name: "log_level"
type: "string"
description: "日志级别(默认 base,info,success"
required: false
flag: "-log"
format: "flag"
default: "base,info,success"
- name: "reverse_shell"
type: "string"
description: "反弹Shell目标地址:端口(如: 192.168.1.100:4444"
required: false
flag: "-rsh"
format: "flag"
- name: "sshkey_file"
type: "string"
description: "SSH私钥文件路径"
required: false
flag: "-sshkey"
format: "flag"
- name: "download_url"
type: "string"
description: "要下载的文件URL"
required: false
flag: "-download-url"
format: "flag"
- name: "download_path"
type: "string"
description: "下载文件保存路径"
required: false
flag: "-download-path"
format: "flag"
- name: "additional_args"
type: "string"
description: |
额外的fscan参数。用于传递未在参数列表中定义的fscan选项。
**示例值:**
- "-nobr -nopoc" (禁用爆破和POC,仅做端口扫描)
- "-ao" (仅进行存活探测)
- "-silent -nocolor" (静默无颜色输出)
- "-debug" (开启调试模式)
- "-full" (全量POC扫描)
- "-no" (禁用结果保存)
- "-dns" (启用DNS日志记录)
**注意事项:**
- 多个参数用空格分隔
- 确保参数格式正确,避免命令注入
- 此参数会直接追加到命令末尾
required: false
format: "positional"
+338 -28
View File
@@ -9,6 +9,10 @@
--secondary-color: #2d2d2d; --secondary-color: #2d2d2d;
--accent-color: #0066ff; --accent-color: #0066ff;
--accent-hover: #0052cc; --accent-hover: #0052cc;
--brand-core: #141824;
--brand-ai-start: #0066ff;
--brand-ai-end: #7c3aed;
--brand-gradient: linear-gradient(135deg, var(--brand-ai-start) 0%, var(--brand-ai-end) 100%);
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #f8f9fa; --bg-secondary: #f8f9fa;
--bg-tertiary: #f1f3f5; --bg-tertiary: #f1f3f5;
@@ -125,11 +129,19 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
.main-sidebar-header .logo span { .main-sidebar-header .logo span,
.main-sidebar-header .brand-wordmark {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 700;
letter-spacing: -0.3px; letter-spacing: -0.04em;
color: var(--text-primary); color: var(--brand-core);
}
.main-sidebar-header .brand-wordmark__ai {
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.main-sidebar-nav { .main-sidebar-nav {
@@ -592,37 +604,89 @@ header {
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
} }
.logo svg { .logo svg {
color: var(--accent-color); color: var(--accent-color);
} }
.logo h1 { /* 品牌字标:CyberStrike + AI 渐变 */
.brand-wordmark {
display: inline-flex;
align-items: baseline;
margin: 0;
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1;
white-space: nowrap;
}
.brand-wordmark--lg {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; }
letter-spacing: -0.5px;
.brand-wordmark--sm {
font-size: 1.25rem;
}
.brand-wordmark__core {
color: var(--brand-core);
}
.brand-wordmark__ai {
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 800;
}
.brand-logo {
width: 36px;
height: 36px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 102, 255, 0.18);
flex-shrink: 0;
object-fit: cover;
}
.logo h1,
.logo .brand-wordmark {
font-size: 1.375rem;
} }
.header-logo-link { .header-logo-link {
cursor: pointer; cursor: pointer;
transition: opacity 0.2s ease; transition: transform 0.2s ease, opacity 0.2s ease;
} }
.header-logo-link:hover { .header-logo-link:hover {
opacity: 0.85; opacity: 1;
transform: translateY(-1px);
}
.header-logo-link:hover .brand-logo {
box-shadow: 0 4px 14px rgba(0, 102, 255, 0.28);
} }
.version-badge { .version-badge {
display: inline-block; display: inline-flex;
margin-left: 6px; align-items: center;
font-size: 0.6875rem; margin-left: 4px;
font-weight: 400; padding: 3px 9px;
color: var(--text-muted); font-size: 0.625rem;
letter-spacing: 0.02em; font-weight: 600;
vertical-align: 0.35em; font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
color: var(--brand-ai-start);
background: linear-gradient(135deg, rgba(0, 102, 255, 0.08) 0%, rgba(124, 58, 237, 0.08) 100%);
border: 1px solid rgba(0, 102, 255, 0.22);
border-radius: 999px;
letter-spacing: 0.04em;
vertical-align: middle;
user-select: none; user-select: none;
line-height: 1.4;
} }
.header-right { .header-right {
@@ -3091,10 +3155,36 @@ header {
.login-brand { .login-brand {
padding: 32px 28px 24px; padding: 32px 28px 24px;
text-align: center; text-align: center;
background: linear-gradient(180deg, rgba(0, 102, 255, 0.06) 0%, transparent 100%); background: linear-gradient(180deg, rgba(0, 102, 255, 0.07) 0%, rgba(124, 58, 237, 0.04) 50%, transparent 100%);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.login-brand-logo {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: 14px;
box-shadow: 0 6px 20px rgba(0, 102, 255, 0.2);
object-fit: cover;
}
.login-title {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: 0.35em;
font-size: 1.25rem;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: -0.01em;
}
.login-title .brand-wordmark {
font-size: 1.375rem;
}
.login-brand h2 { .login-brand h2 {
margin: 0; margin: 0;
font-size: 1.375rem; font-size: 1.375rem;
@@ -3530,8 +3620,19 @@ header {
flex-shrink: 0; flex-shrink: 0;
} }
.logo h1 { .logo h1,
font-size: 1.25rem; .logo .brand-wordmark {
font-size: 1.2rem;
}
.brand-logo {
width: 32px;
height: 32px;
}
.version-badge {
padding: 2px 7px;
font-size: 0.5625rem;
} }
.header-subtitle { .header-subtitle {
@@ -4298,6 +4399,31 @@ header {
margin-bottom: 12px; margin-bottom: 12px;
} }
.robot-cmd-category {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
margin: 16px 0 6px;
letter-spacing: 0.02em;
}
.robot-cmd-category:first-of-type {
margin-top: 8px;
}
.robot-cmd-list {
color: var(--text-muted);
font-size: 13px;
line-height: 1.8;
margin: 0 0 4px 16px;
padding: 0;
}
.robot-cmd-footer {
margin-top: 12px !important;
margin-bottom: 0 !important;
}
.form-hint { .form-hint {
display: block; display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
@@ -6249,6 +6375,7 @@ header {
.mcp-stats-dist-panel .mcp-stats-tools-legend { .mcp-stats-dist-panel .mcp-stats-tools-legend {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -12970,6 +13097,7 @@ header {
align-items: center; align-items: center;
border-radius: 8px; border-radius: 8px;
margin: 2px 0; margin: 2px 0;
min-width: 0;
} }
.webshell-tree-row.active { .webshell-tree-row.active {
@@ -13041,6 +13169,12 @@ header {
font-weight: 600; font-weight: 600;
} }
.webshell-tree-row.selected-file .webshell-dir-item {
background: rgba(0, 102, 255, 0.08);
color: var(--accent-color);
font-weight: 600;
}
.webshell-file-main { .webshell-file-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -13237,6 +13371,11 @@ header {
text-align: left; text-align: left;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
transition: background 0.15s ease; transition: background 0.15s ease;
min-width: 0;
}
.webshell-col-name {
min-width: 0;
} }
.webshell-file-empty-state { .webshell-file-empty-state {
@@ -13256,6 +13395,15 @@ header {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.webshell-file-table tbody tr.webshell-file-row-selected {
background: rgba(0, 102, 255, 0.1);
}
.webshell-file-table tbody tr.webshell-file-row-selected a.webshell-file-link {
color: var(--accent-hover);
font-weight: 600;
}
.webshell-file-table tbody tr:last-child td { .webshell-file-table tbody tr:last-child td {
border-bottom: none; border-bottom: none;
} }
@@ -13267,6 +13415,12 @@ header {
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: 4px;
transition: background 0.15s ease, color 0.15s ease; transition: background 0.15s ease, color 0.15s ease;
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
} }
.webshell-file-table a.webshell-file-link:hover { .webshell-file-table a.webshell-file-link:hover {
@@ -13430,6 +13584,17 @@ header {
padding: 12px 0; padding: 12px 0;
} }
.webshell-file-content-path {
font-size: 0.85rem;
color: var(--text-secondary);
padding: 8px 12px;
font-family: ui-monospace, monospace;
word-break: break-all;
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
border-radius: 8px;
border: 1px solid var(--border-color);
}
.webshell-file-content .btn-ghost { .webshell-file-content .btn-ghost {
margin-top: 12px; margin-top: 12px;
padding: 8px 16px; padding: 8px 16px;
@@ -18122,7 +18287,6 @@ header {
border-color: rgba(138, 43, 226, 0.3); border-color: rgba(138, 43, 226, 0.3);
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2); box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
} }
.role-selection-item-content-main { .role-selection-item-content-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -21042,13 +21206,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
#page-projects .page-content.projects-page-layout { #page-projects .page-content.projects-page-layout {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 16px; gap: 12px;
min-height: calc(100vh - 128px); min-height: calc(100vh - 128px);
padding: 16px 20px 24px; padding: 16px clamp(12px, 1.4vw, 20px) 24px;
background: transparent; background: transparent;
} }
.projects-sidebar-card { .projects-sidebar-card {
width: 260px; width: clamp(200px, 15vw, 236px);
flex-shrink: 0; flex-shrink: 0;
align-self: stretch; align-self: stretch;
display: flex; display: flex;
@@ -21220,10 +21384,11 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
border-radius: 14px; border-radius: 14px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
overflow: hidden; overflow: hidden;
min-height: 420px; min-height: 0;
align-self: stretch; align-self: stretch;
} }
.projects-detail-header { .projects-detail-header {
flex-shrink: 0;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
@@ -21311,6 +21476,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
padding: 12px 24px; padding: 12px 24px;
background: #f8fafc; background: #f8fafc;
border-bottom: 1px solid #eef2f7; border-bottom: 1px solid #eef2f7;
flex-shrink: 0;
} }
.projects-tab { .projects-tab {
padding: 8px 16px; padding: 8px 16px;
@@ -21556,9 +21722,94 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
} }
#project-panel-facts,
#project-panel-conversations,
#project-panel-vulns {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
#project-panel-facts .projects-fact-toolbar,
#project-panel-vulns .projects-fact-toolbar,
#project-panel-conversations .projects-panel-toolbar {
flex: 0 0 auto;
}
#project-panel-facts .projects-table-wrap,
#project-panel-conversations .projects-table-wrap,
#project-panel-vulns .projects-table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
#project-panel-facts .projects-table-wrap .data-table--projects thead th,
#project-panel-conversations .projects-table-wrap .data-table--projects thead th,
#project-panel-vulns .projects-table-wrap .data-table--projects thead th {
position: sticky;
top: 0;
z-index: 2;
box-shadow: 0 1px 0 var(--border-color, #e2e8f0);
}
.projects-panel-toolbar--hint {
margin-bottom: 14px;
padding: 0;
background: transparent;
border: none;
border-radius: 0;
}
.projects-panel-toolbar--hint .projects-fact-toolbar-hint {
margin: 0;
}
#project-panel-conversations .data-table--projects th:nth-child(1),
#project-panel-conversations .data-table--projects td:nth-child(1) {
width: 48%;
}
#project-panel-conversations .data-table--projects th:nth-child(2),
#project-panel-conversations .data-table--projects td:nth-child(2) {
width: 22%;
}
#project-panel-conversations .data-table--projects th:nth-child(3),
#project-panel-conversations .data-table--projects td:nth-child(3) {
width: 30%;
}
.projects-vuln-toolbar-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.projects-vuln-toolbar-top .projects-fact-toolbar-hint {
flex: 1 1 240px;
margin: 0;
}
#project-panel-vulns .data-table--projects th:nth-child(1),
#project-panel-vulns .data-table--projects td:nth-child(1) {
width: 46%;
}
#project-panel-vulns .data-table--projects th:nth-child(2),
#project-panel-vulns .data-table--projects td:nth-child(2) {
width: 14%;
}
#project-panel-vulns .data-table--projects th:nth-child(3),
#project-panel-vulns .data-table--projects td:nth-child(3) {
width: 14%;
}
#project-panel-vulns .data-table--projects th:nth-child(4),
#project-panel-vulns .data-table--projects td:nth-child(4) {
width: 26%;
}
.projects-table-wrap .data-table--projects {
min-width: 0;
table-layout: fixed;
}
.data-table--projects .col-actions { .data-table--projects .col-actions {
width: auto; width: auto;
min-width: 240px; min-width: 0;
white-space: normal;
text-align: left; text-align: left;
} }
.data-table--projects thead th.col-actions { .data-table--projects thead th.col-actions {
@@ -21568,13 +21819,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 4px;
} }
.projects-action-btn { .projects-action-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 5px 11px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
line-height: 1.25; line-height: 1.25;
@@ -21619,6 +21870,65 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
color: #b91c1c; color: #b91c1c;
background: #fef2f2; background: #fef2f2;
} }
#project-panel-facts .data-table--projects th:nth-child(1),
#project-panel-facts .data-table--projects td:nth-child(1) {
width: 19%;
}
#project-panel-facts .data-table--projects th:nth-child(2),
#project-panel-facts .data-table--projects td:nth-child(2) {
width: 9%;
}
#project-panel-facts .data-table--projects th:nth-child(3),
#project-panel-facts .data-table--projects td:nth-child(3) {
width: 28%;
}
#project-panel-facts .data-table--projects th:nth-child(4),
#project-panel-facts .data-table--projects td:nth-child(4) {
width: 8%;
}
#project-panel-facts .data-table--projects th:nth-child(5),
#project-panel-facts .data-table--projects td:nth-child(5) {
width: 9%;
}
#project-panel-facts .data-table--projects th:nth-child(6),
#project-panel-facts .data-table--projects td:nth-child(6) {
width: 8%;
}
#project-panel-facts .data-table--projects th:nth-child(7),
#project-panel-facts .data-table--projects td:nth-child(7) {
width: 19%;
}
@media (max-width: 1400px) {
.projects-detail-header {
padding: 16px 18px 14px;
gap: 14px;
}
.projects-tabs {
padding: 10px 18px;
flex-wrap: wrap;
}
.projects-panel {
padding: 14px 18px 18px;
}
.projects-action-btn {
padding: 4px 9px;
}
}
@media (max-width: 1680px) {
#project-panel-facts .data-table--projects th,
#project-panel-facts .data-table--projects td {
padding-left: 10px;
padding-right: 10px;
}
#project-panel-facts .data-table--projects th:nth-child(3),
#project-panel-facts .data-table--projects td:nth-child(3) {
width: 24%;
}
#project-panel-facts .data-table--projects th:nth-child(7),
#project-panel-facts .data-table--projects td:nth-child(7) {
width: 23%;
}
}
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */ /* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
.projects-settings-layout { .projects-settings-layout {
width: 100%; width: 100%;
+192 -1
View File
@@ -48,6 +48,7 @@
}, },
"login": { "login": {
"title": "Sign in to CyberStrikeAI", "title": "Sign in to CyberStrikeAI",
"titlePrefix": "Sign in to",
"subtitle": "Enter the access password from config", "subtitle": "Enter the access password from config",
"passwordLabel": "Password", "passwordLabel": "Password",
"passwordPlaceholder": "Enter password", "passwordPlaceholder": "Enter password",
@@ -58,6 +59,7 @@
"chat": "Chat", "chat": "Chat",
"infoCollect": "Recon", "infoCollect": "Recon",
"tasks": "Tasks", "tasks": "Tasks",
"projects": "Projects",
"vulnerabilities": "Vulnerabilities", "vulnerabilities": "Vulnerabilities",
"webshell": "WebShell Management", "webshell": "WebShell Management",
"chatFiles": "File Management", "chatFiles": "File Management",
@@ -222,6 +224,182 @@
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat", "noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
"startScanBtn": "Go to chat to scan" "startScanBtn": "Go to chat to scan"
}, },
"projects": {
"title": "Projects",
"showArchived": "Show archived",
"newProjectCta": "+ New project",
"projectList": "Project list",
"searchProjectsPlaceholder": "Search projects…",
"selectOrCreateTitle": "Select or create a project",
"selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
"createFirstProject": "Create first project",
"defaultProjectName": "Project",
"statusActive": "Active",
"statusArchived": "Archived",
"vulnerabilityManagement": "Vulnerability management",
"addFactCta": "+ Add fact",
"tabFacts": "Fact board",
"tabConversations": "Bound conversations",
"tabVulns": "Related vulnerabilities",
"tabSettings": "Settings",
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
"searchFactsSr": "Search facts",
"searchFactsPlaceholder": "Search key, summary, body…",
"category": "Category",
"all": "All",
"confidence": "Confidence",
"confidenceConfirmed": "Confirmed",
"confidenceTentative": "Tentative",
"confidenceDeprecated": "Deprecated",
"displayOptions": "Display options",
"sparseOnly": "Sparse only",
"hideDeprecated": "Hide deprecated",
"summary": "Summary",
"updated": "Updated",
"boundConversationsHint": "Conversations bound to this project; click to open",
"titleLabel": "Title",
"projectVulnSummaryHint": "Vulnerability summary under this project",
"searchVulnsSr": "Search vulnerabilities",
"searchVulnsPlaceholder": "Search title, description, type, target…",
"noMatchingVulns": "No matching vulnerabilities, try adjusting filters",
"viewInVulnerabilityManagement": "View in vulnerability management",
"severity": "Severity",
"status": "Status",
"modalNewTitle": "New project",
"modalNewSubtitle": "After creation, bind conversations to share fact board across chats",
"projectName": "Project name",
"projectNamePlaceholder": "e.g. Client A Web pentest",
"projectDescription": "Project description",
"projectDescriptionPlaceholder": "Scope, authorization boundary, notes…",
"createProject": "Create project",
"newProject": "New project",
"chatSelectorButton": "Share fact board across chats after binding a project",
"selectProject": "Select project",
"noProject": "No project",
"factBodyEnvTitle": "Environment fact",
"factBodyHasDetail": "Has details",
"factBodySparseTitle": "Missing attack-chain/POC structure",
"factBodySparse": "Incomplete",
"factBodyReproducibleTitle": "Contains reproducible structure",
"factBodyReproducible": "Reproducible",
"factHintAttackSparse": "Attack-chain fact: fill complete body (steps, HTTP/command, response evidence); avoid conclusion-only notes. You can insert the attack-chain template.",
"factHintAttackReady": "Attack-chain fact: body is used for audit reproduction, keep original request/response and step-by-step flow.",
"factHintEnv": "Environment fact: body should include evidence source; for findings/exploitation use finding|chain|exploit|poc category.",
"confirmOverwriteBodyTemplate": "Overwrite current body content with template?",
"loadProjectsFailed": "Failed to load projects",
"restoreTitle": "Restore as tentative and re-index into board",
"restore": "Restore",
"deprecateTitle": "Mark as deprecated",
"deprecate": "Deprecate",
"editTitle": "Edit fields",
"viewBodyTitle": "View full body",
"details": "Details",
"deleteForeverTitle": "Delete permanently",
"noProjects": "No projects",
"noMatchingProjects": "No matching projects",
"pinned": "Pinned",
"archived": "Archived",
"statsFacts": "{{count}} facts",
"statsVulns": "{{count}} vulnerabilities",
"statsConversations": "{{count}} conversations",
"statsSparse": "{{count}} incomplete",
"projectNotFound": "Project not found",
"updatedPrefix": "Updated {{time}}",
"noMatchingFacts": "No matching facts, try adjusting filters",
"noFacts": "No facts yet. Click Add fact or let Agent write facts automatically",
"relatedVulnIdTitle": "Related vulnerability ID",
"noBoundConversations": "No bound conversations yet; select this project in chat to bind",
"untitledConversation": "Untitled conversation",
"open": "Open",
"unbindProjectTitle": "Unbind project",
"unbind": "Unbind",
"confirmUnbindConversation": "Unbind this conversation from current project?",
"unbindFailed": "Unbind failed",
"factMetaCategory": "Category: {{value}}",
"factMetaConfidence": "Confidence: {{value}}",
"factMetaUpdated": "Updated: {{time}}",
"factMetaRelatedVuln": "Related vulnerability: {{value}}",
"factMetaSourceConversation": "Source conversation: {{value}}",
"factMetaHasPrevious": "Has previous version",
"emptyBody": "(empty body)",
"factSparseWarn": "This fact belongs to attack-chain/exploit category, but body lacks reproducible structure (steps, HTTP/command, request/response, etc.). Edit and complete it for audit reproduction.",
"factPreviousMeta": "Archived at {{time}} · Summary: {{summary}} · Confidence: {{confidence}}",
"loadVulnerabilityListFailed": "Failed to load vulnerability list",
"noVulnerabilitiesInProject": "No vulnerabilities in this project yet. Create one first or let Agent record it.",
"promptLinkFactToVuln": "Enter index to link fact \"{{factKey}}\":\n\n{{lines}}",
"invalidIndex": "Invalid index",
"linkFailed": "Link failed",
"linkSuccess": "Linked vulnerability",
"promptConversationIdForVulnCreate": "Conversation ID is required to create vulnerability (can be source conversation):",
"cancelledNoConversationId": "Cancelled: conversation_id not provided",
"createVulnerabilityFailed": "Failed to create vulnerability",
"createVulnerabilityAndLinkSuccess": "Created vulnerability and linked: {{value}}",
"confirmDeprecateFact": "Mark fact {{factKey}} as deprecated?",
"operationFailed": "Operation failed",
"confirmRestoreFact": "Restore fact {{factKey}}? It will re-enter board index with tentative status.",
"noVulnerabilityRecords": "No vulnerability records in this project",
"viewRelatedFactsTitle": "View related facts",
"facts": "Facts",
"loadRelatedFactsFailed": "Failed to load related facts",
"noFactsForVulnerability": "This vulnerability has no related facts yet. Link vulnerability or generate vulnerability draft from fact detail.",
"promptChooseFactByIndex": "This vulnerability is linked to {{count}} facts. Enter index to view:\n{{lines}}",
"enterProjectName": "Please enter project name",
"saveFailed": "Save failed",
"invalidJson": "Invalid JSON format",
"scopeNoteAuthorizedWebOnly": "Authorized for Web application layer testing only",
"invalidScopeJson": "Invalid scope JSON, please fix it first or click Format",
"saved": "Saved",
"confirmArchiveProject": "After archiving, this project is hidden from active list by default. Continue?",
"confirmRestoreProjectActive": "Restore to active?",
"confirmDeleteProject": "Delete this project? Facts will be deleted and conversations unbound.",
"deleteFailed": "Delete failed",
"addFact": "Add fact",
"saveFact": "Save fact",
"editFact": "Edit fact",
"saveChanges": "Save changes",
"customCategoryOption": "{{value}} (custom)",
"selectProjectFirst": "Please select a project first",
"loadFactFailed": "Failed to load fact",
"factKeySummaryRequired": "fact_key and summary are required",
"confirmSaveSparseFact": "This fact is attack-chain/exploit related, but body does not contain reproducible structure (steps, HTTP/command, request/response).\nSave anyway? It's recommended to insert attack-chain template and fill POC first.",
"confirmDeleteFact": "Delete this fact?",
"notUpdatedYet": "Not updated yet",
"clearStaleProjectBindingFailed": "Failed to clear stale project binding",
"noProjectDescription": "No project binding",
"noProjectsClickCreate": "No projects yet, click New project below",
"sharedFactBoard": "Shared fact board",
"loadFailedRetry": "Load failed, please retry later",
"projectBound": "Project bound",
"projectUnbound": "Project unbound",
"updateProjectBindingFailed": "Failed to update project binding",
"basicInfoTitle": "Basic information",
"basicInfoHint": "Name and description are shown in project details",
"settingsIntroTitle": "Project settings",
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
"pinProject": "Pin project (show first in list)",
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
"scopeTitle": "Test scope",
"scopeHint": "JSON format for Agent authorization boundary and target assets",
"formatJson": "Format",
"example": "Example",
"scopeJsonLabel": "Scope JSON",
"scopeFootnote": "Supports targets, exclude, notes and more. Empty means no scope limit.",
"dangerZoneTitle": "Danger zone",
"dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.",
"archiveRestore": "Archive / Restore",
"deleteProject": "Delete project",
"saveChangesHint": "Click save to sync changes to server",
"saveSettings": "Save changes",
"factModalSubtitle": "Summary is indexed on board; body stores attack chain and POC for audit reproduction (separate from vulnerability records).",
"relatedVulnIdLabel": "Related vulnerability ID",
"optional": "Optional",
"factDetails": "Fact details",
"previousVersion": "Previous version",
"currentVersion": "Current version",
"linkVulnerability": "Link vulnerability",
"createVulnerabilityDraft": "Create vulnerability draft",
"generatedFromFact": "Generated from project fact {{factKey}}"
},
"chat": { "chat": {
"newChat": "New chat", "newChat": "New chat",
"toggleConversationPanel": "Collapse/expand conversation list", "toggleConversationPanel": "Collapse/expand conversation list",
@@ -296,6 +474,8 @@
"einoAgentReplyTitle": "Sub-agent reply", "einoAgentReplyTitle": "Sub-agent reply",
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})", "einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.", "einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
"einoRunRetryTitle": "🔁 Transient error retry",
"einoRunRetryErrorDetail": "Error detail",
"iterationLimitReachedTitle": "⛔ Iteration limit reached", "iterationLimitReachedTitle": "⛔ Iteration limit reached",
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.", "iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
"einoPendingOrphanedTitle": "🧹 Tool call reconciliation", "einoPendingOrphanedTitle": "🧹 Tool call reconciliation",
@@ -1915,17 +2095,25 @@
"settingsRobotsExtra": { "settingsRobotsExtra": {
"botCommandsTitle": "Bot command instructions", "botCommandsTitle": "Bot command instructions",
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):", "botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
"botCmdCategoryGeneral": "General",
"botCmdCategoryConversation": "Conversation",
"botCmdCategoryRole": "Role",
"botCmdCategoryProject": "Project",
"botCmdHelp": "Show this help", "botCmdHelp": "Show this help",
"botCmdList": "List conversations", "botCmdList": "List conversations",
"botCmdSwitch": "Switch to conversation", "botCmdSwitch": "Switch to conversation",
"botCmdNew": "Start new conversation", "botCmdNew": "Start new conversation",
"botCmdClear": "Clear context", "botCmdClear": "Clear context",
"botCmdCurrent": "Show current conversation", "botCmdCurrent": "Show current conversation, role and project",
"botCmdStop": "Stop running task", "botCmdStop": "Stop running task",
"botCmdRoles": "List roles", "botCmdRoles": "List roles",
"botCmdRole": "Switch role", "botCmdRole": "Switch role",
"botCmdDelete": "Delete conversation", "botCmdDelete": "Delete conversation",
"botCmdVersion": "Show version", "botCmdVersion": "Show version",
"botCmdProjects": "List projects",
"botCmdNewProject": "Create project and bind current conversation",
"botCmdBindProject": "Bind current conversation to a project",
"botCmdUnbindProject": "Unbind project from current conversation",
"botCommandsFooter": "Otherwise, send any text for AI penetration testing / security analysis." "botCommandsFooter": "Otherwise, send any text for AI penetration testing / security analysis."
}, },
"mcpDetailModal": { "mcpDetailModal": {
@@ -2097,6 +2285,9 @@
"role": "Role", "role": "Role",
"defaultRole": "Default", "defaultRole": "Default",
"roleHint": "Select a role; all tasks will be executed using that role's configuration (prompt and tools).", "roleHint": "Select a role; all tasks will be executed using that role's configuration (prompt and tools).",
"project": "Project",
"projectNone": "(Unbound)",
"projectHint": "Optionally bind this queue to a project; leave empty to keep it unbound.",
"agentMode": "Agent mode", "agentMode": "Agent mode",
"agentModeSingle": "Single-agent (ReAct)", "agentModeSingle": "Single-agent (ReAct)",
"agentModeMulti": "Multi-agent (Eino)", "agentModeMulti": "Multi-agent (Eino)",
+192 -1
View File
@@ -48,6 +48,7 @@
}, },
"login": { "login": {
"title": "登录 CyberStrikeAI", "title": "登录 CyberStrikeAI",
"titlePrefix": "登录",
"subtitle": "请输入配置中的访问密码", "subtitle": "请输入配置中的访问密码",
"passwordLabel": "密码", "passwordLabel": "密码",
"passwordPlaceholder": "输入登录密码", "passwordPlaceholder": "输入登录密码",
@@ -58,6 +59,7 @@
"chat": "对话", "chat": "对话",
"infoCollect": "信息收集", "infoCollect": "信息收集",
"tasks": "任务管理", "tasks": "任务管理",
"projects": "项目管理",
"vulnerabilities": "漏洞管理", "vulnerabilities": "漏洞管理",
"webshell": "WebShell管理", "webshell": "WebShell管理",
"chatFiles": "文件管理", "chatFiles": "文件管理",
@@ -211,6 +213,182 @@
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里", "noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
"startScanBtn": "前往对话发起扫描" "startScanBtn": "前往对话发起扫描"
}, },
"projects": {
"title": "项目管理",
"showArchived": "显示已归档",
"newProjectCta": "+ 新建项目",
"projectList": "项目列表",
"searchProjectsPlaceholder": "搜索项目…",
"selectOrCreateTitle": "选择或创建项目",
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
"createFirstProject": "创建第一个项目",
"defaultProjectName": "项目",
"statusActive": "进行中",
"statusArchived": "已归档",
"vulnerabilityManagement": "漏洞管理",
"addFactCta": "+ 添加事实",
"tabFacts": "事实黑板",
"tabConversations": "关联对话",
"tabVulns": "关联漏洞",
"tabSettings": "设置",
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 bodyAgent 通过 get_project_fact 复现",
"searchFactsSr": "搜索事实",
"searchFactsPlaceholder": "搜索 key、摘要、body…",
"category": "分类",
"all": "全部",
"confidence": "置信度",
"confidenceConfirmed": "已确认",
"confidenceTentative": "待确认",
"confidenceDeprecated": "已废弃",
"displayOptions": "显示选项",
"sparseOnly": "仅待补全",
"hideDeprecated": "隐藏废弃",
"summary": "摘要",
"updated": "更新",
"boundConversationsHint": "绑定到本项目的对话;点击可打开会话",
"titleLabel": "标题",
"projectVulnSummaryHint": "本项目下记录的漏洞汇总",
"searchVulnsSr": "搜索漏洞",
"searchVulnsPlaceholder": "搜索标题、描述、类型、目标…",
"noMatchingVulns": "无匹配漏洞,请调整筛选条件",
"viewInVulnerabilityManagement": "在漏洞管理中查看",
"severity": "严重度",
"status": "状态",
"modalNewTitle": "新建项目",
"modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板",
"projectName": "项目名称",
"projectNamePlaceholder": "例如:某客户 Web 渗透",
"projectDescription": "项目描述",
"projectDescriptionPlaceholder": "测试范围、授权边界、注意事项…",
"createProject": "创建项目",
"newProject": "新建项目",
"chatSelectorButton": "绑定项目后共享事实黑板(跨对话)",
"selectProject": "选择项目",
"noProject": "无项目",
"factBodyEnvTitle": "环境类事实",
"factBodyHasDetail": "有详情",
"factBodySparseTitle": "缺少攻击链/POC 结构",
"factBodySparse": "待补全",
"factBodyReproducibleTitle": "含可复现结构",
"factBodyReproducible": "可复现",
"factHintAttackSparse": "⚠ 攻击链类事实:请填写完整 body(步骤、HTTP/命令、响应现象),勿仅写结论。可点「插入攻击链模板」。",
"factHintAttackReady": "攻击链类:body 将用于审计复现,请保留原始请求/响应与逐步步骤。",
"factHintEnv": "环境认知类:body 建议记录来源证据;发现/利用请改用 finding|chain|exploit|poc 分类。",
"confirmOverwriteBodyTemplate": "将覆盖当前 body 内容为模板,是否继续?",
"loadProjectsFailed": "加载项目失败",
"restoreTitle": "恢复为待确认并重新进入黑板索引",
"restore": "恢复",
"deprecateTitle": "标记为已废弃",
"deprecate": "废弃",
"editTitle": "编辑各字段",
"viewBodyTitle": "查看完整 body",
"details": "详情",
"deleteForeverTitle": "永久删除",
"noProjects": "暂无项目",
"noMatchingProjects": "无匹配项目",
"pinned": "置顶",
"archived": "归档",
"statsFacts": "{{count}} 条事实",
"statsVulns": "{{count}} 个漏洞",
"statsConversations": "{{count}} 个对话",
"statsSparse": "{{count}} 待补全",
"projectNotFound": "项目不存在",
"updatedPrefix": "更新于 {{time}}",
"noMatchingFacts": "无匹配事实,请调整筛选条件",
"noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入",
"relatedVulnIdTitle": "关联漏洞 ID",
"noBoundConversations": "暂无绑定对话;在对话页选择本项目即可关联",
"untitledConversation": "未命名对话",
"open": "打开",
"unbindProjectTitle": "解除项目绑定",
"unbind": "解绑",
"confirmUnbindConversation": "解除该对话与当前项目的绑定?",
"unbindFailed": "解绑失败",
"factMetaCategory": "分类: {{value}}",
"factMetaConfidence": "置信度: {{value}}",
"factMetaUpdated": "更新: {{time}}",
"factMetaRelatedVuln": "关联漏洞: {{value}}",
"factMetaSourceConversation": "来源对话: {{value}}",
"factMetaHasPrevious": "含上一版本",
"emptyBody": "(无 body)",
"factSparseWarn": "⚠ 该事实属于攻击链/利用类,但 body 缺少可复现结构(攻击链步骤、HTTP/命令、请求响应等)。建议编辑后补全以便审计复现。",
"factPreviousMeta": "归档于 {{time}} · 摘要: {{summary}} · 置信度: {{confidence}}",
"loadVulnerabilityListFailed": "加载漏洞列表失败",
"noVulnerabilitiesInProject": "本项目暂无漏洞,请先创建或让 Agent 记录漏洞",
"promptLinkFactToVuln": "输入序号以关联事实「{{factKey}}」:\n\n{{lines}}",
"invalidIndex": "序号无效",
"linkFailed": "关联失败",
"linkSuccess": "已关联漏洞",
"promptConversationIdForVulnCreate": "创建漏洞需要对话 ID(可与来源会话一致):",
"cancelledNoConversationId": "已取消:未提供 conversation_id",
"createVulnerabilityFailed": "创建漏洞失败",
"createVulnerabilityAndLinkSuccess": "已创建漏洞并关联:{{value}}",
"confirmDeprecateFact": "将事实 {{factKey}} 标记为已废弃?",
"operationFailed": "操作失败",
"confirmRestoreFact": "恢复事实 {{factKey}}?将重新进入黑板索引(状态:待确认)。",
"noVulnerabilityRecords": "本项目暂无漏洞记录",
"viewRelatedFactsTitle": "查看关联事实",
"facts": "事实",
"loadRelatedFactsFailed": "加载关联事实失败",
"noFactsForVulnerability": "该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接",
"promptChooseFactByIndex": "该漏洞关联 {{count}} 条事实,输入序号查看:\n{{lines}}",
"enterProjectName": "请输入项目名称",
"saveFailed": "保存失败",
"invalidJson": "JSON 格式无效",
"scopeNoteAuthorizedWebOnly": "仅授权 Web 应用层测试",
"invalidScopeJson": "测试范围 JSON 无效,请先修正或点击「格式化」",
"saved": "已保存",
"confirmArchiveProject": "归档后默认不再出现在活跃列表,是否继续?",
"confirmRestoreProjectActive": "恢复为 active",
"confirmDeleteProject": "确定删除该项目?事实将一并删除,对话将解除绑定。",
"deleteFailed": "删除失败",
"addFact": "添加事实",
"saveFact": "保存事实",
"editFact": "编辑事实",
"saveChanges": "保存修改",
"customCategoryOption": "{{value}}(自定义)",
"selectProjectFirst": "请先选择项目",
"loadFactFailed": "加载事实失败",
"factKeySummaryRequired": "fact_key 与 summary 必填",
"confirmSaveSparseFact": "该事实属于攻击链/利用类,但 body 尚未包含可复现结构(步骤、HTTP/命令、请求响应等)。\n仍要保存吗?建议先插入攻击链模板并填写 POC。",
"confirmDeleteFact": "删除该事实?",
"notUpdatedYet": "尚未更新",
"clearStaleProjectBindingFailed": "清除失效的项目绑定失败",
"noProjectDescription": "不绑定项目黑板",
"noProjectsClickCreate": "暂无项目,点击下方「新建项目」",
"sharedFactBoard": "共享事实黑板",
"loadFailedRetry": "加载失败,请稍后重试",
"projectBound": "已绑定项目",
"projectUnbound": "已解除项目绑定",
"updateProjectBindingFailed": "更新项目绑定失败",
"basicInfoTitle": "基本信息",
"basicInfoHint": "名称与描述会显示在项目详情中",
"settingsIntroTitle": "项目设置",
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
"pinProject": "置顶项目(列表优先显示)",
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
"scopeTitle": "测试范围",
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
"formatJson": "格式化",
"example": "示例",
"scopeJsonLabel": "范围 JSON",
"scopeFootnote": "支持 targets、exclude、notes 等字段,留空表示不限制范围。",
"dangerZoneTitle": "危险操作",
"dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。",
"archiveRestore": "归档 / 恢复",
"deleteProject": "删除项目",
"saveChangesHint": "修改后请点击保存以同步到服务器",
"saveSettings": "保存更改",
"factModalSubtitle": "摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)",
"relatedVulnIdLabel": "关联漏洞 ID",
"optional": "可选",
"factDetails": "事实详情",
"previousVersion": "上一版本",
"currentVersion": "当前版本",
"linkVulnerability": "关联漏洞",
"createVulnerabilityDraft": "生成漏洞草稿",
"generatedFromFact": "由项目事实 {{factKey}} 生成"
},
"chat": { "chat": {
"newChat": "新对话", "newChat": "新对话",
"toggleConversationPanel": "折叠/展开对话列表", "toggleConversationPanel": "折叠/展开对话列表",
@@ -285,6 +463,8 @@
"einoAgentReplyTitle": "子代理回复", "einoAgentReplyTitle": "子代理回复",
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}", "einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}",
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。", "einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
"einoRunRetryTitle": "🔁 临时错误重试",
"einoRunRetryErrorDetail": "具体报错",
"iterationLimitReachedTitle": "⛔ 达到迭代上限", "iterationLimitReachedTitle": "⛔ 达到迭代上限",
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。", "iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
"einoPendingOrphanedTitle": "🧹 工具调用收尾补偿", "einoPendingOrphanedTitle": "🧹 工具调用收尾补偿",
@@ -1904,17 +2084,25 @@
"settingsRobotsExtra": { "settingsRobotsExtra": {
"botCommandsTitle": "机器人命令说明", "botCommandsTitle": "机器人命令说明",
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):", "botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
"botCmdCategoryGeneral": "通用",
"botCmdCategoryConversation": "对话",
"botCmdCategoryRole": "角色",
"botCmdCategoryProject": "项目",
"botCmdHelp": "显示本帮助 | Show this help", "botCmdHelp": "显示本帮助 | Show this help",
"botCmdList": "列出所有对话标题与 ID | List conversations", "botCmdList": "列出所有对话标题与 ID | List conversations",
"botCmdSwitch": "指定对话继续 | Switch to conversation", "botCmdSwitch": "指定对话继续 | Switch to conversation",
"botCmdNew": "开启新对话 | Start new conversation", "botCmdNew": "开启新对话 | Start new conversation",
"botCmdClear": "清空当前上下文 | Clear context", "botCmdClear": "清空当前上下文 | Clear context",
"botCmdCurrent": "显示当前对话 ID 与标题 | Show current conversation", "botCmdCurrent": "显示当前对话、角色与项目 | Show current conversation",
"botCmdStop": "中断当前任务 | Stop running task", "botCmdStop": "中断当前任务 | Stop running task",
"botCmdRoles": "列出所有可用角色 | List roles", "botCmdRoles": "列出所有可用角色 | List roles",
"botCmdRole": "切换当前角色 | Switch role", "botCmdRole": "切换当前角色 | Switch role",
"botCmdDelete": "删除指定对话 | Delete conversation", "botCmdDelete": "删除指定对话 | Delete conversation",
"botCmdVersion": "显示当前版本号 | Show version", "botCmdVersion": "显示当前版本号 | Show version",
"botCmdProjects": "列出所有项目 | List projects",
"botCmdNewProject": "创建项目并绑定当前对话 | Create & bind project",
"botCmdBindProject": "将当前对话绑定到项目 | Bind conversation",
"botCmdUnbindProject": "解除当前对话的项目绑定 | Unbind project",
"botCommandsFooter": "除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis." "botCommandsFooter": "除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis."
}, },
"mcpDetailModal": { "mcpDetailModal": {
@@ -2086,6 +2274,9 @@
"role": "角色", "role": "角色",
"defaultRole": "默认", "defaultRole": "默认",
"roleHint": "选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。", "roleHint": "选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。",
"project": "所属项目",
"projectNone": "(未绑定)",
"projectHint": "可为队列绑定项目;留空则不绑定项目上下文。",
"agentMode": "代理模式", "agentMode": "代理模式",
"agentModeSingle": "单代理(ReAct", "agentModeSingle": "单代理(ReAct",
"agentModeMulti": "多代理(Eino", "agentModeMulti": "多代理(Eino",
+16
View File
@@ -995,6 +995,8 @@ async function sendMessage() {
} }
} }
} }
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
buffer += decoder.decode();
// 处理剩余的buffer // 处理剩余的buffer
if (buffer.trim()) { if (buffer.trim()) {
@@ -2479,6 +2481,20 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + execLine; itemTitle = agPx + execLine;
} else if (eventType === 'eino_agent_reply') { } else if (eventType === 'eino_agent_reply') {
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'); itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
} else if (eventType === 'eino_run_retry') {
itemTitle = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const errRaw = data && data.error != null ? String(data.error).trim() : '';
if (errRaw) {
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
if (!title || String(title).indexOf(errRaw) === -1) {
const merged = title ? (String(title) + '\n' + detailLabel + '' + errRaw) : (detailLabel + '' + errRaw);
detail.message = merged;
}
}
} else if (eventType === 'knowledge_retrieval') { } else if (eventType === 'knowledge_retrieval') {
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索'); itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
} else if (eventType === 'error') { } else if (eventType === 'error') {
+277 -29
View File
@@ -208,22 +208,38 @@ if (typeof window !== 'undefined') {
window.formatTimelineStreamBody = formatTimelineStreamBody; window.formatTimelineStreamBody = formatTimelineStreamBody;
} }
// 存储工具调用ID到DOM元素的映射,用于更新执行状态 // 存储工具调用ID到DOM元素的映射,用于更新执行状态
// 键必须带 progressId 作用域,避免不同任务复用相同 toolCallId 时串线。
const toolCallStatusMap = new Map(); const toolCallStatusMap = new Map();
function toolCallMapKey(progressId, toolCallId) {
return String(progressId) + '::' + String(toolCallId);
}
function getToolCallMapping(progressId, toolCallId) {
if (!toolCallId) return null;
const scoped = toolCallStatusMap.get(toolCallMapKey(progressId, toolCallId));
if (scoped) return scoped;
// 兼容历史遗留:若 map 中还有旧格式 key(仅 toolCallId),兜底读取。
return toolCallStatusMap.get(String(toolCallId)) || null;
}
function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) { function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
if (!progressId) return; if (!progressId) return;
const pid = String(progressId); const pid = String(progressId);
for (const [toolCallId, mapping] of Array.from(toolCallStatusMap.entries())) { for (const [mapKey, mapping] of Array.from(toolCallStatusMap.entries())) {
if (!mapping) continue; if (!mapping) continue;
if (mapping.progressId != null && String(mapping.progressId) !== pid) continue; if (mapping.progressId != null && String(mapping.progressId) !== pid) continue;
updateToolCallStatus(toolCallId, finalStatus); const tcid = mapping.toolCallId || (String(mapKey).includes('::') ? String(mapKey).split('::').slice(1).join('::') : String(mapKey));
toolCallStatusMap.delete(toolCallId); updateToolCallStatus(mapping.progressId || progressId, tcid, finalStatus);
toolCallStatusMap.delete(mapKey);
} }
} }
// 模型流式输出缓存:progressId -> { assistantId, buffer } // 模型流式输出缓存:progressId -> { assistantId, buffer }
const responseStreamStateByProgressId = new Map(); const responseStreamStateByProgressId = new Map();
// 主通道当前迭代轮次缓存:progressId -> { iteration, orchestration }
const mainIterationStateByProgressId = new Map();
/** 同一段主通道流式输出(Eino 可能重复 response_start */ /** 同一段主通道流式输出(Eino 可能重复 response_start */
function sameMainResponseStreamMeta(a, b) { function sameMainResponseStreamMeta(a, b) {
@@ -236,6 +252,40 @@ function sameMainResponseStreamMeta(a, b) {
return orchA === orchB; return orchA === orchB;
} }
function resolveMainIterationTag(progressId, responseData) {
const d = responseData || {};
if (d.iteration != null) {
return String(d.iteration);
}
const cached = mainIterationStateByProgressId.get(String(progressId));
if (!cached || cached.iteration == null) {
return '';
}
const cachedOrch = String(cached.orchestration != null ? cached.orchestration : '').trim();
const streamOrch = String(d.orchestration != null ? d.orchestration : '').trim();
if (cachedOrch && streamOrch && cachedOrch !== streamOrch) {
return '';
}
return String(cached.iteration);
}
function buildMainResponseStreamIdentity(progressId, responseData) {
const d = responseData || {};
const agent = String(d.einoAgent != null ? d.einoAgent : '').trim();
const orch = String(d.orchestration != null ? d.orchestration : '').trim();
const iterTag = resolveMainIterationTag(progressId, d);
return agent + '|' + orch + '|iter=' + iterTag;
}
function extractIterationTagFromStreamIdentity(identity) {
const s = String(identity || '');
const idx = s.lastIndexOf('|iter=');
if (idx < 0) {
return '';
}
return s.slice(idx + 6);
}
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer }) // AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
const thinkingStreamStateByProgressId = new Map(); const thinkingStreamStateByProgressId = new Map();
@@ -366,6 +416,118 @@ function _normalizeUnicodeBulletMarkersToMdDash(segment) {
.replace(/^\s*\u00b7\s+/gm, '- '); .replace(/^\s*\u00b7\s+/gm, '- ');
} }
/**
* 修正模型常见的强调语法偏差:
* 1) 把 `\*\*文本\*\*` 还原为 `**文本**`(常见于多层转义输出)
* 2) 把 `** 文本 **` 收敛为 `**文本**`(避免分隔符内空格导致不生效)
* 仅处理单行内容,避免跨段落误匹配。
*/
function _normalizeEmphasisMarkersForMarkdown(segment) {
const raw = String(segment);
const maskInlineCode = (input) => {
const blocks = [];
const masked = input.replace(/`[^`\n]*`/g, (m) => {
const token = '__CS_INLINE_CODE_' + blocks.length + '__';
blocks.push(m);
return token;
});
return { masked, blocks };
};
const unmaskInlineCode = (input, blocks) => {
let out = input;
for (let i = 0; i < blocks.length; i++) {
out = out.replace('__CS_INLINE_CODE_' + i + '__', blocks[i]);
}
return out;
};
const isWordLike = (ch) => /[\u4e00-\u9fffA-Za-z0-9]/.test(ch || '');
const countUnescapedStrongMarkers = (text) => {
let count = 0;
for (let i = 0; i < text.length - 1; i++) {
if (text.charAt(i) === '*' && text.charAt(i + 1) === '*') {
if (i > 0 && text.charAt(i - 1) === '\\') {
continue;
}
count++;
i++;
}
}
return count;
};
const normalizeLine = (line) => {
let lineWork = line;
// 奇数个 `**` 往往意味着有一个孤立标记;仅清理「空白夹着的 **」这类高置信噪声。
while (countUnescapedStrongMarkers(lineWork) % 2 === 1) {
const next = lineWork.replace(/\s\*\*\s/g, ' ');
if (next === lineWork) break;
lineWork = next;
}
let out = '';
let cursor = 0;
while (cursor < lineWork.length) {
const open = lineWork.indexOf('**', cursor);
if (open < 0) {
out += lineWork.slice(cursor);
break;
}
// 允许 `\*\*text\*\*` 先还原,escaped 星号本身不作为强调标记。
if (open > 0 && lineWork.charAt(open - 1) === '\\') {
out += lineWork.slice(cursor, open + 2);
cursor = open + 2;
continue;
}
let close = open + 2;
while (true) {
close = lineWork.indexOf('**', close);
if (close < 0) break;
if (close > 0 && lineWork.charAt(close - 1) === '\\') {
close += 2;
continue;
}
break;
}
if (close < 0) {
out += lineWork.slice(cursor);
break;
}
let prefix = lineWork.slice(cursor, open);
const innerRaw = lineWork.slice(open + 2, close);
const inner = innerRaw.trim();
const next = lineWork.charAt(close + 2);
const prevTail = prefix.charAt(prefix.length - 1);
// 内部为空时不改写,避免把 `****` 等异常输入改坏。
if (!inner) {
out += lineWork.slice(cursor, close + 2);
cursor = close + 2;
continue;
}
// CJK/字母数字与强调标记紧邻时补边界空格,提升解析稳定性。
if (isWordLike(prevTail) && !/\s$/.test(prefix)) {
prefix += ' ';
}
out += prefix + '**' + inner + '**';
if (isWordLike(next)) {
out += ' ';
}
cursor = close + 2;
}
return out;
};
// 先还原常见 escaped strong,再做成对规范化。
let s = raw.replace(/\\\*\*([^\n*][^\n]*?[^\n*])\\\*\*/g, '**$1**');
const masked = maskInlineCode(s);
s = masked.masked
.split('\n')
.map(normalizeLine)
.join('\n');
s = unmaskInlineCode(s, masked.blocks);
return s;
}
/** /**
* 解析前归一化助手 Markdown:去掉零宽字符,NFKC 将全角 * ` _ 等转为 ASCII * 解析前归一化助手 Markdown:去掉零宽字符,NFKC 将全角 * ` _ 等转为 ASCII
* 避免 marked 无法识别强调/行内代码而原样显示 **、反引号; * 避免 marked 无法识别强调/行内代码而原样显示 **、反引号;
@@ -382,6 +544,7 @@ function normalizeAssistantMarkdownSource(text) {
} catch (e) { } catch (e) {
/* ignore */ /* ignore */
} }
s = _normalizeEmphasisMarkersForMarkdown(s);
s = _stripXmlReasoningWrappersForMarkdown(s); s = _stripXmlReasoningWrappersForMarkdown(s);
const fb = _maskFencedCodeBlocksForMdPreprocess(s); const fb = _maskFencedCodeBlocksForMdPreprocess(s);
s = _unwrapHtmlBlockWrappersForMarkdown(fb.masked); s = _unwrapHtmlBlockWrappersForMarkdown(fb.masked);
@@ -1257,6 +1420,22 @@ function mergeMcpExecutionIDLists(prev, next) {
return out; return out;
} }
function formatEinoRunRetryMessage(message, data) {
const d = data && typeof data === 'object' ? data : {};
const base = String(message || '').trim();
const errRaw = d.error != null ? String(d.error).trim() : '';
if (!errRaw) {
return base;
}
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
if (base && base.indexOf(errRaw) !== -1) {
return base;
}
return base ? (base + '\n' + detailLabel + '' + errRaw) : (detailLabel + '' + errRaw);
}
// 处理流式事件 // 处理流式事件
function handleStreamEvent(event, progressElement, progressId, function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) { getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
@@ -1342,6 +1521,13 @@ function handleStreamEvent(event, progressElement, progressId,
case 'iteration': { case 'iteration': {
const d = event.data || {}; const d = event.data || {};
const n = d.iteration != null ? d.iteration : 1; const n = d.iteration != null ? d.iteration : 1;
const scope = d.einoScope != null ? String(d.einoScope).trim() : '';
if (scope !== 'sub') {
mainIterationStateByProgressId.set(String(progressId), {
iteration: n,
orchestration: d.orchestration != null ? d.orchestration : ''
});
}
let iterTitle; let iterTitle;
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') { if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
const phase = translatePlanExecuteAgentName(d.einoAgent != null ? d.einoAgent : ''); const phase = translatePlanExecuteAgentName(d.einoAgent != null ? d.einoAgent : '');
@@ -1568,6 +1754,20 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
} }
case 'eino_run_retry': {
const d = event.data || {};
const title = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const msg = formatEinoRunRetryMessage(event.message, d);
addTimelineItem(timeline, 'warning', {
title: title,
message: msg,
data: d
});
break;
}
case 'iteration_limit_reached': { case 'iteration_limit_reached': {
addTimelineItem(timeline, 'warning', { addTimelineItem(timeline, 'warning', {
title: typeof window.t === 'function' ? window.t('chat.iterationLimitReachedTitle') : '⛔ 达到迭代上限', title: typeof window.t === 'function' ? window.t('chat.iterationLimitReachedTitle') : '⛔ 达到迭代上限',
@@ -1601,6 +1801,17 @@ function handleStreamEvent(event, progressElement, progressId,
const index = toolInfo.index || 0; const index = toolInfo.index || 0;
const total = toolInfo.total || 0; const total = toolInfo.total || 0;
const toolCallId = toolInfo.toolCallId || null; const toolCallId = toolInfo.toolCallId || null;
if (toolCallId) {
const existing = getToolCallMapping(progressId, toolCallId);
if (existing && existing.itemId) {
const existingItem = document.getElementById(existing.itemId);
if (existingItem) {
// 同一 toolCallId 的重复 tool_call(重试/补发)只更新状态,不重复追加条目。
updateToolCallStatus(progressId, toolCallId, 'running');
break;
}
}
}
const toolCallTitle = formatToolCallTimelineTitle(toolName, index, total); const toolCallTitle = formatToolCallTimelineTitle(toolName, index, total);
const toolCallItemId = addTimelineItem(timeline, 'tool_call', { const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle, title: timelineAgentBracketPrefix(toolInfo) + '🔧 ' + toolCallTitle,
@@ -1611,14 +1822,16 @@ function handleStreamEvent(event, progressElement, progressId,
// 如果有toolCallId,存储映射关系以便后续更新状态 // 如果有toolCallId,存储映射关系以便后续更新状态
if (toolCallId && toolCallItemId) { if (toolCallId && toolCallItemId) {
toolCallStatusMap.set(toolCallId, { const mapKey = toolCallMapKey(progressId, toolCallId);
toolCallStatusMap.set(mapKey, {
toolCallId: toolCallId,
itemId: toolCallItemId, itemId: toolCallItemId,
timeline: timeline, timeline: timeline,
progressId: progressId progressId: progressId
}); });
// 添加执行中状态指示器 // 添加执行中状态指示器
updateToolCallStatus(toolCallId, 'running'); updateToolCallStatus(progressId, toolCallId, 'running');
} }
break; break;
@@ -1633,7 +1846,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (!deltaText) break; if (!deltaText) break;
if (!state) { if (!state) {
const mapping = toolCallStatusMap.get(toolCallId); const mapping = getToolCallMapping(progressId, toolCallId);
let callItemId = mapping && mapping.itemId ? mapping.itemId : null; let callItemId = mapping && mapping.itemId ? mapping.itemId : null;
if (callItemId) { if (callItemId) {
const callItem = document.getElementById(callItemId); const callItem = document.getElementById(callItemId);
@@ -1679,24 +1892,26 @@ function handleStreamEvent(event, progressElement, progressId,
mergeToolResultIntoCallItem(streamCallItem, resultInfo); mergeToolResultIntoCallItem(streamCallItem, resultInfo);
} }
toolResultStreamStateByKey.delete(key); toolResultStreamStateByKey.delete(key);
if (toolCallStatusMap.has(resultToolCallId)) { const mapKey = toolCallMapKey(progressId, resultToolCallId);
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed'); if (toolCallStatusMap.has(mapKey)) {
toolCallStatusMap.delete(resultToolCallId); updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(mapKey);
} }
break; break;
} }
if (attachToolResultToCall(resultToolCallId, resultInfo)) { if (attachToolResultToCall(progressId, resultToolCallId, resultInfo)) {
if (toolCallStatusMap.has(resultToolCallId)) { const mapKey = toolCallMapKey(progressId, resultToolCallId);
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed'); if (toolCallStatusMap.has(mapKey)) {
toolCallStatusMap.delete(resultToolCallId); updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(mapKey);
} }
break; break;
} }
} }
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) { if (resultToolCallId && toolCallStatusMap.has(toolCallMapKey(progressId, resultToolCallId))) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed'); updateToolCallStatus(progressId, resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(resultToolCallId); toolCallStatusMap.delete(toolCallMapKey(progressId, resultToolCallId));
} }
addTimelineItem(timeline, 'tool_result', { addTimelineItem(timeline, 'tool_result', {
title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText, title: timelineAgentBracketPrefix(resultInfo) + statusIcon + ' ' + resultExecText,
@@ -1880,6 +2095,8 @@ function handleStreamEvent(event, progressElement, progressId,
const responseOriginalConversationId = responseTaskState?.conversationId; const responseOriginalConversationId = responseTaskState?.conversationId;
const responseData = event.data || {}; const responseData = event.data || {};
const streamIdentity = buildMainResponseStreamIdentity(progressId, responseData);
const streamIterTag = extractIterationTagFromStreamIdentity(streamIdentity);
const mcpIds = responseData.mcpExecutionIds || []; const mcpIds = responseData.mcpExecutionIds || [];
setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds)); setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds));
@@ -1899,25 +2116,33 @@ function handleStreamEvent(event, progressElement, progressId,
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡 // 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
const prevStream = responseStreamStateByProgressId.get(progressId); const prevStream = responseStreamStateByProgressId.get(progressId);
if (prevStream && prevStream.itemId && sameMainResponseStreamMeta(prevStream.streamMeta, responseData)) { const prevIterTag = extractIterationTagFromStreamIdentity(prevStream && prevStream.streamIdentity ? prevStream.streamIdentity : '');
const compatibleIterTag = !prevIterTag || !streamIterTag || prevIterTag === streamIterTag;
if (
prevStream &&
prevStream.itemId &&
sameMainResponseStreamMeta(prevStream.streamMeta, responseData) &&
compatibleIterTag
) {
// Eino 可能对同一段流重复发 response_start;复用已有条目与 buffer,避免多条「助手输出」 // Eino 可能对同一段流重复发 response_start;复用已有条目与 buffer,避免多条「助手输出」
prevStream.streamMeta = Object.assign({}, prevStream.streamMeta || {}, responseData); prevStream.streamMeta = Object.assign({}, prevStream.streamMeta || {}, responseData);
// 若此前轮次未知(空),在后续事件带来轮次后升级 identity,避免跨轮误复用。
prevStream.streamIdentity = streamIdentity;
responseStreamStateByProgressId.set(progressId, prevStream); responseStreamStateByProgressId.set(progressId, prevStream);
break; break;
} }
if (prevStream && prevStream.itemId) {
const oldItem = document.getElementById(prevStream.itemId);
if (oldItem && oldItem.parentNode) {
oldItem.parentNode.removeChild(oldItem);
}
}
const title = einoMainStreamPlanningTitle(responseData); const title = einoMainStreamPlanningTitle(responseData);
const itemId = addTimelineItem(timeline, 'thinking', { const itemId = addTimelineItem(timeline, 'thinking', {
title: title, title: title,
message: ' ', message: ' ',
data: Object.assign({}, responseData, { responseStreamPlaceholder: true }) data: Object.assign({}, responseData, { responseStreamPlaceholder: true })
}); });
responseStreamStateByProgressId.set(progressId, { itemId: itemId, buffer: '', streamMeta: responseData }); responseStreamStateByProgressId.set(progressId, {
itemId: itemId,
buffer: '',
streamMeta: responseData,
streamIdentity: streamIdentity
});
break; break;
} }
@@ -2086,11 +2311,13 @@ function handleStreamEvent(event, progressElement, progressId,
loadActiveTasks(); loadActiveTasks();
// Close any remaining running tool calls for this progress. // Close any remaining running tool calls for this progress.
finalizeOutstandingToolCallsForProgress(progressId, 'failed'); finalizeOutstandingToolCallsForProgress(progressId, 'failed');
mainIterationStateByProgressId.delete(String(progressId));
break; break;
case 'done': case 'done':
// 清理流式输出状态 // 清理流式输出状态
responseStreamStateByProgressId.delete(progressId); responseStreamStateByProgressId.delete(progressId);
mainIterationStateByProgressId.delete(String(progressId));
thinkingStreamStateByProgressId.delete(progressId); thinkingStreamStateByProgressId.delete(progressId);
einoAgentReplyStreamStateByProgressId.delete(progressId); einoAgentReplyStreamStateByProgressId.delete(progressId);
// 清理工具流式输出占位 // 清理工具流式输出占位
@@ -2511,6 +2738,22 @@ async function attachRunningTaskEventStream(conversationId) {
} }
} }
} }
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
buffer += decoder.decode();
if (buffer.trim()) {
const lines = buffer.split('\n');
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
} catch (e) {
console.error('task-events parse', e);
}
}
}
}
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) { if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
clearCsTaskReplay(); clearCsTaskReplay();
} }
@@ -2676,9 +2919,9 @@ function findToolCallItemById(root, toolCallId) {
} }
} }
function attachToolResultToCall(toolCallId, data, options) { function attachToolResultToCall(progressId, toolCallId, data, options) {
if (!toolCallId || !data) return false; if (!toolCallId || !data) return false;
const mapping = toolCallStatusMap.get(toolCallId); const mapping = getToolCallMapping(progressId, toolCallId);
let item = null; let item = null;
if (mapping && mapping.itemId) { if (mapping && mapping.itemId) {
item = document.getElementById(mapping.itemId); item = document.getElementById(mapping.itemId);
@@ -2755,8 +2998,8 @@ window.parseToolCallArgsFromData = parseToolCallArgsFromData;
window.buildToolResultSectionHtml = buildToolResultSectionHtml; window.buildToolResultSectionHtml = buildToolResultSectionHtml;
// 更新工具调用状态 // 更新工具调用状态
function updateToolCallStatus(toolCallId, status) { function updateToolCallStatus(progressId, toolCallId, status) {
const mapping = toolCallStatusMap.get(toolCallId); const mapping = getToolCallMapping(progressId, toolCallId);
if (!mapping) return; if (!mapping) return;
const item = document.getElementById(mapping.itemId); const item = document.getElementById(mapping.itemId);
@@ -2937,6 +3180,11 @@ function addTimelineItem(timeline, type, options) {
${escapeHtml(options.message || taskCancelledLabel)} ${escapeHtml(options.message || taskCancelledLabel)}
</div> </div>
`; `;
} else if (type === 'warning' && options.message) {
const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data)
: options.message;
content += `<div class="timeline-item-content">${formatMarkdown(streamBody)}</div>`;
} else if (type === 'progress' && options.message) { } else if (type === 'progress' && options.message) {
content += `<div class="timeline-item-content timeline-eino-trace"><pre class="tool-result">${escapeHtml(options.message)}</pre></div>`; content += `<div class="timeline-item-content timeline-eino-trace"><pre class="tool-result">${escapeHtml(options.message)}</pre></div>`;
} else if (type === 'user_interrupt_continue' && options.message) { } else if (type === 'user_interrupt_continue' && options.message) {
+262 -127
View File
@@ -11,6 +11,17 @@ let _projectsFetchPromise = null;
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId'; const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
function tp(key, opts) {
if (typeof window.t === 'function') return window.t(key, opts);
return key;
}
function tpFmt(key, fallback, opts) {
const text = tp(key, opts);
if (!text || text === key) return fallback;
return text;
}
/** 与后端 internal/project/fact_template.go 对齐 */ /** 与后端 internal/project/fact_template.go 对齐 */
const FACT_ATTACK_CHAIN_BODY_TEMPLATE = `## 结论(可验证,一句话) const FACT_ATTACK_CHAIN_BODY_TEMPLATE = `## 结论(可验证,一句话)
<勿仅写「存在漏洞」;写明类型 + 位置 + 触发条件> <勿仅写「存在漏洞」;写明类型 + 位置 + 触发条件>
@@ -98,12 +109,12 @@ function isSparseFactBody(category, factKey, body) {
function formatFactBodyBadge(f) { function formatFactBodyBadge(f) {
if (!requiresAttackChainFact(f.category, f.fact_key)) { if (!requiresAttackChainFact(f.category, f.fact_key)) {
const hasBody = !!(f.body || '').trim(); const hasBody = !!(f.body || '').trim();
return `<span class="projects-fact-badge projects-fact-badge--na" title="环境类事实">${hasBody ? '有详情' : '—'}</span>`; return `<span class="projects-fact-badge projects-fact-badge--na" title="${escapeHtml(tp('projects.factBodyEnvTitle'))}">${hasBody ? escapeHtml(tp('projects.factBodyHasDetail')) : '—'}</span>`;
} }
if (isSparseFactBody(f.category, f.fact_key, f.body)) { if (isSparseFactBody(f.category, f.fact_key, f.body)) {
return '<span class="projects-fact-badge projects-fact-badge--warn" title="缺少攻击链/POC 结构">待补全</span>'; return `<span class="projects-fact-badge projects-fact-badge--warn" title="${escapeHtml(tp('projects.factBodySparseTitle'))}">${escapeHtml(tp('projects.factBodySparse'))}</span>`;
} }
return '<span class="projects-fact-badge projects-fact-badge--ok" title="含可复现结构">可复现</span>'; return `<span class="projects-fact-badge projects-fact-badge--ok" title="${escapeHtml(tp('projects.factBodyReproducibleTitle'))}">${escapeHtml(tp('projects.factBodyReproducible'))}</span>`;
} }
function updateFactFormHints() { function updateFactFormHints() {
@@ -115,11 +126,11 @@ function updateFactFormHints() {
if (requiresAttackChainFact(cat, key)) { if (requiresAttackChainFact(cat, key)) {
const sparse = isSparseFactBody(cat, key, body); const sparse = isSparseFactBody(cat, key, body);
hint.textContent = sparse hint.textContent = sparse
? '⚠ 攻击链类事实:请填写完整 body(步骤、HTTP/命令、响应现象),勿仅写结论。可点「插入攻击链模板」。' ? tp('projects.factHintAttackSparse')
: '攻击链类:body 将用于审计复现,请保留原始请求/响应与逐步步骤。'; : tp('projects.factHintAttackReady');
hint.classList.toggle('projects-field-hint--warn', sparse); hint.classList.toggle('projects-field-hint--warn', sparse);
} else { } else {
hint.textContent = '环境认知类:body 建议记录来源证据;发现/利用请改用 finding|chain|exploit|poc 分类。'; hint.textContent = tp('projects.factHintEnv');
hint.classList.remove('projects-field-hint--warn'); hint.classList.remove('projects-field-hint--warn');
} }
} }
@@ -128,7 +139,7 @@ function insertFactBodyTemplate(kind) {
const ta = document.getElementById('fact-modal-body'); const ta = document.getElementById('fact-modal-body');
if (!ta) return; if (!ta) return;
const tpl = kind === 'env' ? FACT_ENV_BODY_TEMPLATE : FACT_ATTACK_CHAIN_BODY_TEMPLATE; const tpl = kind === 'env' ? FACT_ENV_BODY_TEMPLATE : FACT_ATTACK_CHAIN_BODY_TEMPLATE;
if (ta.value.trim() && !confirm('将覆盖当前 body 内容为模板,是否继续?')) return; if (ta.value.trim() && !confirm(tp('projects.confirmOverwriteBodyTemplate'))) return;
ta.value = tpl; ta.value = tpl;
updateFactFormHints(); updateFactFormHints();
ta.focus(); ta.focus();
@@ -160,7 +171,7 @@ async function fetchProjectsList(includeArchived) {
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked; const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200'; const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200';
const res = await apiFetch(url); const res = await apiFetch(url);
if (!res.ok) throw new Error('加载项目失败'); if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
const data = await res.json(); const data = await res.json();
projectsCache = Array.isArray(data) ? data : []; projectsCache = Array.isArray(data) ? data : [];
rebuildProjectNameMap(projectsCache); rebuildProjectNameMap(projectsCache);
@@ -215,9 +226,9 @@ function initProjectsModalEscape() {
window._projectsModalEscapeBound = true; window._projectsModalEscapeBound = true;
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (document.getElementById('project-modal')?.style.display === 'flex') closeProjectModal(); if (isProjectsOverlayVisible('project-modal')) closeProjectModal();
else if (document.getElementById('fact-modal')?.style.display === 'flex') closeFactModal(); else if (isProjectsOverlayVisible('fact-modal')) closeFactModal();
else if (document.getElementById('fact-detail-modal')?.style.display === 'flex') closeFactDetailModal(); else if (isProjectsOverlayVisible('fact-detail-modal')) closeFactDetailModal();
}); });
} }
@@ -225,6 +236,7 @@ async function initProjectsPage() {
const page = document.getElementById('page-projects'); const page = document.getElementById('page-projects');
if (!page || page.style.display === 'none') return; if (!page || page.style.display === 'none') return;
initProjectsModalEscape(); initProjectsModalEscape();
syncProjectsModalBodyLock();
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
await loadProjectsList(); await loadProjectsList();
if (!currentProjectId && projectsCache.length) { if (!currentProjectId && projectsCache.length) {
@@ -295,12 +307,12 @@ function formatConfidenceBadge(confidence) {
let label = c || '—'; let label = c || '—';
if (c === 'confirmed') { if (c === 'confirmed') {
cls = 'projects-confidence--confirmed'; cls = 'projects-confidence--confirmed';
label = '已确认'; label = tp('projects.confidenceConfirmed');
} else if (c === 'deprecated') { } else if (c === 'deprecated') {
cls = 'projects-confidence--deprecated'; cls = 'projects-confidence--deprecated';
label = '已废弃'; label = tp('projects.confidenceDeprecated');
} else if (c === 'tentative') { } else if (c === 'tentative') {
label = '待确认'; label = tp('projects.confidenceTentative');
} }
return `<span class="projects-confidence ${cls}">${escapeHtml(label)}</span>`; return `<span class="projects-confidence ${cls}">${escapeHtml(label)}</span>`;
} }
@@ -308,13 +320,13 @@ function formatConfidenceBadge(confidence) {
function renderProjectFactActions(keyEsc, idEsc, confidence) { function renderProjectFactActions(keyEsc, idEsc, confidence) {
const isDeprecated = (confidence || '').toLowerCase() === 'deprecated'; const isDeprecated = (confidence || '').toLowerCase() === 'deprecated';
const toggleBtn = isDeprecated const toggleBtn = isDeprecated
? `<button type="button" class="projects-action-btn projects-action-btn--restore" data-fact-key="${keyEsc}" onclick="restoreProjectFactByKey(this.dataset.factKey)" title="恢复为待确认并重新进入黑板索引">恢复</button>` ? `<button type="button" class="projects-action-btn projects-action-btn--restore" data-fact-key="${keyEsc}" onclick="restoreProjectFactByKey(this.dataset.factKey)" title="${escapeHtml(tp('projects.restoreTitle'))}">${escapeHtml(tp('projects.restore'))}</button>`
: `<button type="button" class="projects-action-btn projects-action-btn--mute" data-fact-key="${keyEsc}" onclick="deprecateProjectFactByKey(this.dataset.factKey)" title="标记为已废弃">废弃</button>`; : `<button type="button" class="projects-action-btn projects-action-btn--mute" data-fact-key="${keyEsc}" onclick="deprecateProjectFactByKey(this.dataset.factKey)" title="${escapeHtml(tp('projects.deprecateTitle'))}">${escapeHtml(tp('projects.deprecate'))}</button>`;
return `<div class="projects-table-actions"> return `<div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--edit" data-fact-key="${keyEsc}" onclick="showEditFactModal(this.dataset.factKey)" title="编辑各字段">编辑</button> <button type="button" class="projects-action-btn projects-action-btn--edit" data-fact-key="${keyEsc}" onclick="showEditFactModal(this.dataset.factKey)" title="${escapeHtml(tp('projects.editTitle'))}">${escapeHtml(tp('common.edit'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--view" data-fact-key="${keyEsc}" onclick="viewProjectFactBody(this.dataset.factKey)" title="查看完整 body">详情</button> <button type="button" class="projects-action-btn projects-action-btn--view" data-fact-key="${keyEsc}" onclick="viewProjectFactBody(this.dataset.factKey)" title="${escapeHtml(tp('projects.viewBodyTitle'))}">${escapeHtml(tp('projects.details'))}</button>
${toggleBtn} ${toggleBtn}
<button type="button" class="projects-action-btn projects-action-btn--danger" data-fact-id="${idEsc}" onclick="deleteProjectFact(this.dataset.factId)" title="永久删除">删除</button> <button type="button" class="projects-action-btn projects-action-btn--danger" data-fact-id="${idEsc}" onclick="deleteProjectFact(this.dataset.factId)" title="${escapeHtml(tp('projects.deleteForeverTitle'))}">${escapeHtml(tp('common.delete'))}</button>
</div>`; </div>`;
} }
@@ -324,6 +336,50 @@ function formatSeverityBadge(severity) {
return `<span class="projects-severity ${cls}">${escapeHtml(severity || '—')}</span>`; return `<span class="projects-severity ${cls}">${escapeHtml(severity || '—')}</span>`;
} }
function formatVulnStatusBadge(status) {
const s = (status || 'open').toLowerCase();
const labelMap = {
open: 'vulnerabilityPage.statusOpen',
confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive',
};
const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
const cls = ['open', 'confirmed', 'fixed', 'false_positive'].includes(s) ? s : 'open';
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
}
let _projectVulnsFilterDebounce = null;
function buildProjectVulnsQueryParams() {
const params = new URLSearchParams();
params.set('project_id', currentProjectId);
params.set('limit', '200');
const search = document.getElementById('project-vulns-search')?.value?.trim();
const severity = document.getElementById('project-vulns-filter-severity')?.value?.trim();
const status = document.getElementById('project-vulns-filter-status')?.value?.trim();
if (search) params.set('q', search);
if (severity) params.set('severity', severity);
if (status) params.set('status', status);
return params;
}
function projectVulnsHasActiveFilter() {
return !!(
document.getElementById('project-vulns-search')?.value?.trim() ||
document.getElementById('project-vulns-filter-severity')?.value ||
document.getElementById('project-vulns-filter-status')?.value
);
}
function debouncedLoadProjectVulnerabilities() {
if (_projectVulnsFilterDebounce) clearTimeout(_projectVulnsFilterDebounce);
_projectVulnsFilterDebounce = setTimeout(() => {
_projectVulnsFilterDebounce = null;
loadProjectVulnerabilities();
}, 280);
}
function getProjectsListFilter() { function getProjectsListFilter() {
return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase(); return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase();
} }
@@ -342,12 +398,12 @@ function renderProjectsSidebar() {
: projectsCache; : projectsCache;
if (!projectsCache.length) { if (!projectsCache.length) {
el.innerHTML = el.innerHTML =
'<div class="projects-empty">暂无项目<br><button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">新建项目</button></div>'; `<div class="projects-empty">${escapeHtml(tp('projects.noProjects'))}<br><button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">${escapeHtml(tp('projects.newProject'))}</button></div>`;
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
return; return;
} }
if (!list.length) { if (!list.length) {
el.innerHTML = '<div class="projects-empty">无匹配项目</div>'; el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`;
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
return; return;
} }
@@ -355,8 +411,8 @@ function renderProjectsSidebar() {
const active = p.id === currentProjectId ? ' is-active' : ''; const active = p.id === currentProjectId ? ' is-active' : '';
const archived = p.status === 'archived' ? ' is-archived' : ''; const archived = p.status === 'archived' ? ' is-archived' : '';
const badges = [ const badges = [
p.pinned ? '<span class="projects-list-item-badge">置顶</span>' : '', p.pinned ? `<span class="projects-list-item-badge">${escapeHtml(tp('projects.pinned'))}</span>` : '',
p.status === 'archived' ? '<span class="projects-list-item-badge">归档</span>' : '', p.status === 'archived' ? `<span class="projects-list-item-badge">${escapeHtml(tp('projects.archived'))}</span>` : '',
].join(''); ].join('');
return `<div class="projects-list-item${active}${archived}" data-id="${escapeHtml(p.id)}" onclick="selectProject('${escapeHtml(p.id)}')"> return `<div class="projects-list-item${active}${archived}" data-id="${escapeHtml(p.id)}" onclick="selectProject('${escapeHtml(p.id)}')">
<div class="projects-list-item-body"> <div class="projects-list-item-body">
@@ -372,7 +428,7 @@ function updateProjectStatusPill(status) {
const el = document.getElementById('projects-detail-status'); const el = document.getElementById('projects-detail-status');
if (!el) return; if (!el) return;
const archived = status === 'archived'; const archived = status === 'archived';
el.textContent = archived ? '已归档' : '进行中'; el.textContent = archived ? tp('projects.statusArchived') : tp('projects.statusActive');
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active'); el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
} }
@@ -386,13 +442,13 @@ function updateProjectStats(stats) {
const vc = s.vuln_count ?? s.vulnCount ?? 0; const vc = s.vuln_count ?? s.vulnCount ?? 0;
const cc = s.conversation_count ?? s.conversationCount ?? 0; const cc = s.conversation_count ?? s.conversationCount ?? 0;
const sc = s.sparse_fact_count ?? s.sparseFactCount ?? 0; const sc = s.sparse_fact_count ?? s.sparseFactCount ?? 0;
if (f) f.textContent = `${fc} 条事实`; if (f) f.textContent = tpFmt('projects.statsFacts', `${fc} facts`, { count: fc });
if (v) v.textContent = `${vc} 个漏洞`; if (v) v.textContent = tpFmt('projects.statsVulns', `${vc} vulnerabilities`, { count: vc });
if (c) c.textContent = `${cc} 个对话`; if (c) c.textContent = tpFmt('projects.statsConversations', `${cc} conversations`, { count: cc });
if (sparse) { if (sparse) {
if (sc > 0) { if (sc > 0) {
sparse.hidden = false; sparse.hidden = false;
sparse.textContent = `${sc} 待补全`; sparse.textContent = tpFmt('projects.statsSparse', `${sc} to complete`, { count: sc });
} else { } else {
sparse.hidden = true; sparse.hidden = true;
} }
@@ -405,18 +461,24 @@ async function selectProject(id) {
const catEl = document.getElementById('project-facts-filter-category'); const catEl = document.getElementById('project-facts-filter-category');
const confEl = document.getElementById('project-facts-filter-confidence'); const confEl = document.getElementById('project-facts-filter-confidence');
const sparseEl = document.getElementById('project-facts-filter-sparse'); const sparseEl = document.getElementById('project-facts-filter-sparse');
const vulnSearchEl = document.getElementById('project-vulns-search');
const vulnSevEl = document.getElementById('project-vulns-filter-severity');
const vulnStatusEl = document.getElementById('project-vulns-filter-status');
if (searchEl) searchEl.value = ''; if (searchEl) searchEl.value = '';
if (catEl) catEl.value = ''; if (catEl) catEl.value = '';
if (confEl) confEl.value = ''; if (confEl) confEl.value = '';
if (sparseEl) sparseEl.checked = false; if (sparseEl) sparseEl.checked = false;
if (vulnSearchEl) vulnSearchEl.value = '';
if (vulnSevEl) vulnSevEl.value = '';
if (vulnStatusEl) vulnStatusEl.value = '';
renderProjectsSidebar(); renderProjectsSidebar();
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
try { try {
const res = await apiFetch(`/api/projects/${id}`); const res = await apiFetch(`/api/projects/${id}`);
if (!res.ok) throw new Error('项目不存在'); if (!res.ok) throw new Error(tp('projects.projectNotFound'));
const p = await res.json(); const p = await res.json();
const titleEl = document.getElementById('projects-detail-title'); const titleEl = document.getElementById('projects-detail-title');
if (titleEl) titleEl.textContent = p.name || '项目'; if (titleEl) titleEl.textContent = p.name || tp('projects.defaultProjectName');
document.getElementById('project-edit-name').value = p.name || ''; document.getElementById('project-edit-name').value = p.name || '';
document.getElementById('project-edit-description').value = p.description || ''; document.getElementById('project-edit-description').value = p.description || '';
document.getElementById('project-edit-scope').value = p.scope_json || ''; document.getElementById('project-edit-scope').value = p.scope_json || '';
@@ -426,7 +488,7 @@ async function selectProject(id) {
if (pinEl) pinEl.checked = !!p.pinned; if (pinEl) pinEl.checked = !!p.pinned;
updateProjectStatusPill(p.status || 'active'); updateProjectStatusPill(p.status || 'active');
const metaEl = document.getElementById('projects-detail-meta'); const metaEl = document.getElementById('projects-detail-meta');
if (metaEl) metaEl.textContent = `更新于 ${formatProjectTime(p.updated_at)}`; if (metaEl) metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${formatProjectTime(p.updated_at)}`, { time: formatProjectTime(p.updated_at) });
const descEl = document.getElementById('projects-detail-desc'); const descEl = document.getElementById('projects-detail-desc');
if (descEl) { if (descEl) {
const desc = (p.description || '').trim(); const desc = (p.description || '').trim();
@@ -486,11 +548,11 @@ function debouncedLoadProjectFacts() {
async function loadProjectFacts() { async function loadProjectFacts() {
const tbody = document.getElementById('project-facts-tbody'); const tbody = document.getElementById('project-facts-tbody');
if (!tbody || !currentProjectId) return; if (!tbody || !currentProjectId) return;
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">加载中…</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loading'))}</td></tr>`;
const qs = buildProjectFactsQueryParams().toString(); const qs = buildProjectFactsQueryParams().toString();
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
if (!res.ok) { if (!res.ok) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">加载失败</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return; return;
} }
const facts = await res.json(); const facts = await res.json();
@@ -501,7 +563,7 @@ async function loadProjectFacts() {
document.getElementById('project-facts-filter-confidence')?.value || document.getElementById('project-facts-filter-confidence')?.value ||
document.getElementById('project-facts-filter-sparse')?.checked; document.getElementById('project-facts-filter-sparse')?.checked;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${ tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${
hasFilter ? '无匹配事实,请调整筛选条件' : '暂无事实,点击「添加事实」或由 Agent 自动写入' hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
}</td></tr>`; }</td></tr>`;
refreshProjectHeaderStats(); refreshProjectHeaderStats();
return; return;
@@ -510,7 +572,7 @@ async function loadProjectFacts() {
const keyEsc = escapeHtml(f.fact_key); const keyEsc = escapeHtml(f.fact_key);
const idEsc = escapeHtml(f.id); const idEsc = escapeHtml(f.id);
const vulnLink = f.related_vulnerability_id const vulnLink = f.related_vulnerability_id
? `<span class="projects-fact-vuln-link" title="关联漏洞 ID">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>` ? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
: ''; : '';
return `<tr> return `<tr>
<td><code>${keyEsc}</code>${vulnLink}</td> <td><code>${keyEsc}</code>${vulnLink}</td>
@@ -540,32 +602,31 @@ async function refreshProjectHeaderStats() {
async function loadProjectConversations() { async function loadProjectConversations() {
const tbody = document.getElementById('project-conversations-tbody'); const tbody = document.getElementById('project-conversations-tbody');
if (!tbody || !currentProjectId) return; if (!tbody || !currentProjectId) return;
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="3">加载中…</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="3">${escapeHtml(tp('common.loading'))}</td></tr>`;
const res = await apiFetch(`/api/projects/${currentProjectId}/conversations?limit=100`); const res = await apiFetch(`/api/projects/${currentProjectId}/conversations?limit=100`);
if (!res.ok) { if (!res.ok) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="3">加载失败</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="3">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return; return;
} }
const data = await res.json(); const data = await res.json();
const items = data.conversations || []; const items = data.conversations || [];
if (!items.length) { if (!items.length) {
tbody.innerHTML = tbody.innerHTML = `<tr class="is-empty-row"><td colspan="3">${escapeHtml(tp('projects.noBoundConversations'))}</td></tr>`;
'<tr class="is-empty-row"><td colspan="3">暂无绑定对话;在对话页选择本项目即可关联</td></tr>';
return; return;
} }
tbody.innerHTML = items tbody.innerHTML = items
.map((conv) => { .map((conv) => {
const id = conv.id; const id = conv.id;
const idEsc = escapeHtml(id); const idEsc = escapeHtml(id);
const title = escapeHtml(conv.title || '未命名对话'); const title = escapeHtml(conv.title || tp('projects.untitledConversation'));
const updated = formatProjectTime(conv.updatedAt || conv.updated_at, conv.createdAt || conv.created_at); const updated = formatProjectTime(conv.updatedAt || conv.updated_at, conv.createdAt || conv.created_at);
return `<tr> return `<tr>
<td class="cell-summary" title="${title}">${title}</td> <td class="cell-summary" title="${title}">${title}</td>
<td>${escapeHtml(updated)}</td> <td>${escapeHtml(updated)}</td>
<td class="col-actions"> <td class="col-actions">
<div class="projects-table-actions"> <div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">打开</button> <button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="解除项目绑定">解绑</button> <button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
</div> </div>
</td> </td>
</tr>`; </tr>`;
@@ -586,13 +647,13 @@ function openProjectConversation(conversationId) {
} }
async function unbindConversationFromProject(conversationId) { async function unbindConversationFromProject(conversationId) {
if (!conversationId || !confirm('解除该对话与当前项目的绑定?')) return; if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, { const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: '' }), body: JSON.stringify({ projectId: '' }),
}); });
if (!res.ok) return alert('解绑失败'); if (!res.ok) return alert(tp('projects.unbindFailed'));
loadProjectConversations(); loadProjectConversations();
refreshProjectHeaderStats(); refreshProjectHeaderStats();
} }
@@ -603,27 +664,28 @@ let _projectFactsFilterDebounce = null;
async function viewProjectFactBody(factKey) { async function viewProjectFactBody(factKey) {
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
if (!res.ok) return alert('加载失败'); if (!res.ok) return alert(tp('common.loadFailed'));
const f = await res.json(); const f = await res.json();
_factDetailKey = f.fact_key; _factDetailKey = f.fact_key;
_factDetailFact = f; _factDetailFact = f;
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`; document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
const metaParts = [ const metaParts = [
`分类: ${f.category}`, tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
`置信度: ${f.confidence}`, tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
`更新: ${formatProjectTime(f.updated_at, f.created_at)}`, tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
time: formatProjectTime(f.updated_at, f.created_at),
}),
]; ];
if (f.related_vulnerability_id) metaParts.push(`关联漏洞: ${f.related_vulnerability_id}`); if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
if (f.source_conversation_id) metaParts.push(`来源对话: ${f.source_conversation_id}`); if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
if (f.supersedes_fact_id) metaParts.push('含上一版本'); if (f.supersedes_fact_id) metaParts.push(tp('projects.factMetaHasPrevious'));
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · '); document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || '(无 body)'; document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
const warnEl = document.getElementById('fact-detail-sparse-warn'); const warnEl = document.getElementById('fact-detail-sparse-warn');
if (warnEl) { if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) { if (isSparseFactBody(f.category, f.fact_key, f.body)) {
warnEl.hidden = false; warnEl.hidden = false;
warnEl.textContent = warnEl.textContent = tp('projects.factSparseWarn');
'⚠ 该事实属于攻击链/利用类,但 body 缺少可复现结构(攻击链步骤、HTTP/命令、请求响应等)。建议编辑后补全以便审计复现。';
} else { } else {
warnEl.hidden = true; warnEl.hidden = true;
warnEl.textContent = ''; warnEl.textContent = '';
@@ -640,9 +702,16 @@ async function viewProjectFactBody(factKey) {
if (prevRes.ok) { if (prevRes.ok) {
const prev = await prevRes.json(); const prev = await prevRes.json();
prevWrap.hidden = false; prevWrap.hidden = false;
document.getElementById('fact-detail-prev-meta').textContent = document.getElementById('fact-detail-prev-meta').textContent = tpFmt(
`归档于 ${formatProjectTime(prev.archived_at)} · 摘要: ${prev.summary || '—'} · 置信度: ${prev.confidence || '—'}`; 'projects.factPreviousMeta',
document.getElementById('fact-detail-prev-body').textContent = prev.body || '(无 body)'; `Archived at ${formatProjectTime(prev.archived_at)} · Summary: ${prev.summary || '—'} · Confidence: ${prev.confidence || '—'}`,
{
time: formatProjectTime(prev.archived_at),
summary: prev.summary || '—',
confidence: prev.confidence || '—',
},
);
document.getElementById('fact-detail-prev-body').textContent = prev.body || tp('projects.emptyBody');
} }
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
@@ -672,15 +741,21 @@ async function linkFactToExistingVulnerability() {
const f = _factDetailFact; const f = _factDetailFact;
if (!f || !currentProjectId) return; if (!f || !currentProjectId) return;
const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=50`); const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=50`);
if (!res.ok) return alert('加载漏洞列表失败'); if (!res.ok) return alert(tp('projects.loadVulnerabilityListFailed'));
const data = await res.json(); const data = await res.json();
const items = data.Vulnerabilities || data.vulnerabilities || data.items || []; const items = data.Vulnerabilities || data.vulnerabilities || data.items || [];
if (!items.length) return alert('本项目暂无漏洞,请先创建或让 Agent 记录漏洞'); if (!items.length) return alert(tp('projects.noVulnerabilitiesInProject'));
const lines = items.map((v, i) => `${i + 1}. [${v.severity}] ${v.title} (${v.id})`); const lines = items.map((v, i) => `${i + 1}. [${v.severity}] ${v.title} (${v.id})`);
const pick = prompt(`输入序号以关联事实「${f.fact_key}」:\n\n${lines.join('\n')}`); const pick = prompt(
tp('projects.promptLinkFactToVuln', {
factKey: f.fact_key,
lines: lines.join('\n'),
interpolation: { escapeValue: false },
}) || `Enter index to link fact "${f.fact_key}":\n\n${lines.join('\n')}`,
);
if (pick == null || pick === '') return; if (pick == null || pick === '') return;
const idx = parseInt(pick, 10) - 1; const idx = parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= items.length) return alert('序号无效'); if (Number.isNaN(idx) || idx < 0 || idx >= items.length) return alert(tp('projects.invalidIndex'));
const vulnId = items[idx].id; const vulnId = items[idx].id;
const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, { const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, {
method: 'PUT', method: 'PUT',
@@ -694,8 +769,8 @@ async function linkFactToExistingVulnerability() {
related_vulnerability_id: vulnId, related_vulnerability_id: vulnId,
}), }),
}); });
if (!upd.ok) return alert('关联失败'); if (!upd.ok) return alert(tp('projects.linkFailed'));
alert('已关联漏洞'); alert(tp('projects.linkSuccess'));
closeFactDetailModal(); closeFactDetailModal();
loadProjectFacts(); loadProjectFacts();
} }
@@ -707,15 +782,19 @@ async function createVulnerabilityFromCurrentFact() {
(f.source_conversation_id || '').trim() || (f.source_conversation_id || '').trim() ||
(typeof window.currentConversationId === 'string' ? window.currentConversationId.trim() : ''); (typeof window.currentConversationId === 'string' ? window.currentConversationId.trim() : '');
if (!convId) { if (!convId) {
convId = prompt('创建漏洞需要对话 ID(可与来源会话一致):', '')?.trim() || ''; convId = prompt(tp('projects.promptConversationIdForVulnCreate'), '')?.trim() || '';
} }
if (!convId) return alert('已取消:未提供 conversation_id'); if (!convId) return alert(tp('projects.cancelledNoConversationId'));
const severity = inferSeverityFromFact(f); const severity = inferSeverityFromFact(f);
const body = { const body = {
conversation_id: convId, conversation_id: convId,
project_id: currentProjectId, project_id: currentProjectId,
title: (f.summary || f.fact_key).slice(0, 200), title: (f.summary || f.fact_key).slice(0, 200),
description: `由项目事实 ${f.fact_key} 生成`, description:
tp('projects.generatedFromFact', {
factKey: f.fact_key,
interpolation: { escapeValue: false },
}) || `Generated from project fact ${f.fact_key}`,
severity, severity,
status: 'open', status: 'open',
type: f.category || 'finding', type: f.category || 'finding',
@@ -731,7 +810,7 @@ async function createVulnerabilityFromCurrentFact() {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
return alert(err.error || '创建漏洞失败'); return alert(err.error || tp('projects.createVulnerabilityFailed'));
} }
const vuln = await res.json(); const vuln = await res.json();
await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, { await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, {
@@ -746,7 +825,12 @@ async function createVulnerabilityFromCurrentFact() {
related_vulnerability_id: vuln.id, related_vulnerability_id: vuln.id,
}), }),
}); });
alert(`已创建漏洞并关联:${vuln.title || vuln.id}`); const createdVulnLabel = vuln.title || vuln.id;
const successMsg = tp('projects.createVulnerabilityAndLinkSuccess', {
value: createdVulnLabel,
interpolation: { escapeValue: false },
});
alert(successMsg || `Created and linked vulnerability: ${createdVulnLabel}`);
closeFactDetailModal(); closeFactDetailModal();
loadProjectFacts(); loadProjectFacts();
if (currentProjectTab === 'vulns') loadProjectVulnerabilities(); if (currentProjectTab === 'vulns') loadProjectVulnerabilities();
@@ -761,18 +845,28 @@ function inferSeverityFromFact(f) {
} }
async function deprecateProjectFactByKey(factKey) { async function deprecateProjectFactByKey(factKey) {
if (!confirm(`将事实 ${factKey} 标记为已废弃?`)) return; if (!confirm(
tp('projects.confirmDeprecateFact', {
factKey,
interpolation: { escapeValue: false },
}) || `Deprecate fact ${factKey}?`,
)) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/facts/deprecate`, { const res = await apiFetch(`/api/projects/${currentProjectId}/facts/deprecate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fact_key: factKey }), body: JSON.stringify({ fact_key: factKey }),
}); });
if (!res.ok) return alert('操作失败'); if (!res.ok) return alert(tp('projects.operationFailed'));
loadProjectFacts(); loadProjectFacts();
} }
async function restoreProjectFactByKey(factKey) { async function restoreProjectFactByKey(factKey) {
if (!confirm(`恢复事实 ${factKey}?将重新进入黑板索引(状态:待确认)。`)) return; if (!confirm(
tp('projects.confirmRestoreFact', {
factKey,
interpolation: { escapeValue: false },
}) || `Restore fact ${factKey}? It will re-enter the board index with tentative status.`,
)) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/facts/restore`, { const res = await apiFetch(`/api/projects/${currentProjectId}/facts/restore`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -780,7 +874,7 @@ async function restoreProjectFactByKey(factKey) {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
return alert(err.error || '操作失败'); return alert(err.error || tp('projects.operationFailed'));
} }
loadProjectFacts(); loadProjectFacts();
} }
@@ -801,16 +895,19 @@ function openVulnerabilitiesForProject(projectId) {
async function loadProjectVulnerabilities() { async function loadProjectVulnerabilities() {
const tbody = document.getElementById('project-vulns-tbody'); const tbody = document.getElementById('project-vulns-tbody');
if (!tbody || !currentProjectId) return; if (!tbody || !currentProjectId) return;
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="4">加载中…</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loading'))}</td></tr>`;
const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`); const qs = buildProjectVulnsQueryParams().toString();
const res = await apiFetch(`/api/vulnerabilities?${qs}`);
if (!res.ok) { if (!res.ok) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="4">加载失败</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return; return;
} }
const data = await res.json(); const data = await res.json();
const items = data.Vulnerabilities || data.vulnerabilities || data.items || []; const items = data.Vulnerabilities || data.vulnerabilities || data.items || (Array.isArray(data) ? data : []);
if (!items.length) { if (!items.length) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="4">本项目暂无漏洞记录</td></tr>'; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${
projectVulnsHasActiveFilter() ? tp('projects.noMatchingVulns') : tp('projects.noVulnerabilityRecords')
}</td></tr>`;
refreshProjectHeaderStats(); refreshProjectHeaderStats();
return; return;
} }
@@ -819,11 +916,11 @@ async function loadProjectVulnerabilities() {
return `<tr> return `<tr>
<td class="cell-summary" title="${escapeHtml(v.title)}">${escapeHtml(v.title)}</td> <td class="cell-summary" title="${escapeHtml(v.title)}">${escapeHtml(v.title)}</td>
<td>${formatSeverityBadge(v.severity)}</td> <td>${formatSeverityBadge(v.severity)}</td>
<td>${escapeHtml(v.status)}</td> <td>${formatVulnStatusBadge(v.status)}</td>
<td class="col-actions"> <td class="col-actions">
<div class="projects-table-actions"> <div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="openVulnerabilityDetail(this.dataset.vulnId)">查看</button> <button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="openVulnerabilityDetail(this.dataset.vulnId)">${escapeHtml(tp('common.view'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="viewFactsForVulnerability(this.dataset.vulnId)" title="查看关联事实">事实</button> <button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="viewFactsForVulnerability(this.dataset.vulnId)" title="${escapeHtml(tp('projects.viewRelatedFactsTitle'))}">${escapeHtml(tp('projects.facts'))}</button>
</div> </div>
</td> </td>
</tr>`; </tr>`;
@@ -853,10 +950,10 @@ async function viewFactsForVulnerability(vulnId) {
if (hideDepEl) hideDepEl.checked = true; if (hideDepEl) hideDepEl.checked = true;
const params = new URLSearchParams({ limit: '50', related_vulnerability_id: vulnId }); const params = new URLSearchParams({ limit: '50', related_vulnerability_id: vulnId });
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${params}`); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${params}`);
if (!res.ok) return alert('加载关联事实失败'); if (!res.ok) return alert(tp('projects.loadRelatedFactsFailed'));
const facts = await res.json(); const facts = await res.json();
if (!facts.length) { if (!facts.length) {
alert('该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接'); alert(tp('projects.noFactsForVulnerability'));
loadProjectFacts(); loadProjectFacts();
return; return;
} }
@@ -865,7 +962,11 @@ async function viewFactsForVulnerability(vulnId) {
return; return;
} }
const pick = prompt( const pick = prompt(
`该漏洞关联 ${facts.length} 条事实,输入序号查看:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`, tp('projects.promptChooseFactByIndex', {
count: facts.length,
lines: facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n'),
interpolation: { escapeValue: false },
}) || `This vulnerability is linked to ${facts.length} facts. Enter index to view:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`,
); );
if (pick == null || pick === '') { if (pick == null || pick === '') {
loadProjectFacts(); loadProjectFacts();
@@ -880,26 +981,45 @@ function openProjectsOverlay(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
el.style.display = 'flex'; el.style.display = 'flex';
document.body.classList.add('projects-modal-open'); syncProjectsModalBodyLock();
const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input'); const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input');
if (focusTarget) { if (focusTarget) {
setTimeout(() => focusTarget.focus(), 80); setTimeout(() => focusTarget.focus(), 80);
} }
} }
function isProjectsOverlayVisible(id) {
const el = document.getElementById(id);
if (!el) return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}
function hasVisibleProjectsOverlay() {
const overlays = document.querySelectorAll('.projects-modal-overlay');
return Array.from(overlays).some((el) => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
});
}
function syncProjectsModalBodyLock() {
if (hasVisibleProjectsOverlay()) document.body.classList.add('projects-modal-open');
else document.body.classList.remove('projects-modal-open');
}
function closeProjectsOverlay(id) { function closeProjectsOverlay(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.style.display = 'none'; if (el) el.style.display = 'none';
const anyOpen = document.querySelector('.projects-modal-overlay[style*="flex"]'); syncProjectsModalBodyLock();
if (!anyOpen) document.body.classList.remove('projects-modal-open');
} }
function showNewProjectModal() { function showNewProjectModal() {
document.getElementById('project-modal-title').textContent = '新建项目'; document.getElementById('project-modal-title').textContent = tp('projects.modalNewTitle');
const sub = document.getElementById('project-modal-subtitle'); const sub = document.getElementById('project-modal-subtitle');
if (sub) sub.textContent = '创建后可绑定对话,跨会话共享事实黑板'; if (sub) sub.textContent = tp('projects.modalNewSubtitle');
const submitBtn = document.getElementById('project-modal-submit-btn'); const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.textContent = '创建项目'; if (submitBtn) submitBtn.textContent = tp('projects.createProject');
document.getElementById('project-modal-name').value = ''; document.getElementById('project-modal-name').value = '';
document.getElementById('project-modal-description').value = ''; document.getElementById('project-modal-description').value = '';
window._projectModalEditId = null; window._projectModalEditId = null;
@@ -915,7 +1035,7 @@ function showNewProjectModalFromChat() {
async function saveProjectModal() { async function saveProjectModal() {
const name = document.getElementById('project-modal-name').value.trim(); const name = document.getElementById('project-modal-name').value.trim();
if (!name) return alert('请输入项目名称'); if (!name) return alert(tp('projects.enterProjectName'));
const body = { const body = {
name, name,
description: document.getElementById('project-modal-description').value.trim(), description: document.getElementById('project-modal-description').value.trim(),
@@ -926,7 +1046,7 @@ async function saveProjectModal() {
: await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); : await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
alert(err.error || '保存失败'); alert(err.error || tp('projects.saveFailed'));
return; return;
} }
const fromChat = !!window._projectModalFromChat; const fromChat = !!window._projectModalFromChat;
@@ -956,7 +1076,7 @@ function formatProjectScopeJson() {
try { try {
el.value = JSON.stringify(JSON.parse(raw), null, 2); el.value = JSON.stringify(JSON.parse(raw), null, 2);
} catch (e) { } catch (e) {
alert('JSON 格式无效:' + (e.message || String(e))); alert(tp('projects.invalidJson') + ': ' + (e.message || String(e)));
} }
} }
@@ -966,7 +1086,7 @@ function insertProjectScopeExample() {
const example = { const example = {
targets: ['https://example.com'], targets: ['https://example.com'],
exclude: ['*.cdn.example.com'], exclude: ['*.cdn.example.com'],
notes: '仅授权 Web 应用层测试', notes: tp('projects.scopeNoteAuthorizedWebOnly'),
}; };
el.value = JSON.stringify(example, null, 2); el.value = JSON.stringify(example, null, 2);
el.focus(); el.focus();
@@ -979,7 +1099,7 @@ async function saveProjectSettings() {
try { try {
JSON.parse(scopeRaw); JSON.parse(scopeRaw);
} catch (e) { } catch (e) {
alert('测试范围 JSON 无效,请先修正或点击「格式化」:' + (e.message || String(e))); alert(tp('projects.invalidScopeJson') + ': ' + (e.message || String(e)));
return; return;
} }
} }
@@ -995,10 +1115,10 @@ async function saveProjectSettings() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) return alert('保存失败'); if (!res.ok) return alert(tp('projects.saveFailed'));
await loadProjectsList(); await loadProjectsList();
await selectProject(currentProjectId); await selectProject(currentProjectId);
alert('已保存'); alert(tp('projects.saved'));
} }
async function archiveCurrentProject() { async function archiveCurrentProject() {
@@ -1006,23 +1126,23 @@ async function archiveCurrentProject() {
const statusEl = document.getElementById('project-edit-status'); const statusEl = document.getElementById('project-edit-status');
const cur = statusEl?.value || 'active'; const cur = statusEl?.value || 'active';
const next = cur === 'archived' ? 'active' : 'archived'; const next = cur === 'archived' ? 'active' : 'archived';
if (!confirm(next === 'archived' ? '归档后默认不再出现在活跃列表,是否继续?' : '恢复为 active')) return; if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return;
const res = await apiFetch(`/api/projects/${currentProjectId}`, { const res = await apiFetch(`/api/projects/${currentProjectId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
if (!res.ok) return alert('操作失败'); if (!res.ok) return alert(tp('projects.operationFailed'));
await loadProjectsList(); await loadProjectsList();
await selectProject(currentProjectId); await selectProject(currentProjectId);
} }
async function deleteCurrentProject() { async function deleteCurrentProject() {
if (!currentProjectId || !confirm('确定删除该项目?事实将一并删除,对话将解除绑定。')) return; if (!currentProjectId || !confirm(tp('projects.confirmDeleteProject'))) return;
const deletedId = currentProjectId; const deletedId = currentProjectId;
const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId); const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId);
const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' }); const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' });
if (!res.ok) return alert('删除失败'); if (!res.ok) return alert(tp('projects.deleteFailed'));
if (getActiveProjectId() === deletedId) setActiveProjectId(''); if (getActiveProjectId() === deletedId) setActiveProjectId('');
currentProjectId = null; currentProjectId = null;
await loadProjectsList(); await loadProjectsList();
@@ -1038,8 +1158,8 @@ function resetFactModalForm() {
window._factModalEditId = null; window._factModalEditId = null;
const keyEl = document.getElementById('fact-modal-key'); const keyEl = document.getElementById('fact-modal-key');
if (keyEl) keyEl.disabled = false; if (keyEl) keyEl.disabled = false;
document.getElementById('fact-modal-title').textContent = '添加事实'; document.getElementById('fact-modal-title').textContent = tp('projects.addFact');
document.getElementById('fact-modal-submit-btn').textContent = '保存事实'; document.getElementById('fact-modal-submit-btn').textContent = tp('projects.saveFact');
document.getElementById('fact-modal-key').value = ''; document.getElementById('fact-modal-key').value = '';
document.getElementById('fact-modal-category').value = 'note'; document.getElementById('fact-modal-category').value = 'note';
document.getElementById('fact-modal-summary').value = ''; document.getElementById('fact-modal-summary').value = '';
@@ -1052,8 +1172,8 @@ function resetFactModalForm() {
function fillFactModalForm(f) { function fillFactModalForm(f) {
window._factModalEditId = f.id; window._factModalEditId = f.id;
document.getElementById('fact-modal-title').textContent = '编辑事实'; document.getElementById('fact-modal-title').textContent = tp('projects.editFact');
document.getElementById('fact-modal-submit-btn').textContent = '保存修改'; document.getElementById('fact-modal-submit-btn').textContent = tp('projects.saveChanges');
document.getElementById('fact-modal-key').value = f.fact_key || ''; document.getElementById('fact-modal-key').value = f.fact_key || '';
const catEl = document.getElementById('fact-modal-category'); const catEl = document.getElementById('fact-modal-category');
const cat = (f.category || 'note').trim().toLowerCase(); const cat = (f.category || 'note').trim().toLowerCase();
@@ -1063,7 +1183,7 @@ function fillFactModalForm(f) {
else { else {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = f.category; opt.value = f.category;
opt.textContent = `${f.category}(自定义)`; opt.textContent = tpFmt('projects.customCategoryOption', `${f.category} (custom)`, { value: f.category });
catEl.appendChild(opt); catEl.appendChild(opt);
catEl.value = f.category; catEl.value = f.category;
} }
@@ -1082,17 +1202,17 @@ function fillFactModalForm(f) {
} }
function showAddFactModal() { function showAddFactModal() {
if (!currentProjectId) return alert('请先选择项目'); if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
resetFactModalForm(); resetFactModalForm();
openProjectsOverlay('fact-modal'); openProjectsOverlay('fact-modal');
} }
async function showEditFactModal(factKey) { async function showEditFactModal(factKey) {
if (!currentProjectId) return alert('请先选择项目'); if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
const res = await apiFetch( const res = await apiFetch(
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`, `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
); );
if (!res.ok) return alert('加载事实失败'); if (!res.ok) return alert(tp('projects.loadFactFailed'));
const f = await res.json(); const f = await res.json();
resetFactModalForm(); resetFactModalForm();
fillFactModalForm(f); fillFactModalForm(f);
@@ -1109,10 +1229,10 @@ async function saveFactModal() {
const summary = document.getElementById('fact-modal-summary').value.trim(); const summary = document.getElementById('fact-modal-summary').value.trim();
const category = document.getElementById('fact-modal-category').value.trim() || 'note'; const category = document.getElementById('fact-modal-category').value.trim() || 'note';
const body = document.getElementById('fact-modal-body').value; const body = document.getElementById('fact-modal-body').value;
if (!fact_key || !summary) return alert('fact_key 与 summary 必填'); if (!fact_key || !summary) return alert(tp('projects.factKeySummaryRequired'));
if (isSparseFactBody(category, fact_key, body)) { if (isSparseFactBody(category, fact_key, body)) {
const ok = confirm( const ok = confirm(
'该事实属于攻击链/利用类,但 body 尚未包含可复现结构(步骤、HTTP/命令、请求响应等)。\n仍要保存吗?建议先插入攻击链模板并填写 POC。', tp('projects.confirmSaveSparseFact'),
); );
if (!ok) return; if (!ok) return;
} }
@@ -1138,14 +1258,14 @@ async function saveFactModal() {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
return alert(err.error || '保存失败'); return alert(err.error || tp('projects.saveFailed'));
} }
closeFactModal(); closeFactModal();
loadProjectFacts(); loadProjectFacts();
} }
async function deleteProjectFact(id) { async function deleteProjectFact(id) {
if (!confirm('删除该事实?')) return; if (!confirm(tp('projects.confirmDeleteFact'))) return;
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
loadProjectFacts(); loadProjectFacts();
} }
@@ -1188,13 +1308,13 @@ function parseProjectDate(t) {
function formatProjectTime(t, fallback) { function formatProjectTime(t, fallback) {
const d = parseProjectDate(t) || (fallback != null ? parseProjectDate(fallback) : null); const d = parseProjectDate(t) || (fallback != null ? parseProjectDate(fallback) : null);
if (!d) return '尚未更新'; if (!d) return tp('projects.notUpdatedYet');
const now = Date.now(); const now = Date.now();
const diff = now - d.getTime(); const diff = now - d.getTime();
if (diff < 60000) return '刚刚'; if (diff < 60000) return tp('common.justNow');
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`; if (diff < 3600000) return tp('common.minutesAgo', { n: Math.floor(diff / 60000) });
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`; if (diff < 86400000) return tp('common.hoursAgo', { n: Math.floor(diff / 3600000) });
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`; if (diff < 604800000) return tp('common.daysAgo', { n: Math.floor(diff / 86400000) });
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} }
@@ -1249,7 +1369,7 @@ async function normalizeStaleChatProjectSelection() {
body: JSON.stringify({ projectId: '' }), body: JSON.stringify({ projectId: '' }),
} }
); );
if (!res.ok) console.warn('清除失效的项目绑定失败'); if (!res.ok) console.warn(tp('projects.clearStaleProjectBindingFailed'));
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
} }
@@ -1265,7 +1385,7 @@ function updateChatProjectButtonLabel() {
const textEl = document.getElementById('chat-project-text'); const textEl = document.getElementById('chat-project-text');
if (!textEl) return; if (!textEl) return;
const id = resolveChatProjectSelection(); const id = resolveChatProjectSelection();
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : '无项目'; textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject');
} }
function renderChatProjectPanelList() { function renderChatProjectPanelList() {
@@ -1273,9 +1393,9 @@ function renderChatProjectPanelList() {
if (!list) return; if (!list) return;
const selected = resolveChatProjectSelection(); const selected = resolveChatProjectSelection();
const activeProjects = projectsCache.filter((p) => p.status !== 'archived'); const activeProjects = projectsCache.filter((p) => p.status !== 'archived');
const items = [{ id: '', name: '无项目', description: '不绑定项目黑板' }, ...activeProjects]; const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
if (!items.length) { if (!items.length) {
list.innerHTML = '<div class="chat-project-panel-empty">暂无项目,点击下方「新建项目」</div>'; list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
return; return;
} }
list.innerHTML = ''; list.innerHTML = '';
@@ -1284,7 +1404,7 @@ function renderChatProjectPanelList() {
const isSelected = isNone ? !selected : selected === p.id; const isSelected = isNone ? !selected : selected === p.id;
const desc = isNone const desc = isNone
? (p.description || '') ? (p.description || '')
: (p.description || '').trim().slice(0, 80) || '共享事实黑板'; : (p.description || '').trim().slice(0, 80) || tp('projects.sharedFactBoard');
const projectId = p.id || ''; const projectId = p.id || '';
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
@@ -1296,7 +1416,7 @@ function renderChatProjectPanelList() {
btn.innerHTML = ` btn.innerHTML = `
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div> <div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
<div class="role-selection-item-content-main"> <div class="role-selection-item-content-main">
<div class="role-selection-item-name-main">${escapeHtml(p.name || '未命名')}</div> <div class="role-selection-item-name-main">${escapeHtml(p.name || tp('common.untitled'))}</div>
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div> <div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
</div> </div>
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''} ${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
@@ -1308,12 +1428,12 @@ function renderChatProjectPanelList() {
async function renderChatProjectPanel() { async function renderChatProjectPanel() {
const list = document.getElementById('chat-project-list'); const list = document.getElementById('chat-project-list');
if (!list) return; if (!list) return;
list.innerHTML = '<div class="chat-project-panel-loading">加载中…</div>'; list.innerHTML = `<div class="chat-project-panel-loading">${escapeHtml(tp('common.loading'))}</div>`;
try { try {
await ensureProjectsLoaded(); await ensureProjectsLoaded();
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
list.innerHTML = '<div class="chat-project-panel-empty">加载失败,请稍后重试</div>'; list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.loadFailedRetry'))}</div>`;
return; return;
} }
renderChatProjectPanelList(); renderChatProjectPanelList();
@@ -1373,11 +1493,11 @@ async function applyChatProjectSelection(projectId) {
} }
window._loadedConversationProjectId = projectId; window._loadedConversationProjectId = projectId;
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification(projectId ? '已绑定项目' : '已解除项目绑定', 'success'); showNotification(projectId ? tp('projects.projectBound') : tp('projects.projectUnbound'), 'success');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert('更新项目绑定失败: ' + (e.message || e)); alert(tp('projects.updateProjectBindingFailed') + ': ' + (e.message || e));
updateChatProjectButtonLabel(); updateChatProjectButtonLabel();
return; return;
} }
@@ -1411,6 +1531,19 @@ async function onChatProjectChange() {
function initChatProjectSelector() { function initChatProjectSelector() {
if (window._chatProjectSelectorInited) return; if (window._chatProjectSelectorInited) return;
window._chatProjectSelectorInited = true; window._chatProjectSelectorInited = true;
if (!window._projectsLanguageListenerBound) {
window._projectsLanguageListenerBound = true;
document.addEventListener('languagechange', () => {
renderProjectsSidebar();
updateChatProjectButtonLabel();
const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
if (currentProjectId) {
refreshProjectHeaderStats().catch(() => {});
switchProjectTab(currentProjectTab || 'facts');
}
});
}
refreshChatProjectSelector().catch(() => {}); refreshChatProjectSelector().catch(() => {});
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const panel = document.getElementById('chat-project-panel'); const panel = document.getElementById('chat-project-panel');
@@ -1462,6 +1595,8 @@ window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
window.openVulnerabilityDetail = openVulnerabilityDetail; window.openVulnerabilityDetail = openVulnerabilityDetail;
window.filterProjectsList = filterProjectsList; window.filterProjectsList = filterProjectsList;
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts; window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
window.linkFactToExistingVulnerability = linkFactToExistingVulnerability; window.linkFactToExistingVulnerability = linkFactToExistingVulnerability;
window.createVulnerabilityFromCurrentFact = createVulnerabilityFromCurrentFact; window.createVulnerabilityFromCurrentFact = createVulnerabilityFromCurrentFact;
window.viewFactsForVulnerability = viewFactsForVulnerability; window.viewFactsForVulnerability = viewFactsForVulnerability;
+39 -1
View File
@@ -812,12 +812,44 @@ const batchQueuesState = {
totalPages: 1 totalPages: 1
}; };
async function refreshBatchProjectSelectOptions() {
const projectSelect = document.getElementById('batch-queue-project-id');
if (!projectSelect) return;
const noneLabel = _t('batchImportModal.projectNone');
projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`;
try {
const response = await apiFetch('/api/projects?status=active&limit=200');
if (!response.ok) {
throw new Error(_t('projects.loadProjectsFailed'));
}
const projects = await response.json();
const list = Array.isArray(projects) ? projects : [];
const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '';
list.forEach((project) => {
if (!project || !project.id) return;
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name || project.id;
if (activeProjectId && project.id === activeProjectId) {
option.selected = true;
}
projectSelect.appendChild(option);
});
} catch (error) {
console.warn('加载项目列表失败:', error);
}
}
// 显示新建任务模态框 // 显示新建任务模态框
async function showBatchImportModal() { async function showBatchImportModal() {
const modal = document.getElementById('batch-import-modal'); const modal = document.getElementById('batch-import-modal');
const input = document.getElementById('batch-tasks-input'); const input = document.getElementById('batch-tasks-input');
const titleInput = document.getElementById('batch-queue-title'); const titleInput = document.getElementById('batch-queue-title');
const roleSelect = document.getElementById('batch-queue-role'); const roleSelect = document.getElementById('batch-queue-role');
const projectSelect = document.getElementById('batch-queue-project-id');
const agentModeSelect = document.getElementById('batch-queue-agent-mode'); const agentModeSelect = document.getElementById('batch-queue-agent-mode');
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode'); const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
const cronExprInput = document.getElementById('batch-queue-cron-expr'); const cronExprInput = document.getElementById('batch-queue-cron-expr');
@@ -831,6 +863,9 @@ async function showBatchImportModal() {
if (roleSelect) { if (roleSelect) {
roleSelect.value = ''; roleSelect.value = '';
} }
if (projectSelect) {
projectSelect.value = '';
}
if (agentModeSelect) { if (agentModeSelect) {
agentModeSelect.value = 'single'; agentModeSelect.value = 'single';
} }
@@ -872,6 +907,7 @@ async function showBatchImportModal() {
console.error('加载角色列表失败:', error); console.error('加载角色列表失败:', error);
} }
} }
await refreshBatchProjectSelectOptions();
modal.style.display = 'block'; modal.style.display = 'block';
input.focus(); input.focus();
@@ -935,6 +971,7 @@ async function createBatchQueue() {
const input = document.getElementById('batch-tasks-input'); const input = document.getElementById('batch-tasks-input');
const titleInput = document.getElementById('batch-queue-title'); const titleInput = document.getElementById('batch-queue-title');
const roleSelect = document.getElementById('batch-queue-role'); const roleSelect = document.getElementById('batch-queue-role');
const projectSelect = document.getElementById('batch-queue-project-id');
const agentModeSelect = document.getElementById('batch-queue-agent-mode'); const agentModeSelect = document.getElementById('batch-queue-agent-mode');
const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode'); const scheduleModeSelect = document.getElementById('batch-queue-schedule-mode');
const cronExprInput = document.getElementById('batch-queue-cron-expr'); const cronExprInput = document.getElementById('batch-queue-cron-expr');
@@ -959,6 +996,7 @@ async function createBatchQueue() {
// 获取角色(可选,空字符串表示默认角色) // 获取角色(可选,空字符串表示默认角色)
const role = roleSelect ? roleSelect.value || '' : ''; const role = roleSelect ? roleSelect.value || '' : '';
const projectId = projectSelect ? (projectSelect.value || '').trim() : '';
const rawMode = agentModeSelect ? agentModeSelect.value : 'single'; const rawMode = agentModeSelect ? agentModeSelect.value : 'single';
const agentMode = isBatchQueueAgentMode(rawMode) ? rawMode : 'single'; const agentMode = isBatchQueueAgentMode(rawMode) ? rawMode : 'single';
const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual'; const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual';
@@ -987,7 +1025,7 @@ async function createBatchQueue() {
scheduleMode, scheduleMode,
cronExpr, cronExpr,
executeNow, executeNow,
projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '', projectId,
}), }),
}); });
+184 -47
View File
@@ -34,6 +34,7 @@ let webshellDbConfigByConn = {};
let webshellDirTreeByConn = {}; let webshellDirTreeByConn = {};
let webshellDirExpandedByConn = {}; let webshellDirExpandedByConn = {};
let webshellDirLoadedByConn = {}; let webshellDirLoadedByConn = {};
let webshellSelectedFileByConn = {};
// 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字
let webshellStreamingTypingId = 0; let webshellStreamingTypingId = 0;
let webshellProbeStatusById = {}; let webshellProbeStatusById = {};
@@ -70,6 +71,23 @@ function webshellConnOS(conn) {
return normalizeWebshellOS(conn && conn.os); return normalizeWebshellOS(conn && conn.os);
} }
/** 生成一次性探活 token,避免固定回显值被包装时误判 */
function buildWebshellProbeToken() {
return '__CSAI_PROBE_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36) + '__';
}
/** 构造跨 Windows/Linux 都可执行的探活命令 */
function buildWebshellProbeCommand(token) {
return 'echo ' + token;
}
/** 探活成功判定:HTTP 成功且输出中包含本次 token */
function isWebshellProbeOutputMatched(output, token) {
if (!token) return false;
var text = (output == null) ? '' : String(output);
return text.indexOf(token) !== -1;
}
/** /**
* 组装 /api/webshell/file 的公共请求体。 * 组装 /api/webshell/file 的公共请求体。
* 所有文件管理调用点都应走此函数,避免遗漏字段(如 connection_id)。 * 所有文件管理调用点都应走此函数,避免遗漏字段(如 connection_id)。
@@ -816,6 +834,7 @@ function probeWebshellConnection(conn) {
if (!conn || typeof apiFetch === 'undefined') { if (!conn || typeof apiFetch === 'undefined') {
return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' }); return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' });
} }
var probeToken = buildWebshellProbeToken();
return apiFetch('/api/webshell/exec', { return apiFetch('/api/webshell/exec', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -827,13 +846,13 @@ function probeWebshellConnection(conn) {
cmd_param: conn.cmdParam || '', cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn), encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn), os: webshellConnOS(conn),
command: 'echo 1' command: buildWebshellProbeCommand(probeToken)
}) })
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
var output = (data && data.output != null) ? String(data.output).trim() : ''; var output = (data && data.output != null) ? String(data.output) : '';
var ok = !!(data && data.ok && output === '1'); var ok = !!(data && data.ok && isWebshellProbeOutputMatched(output, probeToken));
if (ok) return { ok: true, message: wsT('webshell.testSuccess') || '连通性正常,Shell 可访问' }; if (ok) return { ok: true, message: wsT('webshell.testSuccess') || '连通性正常,Shell 可访问' };
var msg = (data && data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败'); var msg = (data && data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败');
return { ok: false, message: msg }; return { ok: false, message: msg };
@@ -931,11 +950,61 @@ function normalizeWebshellPath(path) {
var p = path == null ? '.' : String(path).trim(); var p = path == null ? '.' : String(path).trim();
if (!p || p === '/') return '.'; if (!p || p === '/') return '.';
p = p.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/'); p = p.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
// Windows 盘符根目录保持为 "C:/",避免被裁成 "C:" 后父级计算异常
if (/^[A-Za-z]:\/?$/.test(p)) {
return p.slice(0, 2) + '/';
}
if (!p || p === '.') return '.'; if (!p || p === '.') return '.';
if (p.endsWith('/')) p = p.slice(0, -1); if (p.endsWith('/')) p = p.slice(0, -1);
return p || '.'; return p || '.';
} }
function getWebshellSelectedFile(conn) {
if (!conn || !conn.id) return '';
var p = webshellSelectedFileByConn[conn.id];
if (!p) return '';
return normalizeWebshellPath(p);
}
function setWebshellSelectedFile(conn, path) {
if (!conn || !conn.id) return;
if (!path) {
delete webshellSelectedFileByConn[conn.id];
return;
}
webshellSelectedFileByConn[conn.id] = normalizeWebshellPath(path);
}
function getWebshellParentPath(path) {
var p = normalizeWebshellPath(path);
// Windows 盘符根目录不可再上探
if (/^[A-Za-z]:\/$/.test(p)) return p;
// 允许从当前目录持续上探:. -> .. -> ../.. -> ../../..
if (p === '.') return '..';
if (/^(?:\.\.\/)*\.\.$/.test(p)) return p + '/..';
// 已经是相对上探时,先维持链路;后续 list 成功后会用远端真实路径回填
var idx = p.lastIndexOf('/');
if (idx < 0) return '.';
var parent = p.slice(0, idx) || '.';
if (/^[A-Za-z]:$/.test(parent)) return parent + '/';
return parent;
}
function inferPathFromWindowsDirOutput(rawOutput) {
var text = String(rawOutput || '').replace(/\r/g, '');
var lines = text.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = String(lines[i] || '').trim();
// 中文: C:\xxx 的目录
var zh = line.match(/^([A-Za-z]:\\.*)\s+的目录$/);
if (zh && zh[1]) return normalizeWebshellPath(zh[1]);
// 英文: Directory of C:\xxx
var en = line.match(/^Directory of\s+([A-Za-z]:\\.*)$/i);
if (en && en[1]) return normalizeWebshellPath(en[1]);
}
return '';
}
function getWebshellTerminalSessionKey(connId, sessionId) { function getWebshellTerminalSessionKey(connId, sessionId) {
if (!connId || !sessionId) return ''; if (!connId || !sessionId) return '';
return String(connId) + '::' + String(sessionId); return String(connId) + '::' + String(sessionId);
@@ -2047,11 +2116,7 @@ function selectWebshell(id, stateReady) {
}); });
document.getElementById('webshell-parent-dir').addEventListener('click', function () { document.getElementById('webshell-parent-dir').addEventListener('click', function () {
const p = (pathInput && pathInput.value.trim()) || '.'; const p = (pathInput && pathInput.value.trim()) || '.';
if (p === '.' || p === '/') { pathInput.value = getWebshellParentPath(p);
pathInput.value = '..';
} else {
pathInput.value = p.replace(/\/[^/]+$/, '') || '.';
}
webshellFileListDir(webshellCurrentConn, pathInput.value || '.'); webshellFileListDir(webshellCurrentConn, pathInput.value || '.');
}); });
@@ -3578,9 +3643,14 @@ function webshellFileListDir(conn, path) {
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>'; listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
return; return;
} }
listEl.dataset.currentPath = path; var normalizedPath = normalizeWebshellPath(path);
var inferredPath = inferPathFromWindowsDirOutput(data.output || '');
var displayPath = inferredPath || normalizedPath;
listEl.dataset.currentPath = displayPath;
listEl.dataset.rawOutput = data.output || ''; listEl.dataset.rawOutput = data.output || '';
renderFileList(listEl, path, data.output || '', conn); var pathInput = document.getElementById('webshell-file-path');
if (pathInput) pathInput.value = displayPath;
renderFileList(listEl, displayPath, data.output || '', conn);
}) })
.catch(function (err) { .catch(function (err) {
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '</div>'; listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '</div>';
@@ -3619,6 +3689,27 @@ function modeToType(mode) {
return c; return c;
} }
function parseWindowsDirEntry(line) {
var m = String(line || '').match(/^(\d{4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2})(?:\s*(AM|PM))?\s+(<[^>]+>|[\d,]+)\s+(.+?)\s*$/i);
if (!m) return null;
var kind = (m[4] || '').trim();
var name = (m[5] || '').trim();
if (!name || name === '.' || name === '..') return null;
var isDir = /^<(dir|junction|symlinkd)>$/i.test(kind);
var size = isDir ? '' : kind.replace(/,/g, '');
var mtime = (m[1] + ' ' + m[2] + (m[3] ? (' ' + m[3].toUpperCase()) : '')).trim();
return {
name: name,
isDir: isDir,
size: size,
mtime: mtime,
mode: isDir ? 'd' : '-',
owner: '',
group: '',
type: isDir ? 'dir' : 'file'
};
}
function parseWebshellListItems(rawOutput) { function parseWebshellListItems(rawOutput) {
var lines = (rawOutput || '').split(/\n/).filter(function (l) { return l.trim(); }); var lines = (rawOutput || '').split(/\n/).filter(function (l) { return l.trim(); });
var items = []; var items = [];
@@ -3627,6 +3718,12 @@ function parseWebshellListItems(rawOutput) {
var trimmedLine = String(line || '').trim(); var trimmedLine = String(line || '').trim();
// `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。 // `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。
if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue; if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue;
// `dir` 头尾信息(中英文)与 shell 提示符,不是目录项。
if (/^(驱动器|卷的序列号是|volume in drive|volume serial number is|directory of)/i.test(trimmedLine)) continue;
if (/^[A-Za-z]:\\.*\s+的目录$/i.test(trimmedLine)) continue;
if (/^\d+\s+(个文件|file\(s\))\s+[\d,]+\s+(字节|bytes?)$/i.test(trimmedLine)) continue;
if (/^\d+\s+(个目录|dir\(s\))\s+[\d,]+\s+(可用字节|bytes free)$/i.test(trimmedLine)) continue;
if (/^[^>\n]*>\s*dir(?:\s|$)/i.test(trimmedLine)) continue;
var name = ''; var name = '';
var isDir = false; var isDir = false;
var size = ''; var size = '';
@@ -3646,16 +3743,38 @@ function parseWebshellListItems(rawOutput) {
isDir = mode && mode.startsWith('d'); isDir = mode && mode.startsWith('d');
type = modeToType(mode); type = modeToType(mode);
} else { } else {
var mName = line.match(/\s*(\S+)\s*$/); var winItem = parseWindowsDirEntry(line);
name = mName ? mName[1].trim() : line.trim(); if (winItem) {
if (name === '.' || name === '..') continue; items.push({
isDir = line.startsWith('d') || line.toLowerCase().indexOf('<dir>') !== -1; name: winItem.name,
if (line.startsWith('-') || line.startsWith('d')) { isDir: winItem.isDir,
var parts = line.split(/\s+/); line: line,
size: winItem.size,
mode: winItem.mode,
mtime: winItem.mtime,
owner: winItem.owner,
group: winItem.group,
type: winItem.type
});
continue;
}
// 仅兜底解析 Unix 权限格式,避免把 `dir` 统计行误识别为文件。
if (/^[-dlcbsp]/.test(line)) {
var parts = line.trim().split(/\s+/);
if (parts.length >= 9) {
name = parts.slice(8).join(' ').trim();
} else {
name = parts.length ? parts[parts.length - 1].trim() : line.trim();
}
if (name === '.' || name === '..') continue;
isDir = line.startsWith('d');
parts = line.split(/\s+/);
if (parts.length >= 5) { mode = parts[0]; size = parts[4]; } if (parts.length >= 5) { mode = parts[0]; size = parts[4]; }
if (parts.length >= 4) { owner = parts[2] || ''; group = parts[3] || ''; } if (parts.length >= 4) { owner = parts[2] || ''; group = parts[3] || ''; }
if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) mtime = normalizeLsMtime(parts[5], parts[6], parts[7]); if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) mtime = normalizeLsMtime(parts[5], parts[6], parts[7]);
type = modeToType(mode); type = modeToType(mode);
} else {
continue;
} }
} }
if (name === '.' || name === '..') continue; if (name === '.' || name === '..') continue;
@@ -3680,7 +3799,9 @@ function fetchWebshellDirectoryItems(conn, path) {
} }
function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
currentPath = normalizeWebshellPath(currentPath);
var items = parseWebshellListItems(rawOutput); var items = parseWebshellListItems(rawOutput);
var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn);
if (nameFilter && nameFilter.trim()) { if (nameFilter && nameFilter.trim()) {
var f = nameFilter.trim().toLowerCase(); var f = nameFilter.trim().toLowerCase();
items = items.filter(function (item) { return item.name.toLowerCase().indexOf(f) !== -1; }); items = items.filter(function (item) { return item.name.toLowerCase().indexOf(f) !== -1; });
@@ -3713,10 +3834,11 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
} }
items.forEach(function (item) { items.forEach(function (item) {
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name; var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
var pathNextNorm = normalizeWebshellPath(pathNext);
var nameClass = item.isDir ? 'is-dir' : 'is-file'; var nameClass = item.isDir ? 'is-dir' : 'is-file';
html += '<tr><td class="webshell-col-check">'; html += '<tr class="' + (!item.isDir && selectedPath === pathNextNorm ? 'webshell-file-row-selected' : '') + '"><td class="webshell-col-check">';
if (!item.isDir) html += '<input type="checkbox" class="webshell-file-cb" data-path="' + escapeHtml(pathNext) + '" />'; if (!item.isDir) html += '<input type="checkbox" class="webshell-file-cb" data-path="' + escapeHtml(pathNext) + '" />';
html += '</td><td><a href="#" class="webshell-file-link ' + nameClass + '" data-path="' + escapeHtml(pathNext) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '</a></td>'; html += '</td><td class="webshell-col-name"><a href="#" class="webshell-file-link ' + nameClass + '" title="' + escapeHtml(item.name) + '" data-path="' + escapeHtml(pathNext) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '</a></td>';
html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>'; html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>';
html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>'; html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>';
html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>'; html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>';
@@ -3748,10 +3870,13 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
const isDir = a.getAttribute('data-isdir') === '1'; const isDir = a.getAttribute('data-isdir') === '1';
const pathInput = document.getElementById('webshell-file-path'); const pathInput = document.getElementById('webshell-file-path');
if (isDir) { if (isDir) {
setWebshellSelectedFile(webshellCurrentConn, '');
if (pathInput) pathInput.value = path; if (pathInput) pathInput.value = path;
webshellFileListDir(webshellCurrentConn, path); webshellFileListDir(webshellCurrentConn, path);
} else { } else {
// 打开文件时保留当前“浏览目录”上下文,避免返回时落到单文件视图 // 打开文件时保留当前“浏览目录”上下文,避免返回时落到单文件视图
setWebshellSelectedFile(webshellCurrentConn, path);
renderDirectoryTree(currentPath, items, conn || webshellCurrentConn);
webshellFileRead(webshellCurrentConn, path, listEl, currentPath); webshellFileRead(webshellCurrentConn, path, listEl, currentPath);
} }
}); });
@@ -3759,7 +3884,10 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) { listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl, currentPath); var filePath = btn.getAttribute('data-path');
setWebshellSelectedFile(webshellCurrentConn, filePath);
renderDirectoryTree(currentPath, items, conn || webshellCurrentConn);
webshellFileRead(webshellCurrentConn, filePath, listEl, currentPath);
}); });
}); });
listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) { listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) {
@@ -3821,6 +3949,7 @@ function renderDirectoryTree(currentPath, items, conn) {
var tree = state.tree; var tree = state.tree;
var expanded = state.expanded; var expanded = state.expanded;
var loaded = state.loaded; var loaded = state.loaded;
var selectedPath = getWebshellSelectedFile(conn || webshellCurrentConn);
if (!tree['.']) tree['.'] = []; if (!tree['.']) tree['.'] = [];
if (expanded['.'] !== false) expanded['.'] = true; if (expanded['.'] !== false) expanded['.'] = true;
@@ -3844,26 +3973,29 @@ function renderDirectoryTree(currentPath, items, conn) {
if (node.isDir && !tree[node.path]) tree[node.path] = []; if (node.isDir && !tree[node.path]) tree[node.path] = [];
}); });
// 确保当前路径祖先链存在并展开 // 仅对“真实路径”补祖先链;相对上探链(../..)不构建,避免出现假层级。
var isRelativeUpChain = /^(?:\.\.\/)*\.\.$/.test(curr);
var parts = curr === '.' ? [] : curr.split('/'); var parts = curr === '.' ? [] : curr.split('/');
var parentPath = '.'; var parentPath = '.';
for (var i = 0; i < parts.length; i++) { if (!isRelativeUpChain) {
var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i]; for (var i = 0; i < parts.length; i++) {
if (!tree[parentPath]) tree[parentPath] = []; var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i];
var parentChildren = tree[parentPath]; if (!tree[parentPath]) tree[parentPath] = [];
var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; }); var parentChildren = tree[parentPath];
if (!hasAncestorNode) { var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; });
parentChildren.push({ path: nextPath, name: parts[i], isDir: true }); if (!hasAncestorNode) {
parentChildren.sort(function (a, b) { parentChildren.push({ path: nextPath, name: parts[i], isDir: true });
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1; parentChildren.sort(function (a, b) {
return (a.name || '').localeCompare(b.name || ''); if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
}); return (a.name || '').localeCompare(b.name || '');
});
}
if (!tree[nextPath]) tree[nextPath] = [];
expanded[parentPath] = true;
parentPath = nextPath;
} }
if (!tree[nextPath]) tree[nextPath] = [];
expanded[parentPath] = true;
parentPath = nextPath;
} }
expanded[curr] = true; if (expanded[curr] == null) expanded[curr] = true;
function renderNode(node, depth) { function renderNode(node, depth) {
var path = node.path; var path = node.path;
@@ -3872,15 +4004,16 @@ function renderDirectoryTree(currentPath, items, conn) {
var hasLoadedChildren = isDir ? (loaded[path] === true) : true; var hasLoadedChildren = isDir ? (loaded[path] === true) : true;
var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0); var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0);
var hasChildren = children.length > 0; var hasChildren = children.length > 0;
var isExpanded = isDir ? (expanded[path] !== false) : false; var isExpanded = isDir ? (expanded[path] === true) : false;
var isActive = path === curr; var isActive = path === curr;
var isSelectedFile = !isDir && path === selectedPath;
var name = node.name; var name = node.name;
var icon = isDir ? (path === '.' ? '🗂' : '📁') : '📄'; var icon = isDir ? (path === '.' ? '🗂' : '📁') : '📄';
var nodeHtml = var nodeHtml =
'<div class="webshell-tree-node" data-depth="' + depth + '">' + '<div class="webshell-tree-node" data-depth="' + depth + '">' +
'<div class="webshell-tree-row' + (isActive ? ' active' : '') + '">' + '<div class="webshell-tree-row' + (isActive ? ' active' : '') + (isSelectedFile ? ' selected-file' : '') + '">' +
'<button type="button" class="webshell-tree-toggle' + (canExpand ? '' : ' empty') + '" data-path="' + escapeHtml(path) + '">' + (canExpand ? (isExpanded ? '▾' : '▸') : '·') + '</button>' + '<button type="button" class="webshell-tree-toggle' + (canExpand ? '' : ' empty') + '" data-path="' + escapeHtml(path) + '">' + (canExpand ? (isExpanded ? '▾' : '▸') : '·') + '</button>' +
'<button type="button" class="webshell-dir-item' + (isDir ? ' is-dir' : ' is-file') + '" data-path="' + escapeHtml(path) + '" data-isdir="' + (isDir ? '1' : '0') + '"><span class="webshell-tree-icon">' + icon + '</span><span class="webshell-tree-name">' + escapeHtml(name) + '</span></button>' + '<button type="button" class="webshell-dir-item' + (isDir ? ' is-dir' : ' is-file') + '" title="' + escapeHtml(name) + '" data-path="' + escapeHtml(path) + '" data-isdir="' + (isDir ? '1' : '0') + '"><span class="webshell-tree-icon">' + icon + '</span><span class="webshell-tree-name">' + escapeHtml(name) + '</span></button>' +
'</div>'; '</div>';
if (isDir && hasChildren && isExpanded) { if (isDir && hasChildren && isExpanded) {
nodeHtml += '<div class="webshell-tree-children">'; nodeHtml += '<div class="webshell-tree-children">';
@@ -3899,7 +4032,7 @@ function renderDirectoryTree(currentPath, items, conn) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.'); var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.');
if (expanded[p] !== false) { if (expanded[p] === true) {
expanded[p] = false; expanded[p] = false;
renderDirectoryTree(curr, items, conn || webshellCurrentConn); renderDirectoryTree(curr, items, conn || webshellCurrentConn);
return; return;
@@ -3939,12 +4072,15 @@ function renderDirectoryTree(currentPath, items, conn) {
var isDir = btn.getAttribute('data-isdir') === '1'; var isDir = btn.getAttribute('data-isdir') === '1';
var pathInput = document.getElementById('webshell-file-path'); var pathInput = document.getElementById('webshell-file-path');
if (isDir) { if (isDir) {
setWebshellSelectedFile(webshellCurrentConn, '');
if (pathInput) pathInput.value = p; if (pathInput) pathInput.value = p;
webshellFileListDir(webshellCurrentConn, p); webshellFileListDir(webshellCurrentConn, p);
return; return;
} }
var listEl = document.getElementById('webshell-file-list'); var listEl = document.getElementById('webshell-file-list');
var browsePath = p.replace(/\/[^/]+$/, '') || '.'; var browsePath = p.replace(/\/[^/]+$/, '') || '.';
setWebshellSelectedFile(webshellCurrentConn, p);
renderDirectoryTree(curr, items, conn || webshellCurrentConn);
if (listEl) webshellFileRead(webshellCurrentConn, p, listEl, browsePath); if (listEl) webshellFileRead(webshellCurrentConn, p, listEl, browsePath);
}); });
}); });
@@ -4101,7 +4237,7 @@ function webshellFileRead(conn, path, listEl, browsePath) {
// 兜底:若路径被污染成文件路径,回退到父目录 // 兜底:若路径被污染成文件路径,回退到父目录
backPath = path.replace(/\/[^/]+$/, '') || '.'; backPath = path.replace(/\/[^/]+$/, '') || '.';
} }
listEl.innerHTML = '<div class="webshell-file-content"><pre>' + escapeHtml(out) + '</pre><button type="button" class="btn-ghost" id="webshell-file-back-btn" data-back-path="' + escapeHtml(backPath) + '">' + wsT('webshell.back') + '</button></div>'; listEl.innerHTML = '<div class="webshell-file-content"><div class="webshell-file-content-path">' + escapeHtml(path) + '</div><pre>' + escapeHtml(out) + '</pre><button type="button" class="btn-ghost" id="webshell-file-back-btn" data-back-path="' + escapeHtml(backPath) + '">' + wsT('webshell.back') + '</button></div>';
var backBtn = document.getElementById('webshell-file-back-btn'); var backBtn = document.getElementById('webshell-file-back-btn');
if (backBtn) { if (backBtn) {
backBtn.addEventListener('click', function () { backBtn.addEventListener('click', function () {
@@ -4467,7 +4603,7 @@ document.addEventListener('conversation-deleted', function (e) {
} }
}); });
// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1 // 测试连通性(不保存,仅用当前表单参数请求 Shell 执行一次性探活命令
function testWebshellConnection() { function testWebshellConnection() {
var url = (document.getElementById('webshell-url') || {}).value; var url = (document.getElementById('webshell-url') || {}).value;
if (url && typeof url.trim === 'function') url = url.trim(); if (url && typeof url.trim === 'function') url = url.trim();
@@ -4484,13 +4620,14 @@ function testWebshellConnection() {
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value); var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value); var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var btn = document.getElementById('webshell-test-btn'); var btn = document.getElementById('webshell-test-btn');
var probeToken = buildWebshellProbeToken();
if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; } if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; }
if (typeof apiFetch === 'undefined') { if (typeof apiFetch === 'undefined') {
if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); }
alert(wsT('webshell.testFailed') || '连通性测试失败'); alert(wsT('webshell.testFailed') || '连通性测试失败');
return; return;
} }
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价) // 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo token 在 cmd 和 sh 下行为等价)
apiFetch('/api/webshell/exec', { apiFetch('/api/webshell/exec', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -4502,7 +4639,7 @@ function testWebshellConnection() {
cmd_param: cmdParam || '', cmd_param: cmdParam || '',
encoding: encoding, encoding: encoding,
os: osTag, os: osTag,
command: 'echo 1' command: buildWebshellProbeCommand(probeToken)
}) })
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -4512,14 +4649,14 @@ function testWebshellConnection() {
alert(wsT('webshell.testFailed') || '连通性测试失败'); alert(wsT('webshell.testFailed') || '连通性测试失败');
return; return;
} }
// 仅 HTTP 200 不算通过,需校验是否真的执行了 echo 1(响应体 trim 后应为 "1" // 仅 HTTP 200 不算通过,需校验响应中是否包含本次一次性探活 token
var output = (data.output != null) ? String(data.output).trim() : ''; var output = (data.output != null) ? String(data.output) : '';
var reallyOk = data.ok && output === '1'; var reallyOk = data.ok && isWebshellProbeOutputMatched(output, probeToken);
if (reallyOk) { if (reallyOk) {
alert(wsT('webshell.testSuccess') || '连通性正常,Shell 可访问'); alert(wsT('webshell.testSuccess') || '连通性正常,Shell 可访问');
} else { } else {
var msg; var msg;
if (data.ok && output !== '1') if (data.ok && !isWebshellProbeOutputMatched(output, probeToken))
msg = wsT('webshell.testNoExpectedOutput') || 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名'; msg = wsT('webshell.testNoExpectedOutput') || 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名';
else else
msg = (data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败'); msg = (data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败');
+181 -103
View File
@@ -14,7 +14,13 @@
<div id="login-overlay" class="login-overlay" style="display: none;"> <div id="login-overlay" class="login-overlay" style="display: none;">
<div class="login-card"> <div class="login-card">
<div class="login-brand"> <div class="login-brand">
<h2 data-i18n="login.title">登录 CyberStrikeAI</h2> <img src="/static/logo.png" alt="" class="login-brand-logo" width="56" height="56">
<h2 class="login-title">
<span data-i18n="login.titlePrefix">登录</span>
<span class="brand-wordmark brand-wordmark--sm" aria-label="CyberStrikeAI">
<span class="brand-wordmark__core">CyberStrike</span><span class="brand-wordmark__ai">AI</span>
</span>
</h2>
<p class="login-subtitle" data-i18n="login.subtitle">请输入配置中的访问密码</p> <p class="login-subtitle" data-i18n="login.subtitle">请输入配置中的访问密码</p>
</div> </div>
<form id="login-form" class="login-form"> <form id="login-form" class="login-form">
@@ -34,8 +40,10 @@
<header> <header>
<div class="header-content"> <div class="header-content">
<div class="logo header-logo-link" onclick="switchPage('dashboard')" role="button" data-i18n="header.backToDashboard" data-i18n-attr="title" data-i18n-skip-text="true" title="返回仪表盘"> <div class="logo header-logo-link" onclick="switchPage('dashboard')" role="button" data-i18n="header.backToDashboard" data-i18n-attr="title" data-i18n-skip-text="true" title="返回仪表盘">
<img src="/static/logo.png" alt="CyberStrikeAI Logo" style="width: 32px; height: 32px; margin-right: 8px;"> <img src="/static/logo.png" alt="CyberStrikeAI Logo" class="brand-logo" width="36" height="36">
<h1>CyberStrikeAI</h1> <h1 class="brand-wordmark brand-wordmark--lg">
<span class="brand-wordmark__core">CyberStrike</span><span class="brand-wordmark__ai">AI</span>
</h1>
<span class="version-badge" data-i18n="header.version" data-i18n-attr="title" data-i18n-skip-text="true" title="当前版本">{{.Version}}</span> <span class="version-badge" data-i18n="header.version" data-i18n-attr="title" data-i18n-skip-text="true" title="当前版本">{{.Version}}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
@@ -162,13 +170,13 @@
</div> </div>
</div> </div>
<div class="nav-item" data-page="projects"> <div class="nav-item" data-page="projects">
<div class="nav-item-content" data-title="项目管理" onclick="switchPage('projects')"> <div class="nav-item-content" data-title="项目管理" onclick="switchPage('projects')" data-i18n="nav.projects" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon> <polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline> <polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline> <polyline points="2 12 12 17 22 12"></polyline>
</svg> </svg>
<span>项目管理</span> <span data-i18n="nav.projects">项目管理</span>
</div> </div>
</div> </div>
<div class="nav-item" data-page="vulnerabilities"> <div class="nav-item" data-page="vulnerabilities">
@@ -956,16 +964,16 @@
<div class="chat-input-primary-row"> <div class="chat-input-primary-row">
<div class="chat-input-leading"> <div class="chat-input-leading">
<div class="role-selector-wrapper project-selector-wrapper"> <div class="role-selector-wrapper project-selector-wrapper">
<button type="button" id="chat-project-btn" class="role-selector-btn" onclick="toggleChatProjectPanel()" aria-label="选择项目" aria-haspopup="listbox" aria-expanded="false" title="绑定项目后共享事实黑板(跨对话)"> <button type="button" id="chat-project-btn" class="role-selector-btn" onclick="toggleChatProjectPanel()" aria-label="选择项目" aria-haspopup="listbox" aria-expanded="false" title="绑定项目后共享事实黑板(跨对话)" data-i18n="projects.chatSelectorButton" data-i18n-attr="aria-label,title">
<span class="role-selector-icon" aria-hidden="true">📁</span> <span class="role-selector-icon" aria-hidden="true">📁</span>
<span id="chat-project-text" class="role-selector-text">无项目</span> <span id="chat-project-text" class="role-selector-text" data-i18n="projects.noProject">无项目</span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> </button>
<div id="chat-project-panel" class="role-selection-panel chat-project-panel" style="display: none;" role="listbox" aria-labelledby="chat-project-panel-title"> <div id="chat-project-panel" class="role-selection-panel chat-project-panel" style="display: none;" role="listbox" aria-labelledby="chat-project-panel-title">
<div class="role-selection-panel-header"> <div class="role-selection-panel-header">
<h3 id="chat-project-panel-title" class="role-selection-panel-title">选择项目</h3> <h3 id="chat-project-panel-title" class="role-selection-panel-title" data-i18n="projects.selectProject">选择项目</h3>
<button type="button" class="role-selection-panel-close" onclick="closeChatProjectPanel()" title="关闭" aria-label="关闭"> <button type="button" class="role-selection-panel-close" onclick="closeChatProjectPanel()" title="关闭" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -977,7 +985,7 @@
<div class="chat-project-panel-footer"> <div class="chat-project-panel-footer">
<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()"> <button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()">
<span class="chat-project-panel-create-icon" aria-hidden="true">+</span> <span class="chat-project-panel-create-icon" aria-hidden="true">+</span>
<span class="chat-project-panel-create-label">新建项目</span> <span class="chat-project-panel-create-label" data-i18n="projects.newProject">新建项目</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1426,36 +1434,36 @@
<!-- 项目管理页面 --> <!-- 项目管理页面 -->
<div id="page-projects" class="page projects-page"> <div id="page-projects" class="page projects-page">
<div class="page-header"> <div class="page-header">
<h2>项目管理</h2> <h2 data-i18n="projects.title">项目管理</h2>
<div class="page-header-actions"> <div class="page-header-actions">
<label class="projects-show-archived-label"><input type="checkbox" id="projects-show-archived" onchange="loadProjectsList()"> 显示已归档</label> <label class="projects-show-archived-label"><input type="checkbox" id="projects-show-archived" onchange="loadProjectsList()"> <span data-i18n="projects.showArchived">显示已归档</span></label>
<button class="btn-secondary" type="button" onclick="loadProjectsList()">刷新</button> <button class="btn-secondary" type="button" onclick="loadProjectsList()" data-i18n="common.refresh">刷新</button>
<button class="btn-primary" type="button" onclick="showNewProjectModal()">+ 新建项目</button> <button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.newProjectCta">+ 新建项目</button>
</div> </div>
</div> </div>
<div class="page-content projects-page-layout"> <div class="page-content projects-page-layout">
<aside class="projects-sidebar-card"> <aside class="projects-sidebar-card">
<div class="projects-sidebar-head"> <div class="projects-sidebar-head">
<span class="projects-sidebar-title">项目列表</span> <span class="projects-sidebar-title" data-i18n="projects.projectList">项目列表</span>
<span class="projects-sidebar-count" id="projects-list-count">0</span> <span class="projects-sidebar-count" id="projects-list-count">0</span>
</div> </div>
<div class="projects-sidebar-search"> <div class="projects-sidebar-search">
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off"> <input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder">
</div> </div>
<div id="projects-list" class="projects-list"></div> <div id="projects-list" class="projects-list"></div>
</aside> </aside>
<main class="projects-detail" id="projects-detail-main"> <main class="projects-detail" id="projects-detail-main">
<div class="projects-detail-placeholder" id="projects-detail-placeholder"> <div class="projects-detail-placeholder" id="projects-detail-placeholder">
<h3>选择或创建项目</h3> <h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3>
<p>项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p> <p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
<button class="btn-primary" type="button" onclick="showNewProjectModal()">创建第一个项目</button> <button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
</div> </div>
<div class="projects-detail-inner" id="projects-detail-inner" hidden> <div class="projects-detail-inner" id="projects-detail-inner" hidden>
<header class="projects-detail-header"> <header class="projects-detail-header">
<div class="projects-detail-header-main"> <div class="projects-detail-header-main">
<div class="projects-detail-title-row"> <div class="projects-detail-title-row">
<h3 id="projects-detail-title" class="projects-detail-title">项目</h3> <h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active">进行中</span> <span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
</div> </div>
<p id="projects-detail-meta" class="projects-detail-meta"></p> <p id="projects-detail-meta" class="projects-detail-meta"></p>
<p id="projects-detail-desc" class="projects-detail-desc"></p> <p id="projects-detail-desc" class="projects-detail-desc"></p>
@@ -1467,15 +1475,15 @@
</div> </div>
</div> </div>
<div class="projects-detail-header-actions"> <div class="projects-detail-header-actions">
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()">漏洞管理</button> <button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()">+ 添加事实</button> <button type="button" class="btn-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
</div> </div>
</header> </header>
<nav class="projects-tabs" role="tablist"> <nav class="projects-tabs" role="tablist">
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')">事实黑板</button> <button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button>
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')">关联对话</button> <button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button>
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')">关联漏洞</button> <button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button>
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')">设置</button> <button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button>
</nav> </nav>
<div id="project-panel-facts" class="projects-panel" role="tabpanel"> <div id="project-panel-facts" class="projects-panel" role="tabpanel">
<div class="projects-fact-toolbar"> <div class="projects-fact-toolbar">
@@ -1484,21 +1492,21 @@
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/> <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg> </svg>
<span>索引仅含 <strong>key</strong><strong>摘要</strong>(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 <strong>body</strong>Agent 通过 <code>get_project_fact</code> 复现</span> <span data-i18n="projects.factToolbarHint">索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 bodyAgent 通过 get_project_fact 复现</span>
</p> </p>
<div class="projects-fact-toolbar-filters" role="search"> <div class="projects-fact-toolbar-filters" role="search">
<label class="projects-fact-filter-field projects-fact-filter-field--search"> <label class="projects-fact-filter-field projects-fact-filter-field--search">
<span class="sr-only">搜索事实</span> <span class="sr-only" data-i18n="projects.searchFactsSr">搜索事实</span>
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"> <svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/> <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg> </svg>
<input type="search" id="project-facts-search" placeholder="搜索 key、摘要、body…" oninput="debouncedLoadProjectFacts()" autocomplete="off"> <input type="search" id="project-facts-search" placeholder="搜索 key、摘要、body…" oninput="debouncedLoadProjectFacts()" autocomplete="off" data-i18n="projects.searchFactsPlaceholder" data-i18n-attr="placeholder">
</label> </label>
<label class="projects-fact-filter-field"> <label class="projects-fact-filter-field">
<span class="projects-fact-filter-label">分类</span> <span class="projects-fact-filter-label" data-i18n="projects.category">分类</span>
<select id="project-facts-filter-category" onchange="loadProjectFacts()"> <select id="project-facts-filter-category" onchange="loadProjectFacts()">
<option value="">全部</option> <option value="" data-i18n="projects.all">全部</option>
<option value="target">target</option> <option value="target">target</option>
<option value="auth">auth</option> <option value="auth">auth</option>
<option value="infra">infra</option> <option value="infra">infra</option>
@@ -1511,52 +1519,97 @@
</select> </select>
</label> </label>
<label class="projects-fact-filter-field"> <label class="projects-fact-filter-field">
<span class="projects-fact-filter-label">置信度</span> <span class="projects-fact-filter-label" data-i18n="projects.confidence">置信度</span>
<select id="project-facts-filter-confidence" onchange="loadProjectFacts()"> <select id="project-facts-filter-confidence" onchange="loadProjectFacts()">
<option value="">全部</option> <option value="" data-i18n="projects.all">全部</option>
<option value="confirmed">已确认</option> <option value="confirmed" data-i18n="projects.confidenceConfirmed">已确认</option>
<option value="tentative">待确认</option> <option value="tentative" data-i18n="projects.confidenceTentative">待确认</option>
<option value="deprecated">已废弃</option> <option value="deprecated" data-i18n="projects.confidenceDeprecated">已废弃</option>
</select> </select>
</label> </label>
<div class="projects-fact-filter-toggles" role="group" aria-label="显示选项"> <div class="projects-fact-filter-toggles" role="group" aria-label="显示选项" data-i18n="projects.displayOptions" data-i18n-attr="aria-label">
<label class="projects-fact-toggle"> <label class="projects-fact-toggle">
<input type="checkbox" id="project-facts-filter-sparse" onchange="loadProjectFacts()"> <input type="checkbox" id="project-facts-filter-sparse" onchange="loadProjectFacts()">
<span>仅待补全</span> <span data-i18n="projects.sparseOnly">仅待补全</span>
</label> </label>
<label class="projects-fact-toggle"> <label class="projects-fact-toggle">
<input type="checkbox" id="project-facts-filter-hide-deprecated" checked onchange="loadProjectFacts()"> <input type="checkbox" id="project-facts-filter-hide-deprecated" checked onchange="loadProjectFacts()">
<span>隐藏废弃</span> <span data-i18n="projects.hideDeprecated">隐藏废弃</span>
</label> </label>
</div> </div>
</div> </div>
</div> </div>
<div class="projects-table-wrap"> <div class="projects-table-wrap">
<table class="data-table data-table--projects"> <table class="data-table data-table--projects">
<thead><tr><th>Key</th><th>分类</th><th>摘要</th><th>Body</th><th>置信度</th><th>更新</th><th class="col-actions">操作</th></tr></thead> <thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
<tbody id="project-facts-tbody"></tbody> <tbody id="project-facts-tbody"></tbody>
</table> </table>
</div> </div>
</div> </div>
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden> <div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
<div class="projects-panel-toolbar"> <div class="projects-panel-toolbar projects-panel-toolbar--hint">
<span class="projects-panel-hint">绑定到本项目的对话;点击可打开会话</span> <p class="projects-fact-toolbar-hint" role="note">
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span data-i18n="projects.boundConversationsHint">绑定到本项目的对话;点击可打开会话</span>
</p>
</div> </div>
<div class="projects-table-wrap"> <div class="projects-table-wrap">
<table class="data-table data-table--projects"> <table class="data-table data-table--projects">
<thead><tr><th>标题</th><th>更新</th><th class="col-actions">操作</th></tr></thead> <thead><tr><th data-i18n="projects.titleLabel">标题</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
<tbody id="project-conversations-tbody"></tbody> <tbody id="project-conversations-tbody"></tbody>
</table> </table>
</div> </div>
</div> </div>
<div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden> <div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden>
<div class="projects-panel-toolbar"> <div class="projects-fact-toolbar">
<span class="projects-panel-hint">本项目下记录的漏洞汇总</span> <div class="projects-vuln-toolbar-top">
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()">在漏洞管理中查看</button> <p class="projects-fact-toolbar-hint" role="note">
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span data-i18n="projects.projectVulnSummaryHint">本项目下记录的漏洞汇总</span>
</p>
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.viewInVulnerabilityManagement">在漏洞管理中查看</button>
</div>
<div class="projects-fact-toolbar-filters" role="search">
<label class="projects-fact-filter-field projects-fact-filter-field--search">
<span class="sr-only" data-i18n="projects.searchVulnsSr">搜索漏洞</span>
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input type="search" id="project-vulns-search" placeholder="搜索标题、描述、类型、目标…" oninput="debouncedLoadProjectVulnerabilities()" autocomplete="off" data-i18n="projects.searchVulnsPlaceholder" data-i18n-attr="placeholder">
</label>
<label class="projects-fact-filter-field">
<span class="projects-fact-filter-label" data-i18n="projects.severity">严重度</span>
<select id="project-vulns-filter-severity" onchange="loadProjectVulnerabilities()">
<option value="" data-i18n="projects.all">全部</option>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
<option value="info">info</option>
</select>
</label>
<label class="projects-fact-filter-field">
<span class="projects-fact-filter-label" data-i18n="projects.status">状态</span>
<select id="project-vulns-filter-status" onchange="loadProjectVulnerabilities()">
<option value="" data-i18n="projects.all">全部</option>
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
</select>
</label>
</div>
</div> </div>
<div class="projects-table-wrap"> <div class="projects-table-wrap">
<table class="data-table data-table--projects"> <table class="data-table data-table--projects">
<thead><tr><th>标题</th><th>严重度</th><th>状态</th><th class="col-actions">操作</th></tr></thead> <thead><tr><th data-i18n="projects.titleLabel">标题</th><th data-i18n="projects.severity">严重度</th><th data-i18n="projects.status">状态</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
<tbody id="project-vulns-tbody"></tbody> <tbody id="project-vulns-tbody"></tbody>
</table> </table>
</div> </div>
@@ -1565,8 +1618,8 @@
<div class="projects-settings-layout"> <div class="projects-settings-layout">
<header class="projects-settings-intro"> <header class="projects-settings-intro">
<div class="projects-settings-intro-text"> <div class="projects-settings-intro-text">
<h4 class="projects-settings-intro-title">项目设置</h4> <h4 class="projects-settings-intro-title" data-i18n="projects.settingsIntroTitle">项目设置</h4>
<p class="projects-settings-intro-hint">配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。</p> <p class="projects-settings-intro-hint" data-i18n="projects.settingsIntroHint">配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。</p>
</div> </div>
</header> </header>
<div class="projects-settings-grid"> <div class="projects-settings-grid">
@@ -1577,35 +1630,35 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</span> </span>
<div> <div>
<h4 class="projects-settings-card-title">基本信息</h4> <h4 class="projects-settings-card-title" data-i18n="projects.basicInfoTitle">基本信息</h4>
<p class="projects-settings-card-hint">名称与描述会显示在项目详情中</p> <p class="projects-settings-card-hint" data-i18n="projects.basicInfoHint">名称与描述会显示在项目详情中</p>
</div> </div>
</div> </div>
</div> </div>
<div class="projects-settings-card-body"> <div class="projects-settings-card-body">
<div class="projects-form-row projects-form-row--2"> <div class="projects-form-row projects-form-row--2">
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-edit-name">项目名称</label> <label for="project-edit-name" data-i18n="projects.projectName">项目名称</label>
<input type="text" id="project-edit-name" class="form-input" placeholder="例如:某客户 Web 渗透"> <input type="text" id="project-edit-name" class="form-input" placeholder="例如:某客户 Web 渗透" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-edit-status">状态</label> <label for="project-edit-status" data-i18n="projects.status">状态</label>
<div class="projects-status-select-wrap"> <div class="projects-status-select-wrap">
<select id="project-edit-status" class="form-input projects-status-select"> <select id="project-edit-status" class="form-input projects-status-select">
<option value="active">进行中</option> <option value="active" data-i18n="projects.statusActive">进行中</option>
<option value="archived">已归档</option> <option value="archived" data-i18n="projects.statusArchived">已归档</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label class="projects-filter-check projects-pin-toggle"> <label class="projects-filter-check projects-pin-toggle">
<input type="checkbox" id="project-edit-pinned"> 置顶项目(列表优先显示) <input type="checkbox" id="project-edit-pinned"> <span data-i18n="projects.pinProject">置顶项目(列表优先显示)</span>
</label> </label>
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-edit-description">描述</label> <label for="project-edit-description" data-i18n="projects.projectDescription">描述</label>
<textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…"></textarea> <textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
</div> </div>
</div> </div>
</section> </section>
@@ -1616,21 +1669,21 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span> </span>
<div> <div>
<h4 class="projects-settings-card-title">测试范围</h4> <h4 class="projects-settings-card-title" data-i18n="projects.scopeTitle">测试范围</h4>
<p class="projects-settings-card-hint">JSON 格式,供 Agent 理解授权边界与目标资产</p> <p class="projects-settings-card-hint" data-i18n="projects.scopeHint">JSON 格式,供 Agent 理解授权边界与目标资产</p>
</div> </div>
</div> </div>
<div class="projects-scope-toolbar"> <div class="projects-scope-toolbar">
<button type="button" class="btn-ghost btn-small" onclick="formatProjectScopeJson()" title="格式化 JSON">格式化</button> <button type="button" class="btn-ghost btn-small" onclick="formatProjectScopeJson()" title="格式化 JSON" data-i18n="projects.formatJson" data-i18n-attr="title">格式化</button>
<button type="button" class="btn-ghost btn-small" onclick="insertProjectScopeExample()" title="插入示例">示例</button> <button type="button" class="btn-ghost btn-small" onclick="insertProjectScopeExample()" title="插入示例" data-i18n="projects.example" data-i18n-attr="title">示例</button>
</div> </div>
</div> </div>
<div class="projects-settings-card-body projects-settings-card-body--fill"> <div class="projects-settings-card-body projects-settings-card-body--fill">
<div class="projects-scope-editor"> <div class="projects-scope-editor">
<label for="project-edit-scope" class="sr-only">范围 JSON</label> <label for="project-edit-scope" class="sr-only" data-i18n="projects.scopeJsonLabel">范围 JSON</label>
<textarea id="project-edit-scope" class="form-input form-input--mono projects-scope-textarea" spellcheck="false" placeholder='{"targets":["https://example.com"],"exclude":["*.cdn.example.com"]}'></textarea> <textarea id="project-edit-scope" class="form-input form-input--mono projects-scope-textarea" spellcheck="false" placeholder='{"targets":["https://example.com"],"exclude":["*.cdn.example.com"]}'></textarea>
</div> </div>
<p class="projects-scope-footnote">支持 <code>targets</code><code>exclude</code><code>notes</code> 等字段,留空表示不限制范围。</p> <p class="projects-scope-footnote" data-i18n="projects.scopeFootnote">支持 targets、exclude、notes 等字段,留空表示不限制范围。</p>
</div> </div>
</section> </section>
</div> </div>
@@ -1640,21 +1693,20 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</span> </span>
<div> <div>
<h4 class="projects-settings-card-title">危险操作</h4> <h4 class="projects-settings-card-title" data-i18n="projects.dangerZoneTitle">危险操作</h4>
<p class="projects-settings-card-hint">归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。</p> <p class="projects-settings-card-hint" data-i18n="projects.dangerZoneHint">归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。</p>
</div> </div>
</div> </div>
<div class="projects-settings-danger-actions"> <div class="projects-settings-danger-actions">
<button class="btn-secondary btn-small" type="button" onclick="archiveCurrentProject()">归档 / 恢复</button> <button class="btn-secondary btn-small" type="button" onclick="archiveCurrentProject()" data-i18n="projects.archiveRestore">归档 / 恢复</button>
<button class="btn-secondary btn-small btn-danger-outline" type="button" onclick="deleteCurrentProject()">删除项目</button> <button class="btn-secondary btn-small btn-danger-outline" type="button" onclick="deleteCurrentProject()" data-i18n="projects.deleteProject">删除项目</button>
</div> </div>
</section> </section>
</div> </div>
<footer class="projects-settings-footer"> <footer class="projects-settings-footer">
<span class="projects-settings-footer-hint">修改后请点击保存以同步到服务器</span> <span class="projects-settings-footer-hint" data-i18n="projects.saveChangesHint">修改后请点击保存以同步到服务器</span>
<button class="btn-primary" type="button" onclick="saveProjectSettings()"> <button class="btn-primary" type="button" onclick="saveProjectSettings()">
<svg 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"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> <span data-i18n="projects.saveSettings">保存更改</span>
保存更改
</button> </button>
</footer> </footer>
</div> </div>
@@ -2847,20 +2899,39 @@
<div class="settings-subsection"> <div class="settings-subsection">
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4> <h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p> <p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p>
<ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;">
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryGeneral">通用</p>
<ul class="robot-cmd-list">
<li><code>帮助</code> <code>help</code><span data-i18n="settingsRobotsExtra.botCmdHelp">显示本帮助 | Show this help</span></li> <li><code>帮助</code> <code>help</code><span data-i18n="settingsRobotsExtra.botCmdHelp">显示本帮助 | Show this help</span></li>
<li><code>版本</code> <code>version</code><span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryConversation">对话</p>
<ul class="robot-cmd-list">
<li><code>列表</code> <code>list</code><span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li> <li><code>列表</code> <code>list</code><span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li>
<li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li> <li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li>
<li><code>新对话</code> <code>new</code><span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li> <li><code>新对话</code> <code>new</code><span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li>
<li><code>清空</code> <code>clear</code><span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li> <li><code>清空</code> <code>clear</code><span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li>
<li><code>当前</code> <code>current</code><span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话 ID 与标题 | Show current conversation</span></li> <li><code>当前</code> <code>current</code><span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话、角色与项目 | Show current conversation</span></li>
<li><code>停止</code> <code>stop</code><span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li> <li><code>停止</code> <code>stop</code><span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p>
<ul class="robot-cmd-list">
<li><code>角色</code> <code>roles</code><span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li> <li><code>角色</code> <code>roles</code><span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li>
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li> <li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
<li><code>版本</code> <code>version</code><span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li>
</ul> </ul>
<p class="settings-description" style="margin-top: 8px;" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p>
<ul class="robot-cmd-list">
<li><code>项目</code> <code>projects</code><span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li>
<li><code>新建项目 &lt;名称&gt;</code> <code>new project &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create &amp; bind project</span></li>
<li><code>绑定项目 &lt;ID或名称&gt;</code> <code>bind project &lt;ID|name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdBindProject">将当前对话绑定到项目 | Bind conversation</span></li>
<li><code>解除项目</code> <code>unbind project</code><span data-i18n="settingsRobotsExtra.botCmdUnbindProject">解除当前对话的项目绑定 | Unbind project</span></li>
</ul>
<p class="settings-description robot-cmd-footer" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
</div> </div>
<div class="settings-actions"> <div class="settings-actions">
@@ -3679,6 +3750,13 @@
</select> </select>
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.roleHint">选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。</div> <div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.roleHint">选择一个角色,所有任务将使用该角色的配置(提示词和工具)执行。</div>
</div> </div>
<div class="form-group">
<label for="batch-queue-project-id" data-i18n="batchImportModal.project">所属项目</label>
<select id="batch-queue-project-id" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
<option value="" data-i18n="batchImportModal.projectNone">(未绑定)</option>
</select>
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.projectHint">可为队列绑定项目;留空则不绑定项目上下文。</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="batch-queue-agent-mode" data-i18n="batchImportModal.agentMode">代理模式</label> <label for="batch-queue-agent-mode" data-i18n="batchImportModal.agentMode">代理模式</label>
<select id="batch-queue-agent-mode" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;"> <select id="batch-queue-agent-mode" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
@@ -4021,25 +4099,25 @@
<div class="projects-modal-header"> <div class="projects-modal-header">
<div class="projects-modal-header-text"> <div class="projects-modal-header-text">
<div> <div>
<h3 id="project-modal-title">新建项目</h3> <h3 id="project-modal-title" data-i18n="projects.modalNewTitle">新建项目</h3>
<p id="project-modal-subtitle" class="projects-modal-subtitle">创建后可绑定对话,跨会话共享事实黑板</p> <p id="project-modal-subtitle" class="projects-modal-subtitle" data-i18n="projects.modalNewSubtitle">创建后可绑定对话,跨会话共享事实黑板</p>
</div> </div>
</div> </div>
<button type="button" class="projects-modal-close" onclick="closeProjectModal()" aria-label="关闭">&times;</button> <button type="button" class="projects-modal-close" onclick="closeProjectModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true">&times;</button>
</div> </div>
<div class="projects-modal-body"> <div class="projects-modal-body">
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-modal-name">项目名称 <span class="required">*</span></label> <label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label>
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off"> <input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="project-modal-description">项目描述</label> <label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label>
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…"></textarea> <textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
</div> </div>
</div> </div>
<div class="projects-modal-footer"> <div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeProjectModal()">取消</button> <button class="btn-secondary" type="button" onclick="closeProjectModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" type="button" id="project-modal-submit-btn" onclick="saveProjectModal()">创建项目</button> <button class="btn-primary" type="button" id="project-modal-submit-btn" onclick="saveProjectModal()" data-i18n="projects.createProject">创建项目</button>
</div> </div>
</div> </div>
</div> </div>
@@ -4048,11 +4126,11 @@
<div class="projects-modal-header"> <div class="projects-modal-header">
<div class="projects-modal-header-text"> <div class="projects-modal-header-text">
<div> <div>
<h3 id="fact-modal-title">添加事实</h3> <h3 id="fact-modal-title" data-i18n="projects.addFact">添加事实</h3>
<p class="projects-modal-subtitle">摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)</p> <p class="projects-modal-subtitle" data-i18n="projects.factModalSubtitle">摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)</p>
</div> </div>
</div> </div>
<button type="button" class="projects-modal-close" onclick="closeFactModal()" aria-label="关闭">&times;</button> <button type="button" class="projects-modal-close" onclick="closeFactModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true">&times;</button>
</div> </div>
<div class="projects-modal-body"> <div class="projects-modal-body">
<div class="projects-form-field"> <div class="projects-form-field">
@@ -4062,7 +4140,7 @@
</div> </div>
<div class="projects-form-row"> <div class="projects-form-row">
<div class="projects-form-field"> <div class="projects-form-field">
<label for="fact-modal-category">分类</label> <label for="fact-modal-category" data-i18n="projects.category">分类</label>
<select id="fact-modal-category" class="form-input" onchange="updateFactFormHints()"> <select id="fact-modal-category" class="form-input" onchange="updateFactFormHints()">
<option value="target">target(目标)</option> <option value="target">target(目标)</option>
<option value="auth">auth(认证)</option> <option value="auth">auth(认证)</option>
@@ -4076,7 +4154,7 @@
</select> </select>
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="fact-modal-confidence">置信度</label> <label for="fact-modal-confidence" data-i18n="projects.confidence">置信度</label>
<select id="fact-modal-confidence" class="form-input"> <select id="fact-modal-confidence" class="form-input">
<option value="tentative">待确认</option> <option value="tentative">待确认</option>
<option value="confirmed">已确认</option> <option value="confirmed">已确认</option>
@@ -4100,13 +4178,13 @@
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p> <p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
</div> </div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="fact-modal-related-vuln">关联漏洞 ID</label> <label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选"> <input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
</div> </div>
</div> </div>
<div class="projects-modal-footer"> <div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeFactModal()">取消</button> <button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" type="button" id="fact-modal-submit-btn" onclick="saveFactModal()">保存事实</button> <button class="btn-primary" type="button" id="fact-modal-submit-btn" onclick="saveFactModal()" data-i18n="projects.saveFact">保存事实</button>
</div> </div>
</div> </div>
</div> </div>
@@ -4115,30 +4193,30 @@
<div class="projects-modal-header"> <div class="projects-modal-header">
<div class="projects-modal-header-text"> <div class="projects-modal-header-text">
<div> <div>
<h3 id="fact-detail-title">事实详情</h3> <h3 id="fact-detail-title" data-i18n="projects.factDetails">事实详情</h3>
<p id="fact-detail-meta" class="projects-modal-subtitle"></p> <p id="fact-detail-meta" class="projects-modal-subtitle"></p>
</div> </div>
</div> </div>
<button type="button" class="projects-modal-close" onclick="closeFactDetailModal()" aria-label="关闭">&times;</button> <button type="button" class="projects-modal-close" onclick="closeFactDetailModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true">&times;</button>
</div> </div>
<div class="projects-modal-body"> <div class="projects-modal-body">
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p> <p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
<div id="fact-detail-prev-wrap" class="fact-detail-prev-wrap" hidden> <div id="fact-detail-prev-wrap" class="fact-detail-prev-wrap" hidden>
<h4 class="fact-detail-prev-title">上一版本</h4> <h4 class="fact-detail-prev-title" data-i18n="projects.previousVersion">上一版本</h4>
<p id="fact-detail-prev-meta" class="projects-modal-subtitle"></p> <p id="fact-detail-prev-meta" class="projects-modal-subtitle"></p>
<pre id="fact-detail-prev-body" class="fact-detail-body fact-detail-body--muted"></pre> <pre id="fact-detail-prev-body" class="fact-detail-body fact-detail-body--muted"></pre>
</div> </div>
<h4 class="fact-detail-current-title">当前版本</h4> <h4 class="fact-detail-current-title" data-i18n="projects.currentVersion">当前版本</h4>
<pre id="fact-detail-body" class="fact-detail-body"></pre> <pre id="fact-detail-body" class="fact-detail-body"></pre>
</div> </div>
<div class="projects-modal-footer projects-modal-footer--split"> <div class="projects-modal-footer projects-modal-footer--split">
<div class="projects-modal-footer-left"> <div class="projects-modal-footer-left">
<button class="btn-secondary btn-small" type="button" id="fact-detail-link-vuln-btn" onclick="linkFactToExistingVulnerability()" hidden>关联漏洞</button> <button class="btn-secondary btn-small" type="button" id="fact-detail-link-vuln-btn" onclick="linkFactToExistingVulnerability()" hidden data-i18n="projects.linkVulnerability">关联漏洞</button>
<button class="btn-secondary btn-small" type="button" id="fact-detail-create-vuln-btn" onclick="createVulnerabilityFromCurrentFact()" hidden>生成漏洞草稿</button> <button class="btn-secondary btn-small" type="button" id="fact-detail-create-vuln-btn" onclick="createVulnerabilityFromCurrentFact()" hidden data-i18n="projects.createVulnerabilityDraft">生成漏洞草稿</button>
</div> </div>
<div class="projects-modal-footer-right"> <div class="projects-modal-footer-right">
<button class="btn-secondary" type="button" onclick="closeFactDetailModal()">关闭</button> <button class="btn-secondary" type="button" onclick="closeFactDetailModal()" data-i18n="common.close">关闭</button>
<button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()">编辑</button> <button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()" data-i18n="common.edit">编辑</button>
</div> </div>
</div> </div>
</div> </div>