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 // chatUpload 의 사용자 노출 에러 응답(한국어). writeErr 로 그대로 UI 에 노출된다. 用語: // 附件→첨부 파일, scope/id/file 은 요청 필드명이라 원문 보존. scope 검증은 intercept.go 의 // "값은 … 중 하나여야 합니다" 패턴, 作业 삭제 문구는 goals_api.go 와 같은 문형, "잘못된 id" // 는 workspace.go errWsIllegalPath("잘못된 경로입니다")와 같은 꼴이다. ...失败 세 종류는 // task_archives.go:229 선례처럼 접두 상수 + err.Error() 로 이어 붙인다. const ( errChatUploadScopeInvalid = "scope 值必须是 task、session、staging 之一" errChatUploadBadID = "id 无效" errChatUploadTaskDeleting = "正在删除任务,无法上传附件" errChatUploadMkdir = "无法创建目录: " errChatUploadParse = "无法解析上传内容或超出大小限制: " errChatUploadNoFile = "没有要上传的文件(表单字段 file)" errChatUploadSaveFailed = "保存失败: " ) // 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 taskScoped := false switch r.URL.Query().Get("scope") { case "task": sub = "tasks" taskScoped = true case "session": sub = "sessions" case "staging": sub = "drafts" default: writeErr(w, 400, errChatUploadScopeInvalid) return } id := r.URL.Query().Get("id") if !safeChatID.MatchString(id) { writeErr(w, 400, errChatUploadBadID) return } if taskScoped { if s.m.ResolveTask(id) == nil { writeErr(w, 404, "task not found") return } if !s.engine.beginTaskOperation(id) { writeErr(w, http.StatusConflict, errChatUploadTaskDeleting) return } defer s.engine.decInflight(id) } dir := filepath.Join(s.m.dir, sub, id, "uploads") if err := os.MkdirAll(dir, 0o755); err != nil { writeErr(w, 500, errChatUploadMkdir+err.Error()) return } r.Body = http.MaxBytesReader(w, r.Body, maxChatUpload) if err := r.ParseMultipartForm(32 << 20); err != nil { writeErr(w, 400, errChatUploadParse+err.Error()) return } files := r.MultipartForm.File["file"] if len(files) == 0 { writeErr(w, 400, errChatUploadNoFile) 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, errChatUploadSaveFailed+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) // [F10 경계 판정 · 두뇌 입력 보존] 이 첨부 매니페스트 헤더(`【用户上传的附件】…Read/Bash…`)는 // 번역하지 않는다. composeAgentMessage 의 반환값은 runConversation/ma.Chat 으로 에이전트에 // 보내지는 메시지라, 이 문구는 "업로드된 파일을 Read/Bash 로 열라"고 모델에 지시하는 에이전트 // 입력(두뇌)이다(BRIEF 경계 #1). 전사는 userActivityWithAttachments 가 첨부 JSON 으로 따로 // 렌더하므로 이 헤더 문자열 자체는 표시 경로에 노출되지 않는다(F16 동형 두뇌 전용). 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) } }