First Commit
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

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
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)
}
}