package server import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/Autumn-27/artex/db" ) // maxChatUpload caps a single chat-attachment upload request (memory + spill). const maxChatUpload = 128 << 20 // 128 MiB // safeChatID guards the {id} path segment against traversal — task ids are numeric, // session ids are alnum/_/- ; anything with "/" or ".." is rejected. var safeChatID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // chatAttachment is one uploaded file as the frontend + agent see it. Path is relative // to the chat's working dir (e.g. "uploads/report.txt"), which is the agent's CWD, so // it can Read/Bash the file directly; Name/Size drive the UI card. type chatAttachment struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` // Abs 是落盘的绝对路径(m.dir 已是绝对)。建任务前暂存(scope=staging)时前端要用它把 // 提示词写进描述;task/session 走 composeAgentMessage 在后端拼路径,不依赖此字段。 Abs string `json:"abs,omitempty"` } // chatUpload implements method-1 file support: it saves one or more files into a chat's // working dir under uploads/, so the agent opens them with its existing Read/Bash tools // and the sent message carries their paths. No LLM-layer change, no multimodal. // // POST /api/chat/upload?scope=task|session|staging&id=, multipart field "file" // (repeatable). Returns {attachments:[{name,path,size,abs}]}. Target dir mirrors the // agent CWD layout: // // scope=task → /tasks//uploads/ // scope=session → /sessions//uploads/ // scope=staging → /drafts//uploads/ (建任务前暂存:任务尚无 ID, // 文件先落这里,前端按返回的 abs 绝对路径写进任务描述) func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) { var sub string switch r.URL.Query().Get("scope") { case "task": sub = "tasks" case "session": sub = "sessions" case "staging": sub = "drafts" default: writeErr(w, 400, "scope 必须是 task / session / staging") return } id := r.URL.Query().Get("id") if !safeChatID.MatchString(id) { writeErr(w, 400, "非法 id") return } dir := filepath.Join(s.m.dir, sub, id, "uploads") if err := os.MkdirAll(dir, 0o755); err != nil { writeErr(w, 500, "建目录失败: "+err.Error()) return } r.Body = http.MaxBytesReader(w, r.Body, maxChatUpload) if err := r.ParseMultipartForm(32 << 20); err != nil { writeErr(w, 400, "解析上传失败或超出大小限制: "+err.Error()) return } files := r.MultipartForm.File["file"] if len(files) == 0 { writeErr(w, 400, "缺少上传文件(表单字段 file)") return } out := make([]chatAttachment, 0, len(files)) for _, hdr := range files { name := filepath.Base(hdr.Filename) // strip any path component if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { continue } dest := uniqueUploadPath(dir, name) if err := saveUpload(hdr, dest); err != nil { writeErr(w, 500, "保存失败: "+err.Error()) return } base := filepath.Base(dest) out = append(out, chatAttachment{Name: base, Path: "uploads/" + base, Size: hdr.Size, Abs: dest}) } writeJSON(w, 200, map[string]any{"attachments": out}) } // uniqueUploadPath returns dir/name, or dir/name-1, dir/name-2… when it already exists, // so re-uploading the same filename never clobbers a prior attachment. func uniqueUploadPath(dir, name string) string { dest := filepath.Join(dir, name) if _, err := os.Stat(dest); os.IsNotExist(err) { return dest } ext := filepath.Ext(name) stem := strings.TrimSuffix(name, ext) for i := 1; ; i++ { cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext)) if _, err := os.Stat(cand); os.IsNotExist(err) { return cand } } } // composeAgentMessage appends an attachment manifest to the user's message so the agent // knows which files were uploaded and where to Read them. baseDir is the agent's working // dir (its CWD); we emit ABSOLUTE paths (baseDir + relative) so the agent can Read/Bash // them unambiguously regardless of how it interprets relative paths. func composeAgentMessage(msg string, atts []chatAttachment, baseDir string) string { if len(atts) == 0 { return msg } var b strings.Builder b.WriteString(msg) b.WriteString("\n\n【用户上传的附件】(绝对路径,需要时用 Read/Bash 查看):") for _, a := range atts { fmt.Fprintf(&b, "\n- %s(%s)", filepath.Join(baseDir, a.Path), humanBytes(a.Size)) } return b.String() } // userActivityWithAttachments builds the persisted 'user' activity. With attachments, // Detail holds JSON {text, attachments} so the transcript renders text + attachment // cards; Summary stays the plain text (the list payload omits Detail, lazy-loaded). func userActivityWithAttachments(worker, text string, atts []chatAttachment) db.Activity { a := db.Activity{Worker: worker, Kind: "user", Summary: text} if len(atts) > 0 { blob, _ := json.Marshal(map[string]any{"text": text, "attachments": atts}) a.Detail = string(blob) } return a } func humanBytes(n int64) string { switch { case n >= 1<<20: return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) case n >= 1<<10: return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) default: return fmt.Sprintf("%d B", n) } }