Files
artex/server/chatupload.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

185 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=<id>, multipart field "file"
// (repeatable). Returns {attachments:[{name,path,size,abs}]}. Target dir mirrors the
// agent CWD layout:
//
// scope=task → <workDir>/tasks/<id>/uploads/
// scope=session → <workDir>/sessions/<id>/uploads/
// scope=staging → <workDir>/drafts/<id>/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)
}
}