From dd311f7a3b341b0ce03877a970012e0376fda03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:13:16 +0800 Subject: [PATCH] Add files via upload --- internal/handler/agent.go | 165 +++++++++++++++++++++++++++++++++- web/static/css/style.css | 94 ++++++++++++++++++++ web/static/js/chat.js | 180 ++++++++++++++++++++++++++++++++++---- web/templates/index.html | 17 +++- 4 files changed, 434 insertions(+), 22 deletions(-) diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 3a63f2e7..ec8584c0 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -2,10 +2,14 @@ package handler import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -108,11 +112,133 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) { h.skillsManager = manager } +// ChatAttachment 聊天附件(用户上传的文件) +type ChatAttachment struct { + FileName string `json:"fileName"` // 文件名 + Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码) + MimeType string `json:"mimeType,omitempty"` +} + // ChatRequest 聊天请求 type ChatRequest struct { - Message string `json:"message" binding:"required"` - ConversationID string `json:"conversationId,omitempty"` - Role string `json:"role,omitempty"` // 角色名称 + Message string `json:"message" binding:"required"` + ConversationID string `json:"conversationId,omitempty"` + Role string `json:"role,omitempty"` // 角色名称 + Attachments []ChatAttachment `json:"attachments,omitempty"` +} + +const ( + maxAttachments = 10 + maxAttachmentBytes = 2 * 1024 * 1024 // 单文件约 2MB(仅用于是否内联展示内容,不限制上传) + chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录) +) + +// saveAttachmentsToDateDir 将附件保存到当前目录下的 chat_uploads/YYYY-MM-DD/,返回每个文件的保存路径(与 attachments 顺序一致) +func saveAttachmentsToDateDir(attachments []ChatAttachment, 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")) + if err = os.MkdirAll(dateDir, 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(dateDir, 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 +} + +// 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 聊天响应 @@ -181,6 +307,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) { 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 var roleTools []string // 角色配置的工具列表 @@ -206,6 +338,16 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) { } } } + var savedPaths []string + if len(req.Attachments) > 0 { + savedPaths, err = saveAttachmentsToDateDir(req.Attachments, 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) @@ -618,6 +760,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { 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 var roleTools []string // 角色配置的工具列表 @@ -645,6 +793,17 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) { } } } + var savedPaths []string + if len(req.Attachments) > 0 { + savedPaths, err = saveAttachmentsToDateDir(req.Attachments, 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为空,表示使用所有工具(默认角色或未配置工具的角色) // 保存用户消息(保存原始消息,不包含角色提示词) diff --git a/web/static/css/style.css b/web/static/css/style.css index ca6f447d..f0406a3d 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1582,12 +1582,106 @@ header { 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 { flex: 1; display: flex; 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 { flex: 1; position: relative; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index bf062e83..132053fd 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -22,6 +22,12 @@ const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft'; let draftSaveTimer = null; 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(防抖版本) function saveChatDraftDebounced(content) { // 清除之前的定时器 @@ -107,14 +113,22 @@ function adjustTextareaHeight(textarea) { // 发送消息 async function sendMessage() { const input = document.getElementById('chat-input'); - const message = input.value.trim(); - - if (!message) { + let message = input.value.trim(); + const hasAttachments = chatAttachments && chatAttachments.length > 0; + + if (!message && !hasAttachments) { return; } - - // 显示用户消息 - addMessage('user', message); + // 有附件且用户未输入时,发一句简短默认提示即可(后端会拼接路径和文件内容给大模型) + if (hasAttachments && !message) { + message = CHAT_FILE_DEFAULT_PROMPT; + } + + // 显示用户消息(含附件名,便于用户确认) + const displayMessage = hasAttachments + ? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n') + : message; + addMessage('user', displayMessage); // 清除防抖定时器,防止在清空输入框后重新保存草稿 if (draftSaveTimer) { @@ -135,7 +149,24 @@ async function sendMessage() { input.value = ''; // 强制重置输入框高度为初始高度(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 progressElement = document.getElementById(progressId); @@ -145,19 +176,12 @@ async function sendMessage() { let mcpExecutionIds = []; try { - // 获取当前选中的角色(从 roles.js 的函数获取) - const roleName = typeof getCurrentRole === 'function' ? getCurrentRole() : ''; - const response = await apiFetch('/api/agent-loop/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - message: message, - conversationId: currentConversationId, - role: roleName || undefined - }), + body: JSON.stringify(body), }); 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() { mentionSuggestionsEl = document.getElementById('mention-suggestions'); if (mentionSuggestionsEl) { @@ -799,6 +947,8 @@ function initializeChatUI() { } activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL); setupMentionSupport(); + ensureChatInputContainerId(); + setupChatFileUpload(); } // 消息计数器,确保ID唯一 diff --git a/web/templates/index.html b/web/templates/index.html index a4ac6e50..7cb84ecb 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -499,7 +499,7 @@
-
+
-
- -
+
+
+
+ +
+
+ +