mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c265e66afb | |||
| 647bb4b5e4 | |||
| dd311f7a3b | |||
| 2e482a3baf | |||
| 67d5e7f11e |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.3.12"
|
version: "v1.3.14"
|
||||||
|
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
+194
-7
@@ -2,10 +2,14 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -108,11 +112,159 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
|
|||||||
h.skillsManager = manager
|
h.skillsManager = manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatAttachment 聊天附件(用户上传的文件)
|
||||||
|
type ChatAttachment struct {
|
||||||
|
FileName string `json:"fileName"` // 文件名
|
||||||
|
Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码)
|
||||||
|
MimeType string `json:"mimeType,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
ConversationID string `json:"conversationId,omitempty"`
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
Role string `json:"role,omitempty"` // 角色名称
|
Role string `json:"role,omitempty"` // 角色名称
|
||||||
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxAttachments = 10
|
||||||
|
maxAttachmentBytes = 2 * 1024 * 1024 // 单文件约 2MB(仅用于是否内联展示内容,不限制上传)
|
||||||
|
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
|
||||||
|
)
|
||||||
|
|
||||||
|
// saveAttachmentsToDateAndConversationDir 将附件保存到 chat_uploads/YYYY-MM-DD/{conversationID}/,返回每个文件的保存路径(与 attachments 顺序一致)
|
||||||
|
// conversationID 为空时使用 "_new" 作为目录名(新对话尚未有 ID)
|
||||||
|
func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conversationID string, logger *zap.Logger) (savedPaths []string, err error) {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取当前工作目录失败: %w", err)
|
||||||
|
}
|
||||||
|
dateDir := filepath.Join(cwd, chatUploadsDirName, time.Now().Format("2006-01-02"))
|
||||||
|
convDirName := strings.TrimSpace(conversationID)
|
||||||
|
if convDirName == "" {
|
||||||
|
convDirName = "_new"
|
||||||
|
} else {
|
||||||
|
convDirName = strings.ReplaceAll(convDirName, string(filepath.Separator), "_")
|
||||||
|
}
|
||||||
|
targetDir := filepath.Join(dateDir, convDirName)
|
||||||
|
if err = os.MkdirAll(targetDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
|
}
|
||||||
|
savedPaths = make([]string, 0, len(attachments))
|
||||||
|
for i, a := range attachments {
|
||||||
|
raw, decErr := attachmentContentToBytes(a)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("附件 %s 解码失败: %w", a.FileName, decErr)
|
||||||
|
}
|
||||||
|
baseName := filepath.Base(a.FileName)
|
||||||
|
if baseName == "" || baseName == "." {
|
||||||
|
baseName = "file"
|
||||||
|
}
|
||||||
|
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
|
||||||
|
ext := filepath.Ext(baseName)
|
||||||
|
nameNoExt := strings.TrimSuffix(baseName, ext)
|
||||||
|
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), shortRand(6))
|
||||||
|
var unique string
|
||||||
|
if ext != "" {
|
||||||
|
unique = nameNoExt + suffix + ext
|
||||||
|
} else {
|
||||||
|
unique = baseName + suffix
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(targetDir, unique)
|
||||||
|
if err = os.WriteFile(fullPath, raw, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入文件 %s 失败: %w", a.FileName, err)
|
||||||
|
}
|
||||||
|
absPath, _ := filepath.Abs(fullPath)
|
||||||
|
savedPaths = append(savedPaths, absPath)
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("对话附件已保存", zap.Int("index", i+1), zap.String("fileName", a.FileName), zap.String("path", absPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return savedPaths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortRand(n int) string {
|
||||||
|
const letters = "0123456789abcdef"
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[int(b[i])%len(letters)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentContentToBytes(a ChatAttachment) ([]byte, error) {
|
||||||
|
content := a.Content
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMessageContentForStorage 返回要存入数据库的用户消息内容:有附件时在正文后追加附件名(及路径),刷新后仍能显示,继续对话时大模型也能从历史中拿到路径
|
||||||
|
func userMessageContentForStorage(message string, attachments []ChatAttachment, savedPaths []string) string {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(message)
|
||||||
|
for i, a := range attachments {
|
||||||
|
b.WriteString("\n📎 ")
|
||||||
|
b.WriteString(a.FileName)
|
||||||
|
if i < len(savedPaths) && savedPaths[i] != "" {
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(savedPaths[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendAttachmentsToMessage 将附件内容拼接到用户消息末尾;若 savedPaths 与 attachments 一一对应,会先写入“已保存到”路径供大模型按路径读取
|
||||||
|
func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedPaths []string, logger *zap.Logger) string {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(msg)
|
||||||
|
if len(savedPaths) == len(attachments) {
|
||||||
|
b.WriteString("\n\n[用户上传的文件已保存到以下路径(可使用 cat/exec 等工具按路径读取)]\n")
|
||||||
|
for i, a := range attachments {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: %s\n", a.FileName, savedPaths[i]))
|
||||||
|
}
|
||||||
|
b.WriteString("\n[以下为附件内容(便于直接参考)]\n")
|
||||||
|
}
|
||||||
|
for i, a := range attachments {
|
||||||
|
b.WriteString(fmt.Sprintf("\n--- 附件 %d: %s ---\n", i+1, a.FileName))
|
||||||
|
content := a.Content
|
||||||
|
mime := strings.ToLower(strings.TrimSpace(a.MimeType))
|
||||||
|
isText := strings.HasPrefix(mime, "text/") || mime == "" ||
|
||||||
|
strings.Contains(mime, "json") || strings.Contains(mime, "xml") ||
|
||||||
|
strings.Contains(mime, "javascript") || strings.Contains(mime, "shell")
|
||||||
|
if isText && len(content) > 0 {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
|
||||||
|
content = string(decoded)
|
||||||
|
}
|
||||||
|
b.WriteString("```\n")
|
||||||
|
b.WriteString(content)
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
} else {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
||||||
|
content = string(decoded)
|
||||||
|
}
|
||||||
|
if utf8.ValidString(content) && len(content) < maxAttachmentBytes {
|
||||||
|
b.WriteString("```\n")
|
||||||
|
b.WriteString(content)
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("(二进制文件,约 %d 字节,已保存到上述路径,可按路径读取)\n", len(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
@@ -181,6 +333,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验附件数量(非流式)
|
||||||
|
if len(req.Attachments) > maxAttachments {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("附件最多 %d 个", maxAttachments)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
@@ -206,9 +364,20 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var savedPaths []string
|
||||||
|
if len(req.Attachments) > 0 {
|
||||||
|
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("保存对话附件失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存上传文件失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths, h.logger)
|
||||||
|
|
||||||
// 保存用户消息(保存原始消息,不包含角色提示词)
|
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||||
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
|
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||||
|
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||||
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()})
|
||||||
@@ -618,6 +787,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验附件数量
|
||||||
|
if len(req.Attachments) > maxAttachments {
|
||||||
|
sendEvent("error", fmt.Sprintf("附件最多 %d 个", maxAttachments), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
@@ -645,10 +820,22 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var savedPaths []string
|
||||||
|
if len(req.Attachments) > 0 {
|
||||||
|
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("保存对话附件失败", zap.Error(err))
|
||||||
|
sendEvent("error", "保存上传文件失败: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 将附件内容拼接到 finalMessage,便于大模型识别上传了哪些文件及内容
|
||||||
|
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths, h.logger)
|
||||||
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
|
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
|
||||||
|
|
||||||
// 保存用户消息(保存原始消息,不包含角色提示词)
|
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||||
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
|
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||||
|
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-14
@@ -232,20 +232,21 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdHelp() string {
|
func (h *RobotHandler) cmdHelp() string {
|
||||||
return `【CyberStrikeAI 机器人命令 / Bot Commands】
|
return "**【CyberStrikeAI 机器人命令】**\n\n" +
|
||||||
· 帮助 / help — 显示本帮助 / Show this help
|
"- `帮助` `help` — 显示本帮助 | Show this help\n" +
|
||||||
· 列表 / 对话列表 / list — 列出所有对话标题与 ID / List conversations
|
"- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" +
|
||||||
· 切换 <ID> / 继续 <ID> / switch <ID> — 指定对话继续 / Switch to conversation
|
"- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" +
|
||||||
· 新对话 / new — 开启新对话 / Start new conversation
|
"- `新对话` `new` — 开启新对话 | Start new conversation\n" +
|
||||||
· 清空 / clear — 清空当前上下文 / Clear context (same as new)
|
"- `清空` `clear` — 清空当前上下文 | Clear context\n" +
|
||||||
· 当前 / current — 显示当前对话 ID 与标题 / Show current conversation
|
"- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" +
|
||||||
· 停止 / stop — 中断当前任务 / Stop running task
|
"- `停止` `stop` — 中断当前任务 | Stop running task\n" +
|
||||||
· 角色 / 角色列表 / roles — 列出所有可用角色 / List roles
|
"- `角色` `roles` — 列出所有可用角色 | List roles\n" +
|
||||||
· 角色 <名> / 切换角色 <名> / role <name> — 切换当前角色 / Switch role
|
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" +
|
||||||
· 删除 <ID> / delete <ID> — 删除指定对话 / Delete conversation
|
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" +
|
||||||
· 版本 / version — 显示当前版本号 / Show version
|
"- `版本` `version` — 显示当前版本号 | Show version\n\n" +
|
||||||
除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。
|
"---\n" +
|
||||||
Otherwise, send any text for AI penetration testing / security analysis.`
|
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" +
|
||||||
|
"Otherwise, send any text for AI penetration testing / security analysis."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdList() string {
|
func (h *RobotHandler) cmdList() string {
|
||||||
|
|||||||
@@ -1582,12 +1582,106 @@ header {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-input-container > .chat-input-with-files {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-container > .chat-input-field {
|
.chat-input-container > .chat-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(0, 102, 255, 0.08);
|
||||||
|
border: 1px solid rgba(0, 102, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip-remove:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-input-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-upload-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-upload-btn:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: rgba(0, 102, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container.drag-over {
|
||||||
|
background: rgba(0, 102, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
outline: 2px dashed rgba(0, 102, 255, 0.35);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-field {
|
.chat-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+165
-15
@@ -22,6 +22,12 @@ const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft';
|
|||||||
let draftSaveTimer = null;
|
let draftSaveTimer = null;
|
||||||
const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
|
const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
|
||||||
|
|
||||||
|
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
|
||||||
|
const MAX_CHAT_FILES = 10;
|
||||||
|
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
|
||||||
|
/** @type {{ fileName: string, content: string, mimeType: string }[]} */
|
||||||
|
let chatAttachments = [];
|
||||||
|
|
||||||
// 保存输入框草稿到localStorage(防抖版本)
|
// 保存输入框草稿到localStorage(防抖版本)
|
||||||
function saveChatDraftDebounced(content) {
|
function saveChatDraftDebounced(content) {
|
||||||
// 清除之前的定时器
|
// 清除之前的定时器
|
||||||
@@ -107,14 +113,22 @@ function adjustTextareaHeight(textarea) {
|
|||||||
// 发送消息
|
// 发送消息
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const message = input.value.trim();
|
let message = input.value.trim();
|
||||||
|
const hasAttachments = chatAttachments && chatAttachments.length > 0;
|
||||||
if (!message) {
|
|
||||||
|
if (!message && !hasAttachments) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 有附件且用户未输入时,发一句简短默认提示即可(后端会拼接路径和文件内容给大模型)
|
||||||
// 显示用户消息
|
if (hasAttachments && !message) {
|
||||||
addMessage('user', message);
|
message = CHAT_FILE_DEFAULT_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示用户消息(含附件名,便于用户确认)
|
||||||
|
const displayMessage = hasAttachments
|
||||||
|
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
|
||||||
|
: message;
|
||||||
|
addMessage('user', displayMessage);
|
||||||
|
|
||||||
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
||||||
if (draftSaveTimer) {
|
if (draftSaveTimer) {
|
||||||
@@ -135,7 +149,24 @@ async function sendMessage() {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
// 强制重置输入框高度为初始高度(40px)
|
// 强制重置输入框高度为初始高度(40px)
|
||||||
input.style.height = '40px';
|
input.style.height = '40px';
|
||||||
|
|
||||||
|
// 构建请求体(含附件)
|
||||||
|
const body = {
|
||||||
|
message: message,
|
||||||
|
conversationId: currentConversationId,
|
||||||
|
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||||
|
};
|
||||||
|
if (hasAttachments) {
|
||||||
|
body.attachments = chatAttachments.map(a => ({
|
||||||
|
fileName: a.fileName,
|
||||||
|
content: a.content,
|
||||||
|
mimeType: a.mimeType || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 发送后清空附件列表
|
||||||
|
chatAttachments = [];
|
||||||
|
renderChatFileChips();
|
||||||
|
|
||||||
// 创建进度消息容器(使用详细的进度展示)
|
// 创建进度消息容器(使用详细的进度展示)
|
||||||
const progressId = addProgressMessage();
|
const progressId = addProgressMessage();
|
||||||
const progressElement = document.getElementById(progressId);
|
const progressElement = document.getElementById(progressId);
|
||||||
@@ -145,19 +176,12 @@ async function sendMessage() {
|
|||||||
let mcpExecutionIds = [];
|
let mcpExecutionIds = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前选中的角色(从 roles.js 的函数获取)
|
|
||||||
const roleName = typeof getCurrentRole === 'function' ? getCurrentRole() : '';
|
|
||||||
|
|
||||||
const response = await apiFetch('/api/agent-loop/stream', {
|
const response = await apiFetch('/api/agent-loop/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
message: message,
|
|
||||||
conversationId: currentConversationId,
|
|
||||||
role: roleName || undefined
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -222,6 +246,130 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 对话文件上传 ----------
|
||||||
|
function renderChatFileChips() {
|
||||||
|
const list = document.getElementById('chat-file-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!chatAttachments.length) return;
|
||||||
|
chatAttachments.forEach((a, i) => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'chat-file-chip';
|
||||||
|
chip.setAttribute('role', 'listitem');
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'chat-file-chip-name';
|
||||||
|
name.title = a.fileName;
|
||||||
|
name.textContent = a.fileName;
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.className = 'chat-file-chip-remove';
|
||||||
|
remove.title = '移除';
|
||||||
|
remove.innerHTML = '×';
|
||||||
|
remove.setAttribute('aria-label', '移除 ' + a.fileName);
|
||||||
|
remove.addEventListener('click', () => removeChatAttachment(i));
|
||||||
|
chip.appendChild(name);
|
||||||
|
chip.appendChild(remove);
|
||||||
|
list.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChatAttachment(index) {
|
||||||
|
chatAttachments.splice(index, 1);
|
||||||
|
renderChatFileChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有附件且输入框为空时,填入一句默认提示(可编辑);后端会单独拼接路径与内容给大模型
|
||||||
|
function appendChatFilePrompt() {
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
if (!input || !chatAttachments.length) return;
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
input.value = CHAT_FILE_DEFAULT_PROMPT;
|
||||||
|
adjustTextareaHeight(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsAttachment(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mimeType = file.type || '';
|
||||||
|
const isTextLike = /^text\//i.test(mimeType) || /^(application\/(json|xml|javascript)|image\/svg\+xml)/i.test(mimeType);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
let content = reader.result;
|
||||||
|
if (typeof content === 'string' && content.startsWith('data:')) {
|
||||||
|
content = content.replace(/^data:[^;]+;base64,/, '');
|
||||||
|
}
|
||||||
|
resolve({ fileName: file.name, content: content, mimeType: mimeType });
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
if (isTextLike) {
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilesToChat(files) {
|
||||||
|
if (!files || !files.length) return;
|
||||||
|
const next = Array.from(files);
|
||||||
|
if (chatAttachments.length + next.length > MAX_CHAT_FILES) {
|
||||||
|
alert('最多同时上传 ' + MAX_CHAT_FILES + ' 个文件,当前已选 ' + chatAttachments.length + ' 个。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addOne = (file) => {
|
||||||
|
return readFileAsAttachment(file).then((a) => {
|
||||||
|
chatAttachments.push(a);
|
||||||
|
renderChatFileChips();
|
||||||
|
appendChatFilePrompt();
|
||||||
|
}).catch(() => {
|
||||||
|
alert('读取文件失败:' + file.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let p = Promise.resolve();
|
||||||
|
next.forEach((file) => { p = p.then(() => addOne(file)); });
|
||||||
|
p.then(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupChatFileUpload() {
|
||||||
|
const inputEl = document.getElementById('chat-file-input');
|
||||||
|
const container = document.getElementById('chat-input-container') || document.querySelector('.chat-input-container');
|
||||||
|
if (!inputEl || !container) return;
|
||||||
|
|
||||||
|
inputEl.addEventListener('change', function () {
|
||||||
|
const files = this.files;
|
||||||
|
if (files && files.length) {
|
||||||
|
addFilesToChat(files);
|
||||||
|
}
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('dragover', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
container.addEventListener('dragleave', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!this.contains(e.relatedTarget)) {
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.addEventListener('drop', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
const files = e.dataTransfer && e.dataTransfer.files;
|
||||||
|
if (files && files.length) addFilesToChat(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 chat-input-container 有 id(若模板未写)
|
||||||
|
function ensureChatInputContainerId() {
|
||||||
|
const c = document.querySelector('.chat-input-container');
|
||||||
|
if (c && !c.id) c.id = 'chat-input-container';
|
||||||
|
}
|
||||||
|
|
||||||
function setupMentionSupport() {
|
function setupMentionSupport() {
|
||||||
mentionSuggestionsEl = document.getElementById('mention-suggestions');
|
mentionSuggestionsEl = document.getElementById('mention-suggestions');
|
||||||
if (mentionSuggestionsEl) {
|
if (mentionSuggestionsEl) {
|
||||||
@@ -799,6 +947,8 @@ function initializeChatUI() {
|
|||||||
}
|
}
|
||||||
activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL);
|
activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL);
|
||||||
setupMentionSupport();
|
setupMentionSupport();
|
||||||
|
ensureChatInputContainerId();
|
||||||
|
setupChatFileUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息计数器,确保ID唯一
|
// 消息计数器,确保ID唯一
|
||||||
|
|||||||
+25
-15
@@ -499,7 +499,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
||||||
<div id="chat-messages" class="chat-messages"></div>
|
<div id="chat-messages" class="chat-messages"></div>
|
||||||
<div class="chat-input-container">
|
<div id="chat-input-container" class="chat-input-container">
|
||||||
<div class="role-selector-wrapper">
|
<div class="role-selector-wrapper">
|
||||||
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" title="选择角色">
|
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" title="选择角色">
|
||||||
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
|
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
|
||||||
@@ -521,10 +521,19 @@
|
|||||||
<div id="role-selection-list" class="role-selection-list-main"></div>
|
<div id="role-selection-list" class="role-selection-list-main"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input-field">
|
<div class="chat-input-with-files">
|
||||||
<textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
|
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
|
||||||
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
|
<div class="chat-input-field">
|
||||||
|
<textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
|
||||||
|
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" title="选择文件">
|
||||||
|
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" title="上传文件(可多选或拖拽到此处)">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="send-btn" onclick="sendMessage()">
|
<button class="send-btn" onclick="sendMessage()">
|
||||||
<span>发送</span>
|
<span>发送</span>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -1291,18 +1300,19 @@
|
|||||||
<h4>机器人命令说明</h4>
|
<h4>机器人命令说明</h4>
|
||||||
<p class="settings-description">在对话中可发送以下命令(支持中英文):</p>
|
<p class="settings-description">在对话中可发送以下命令(支持中英文):</p>
|
||||||
<ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;">
|
<ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;">
|
||||||
<li><strong>帮助</strong> / help — 显示命令帮助</li>
|
<li><code>帮助</code> <code>help</code> — 显示本帮助 | Show this help</li>
|
||||||
<li><strong>列表</strong> / <strong>对话列表</strong> / list — 列出所有对话标题与 ID</li>
|
<li><code>列表</code> <code>list</code> — 列出所有对话标题与 ID | List conversations</li>
|
||||||
<li><strong>切换 <ID></strong> / <strong>继续 <ID></strong> / switch <ID> — 指定对话继续</li>
|
<li><code>切换 <ID></code> <code>switch <ID></code> — 指定对话继续 | Switch to conversation</li>
|
||||||
<li><strong>新对话</strong> / new — 开启新对话</li>
|
<li><code>新对话</code> <code>new</code> — 开启新对话 | Start new conversation</li>
|
||||||
<li><strong>清空</strong> / clear — 清空当前上下文(等同于新对话)</li>
|
<li><code>清空</code> <code>clear</code> — 清空当前上下文 | Clear context</li>
|
||||||
<li><strong>当前</strong> / current — 显示当前对话 ID 与标题</li>
|
<li><code>当前</code> <code>current</code> — 显示当前对话 ID 与标题 | Show current conversation</li>
|
||||||
<li><strong>停止</strong> / stop — 中断当前正在执行的任务</li>
|
<li><code>停止</code> <code>stop</code> — 中断当前任务 | Stop running task</li>
|
||||||
<li><strong>角色</strong> / <strong>角色列表</strong> / roles — 列出所有可用角色</li>
|
<li><code>角色</code> <code>roles</code> — 列出所有可用角色 | List roles</li>
|
||||||
<li><strong>角色 <名></strong> / <strong>切换角色 <名></strong> / role <name> — 切换当前角色</li>
|
<li><code>角色 <名></code> <code>role <name></code> — 切换当前角色 | Switch role</li>
|
||||||
<li><strong>删除 <ID></strong> / delete <ID> — 删除指定对话</li>
|
<li><code>删除 <ID></code> <code>delete <ID></code> — 删除指定对话 | Delete conversation</li>
|
||||||
<li><strong>版本</strong> / version — 显示当前版本号</li>
|
<li><code>版本</code> <code>version</code> — 显示当前版本号 | Show version</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p class="settings-description" style="margin-top: 8px;">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user