mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-07 14:53:59 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ebf023a0 | |||
| 604ce34d5e | |||
| b29b36bfd5 | |||
| 11bab83fc5 | |||
| dc750e3680 | |||
| 0236d1c155 | |||
| be59ddcab6 | |||
| 25464a68e6 | |||
| eabfed09c9 | |||
| cbcbd414cd | |||
| 0933f9365b | |||
| e792891ff3 | |||
| e14e5f15d3 | |||
| 4d5e0c5f21 | |||
| b3238304ce | |||
| 665e2ec73a | |||
| d63d9c25b8 | |||
| d1c63d0ba7 | |||
| 55d6d449cd | |||
| d4bc9646d9 | |||
| b941f5a8d9 | |||
| 97e2c0fd43 | |||
| bd3e48c2d0 | |||
| 8b0b91fddc | |||
| 2b38595b42 |
+2
-2
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.26"
|
||||
version: "v1.6.29"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -77,7 +77,7 @@ fofa:
|
||||
# Agent 配置
|
||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||
agent:
|
||||
max_iterations: 1200 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
||||
max_iterations: 12000 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 178 KiB |
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,19 +13,106 @@ import (
|
||||
"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 连接池参数,提升并发稳定性
|
||||
func configureDBPool(db *sql.DB) {
|
||||
// SQLite 同一时间只允许一个写入者,限制连接数避免 "database is locked" 错误
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
// SQLite 同一时间只允许一个写入者;过高连接数会放大锁竞争和 WAL 回收延迟。
|
||||
db.SetMaxOpenConns(sqliteMaxOpenConns)
|
||||
db.SetMaxIdleConns(sqliteMaxIdleConns)
|
||||
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 数据库连接
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
logger *zap.Logger
|
||||
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 创建数据库连接
|
||||
@@ -37,8 +125,13 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
configureDBPool(db)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
if err := configureSQLitePragmas(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("配置数据库 PRAGMA 失败: %w", err)
|
||||
}
|
||||
|
||||
database := &DB{
|
||||
DB: db,
|
||||
@@ -54,8 +147,10 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
|
||||
// 初始化表
|
||||
if err := database.initTables(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("初始化表失败: %w", err)
|
||||
}
|
||||
database.startPassiveCheckpointLoop("conversations")
|
||||
|
||||
return database, nil
|
||||
}
|
||||
@@ -1159,8 +1254,13 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
configureDBPool(sqlDB)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
||||
}
|
||||
if err := configureSQLitePragmas(sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("配置知识库数据库 PRAGMA 失败: %w", err)
|
||||
}
|
||||
|
||||
database := &DB{
|
||||
DB: sqlDB,
|
||||
@@ -1169,8 +1269,10 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
|
||||
// 初始化知识库表
|
||||
if err := database.initKnowledgeTables(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("初始化知识库表失败: %w", err)
|
||||
}
|
||||
database.startPassiveCheckpointLoop("knowledge")
|
||||
|
||||
return database, nil
|
||||
}
|
||||
@@ -1284,5 +1386,19 @@ func (db *DB) migrateKnowledgeEmbeddingsColumns() error {
|
||||
|
||||
// Close 关闭数据库连接
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1445,7 +1445,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// 对于流式请求,也发送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("Connection", "keep-alive")
|
||||
event := StreamEvent{
|
||||
@@ -1467,7 +1467,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
)
|
||||
|
||||
// 设置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("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no") // 禁用nginx缓冲
|
||||
@@ -2048,7 +2048,7 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
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("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
// EinoSingleAgentLoopStream Eino ADK 单代理(ChatModelAgent + Runner)流式对话;不依赖 multi_agent.enabled。
|
||||
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("Connection", "keep-alive")
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
|
||||
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("Connection", "keep-alive")
|
||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||
|
||||
+203
-18
@@ -40,8 +40,13 @@ const (
|
||||
robotCmdRoles = "角色"
|
||||
robotCmdRolesList = "角色列表"
|
||||
robotCmdSwitchRole = "切换角色"
|
||||
robotCmdDelete = "删除"
|
||||
robotCmdVersion = "版本"
|
||||
robotCmdDelete = "删除"
|
||||
robotCmdVersion = "版本"
|
||||
robotCmdProjects = "项目"
|
||||
robotCmdProjectsList = "项目列表"
|
||||
robotCmdBindProject = "绑定项目"
|
||||
robotCmdNewProject = "新建项目"
|
||||
robotCmdUnbindProject = "解除项目"
|
||||
)
|
||||
|
||||
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
|
||||
@@ -269,21 +274,176 @@ func (h *RobotHandler) robotMessageTimeout() time.Duration {
|
||||
}
|
||||
|
||||
func (h *RobotHandler) cmdHelp() string {
|
||||
return "**【CyberStrikeAI 机器人命令】**\n\n" +
|
||||
"- `帮助` `help` — 显示本帮助 | Show this help\n" +
|
||||
"- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" +
|
||||
"- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" +
|
||||
"- `新对话` `new` — 开启新对话 | Start new conversation\n" +
|
||||
"- `清空` `clear` — 清空当前上下文 | Clear context\n" +
|
||||
"- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" +
|
||||
"- `停止` `stop` — 中断当前任务 | Stop running task\n" +
|
||||
"- `角色` `roles` — 列出所有可用角色 | List roles\n" +
|
||||
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" +
|
||||
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" +
|
||||
"- `版本` `version` — 显示当前版本号 | Show version\n\n" +
|
||||
"---\n" +
|
||||
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" +
|
||||
"Otherwise, send any text for AI penetration testing / security analysis."
|
||||
var b strings.Builder
|
||||
b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
|
||||
b.WriteString("【通用 General】\n")
|
||||
b.WriteString("· 帮助 / help — 显示本帮助\n")
|
||||
b.WriteString("· 版本 / version — 显示当前版本号\n")
|
||||
b.WriteString("\n【对话 Conversation】\n")
|
||||
b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n")
|
||||
b.WriteString("· 切换 <ID> / switch <ID> — 指定对话继续\n")
|
||||
b.WriteString("· 新对话 / new — 开启新对话\n")
|
||||
b.WriteString("· 清空 / clear — 清空当前上下文\n")
|
||||
b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n")
|
||||
b.WriteString("· 停止 / stop — 中断当前任务\n")
|
||||
b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话\n")
|
||||
b.WriteString("\n【角色 Role】\n")
|
||||
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 {
|
||||
@@ -357,7 +517,12 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
|
||||
return "当前对话 ID: " + convID + "(获取标题失败)"
|
||||
}
|
||||
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 {
|
||||
@@ -494,6 +659,26 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
|
||||
return h.cmdDelete(platform, userID, convID), true
|
||||
case text == robotCmdVersion || text == "version":
|
||||
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:
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -44,11 +44,12 @@ func newSDKClientFromSession(session *mcp.ClientSession, client *mcp.Client, log
|
||||
|
||||
// lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient
|
||||
type lazySDKClient struct {
|
||||
serverCfg config.ExternalMCPServerConfig
|
||||
logger *zap.Logger
|
||||
inner ExternalMCPClient // 连接成功后为 *sdkClient
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
serverCfg config.ExternalMCPServerConfig
|
||||
logger *zap.Logger
|
||||
sessionCancel context.CancelFunc
|
||||
inner ExternalMCPClient // connected SDK client
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
}
|
||||
|
||||
func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient {
|
||||
@@ -92,14 +93,61 @@ func (c *lazySDKClient) Initialize(ctx context.Context) error {
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
inner, err := createSDKClient(ctx, c.serverCfg, c.logger)
|
||||
if err != nil {
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
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")
|
||||
return err
|
||||
}
|
||||
|
||||
if result.err != nil {
|
||||
sessionCancel()
|
||||
c.setStatus("error")
|
||||
return result.err
|
||||
}
|
||||
|
||||
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.setStatus("connected")
|
||||
return nil
|
||||
@@ -128,9 +176,14 @@ func (c *lazySDKClient) CallTool(ctx context.Context, name string, args map[stri
|
||||
func (c *lazySDKClient) Close() error {
|
||||
c.mu.Lock()
|
||||
inner := c.inner
|
||||
sessionCancel := c.sessionCancel
|
||||
c.inner = nil
|
||||
c.sessionCancel = nil
|
||||
c.mu.Unlock()
|
||||
c.setStatus("disconnected")
|
||||
if sessionCancel != nil {
|
||||
sessionCancel()
|
||||
}
|
||||
if inner != nil {
|
||||
return inner.Close()
|
||||
}
|
||||
|
||||
+300
-28
@@ -9,6 +9,10 @@
|
||||
--secondary-color: #2d2d2d;
|
||||
--accent-color: #0066ff;
|
||||
--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-secondary: #f8f9fa;
|
||||
--bg-tertiary: #f1f3f5;
|
||||
@@ -125,11 +129,19 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.main-sidebar-header .logo span {
|
||||
.main-sidebar-header .logo span,
|
||||
.main-sidebar-header .brand-wordmark {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
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 {
|
||||
@@ -592,37 +604,89 @@ header {
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
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-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 {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: 0.35em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
padding: 3px 9px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -3091,10 +3155,36 @@ header {
|
||||
.login-brand {
|
||||
padding: 32px 28px 24px;
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
@@ -3530,8 +3620,19 @@ header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.25rem;
|
||||
.logo h1,
|
||||
.logo .brand-wordmark {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
padding: 2px 7px;
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
@@ -4298,6 +4399,31 @@ header {
|
||||
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 {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
@@ -6249,6 +6375,7 @@ header {
|
||||
.mcp-stats-dist-panel .mcp-stats-tools-legend {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -18160,7 +18287,6 @@ header {
|
||||
border-color: rgba(138, 43, 226, 0.3);
|
||||
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
|
||||
}
|
||||
|
||||
.role-selection-item-content-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21080,13 +21206,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
#page-projects .page-content.projects-page-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
min-height: calc(100vh - 128px);
|
||||
padding: 16px 20px 24px;
|
||||
padding: 16px clamp(12px, 1.4vw, 20px) 24px;
|
||||
background: transparent;
|
||||
}
|
||||
.projects-sidebar-card {
|
||||
width: 260px;
|
||||
width: clamp(200px, 15vw, 236px);
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
@@ -21258,10 +21384,11 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
min-height: 420px;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
.projects-detail-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -21349,6 +21476,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
padding: 12px 24px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.projects-tab {
|
||||
padding: 8px 16px;
|
||||
@@ -21594,9 +21722,94 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
overflow: hidden;
|
||||
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 {
|
||||
width: auto;
|
||||
min-width: 240px;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
.data-table--projects thead th.col-actions {
|
||||
@@ -21606,13 +21819,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.projects-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 11px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
@@ -21657,6 +21870,65 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
color: #b91c1c;
|
||||
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 {
|
||||
width: 100%;
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in to CyberStrikeAI",
|
||||
"titlePrefix": "Sign in to",
|
||||
"subtitle": "Enter the access password from config",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
@@ -258,6 +259,9 @@
|
||||
"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",
|
||||
@@ -2091,17 +2095,25 @@
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "Bot command instructions",
|
||||
"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",
|
||||
"botCmdList": "List conversations",
|
||||
"botCmdSwitch": "Switch to conversation",
|
||||
"botCmdNew": "Start new conversation",
|
||||
"botCmdClear": "Clear context",
|
||||
"botCmdCurrent": "Show current conversation",
|
||||
"botCmdCurrent": "Show current conversation, role and project",
|
||||
"botCmdStop": "Stop running task",
|
||||
"botCmdRoles": "List roles",
|
||||
"botCmdRole": "Switch role",
|
||||
"botCmdDelete": "Delete conversation",
|
||||
"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."
|
||||
},
|
||||
"mcpDetailModal": {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
},
|
||||
"login": {
|
||||
"title": "登录 CyberStrikeAI",
|
||||
"titlePrefix": "登录",
|
||||
"subtitle": "请输入配置中的访问密码",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "输入登录密码",
|
||||
@@ -247,6 +248,9 @@
|
||||
"boundConversationsHint": "绑定到本项目的对话;点击可打开会话",
|
||||
"titleLabel": "标题",
|
||||
"projectVulnSummaryHint": "本项目下记录的漏洞汇总",
|
||||
"searchVulnsSr": "搜索漏洞",
|
||||
"searchVulnsPlaceholder": "搜索标题、描述、类型、目标…",
|
||||
"noMatchingVulns": "无匹配漏洞,请调整筛选条件",
|
||||
"viewInVulnerabilityManagement": "在漏洞管理中查看",
|
||||
"severity": "严重度",
|
||||
"status": "状态",
|
||||
@@ -2080,17 +2084,25 @@
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "机器人命令说明",
|
||||
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
||||
"botCmdCategoryGeneral": "通用",
|
||||
"botCmdCategoryConversation": "对话",
|
||||
"botCmdCategoryRole": "角色",
|
||||
"botCmdCategoryProject": "项目",
|
||||
"botCmdHelp": "显示本帮助 | Show this help",
|
||||
"botCmdList": "列出所有对话标题与 ID | List conversations",
|
||||
"botCmdSwitch": "指定对话继续 | Switch to conversation",
|
||||
"botCmdNew": "开启新对话 | Start new conversation",
|
||||
"botCmdClear": "清空当前上下文 | Clear context",
|
||||
"botCmdCurrent": "显示当前对话 ID 与标题 | Show current conversation",
|
||||
"botCmdCurrent": "显示当前对话、角色与项目 | Show current conversation",
|
||||
"botCmdStop": "中断当前任务 | Stop running task",
|
||||
"botCmdRoles": "列出所有可用角色 | List roles",
|
||||
"botCmdRole": "切换当前角色 | Switch role",
|
||||
"botCmdDelete": "删除指定对话 | Delete conversation",
|
||||
"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."
|
||||
},
|
||||
"mcpDetailModal": {
|
||||
|
||||
@@ -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
|
||||
if (buffer.trim()) {
|
||||
|
||||
+192
-8
@@ -238,6 +238,8 @@ function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
|
||||
|
||||
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
||||
const responseStreamStateByProgressId = new Map();
|
||||
// 主通道当前迭代轮次缓存:progressId -> { iteration, orchestration }
|
||||
const mainIterationStateByProgressId = new Map();
|
||||
|
||||
/** 同一段主通道流式输出(Eino 可能重复 response_start) */
|
||||
function sameMainResponseStreamMeta(a, b) {
|
||||
@@ -250,6 +252,40 @@ function sameMainResponseStreamMeta(a, b) {
|
||||
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 })
|
||||
const thinkingStreamStateByProgressId = new Map();
|
||||
|
||||
@@ -380,6 +416,118 @@ function _normalizeUnicodeBulletMarkersToMdDash(segment) {
|
||||
.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,
|
||||
* 避免 marked 无法识别强调/行内代码而原样显示 **、反引号;
|
||||
@@ -396,6 +544,7 @@ function normalizeAssistantMarkdownSource(text) {
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
s = _normalizeEmphasisMarkersForMarkdown(s);
|
||||
s = _stripXmlReasoningWrappersForMarkdown(s);
|
||||
const fb = _maskFencedCodeBlocksForMdPreprocess(s);
|
||||
s = _unwrapHtmlBlockWrappersForMarkdown(fb.masked);
|
||||
@@ -1372,6 +1521,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
case 'iteration': {
|
||||
const d = event.data || {};
|
||||
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;
|
||||
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
|
||||
const phase = translatePlanExecuteAgentName(d.einoAgent != null ? d.einoAgent : '');
|
||||
@@ -1939,6 +2095,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const responseOriginalConversationId = responseTaskState?.conversationId;
|
||||
|
||||
const responseData = event.data || {};
|
||||
const streamIdentity = buildMainResponseStreamIdentity(progressId, responseData);
|
||||
const streamIterTag = extractIterationTagFromStreamIdentity(streamIdentity);
|
||||
const mcpIds = responseData.mcpExecutionIds || [];
|
||||
setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds));
|
||||
|
||||
@@ -1958,25 +2116,33 @@ function handleStreamEvent(event, progressElement, 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,避免多条「助手输出」
|
||||
prevStream.streamMeta = Object.assign({}, prevStream.streamMeta || {}, responseData);
|
||||
// 若此前轮次未知(空),在后续事件带来轮次后升级 identity,避免跨轮误复用。
|
||||
prevStream.streamIdentity = streamIdentity;
|
||||
responseStreamStateByProgressId.set(progressId, prevStream);
|
||||
break;
|
||||
}
|
||||
if (prevStream && prevStream.itemId) {
|
||||
const oldItem = document.getElementById(prevStream.itemId);
|
||||
if (oldItem && oldItem.parentNode) {
|
||||
oldItem.parentNode.removeChild(oldItem);
|
||||
}
|
||||
}
|
||||
const title = einoMainStreamPlanningTitle(responseData);
|
||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||
title: title,
|
||||
message: ' ',
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2145,11 +2311,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
loadActiveTasks();
|
||||
// Close any remaining running tool calls for this progress.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
mainIterationStateByProgressId.delete(String(progressId));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
// 清理流式输出状态
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
mainIterationStateByProgressId.delete(String(progressId));
|
||||
thinkingStreamStateByProgressId.delete(progressId);
|
||||
einoAgentReplyStreamStateByProgressId.delete(progressId);
|
||||
// 清理工具流式输出占位
|
||||
@@ -2570,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) {
|
||||
clearCsTaskReplay();
|
||||
}
|
||||
|
||||
+85
-10
@@ -226,9 +226,9 @@ function initProjectsModalEscape() {
|
||||
window._projectsModalEscapeBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (document.getElementById('project-modal')?.style.display === 'flex') closeProjectModal();
|
||||
else if (document.getElementById('fact-modal')?.style.display === 'flex') closeFactModal();
|
||||
else if (document.getElementById('fact-detail-modal')?.style.display === 'flex') closeFactDetailModal();
|
||||
if (isProjectsOverlayVisible('project-modal')) closeProjectModal();
|
||||
else if (isProjectsOverlayVisible('fact-modal')) closeFactModal();
|
||||
else if (isProjectsOverlayVisible('fact-detail-modal')) closeFactDetailModal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ async function initProjectsPage() {
|
||||
const page = document.getElementById('page-projects');
|
||||
if (!page || page.style.display === 'none') return;
|
||||
initProjectsModalEscape();
|
||||
syncProjectsModalBodyLock();
|
||||
updateProjectsDetailVisibility();
|
||||
await loadProjectsList();
|
||||
if (!currentProjectId && projectsCache.length) {
|
||||
@@ -335,6 +336,50 @@ function formatSeverityBadge(severity) {
|
||||
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() {
|
||||
return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase();
|
||||
}
|
||||
@@ -416,10 +461,16 @@ async function selectProject(id) {
|
||||
const catEl = document.getElementById('project-facts-filter-category');
|
||||
const confEl = document.getElementById('project-facts-filter-confidence');
|
||||
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 (catEl) catEl.value = '';
|
||||
if (confEl) confEl.value = '';
|
||||
if (sparseEl) sparseEl.checked = false;
|
||||
if (vulnSearchEl) vulnSearchEl.value = '';
|
||||
if (vulnSevEl) vulnSevEl.value = '';
|
||||
if (vulnStatusEl) vulnStatusEl.value = '';
|
||||
renderProjectsSidebar();
|
||||
updateProjectsDetailVisibility();
|
||||
try {
|
||||
@@ -845,15 +896,18 @@ async function loadProjectVulnerabilities() {
|
||||
const tbody = document.getElementById('project-vulns-tbody');
|
||||
if (!tbody || !currentProjectId) return;
|
||||
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) {
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('projects.noVulnerabilityRecords'))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${
|
||||
projectVulnsHasActiveFilter() ? tp('projects.noMatchingVulns') : tp('projects.noVulnerabilityRecords')
|
||||
}</td></tr>`;
|
||||
refreshProjectHeaderStats();
|
||||
return;
|
||||
}
|
||||
@@ -862,7 +916,7 @@ async function loadProjectVulnerabilities() {
|
||||
return `<tr>
|
||||
<td class="cell-summary" title="${escapeHtml(v.title)}">${escapeHtml(v.title)}</td>
|
||||
<td>${formatSeverityBadge(v.severity)}</td>
|
||||
<td>${escapeHtml(v.status)}</td>
|
||||
<td>${formatVulnStatusBadge(v.status)}</td>
|
||||
<td class="col-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)">${escapeHtml(tp('common.view'))}</button>
|
||||
@@ -927,18 +981,37 @@ function openProjectsOverlay(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
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');
|
||||
if (focusTarget) {
|
||||
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) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
const anyOpen = document.querySelector('.projects-modal-overlay[style*="flex"]');
|
||||
if (!anyOpen) document.body.classList.remove('projects-modal-open');
|
||||
syncProjectsModalBodyLock();
|
||||
}
|
||||
|
||||
function showNewProjectModal() {
|
||||
@@ -1522,6 +1595,8 @@ window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
|
||||
window.openVulnerabilityDetail = openVulnerabilityDetail;
|
||||
window.filterProjectsList = filterProjectsList;
|
||||
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
|
||||
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
|
||||
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
|
||||
window.linkFactToExistingVulnerability = linkFactToExistingVulnerability;
|
||||
window.createVulnerabilityFromCurrentFact = createVulnerabilityFromCurrentFact;
|
||||
window.viewFactsForVulnerability = viewFactsForVulnerability;
|
||||
|
||||
+85
-13
@@ -14,7 +14,13 @@
|
||||
<div id="login-overlay" class="login-overlay" style="display: none;">
|
||||
<div class="login-card">
|
||||
<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>
|
||||
</div>
|
||||
<form id="login-form" class="login-form">
|
||||
@@ -34,8 +40,10 @@
|
||||
<header>
|
||||
<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="返回仪表盘">
|
||||
<img src="/static/logo.png" alt="CyberStrikeAI Logo" style="width: 32px; height: 32px; margin-right: 8px;">
|
||||
<h1>CyberStrikeAI</h1>
|
||||
<img src="/static/logo.png" alt="CyberStrikeAI Logo" class="brand-logo" width="36" height="36">
|
||||
<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>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -1539,8 +1547,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint" data-i18n="projects.boundConversationsHint">绑定到本项目的对话;点击可打开会话</span>
|
||||
<div class="projects-panel-toolbar projects-panel-toolbar--hint">
|
||||
<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 class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
@@ -1550,9 +1564,48 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint" data-i18n="projects.projectVulnSummaryHint">本项目下记录的漏洞汇总</span>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.viewInVulnerabilityManagement">在漏洞管理中查看</button>
|
||||
<div class="projects-fact-toolbar">
|
||||
<div class="projects-vuln-toolbar-top">
|
||||
<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 class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
@@ -2846,20 +2899,39 @@
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
|
||||
<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>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>切换 <ID></code> <code>switch <ID></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>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>删除 <ID></code> <code>delete <ID></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>role <name></code> — <span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
|
||||
<li><code>删除 <ID></code> <code>delete <ID></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>
|
||||
<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>新建项目 <名称></code> <code>new project <name></code> — <span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create & bind project</span></li>
|
||||
<li><code>绑定项目 <ID或名称></code> <code>bind project <ID|name></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 class="settings-actions">
|
||||
|
||||
Reference in New Issue
Block a user