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
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
185 lines
7.2 KiB
Go
185 lines
7.2 KiB
Go
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)
|
||
}
|
||
}
|