mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-15 12:58:01 +02:00
Add files via upload
This commit is contained in:
@@ -320,6 +320,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||
@@ -439,6 +440,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
app, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler,
|
||||
webshellHandler,
|
||||
chatUploadsHandler,
|
||||
roleHandler,
|
||||
skillsHandler,
|
||||
fofaHandler,
|
||||
@@ -567,6 +569,7 @@ func setupRoutes(
|
||||
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler *handler.VulnerabilityHandler,
|
||||
webshellHandler *handler.WebShellHandler,
|
||||
chatUploadsHandler *handler.ChatUploadsHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
skillsHandler *handler.SkillsHandler,
|
||||
fofaHandler *handler.FofaHandler,
|
||||
@@ -838,6 +841,15 @@ func setupRoutes(
|
||||
protected.POST("/webshell/exec", webshellHandler.Exec)
|
||||
protected.POST("/webshell/file", webshellHandler.FileOp)
|
||||
|
||||
// 对话附件(chat_uploads)管理
|
||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||
protected.DELETE("/chat-uploads", chatUploadsHandler.Delete)
|
||||
protected.PUT("/chat-uploads/rename", chatUploadsHandler.Rename)
|
||||
protected.PUT("/chat-uploads/content", chatUploadsHandler.PutContent)
|
||||
|
||||
// 角色管理
|
||||
protected.GET("/roles", roleHandler.GetRoles)
|
||||
protected.GET("/roles/:name", roleHandler.GetRole)
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
chatUploadsRootDirName = "chat_uploads"
|
||||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||||
)
|
||||
|
||||
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
|
||||
type ChatUploadsHandler struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewChatUploadsHandler 创建处理器
|
||||
func NewChatUploadsHandler(logger *zap.Logger) *ChatUploadsHandler {
|
||||
return &ChatUploadsHandler{logger: logger}
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) absRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName))
|
||||
}
|
||||
|
||||
// resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下
|
||||
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel := strings.TrimSpace(relativePath)
|
||||
if rel == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
full := filepath.Join(root, rel)
|
||||
full, err = filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs, _ := filepath.Abs(root)
|
||||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes chat_uploads root")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// ChatUploadFileItem 列表项
|
||||
type ChatUploadFileItem struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
}
|
||||
|
||||
// List GET /api/chat-uploads
|
||||
func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||||
conversationFilter := strings.TrimSpace(c.Query("conversation"))
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusOK, gin.H{"files": []ChatUploadFileItem{}})
|
||||
return
|
||||
}
|
||||
var files []ChatUploadFileItem
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
parts := strings.Split(relSlash, "/")
|
||||
var dateStr, convID string
|
||||
if len(parts) >= 2 {
|
||||
dateStr = parts[0]
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
convID = parts[1]
|
||||
}
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
return nil
|
||||
}
|
||||
absPath, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Warn("列举对话附件失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModifiedUnix > files[j].ModifiedUnix
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||
}
|
||||
|
||||
// Download GET /api/chat-uploads/download?path=...
|
||||
func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
p := c.Query("path")
|
||||
abs, err := h.resolveUnderChatUploads(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || st.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.FileAttachment(abs, filepath.Base(abs))
|
||||
}
|
||||
|
||||
type chatUploadPathBody struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Delete DELETE /api/chat-uploads
|
||||
func (h *ChatUploadsHandler) Delete(c *gin.Context) {
|
||||
var body chatUploadPathBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Path) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
type chatUploadRenameBody struct {
|
||||
Path string `json:"path"`
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
// Rename PUT /api/chat-uploads/rename
|
||||
func (h *ChatUploadsHandler) Rename(c *gin.Context) {
|
||||
var body chatUploadRenameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
newName := strings.TrimSpace(body.NewName)
|
||||
if newName == "" || strings.ContainsAny(newName, `/\`) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid newName"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(abs)
|
||||
newAbs := filepath.Join(dir, filepath.Base(newName))
|
||||
root, _ := h.absRoot()
|
||||
newAbs, _ = filepath.Abs(newAbs)
|
||||
if newAbs != root && !strings.HasPrefix(newAbs, root+string(filepath.Separator)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target path"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(abs, newAbs); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
newRel, _ := filepath.Rel(root, newAbs)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(newRel)})
|
||||
}
|
||||
|
||||
type chatUploadContentBody struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// GetContent GET /api/chat-uploads/content?path=...
|
||||
func (h *ChatUploadsHandler) GetContent(c *gin.Context) {
|
||||
p := c.Query("path")
|
||||
abs, err := h.resolveUnderChatUploads(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || st.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
if st.Size() > maxChatUploadEditBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large for editor"})
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !utf8.Valid(b) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "binary file not editable in UI"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"content": string(b)})
|
||||
}
|
||||
|
||||
// PutContent PUT /api/chat-uploads/content
|
||||
func (h *ChatUploadsHandler) PutContent(c *gin.Context) {
|
||||
var body chatUploadContentBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if !utf8.ValidString(body.Content) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "content must be valid UTF-8"})
|
||||
return
|
||||
}
|
||||
if len(body.Content) > maxChatUploadEditBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "content too large"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(body.Content), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func chatUploadShortRand(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)
|
||||
}
|
||||
|
||||
// Upload POST /api/chat-uploads (multipart: file, conversationId 可选)
|
||||
func (h *ChatUploadsHandler) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil || fh == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
convID := strings.TrimSpace(c.PostForm("conversationId"))
|
||||
convDir := convID
|
||||
if convDir == "" {
|
||||
convDir = "_manual"
|
||||
} else {
|
||||
convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_")
|
||||
}
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
dateStr := time.Now().Format("2006-01-02")
|
||||
targetDir := filepath.Join(root, dateStr, convDir)
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
baseName := filepath.Base(fh.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"), chatUploadShortRand(6))
|
||||
var unique string
|
||||
if ext != "" {
|
||||
unique = nameNoExt + suffix + ext
|
||||
} else {
|
||||
unique = baseName + suffix
|
||||
}
|
||||
fullPath := filepath.Join(targetDir, unique)
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rel, _ := filepath.Rel(root, fullPath)
|
||||
absSaved, _ := filepath.Abs(fullPath)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"relativePath": filepath.ToSlash(rel),
|
||||
"absolutePath": absSaved,
|
||||
"name": unique,
|
||||
})
|
||||
}
|
||||
+224
-36
@@ -3445,43 +3445,8 @@ header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 所有终端容器统一直线滚动条样式 */
|
||||
.terminal-container .xterm-viewport,
|
||||
.settings-modal .xterm-viewport,
|
||||
.webshell-terminal-container .xterm-viewport,
|
||||
.modal .xterm-viewport {
|
||||
.terminal-container .xterm-viewport {
|
||||
border-radius: 0;
|
||||
/* 终端滚动条统一样式 - 始终隐藏 */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar,
|
||||
.settings-modal .xterm-viewport::-webkit-scrollbar,
|
||||
.webshell-terminal-container .xterm-viewport::-webkit-scrollbar,
|
||||
.modal .xterm-viewport::-webkit-scrollbar {
|
||||
width: 0px; /* Chrome/Safari/Webkit */
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 可选:隐藏额外重叠的滚动条 */
|
||||
.xterm .xterm-viewport,
|
||||
.xterm-viewport {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 确保悬停时不显示 */
|
||||
.xterm .xterm-viewport::-webkit-scrollbar,
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 隐藏任何可能的默认浏览器滚动条 */
|
||||
*::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
@@ -8882,6 +8847,29 @@ header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* WebShell 终端滚动条:深色主题、细窄、圆角,弱化存在感 */
|
||||
.webshell-terminal-container .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(110, 118, 129, 0.5) transparent;
|
||||
}
|
||||
.webshell-terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(110, 118, 129, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(110, 118, 129, 0.65);
|
||||
}
|
||||
.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(139, 148, 158, 0.7);
|
||||
}
|
||||
|
||||
.webshell-file-toolbar {
|
||||
display: flex;
|
||||
@@ -12872,3 +12860,203 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
/* 对话附件文件管理 */
|
||||
.chat-files-intro {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-files-filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-files-table-wrap {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chat-files-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chat-files-table th,
|
||||
.chat-files-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-files-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.chat-files-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-files-cell-name {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-files-cell-conv code {
|
||||
font-size: 0.8rem;
|
||||
max-width: 160px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.chat-files-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-files-action-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-files-action-bar .btn-icon {
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-files-dropdown-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-files-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 220px;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 400;
|
||||
}
|
||||
|
||||
/* JS 使用 fixed 定位时覆盖 absolute,避免被表格区域 overflow 裁切 */
|
||||
.chat-files-dropdown.chat-files-dropdown-fixed {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 10px 16px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item.is-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item.is-danger:hover:not(:disabled) {
|
||||
background: rgba(220, 53, 69, 0.08);
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item.is-disabled {
|
||||
color: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-no-edit {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-files-modal-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-files-edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-files-rename-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-files-toast {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 12px 18px;
|
||||
background: var(--text-primary, #1a1a1a);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-files-toast.chat-files-toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
|
||||
+45
-10
@@ -46,17 +46,18 @@
|
||||
"tasks": "Tasks",
|
||||
"vulnerabilities": "Vulnerabilities",
|
||||
"webshell": "WebShell Management",
|
||||
"chatFiles": "File Management",
|
||||
"mcp": "MCP",
|
||||
"mcpMonitor": "MCP Monitor",
|
||||
"mcpManagement": "MCP Management",
|
||||
"knowledge": "Knowledge",
|
||||
"knowledgeRetrievalLogs": "Retrieval history",
|
||||
"knowledgeManagement": "Knowledge management",
|
||||
"knowledgeManagement": "Knowledge Management",
|
||||
"skills": "Skills",
|
||||
"skillsMonitor": "Skills monitor",
|
||||
"skillsManagement": "Skills management",
|
||||
"skillsManagement": "Skills Management",
|
||||
"roles": "Roles",
|
||||
"rolesManagement": "Roles management",
|
||||
"rolesManagement": "Roles Management",
|
||||
"settings": "System settings"
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -186,7 +187,7 @@
|
||||
"execFailed": "Execution failed"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Task management",
|
||||
"title": "Task Management",
|
||||
"stopTask": "Stop task",
|
||||
"collapseDetail": "Collapse details",
|
||||
"newTask": "New task",
|
||||
@@ -324,7 +325,7 @@
|
||||
"parseModalApplyRun": "Fill and query"
|
||||
},
|
||||
"vulnerability": {
|
||||
"title": "Vulnerability management",
|
||||
"title": "Vulnerability Management",
|
||||
"addVuln": "Add vulnerability",
|
||||
"editVuln": "Edit vulnerability",
|
||||
"loadFailed": "Failed to load vulnerabilities",
|
||||
@@ -552,7 +553,7 @@
|
||||
"loggedOut": "Signed out"
|
||||
},
|
||||
"knowledge": {
|
||||
"title": "Knowledge management",
|
||||
"title": "Knowledge Management",
|
||||
"retrievalLogs": "Retrieval history",
|
||||
"totalItems": "Total items",
|
||||
"categories": "Categories",
|
||||
@@ -565,7 +566,7 @@
|
||||
"goToSettings": "Go to settings"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Role management",
|
||||
"title": "Role Management",
|
||||
"createRole": "Create role",
|
||||
"searchPlaceholder": "Search roles...",
|
||||
"deleteConfirm": "Delete this role?",
|
||||
@@ -579,7 +580,7 @@
|
||||
"noDescriptionShort": "No description"
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills management",
|
||||
"title": "Skills Management",
|
||||
"monitorTitle": "Skills monitor",
|
||||
"createSkill": "Create Skill",
|
||||
"callStats": "Call stats",
|
||||
@@ -994,6 +995,40 @@
|
||||
"exportXlsxTitle": "Export results as Excel",
|
||||
"batchScanTitle": "Create batch task queue from selected rows"
|
||||
},
|
||||
"chatFilesPage": {
|
||||
"title": "File Management",
|
||||
"intro": "Files uploaded in chat appear here. Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
|
||||
"upload": "Upload",
|
||||
"conversationFilter": "Conversation ID",
|
||||
"conversationPlaceholder": "Leave empty for all",
|
||||
"searchName": "File name",
|
||||
"searchNamePlaceholder": "Filter by file name",
|
||||
"colDate": "Date",
|
||||
"colConversation": "Conversation",
|
||||
"colName": "Name",
|
||||
"colSize": "Size",
|
||||
"colModified": "Modified",
|
||||
"colActions": "Actions",
|
||||
"copyPath": "Copy path",
|
||||
"copyPathTitle": "Copy the absolute path on the server; paste into chat to reference this file",
|
||||
"pathCopied": "Path copied — paste it into chat",
|
||||
"uploadOkHint": "Uploaded. Use “Copy path” to copy the absolute path.",
|
||||
"moreActions": "More: open chat, edit, rename, delete",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"rename": "Rename",
|
||||
"openChat": "Open chat",
|
||||
"confirmDelete": "Delete this file?",
|
||||
"editTitle": "Edit file",
|
||||
"renameTitle": "Rename",
|
||||
"newFileName": "New file name",
|
||||
"empty": "No chat uploads yet",
|
||||
"errorLoad": "Failed to load",
|
||||
"editBinaryHint": "Binary files (images, archives, etc.) cannot be edited as text here. Use Download and open locally.",
|
||||
"editUnavailable": "N/A",
|
||||
"editTooLarge": "File exceeds 2MB and cannot be edited here. Download and edit locally.",
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"statTotal": "Total",
|
||||
"filter": "Filter",
|
||||
@@ -1361,10 +1396,10 @@
|
||||
"userPromptHint": "This prompt is appended before user message to guide AI. It does not change system prompt.",
|
||||
"relatedTools": "Related tools (optional)",
|
||||
"defaultRoleToolsTitle": "Default role uses all tools",
|
||||
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP management.",
|
||||
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP Management.",
|
||||
"searchToolsPlaceholder": "Search tools...",
|
||||
"loadingTools": "Loading tools...",
|
||||
"relatedToolsHint": "Select tools to link; empty = use all from MCP management.",
|
||||
"relatedToolsHint": "Select tools to link; empty = use all from MCP Management.",
|
||||
"relatedSkills": "Related Skills (optional)",
|
||||
"searchSkillsPlaceholder": "Search skill...",
|
||||
"loadingSkills": "Loading skills...",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"tasks": "任务管理",
|
||||
"vulnerabilities": "漏洞管理",
|
||||
"webshell": "WebShell管理",
|
||||
"chatFiles": "文件管理",
|
||||
"mcp": "MCP",
|
||||
"mcpMonitor": "MCP状态监控",
|
||||
"mcpManagement": "MCP管理",
|
||||
@@ -994,6 +995,40 @@
|
||||
"exportXlsxTitle": "导出当前结果为 Excel",
|
||||
"batchScanTitle": "将所选行创建为批量任务队列"
|
||||
},
|
||||
"chatFilesPage": {
|
||||
"title": "文件管理",
|
||||
"intro": "管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
|
||||
"upload": "上传文件",
|
||||
"conversationFilter": "会话 ID",
|
||||
"conversationPlaceholder": "留空表示全部",
|
||||
"searchName": "文件名",
|
||||
"searchNamePlaceholder": "筛选文件名",
|
||||
"colDate": "日期",
|
||||
"colConversation": "会话",
|
||||
"colName": "文件名",
|
||||
"colSize": "大小",
|
||||
"colModified": "修改时间",
|
||||
"colActions": "操作",
|
||||
"copyPath": "复制路径",
|
||||
"copyPathTitle": "复制服务器上的绝对路径,可粘贴到对话中让模型引用该文件",
|
||||
"pathCopied": "路径已复制,可到对话中粘贴使用",
|
||||
"uploadOkHint": "上传成功。点击「复制路径」可复制绝对路径到剪贴板。",
|
||||
"moreActions": "更多:打开对话、编辑、重命名、删除",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"rename": "重命名",
|
||||
"openChat": "打开对话",
|
||||
"confirmDelete": "确定删除该文件?",
|
||||
"editTitle": "编辑文件",
|
||||
"renameTitle": "重命名",
|
||||
"newFileName": "新文件名",
|
||||
"empty": "暂无对话附件",
|
||||
"errorLoad": "加载失败",
|
||||
"editBinaryHint": "图片、压缩包等二进制文件无法在此以文本方式编辑,请使用「下载」后在本地查看或处理。",
|
||||
"editUnavailable": "不可编辑",
|
||||
"editTooLarge": "文件超过 2MB,无法在此编辑,请下载后本地处理。",
|
||||
"errorGeneric": "操作失败,请稍后重试。"
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"statTotal": "总漏洞数",
|
||||
"filter": "筛选",
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
// 对话附件(chat_uploads)文件管理
|
||||
|
||||
let chatFilesCache = [];
|
||||
let chatFilesDisplayed = [];
|
||||
let chatFilesEditRelativePath = '';
|
||||
let chatFilesRenameRelativePath = '';
|
||||
|
||||
function initChatFilesPage() {
|
||||
ensureChatFilesDocClickClose();
|
||||
loadChatFilesPage();
|
||||
}
|
||||
|
||||
function chatFilesCloseAllMenus() {
|
||||
document.querySelectorAll('.chat-files-dropdown').forEach((el) => {
|
||||
el.hidden = true;
|
||||
el.style.position = '';
|
||||
el.style.left = '';
|
||||
el.style.top = '';
|
||||
el.style.right = '';
|
||||
el.style.minWidth = '';
|
||||
el.style.zIndex = '';
|
||||
el.classList.remove('chat-files-dropdown-fixed');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 「更多」菜单使用 fixed 定位,避免表格外层 overflow 把菜单裁成一条细线。
|
||||
*/
|
||||
function chatFilesToggleMoreMenu(ev, idx) {
|
||||
if (ev) ev.stopPropagation();
|
||||
const menu = document.getElementById('chat-files-menu-' + idx);
|
||||
const btn = ev && ev.currentTarget;
|
||||
if (!menu) return;
|
||||
const opening = menu.hidden;
|
||||
chatFilesCloseAllMenus();
|
||||
if (!opening) return;
|
||||
|
||||
menu.hidden = false;
|
||||
menu.classList.add('chat-files-dropdown-fixed');
|
||||
if (!btn || typeof btn.getBoundingClientRect !== 'function') return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const margin = 8;
|
||||
const minW = 220;
|
||||
menu.style.boxSizing = 'border-box';
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = '5000';
|
||||
menu.style.minWidth = minW + 'px';
|
||||
menu.style.right = 'auto';
|
||||
|
||||
const w = Math.max(minW, menu.offsetWidth || minW);
|
||||
let left = r.right - w;
|
||||
if (left < margin) left = margin;
|
||||
if (left + w > vw - margin) left = Math.max(margin, vw - margin - w);
|
||||
menu.style.left = left + 'px';
|
||||
|
||||
const gap = 6;
|
||||
let top = r.bottom + gap;
|
||||
const estH = menu.offsetHeight || 120;
|
||||
if (top + estH > vh - margin && r.top - gap - estH >= margin) {
|
||||
top = r.top - gap - estH;
|
||||
}
|
||||
menu.style.top = top + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
window.chatFilesCloseAllMenus = chatFilesCloseAllMenus;
|
||||
window.chatFilesToggleMoreMenu = chatFilesToggleMoreMenu;
|
||||
|
||||
function ensureChatFilesDocClickClose() {
|
||||
if (window.__chatFilesDocClose) return;
|
||||
window.__chatFilesDocClose = true;
|
||||
document.addEventListener('click', function (ev) {
|
||||
if (ev.target.closest && ev.target.closest('.chat-files-dropdown-wrap')) return;
|
||||
chatFilesCloseAllMenus();
|
||||
});
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Escape') chatFilesCloseAllMenus();
|
||||
});
|
||||
window.addEventListener(
|
||||
'scroll',
|
||||
function () {
|
||||
chatFilesCloseAllMenus();
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener('resize', function () {
|
||||
chatFilesCloseAllMenus();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChatFilesPage() {
|
||||
const wrap = document.getElementById('chat-files-list-wrap');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = '<div class="loading-spinner" data-i18n="common.loading">加载中…</div>';
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(wrap);
|
||||
}
|
||||
|
||||
const conv = document.getElementById('chat-files-filter-conv');
|
||||
const convQ = conv ? conv.value.trim() : '';
|
||||
let url = '/api/chat-uploads';
|
||||
if (convQ) {
|
||||
url += '?conversation=' + encodeURIComponent(convQ);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
throw new Error(t || res.status);
|
||||
}
|
||||
const data = await res.json();
|
||||
chatFilesCache = Array.isArray(data.files) ? data.files : [];
|
||||
renderChatFilesTable();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.errorLoad') : '加载失败';
|
||||
wrap.innerHTML = '<div class="error-message">' + escapeHtml(msg + ': ' + (e.message || String(e))) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function chatFilesNameFilter(files) {
|
||||
const el = document.getElementById('chat-files-filter-name');
|
||||
const q = el ? el.value.trim().toLowerCase() : '';
|
||||
if (!q) return files;
|
||||
return files.filter((f) => (f.name || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/** 仅前端按文件名筛选,不重新请求 */
|
||||
function chatFilesFilterNameOnInput() {
|
||||
if (!chatFilesCache.length) return;
|
||||
renderChatFilesTable();
|
||||
}
|
||||
|
||||
function formatChatFileBytes(n) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function chatFilesShowToast(message) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-files-toast';
|
||||
el.setAttribute('role', 'status');
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add('chat-files-toast-visible'));
|
||||
setTimeout(() => {
|
||||
el.classList.remove('chat-files-toast-visible');
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
async function chatFilesCopyText(text) {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch (e2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyChatFilePathIdx(idx) {
|
||||
const f = chatFilesDisplayed[idx];
|
||||
if (!f) return;
|
||||
const text = (f.absolutePath && String(f.absolutePath).trim())
|
||||
? String(f.absolutePath).trim()
|
||||
: ('chat_uploads/' + String(f.relativePath || '').replace(/^\/+/, ''));
|
||||
const ok = await chatFilesCopyText(text);
|
||||
if (ok) {
|
||||
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.pathCopied') : '路径已复制,可粘贴到对话中引用';
|
||||
chatFilesShowToast(msg);
|
||||
} else {
|
||||
const fail = (typeof window.t === 'function') ? window.t('common.copyFailed') : '复制失败';
|
||||
alert(fail);
|
||||
}
|
||||
}
|
||||
|
||||
/** 常见二进制扩展名:此类文件无法在纯文本编辑器中打开 */
|
||||
const CHAT_FILES_BINARY_EXT = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'svgz',
|
||||
'pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst',
|
||||
'mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac',
|
||||
'mp4', 'avi', 'mkv', 'mov', 'wmv', 'webm', 'm4v',
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'app', 'dmg', 'pkg',
|
||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||
'sqlite', 'db', 'sqlite3',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods',
|
||||
'class', 'jar', 'war', 'apk', 'ipa',
|
||||
'iso', 'img'
|
||||
]);
|
||||
|
||||
function chatFileIsBinaryByName(fileName) {
|
||||
if (!fileName || typeof fileName !== 'string') return false;
|
||||
const i = fileName.lastIndexOf('.');
|
||||
if (i < 0 || i === fileName.length - 1) return false;
|
||||
const ext = fileName.slice(i + 1).toLowerCase();
|
||||
return CHAT_FILES_BINARY_EXT.has(ext);
|
||||
}
|
||||
|
||||
function chatFilesEditBlockedHint() {
|
||||
return (typeof window.t === 'function')
|
||||
? window.t('chatFilesPage.editBinaryHint')
|
||||
: '图片、压缩包等二进制文件无法在此以文本方式编辑,请使用「下载」。';
|
||||
}
|
||||
|
||||
function chatFilesAlertMessage(raw) {
|
||||
const s = (raw == null) ? '' : String(raw).trim();
|
||||
const lower = s.toLowerCase();
|
||||
if (lower.includes('binary file not editable') || lower.includes('binary')) {
|
||||
return chatFilesEditBlockedHint();
|
||||
}
|
||||
if (lower.includes('file too large') || lower.includes('entity too large') || lower.includes('413')) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.editTooLarge') : '文件过大,无法在此编辑。';
|
||||
}
|
||||
return s || ((typeof window.t === 'function') ? window.t('chatFilesPage.errorGeneric') : '操作失败');
|
||||
}
|
||||
|
||||
function renderChatFilesTable() {
|
||||
const wrap = document.getElementById('chat-files-list-wrap');
|
||||
if (!wrap) return;
|
||||
|
||||
chatFilesDisplayed = chatFilesNameFilter(chatFilesCache);
|
||||
const emptyMsg = (typeof window.t === 'function') ? window.t('chatFilesPage.empty') : '暂无文件';
|
||||
if (!chatFilesDisplayed.length) {
|
||||
wrap.innerHTML = '<div class="empty-state" data-i18n="chatFilesPage.empty">' + escapeHtml(emptyMsg) + '</div>';
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(wrap);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const thDate = (typeof window.t === 'function') ? window.t('chatFilesPage.colDate') : '日期';
|
||||
const thConv = (typeof window.t === 'function') ? window.t('chatFilesPage.colConversation') : '会话';
|
||||
const thName = (typeof window.t === 'function') ? window.t('chatFilesPage.colName') : '文件名';
|
||||
const thSize = (typeof window.t === 'function') ? window.t('chatFilesPage.colSize') : '大小';
|
||||
const thModified = (typeof window.t === 'function') ? window.t('chatFilesPage.colModified') : '修改时间';
|
||||
const thActions = (typeof window.t === 'function') ? window.t('chatFilesPage.colActions') : '操作';
|
||||
|
||||
const svgCopy = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
const svgDownload = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const svgMore = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>';
|
||||
|
||||
const tCopyTitle = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.copyPathTitle') : '复制服务器上的绝对路径,可粘贴到对话中引用');
|
||||
const tDlTitle = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.download') : '下载');
|
||||
const tMoreTitle = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.moreActions') : '更多操作');
|
||||
|
||||
const rows = chatFilesDisplayed.map((f, idx) => {
|
||||
const rp = f.relativePath || '';
|
||||
const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : rp;
|
||||
const nameEsc = escapeHtml(f.name || '');
|
||||
const conv = f.conversationId || '';
|
||||
const convEsc = escapeHtml(conv);
|
||||
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
|
||||
const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
|
||||
|
||||
const bin = chatFileIsBinaryByName(f.name);
|
||||
const editHint = escapeHtml(chatFilesEditBlockedHint());
|
||||
const editUnavailable = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.editUnavailable')) : '不可编辑';
|
||||
const tEdit = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.edit')) : '编辑';
|
||||
const tOpenChat = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.openChat')) : '打开对话';
|
||||
const tRename = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.rename')) : '重命名';
|
||||
const tDelete = (typeof window.t === 'function') ? escapeHtml(window.t('common.delete')) : '删除';
|
||||
|
||||
const menuParts = [];
|
||||
if (canOpenChat) {
|
||||
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesConversationIdx(${idx});">${tOpenChat}</button>`);
|
||||
}
|
||||
if (!bin) {
|
||||
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesEditIdx(${idx});">${tEdit}</button>`);
|
||||
} else {
|
||||
menuParts.push(`<div class="chat-files-dropdown-item is-disabled" title="${editHint}">${editUnavailable}</div>`);
|
||||
}
|
||||
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesRenameIdx(${idx});">${tRename}</button>`);
|
||||
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
|
||||
const menuHtml = menuParts.join('');
|
||||
|
||||
return `<tr>
|
||||
<td>${escapeHtml(f.date || '—')}</td>
|
||||
<td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td>
|
||||
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}</td>
|
||||
<td>${formatChatFileBytes(f.size || 0)}</td>
|
||||
<td>${escapeHtml(dt)}</td>
|
||||
<td class="chat-files-actions">
|
||||
<div class="chat-files-action-bar">
|
||||
<button type="button" class="btn-icon" title="${tCopyTitle}" onclick="copyChatFilePathIdx(${idx})">${svgCopy}</button>
|
||||
<button type="button" class="btn-icon" title="${tDlTitle}" onclick="downloadChatFileIdx(${idx})">${svgDownload}</button>
|
||||
<div class="chat-files-dropdown-wrap">
|
||||
<button type="button" class="btn-icon" title="${tMoreTitle}" aria-haspopup="true" onclick="chatFilesToggleMoreMenu(event, ${idx})">${svgMore}</button>
|
||||
<div class="chat-files-dropdown" id="chat-files-menu-${idx}" hidden>${menuHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
ensureChatFilesDocClickClose();
|
||||
|
||||
wrap.innerHTML = `<table class="chat-files-table"><thead><tr>
|
||||
<th>${escapeHtml(thDate)}</th>
|
||||
<th>${escapeHtml(thConv)}</th>
|
||||
<th>${escapeHtml(thName)}</th>
|
||||
<th>${escapeHtml(thSize)}</th>
|
||||
<th>${escapeHtml(thModified)}</th>
|
||||
<th>${escapeHtml(thActions)}</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
function openChatFilesConversationIdx(idx) {
|
||||
const f = chatFilesDisplayed[idx];
|
||||
if (!f || !f.conversationId) return;
|
||||
openChatFilesConversation(f.conversationId);
|
||||
}
|
||||
|
||||
function downloadChatFileIdx(idx) {
|
||||
const f = chatFilesDisplayed[idx];
|
||||
if (!f) return;
|
||||
downloadChatFile(f.relativePath, f.name);
|
||||
}
|
||||
|
||||
function openChatFilesEditIdx(idx) {
|
||||
const f = chatFilesDisplayed[idx];
|
||||
if (!f) return;
|
||||
if (chatFileIsBinaryByName(f.name)) {
|
||||
alert(chatFilesEditBlockedHint());
|
||||
return;
|
||||
}
|
||||
openChatFilesEdit(f.relativePath);
|
||||
}
|
||||
|
||||
function openChatFilesRenameIdx(idx) {
|
||||
const f = chatFilesDisplayed[idx];
|
||||
if (!f) return;
|
||||
openChatFilesRename(f.relativePath, f.name);
|
||||
}
|
||||
|
||||
function deleteChatFileIdx(idx) {
|
||||
const f = chatFilesDisplayed[idx];
|
||||
if (!f) return;
|
||||
deleteChatFile(f.relativePath);
|
||||
}
|
||||
|
||||
function openChatFilesConversation(conversationId) {
|
||||
if (!conversationId) return;
|
||||
window.location.hash = 'chat?conversation=' + encodeURIComponent(conversationId);
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('chat');
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (typeof loadConversation === 'function') {
|
||||
loadConversation(conversationId);
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function downloadChatFile(relativePath, filename) {
|
||||
try {
|
||||
const url = '/api/chat-uploads/download?path=' + encodeURIComponent(relativePath);
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename || 'download';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChatFile(relativePath) {
|
||||
const q = (typeof window.t === 'function') ? window.t('chatFilesPage.confirmDelete') : '确定删除该文件?';
|
||||
if (!confirm(q)) return;
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: relativePath })
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
loadChatFilesPage();
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function openChatFilesEdit(relativePath) {
|
||||
chatFilesEditRelativePath = relativePath;
|
||||
const pathEl = document.getElementById('chat-files-edit-path');
|
||||
const ta = document.getElementById('chat-files-edit-textarea');
|
||||
const modal = document.getElementById('chat-files-edit-modal');
|
||||
if (pathEl) pathEl.textContent = relativePath;
|
||||
if (ta) ta.value = '';
|
||||
if (modal) modal.style.display = 'block';
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads/content?path=' + encodeURIComponent(relativePath));
|
||||
if (!res.ok) {
|
||||
let errText = '';
|
||||
try {
|
||||
const err = await res.json();
|
||||
errText = err.error || JSON.stringify(err);
|
||||
} catch (e2) {
|
||||
errText = await res.text();
|
||||
}
|
||||
throw new Error(errText || res.status);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (ta) ta.value = data.content != null ? String(data.content) : '';
|
||||
} catch (e) {
|
||||
if (modal) modal.style.display = 'none';
|
||||
alert(chatFilesAlertMessage(e && e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function closeChatFilesEditModal() {
|
||||
const modal = document.getElementById('chat-files-edit-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
chatFilesEditRelativePath = '';
|
||||
}
|
||||
|
||||
async function saveChatFilesEdit() {
|
||||
const ta = document.getElementById('chat-files-edit-textarea');
|
||||
if (!ta || !chatFilesEditRelativePath) return;
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads/content', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: chatFilesEditRelativePath, content: ta.value })
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
closeChatFilesEditModal();
|
||||
loadChatFilesPage();
|
||||
} catch (e) {
|
||||
alert(chatFilesAlertMessage(e && e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function openChatFilesRename(relativePath, currentName) {
|
||||
chatFilesRenameRelativePath = relativePath;
|
||||
const input = document.getElementById('chat-files-rename-input');
|
||||
const modal = document.getElementById('chat-files-rename-modal');
|
||||
if (input) input.value = currentName || '';
|
||||
if (modal) modal.style.display = 'block';
|
||||
setTimeout(() => { if (input) input.focus(); }, 100);
|
||||
}
|
||||
|
||||
function closeChatFilesRenameModal() {
|
||||
const modal = document.getElementById('chat-files-rename-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
chatFilesRenameRelativePath = '';
|
||||
}
|
||||
|
||||
async function submitChatFilesRename() {
|
||||
const input = document.getElementById('chat-files-rename-input');
|
||||
const newName = input ? input.value.trim() : '';
|
||||
if (!newName || !chatFilesRenameRelativePath) {
|
||||
closeChatFilesRenameModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads/rename', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: chatFilesRenameRelativePath, newName: newName })
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
closeChatFilesRenameModal();
|
||||
loadChatFilesPage();
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function onChatFilesUploadPick(ev) {
|
||||
const input = ev.target;
|
||||
const file = input && input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const conv = document.getElementById('chat-files-filter-conv');
|
||||
if (conv && conv.value.trim()) {
|
||||
form.append('conversationId', conv.value.trim());
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
loadChatFilesPage();
|
||||
if (data && data.ok) {
|
||||
const msg = (typeof window.t === 'function')
|
||||
? window.t('chatFilesPage.uploadOkHint')
|
||||
: '上传成功。在列表中点击「复制路径」即可粘贴到对话中引用。';
|
||||
chatFilesShowToast(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 语言切换后重新渲染列表:表头与「更多」菜单由 JS 拼接,无 data-i18n,需用当前语言的 t() 再生成一遍
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (typeof window.currentPage !== 'function') return;
|
||||
if (window.currentPage() !== 'chat-files') return;
|
||||
if (typeof renderChatFilesTable === 'function') {
|
||||
renderChatFilesTable();
|
||||
}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ function initRouter() {
|
||||
if (hash) {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
@@ -299,6 +299,11 @@ function initPage(pageId) {
|
||||
initWebshellPage();
|
||||
}
|
||||
break;
|
||||
case 'chat-files':
|
||||
if (typeof initChatFilesPage === 'function') {
|
||||
initChatFilesPage();
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
// 初始化设置页面(不需要加载工具列表)
|
||||
if (typeof loadConfig === 'function') {
|
||||
@@ -368,7 +373,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
|
||||
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
|
||||
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
|
||||
@@ -150,6 +150,14 @@
|
||||
<span data-i18n="nav.webshell">WebShell管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="chat-files">
|
||||
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span data-i18n="nav.chatFiles">文件管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item nav-item-has-submenu" data-page="mcp">
|
||||
<div class="nav-item-content" data-title="MCP" onclick="toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -1000,6 +1008,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话附件 / 文件管理 -->
|
||||
<div id="page-chat-files" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<button type="button" class="btn-primary" onclick="document.getElementById('chat-files-upload-input').click()" data-i18n="chatFilesPage.upload">上传文件</button>
|
||||
<input type="file" id="chat-files-upload-input" style="display:none" onchange="onChatFilesUploadPick(event)" />
|
||||
<button class="btn-secondary" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<p class="chat-files-intro" data-i18n="chatFilesPage.intro">管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。</p>
|
||||
<div class="tasks-filters chat-files-filters">
|
||||
<label>
|
||||
<span data-i18n="chatFilesPage.conversationFilter">会话 ID</span>
|
||||
<input type="text" id="chat-files-filter-conv" class="form-control" data-i18n="chatFilesPage.conversationPlaceholder" data-i18n-attr="placeholder" placeholder="留空表示全部" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
|
||||
</label>
|
||||
<label style="flex:1;min-width:180px;max-width:360px;">
|
||||
<span data-i18n="chatFilesPage.searchName">文件名</span>
|
||||
<input type="text" id="chat-files-filter-name" class="form-control" data-i18n="chatFilesPage.searchNamePlaceholder" data-i18n-attr="placeholder" placeholder="筛选文件名" oninput="chatFilesFilterNameOnInput()" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
|
||||
</label>
|
||||
<button class="btn-secondary" type="button" onclick="loadChatFilesPage()" data-i18n="common.search">搜索</button>
|
||||
</div>
|
||||
<div id="chat-files-list-wrap" class="chat-files-table-wrap">
|
||||
<div class="loading-spinner" data-i18n="common.loading">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务管理页面 -->
|
||||
<div id="page-tasks" class="page">
|
||||
<div class="page-header">
|
||||
@@ -1721,6 +1758,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-files-edit-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 720px;">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="chatFilesPage.editTitle">编辑文件</h2>
|
||||
<span class="modal-close" onclick="closeChatFilesEditModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="chat-files-modal-path"><code id="chat-files-edit-path"></code></p>
|
||||
<textarea id="chat-files-edit-textarea" class="form-control chat-files-edit-textarea" rows="18"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeChatFilesEditModal()" data-i18n="common.cancel">取消</button>
|
||||
<button type="button" class="btn-primary" onclick="saveChatFilesEdit()" data-i18n="common.save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-files-rename-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 480px;">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="chatFilesPage.renameTitle">重命名</h2>
|
||||
<span class="modal-close" onclick="closeChatFilesRenameModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="chat-files-rename-label">
|
||||
<span data-i18n="chatFilesPage.newFileName">新文件名</span>
|
||||
<input type="text" id="chat-files-rename-input" class="form-control" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeChatFilesRenameModal()" data-i18n="common.cancel">取消</button>
|
||||
<button type="button" class="btn-primary" onclick="submitChatFilesRename()" data-i18n="common.ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marked.js for Markdown parsing -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
||||
@@ -2328,6 +2401,7 @@ version: 1.0.0<br>
|
||||
<script src="/static/js/skills.js"></script>
|
||||
<script src="/static/js/vulnerability.js?v=4"></script>
|
||||
<script src="/static/js/webshell.js"></script>
|
||||
<script src="/static/js/chat-files.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
<script src="/static/js/roles.js"></script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user