管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。
+From 251b5fd4409d5e4b60900ad53b5d545a12409108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:20:58 +0800 Subject: [PATCH] Add files via upload --- internal/app/app.go | 12 + internal/handler/chat_uploads.go | 363 +++++++++++++++++++++ web/static/css/style.css | 260 +++++++++++++-- web/static/i18n/en-US.json | 55 +++- web/static/i18n/zh-CN.json | 35 ++ web/static/js/chat-files.js | 541 +++++++++++++++++++++++++++++++ web/static/js/router.js | 9 +- web/templates/index.html | 74 +++++ 8 files changed, 1301 insertions(+), 48 deletions(-) create mode 100644 internal/handler/chat_uploads.go create mode 100644 web/static/js/chat-files.js diff --git a/internal/app/app.go b/internal/app/app.go index 65e8e715..7bac9b08 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/handler/chat_uploads.go b/internal/handler/chat_uploads.go new file mode 100644 index 00000000..01b59789 --- /dev/null +++ b/internal/handler/chat_uploads.go @@ -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, + }) +} diff --git a/web/static/css/style.css b/web/static/css/style.css index c7758576..d4b93ddd 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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); +} + diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index fd50f7fa..e7cd64cd 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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...", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 88627ac6..f4a89312 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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": "筛选", diff --git a/web/static/js/chat-files.js b/web/static/js/chat-files.js new file mode 100644 index 00000000..44222507 --- /dev/null +++ b/web/static/js/chat-files.js @@ -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 = '
${convEsc}| ${escapeHtml(thDate)} | +${escapeHtml(thConv)} | +${escapeHtml(thName)} | +${escapeHtml(thSize)} | +${escapeHtml(thModified)} | +${escapeHtml(thActions)} | +
|---|
管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。
+