- 前置:task 工作目录 <workDir>/<taskID> → <workDir>/tasks/<taskID>(改中心
ensureRunDir,与 sessions/<id> 对称;三 agent 全走它)。
- 上传接口(方案B,一个/两 scope):POST /api/chat/upload?scope=task|session&id=X,
落到 tasks|sessions/<id>/uploads/,路径安全化 + 同名加序号,返回 {name,path,size}。
- 后端注入(task main agent):chat handler 收 attachments;user 活动 Detail 存
{text,attachments} JSON 供消息渲染;发给 agent 的清单用【绝对路径】(baseDir+相对,
与落盘/CWD 完全对齐)保证一定 Read 得到。
- 前端(会话与任务共用 transcript 渲染):
· api.chatUpload + chat(attachments) + ChatAttachment 类型;
· transcript UserRow 解析 Detail JSON,气泡上方渲染附件卡片(乐观回显内联 detail);
· sessions-tab 输入区加回形针上传按钮 + 发送前 chip 预览(可移除)+ 带附件发送。
- 说明:文本/代码/二进制经 Read/Bash 处理;图片视觉不支持(norma 无 image block)。
会话(ChatAgent)面暂只共用了渲染,后端注入 + Composer 上传待后续补。
144 lines
4.8 KiB
Go
144 lines
4.8 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
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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&id=<id>, multipart field "file" (repeatable).
|
||
// Returns {attachments:[{name,path,size}]}. Target dir mirrors the agent CWD layout:
|
||
//
|
||
// scope=task → <workDir>/tasks/<id>/uploads/
|
||
// scope=session → <workDir>/sessions/<id>/uploads/
|
||
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"
|
||
default:
|
||
writeErr(w, 400, "scope 必须是 task 或 session")
|
||
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})
|
||
}
|
||
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)
|
||
}
|
||
}
|