diff --git a/agent/mainagent.go b/agent/mainagent.go index e3765be..6736ded 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -77,7 +77,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t base := append(tsx.MainAgentTools(), actool.DefaultTools()...) tools, def, cleanup := AugmentTools(ctx, "mainagent", base) defer cleanup() - // 本任务的工作目录 /,先建好。 + // 本任务的工作目录 /tasks/,先建好。 mainDir := ensureRunDir(m.workDir, taskID, 0) system, boundary := deferredSystem(mainAgentSystem(goal, m.workDir, mainDir), def) opts := agentcore.Options{ @@ -99,7 +99,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t TavilySearchAPIKey: m.webSearch.TavilyKey, WebSearchProxy: m.webSearch.Proxy, BashEnv: proxyEnv(m.proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA - WorkingDir: mainDir, // 本任务工作目录 / + WorkingDir: mainDir, // 本任务工作目录 /tasks/ ToolOutputDir: cmdOutDir(mainDir), MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management) Compaction: compactionConfig(m.window), // long chats stay within the window diff --git a/agent/planner.go b/agent/planner.go index 8a98bd8..9b46c30 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -275,7 +275,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts if tc.Final { situational += "\n\n【任务终局收尾(本轮特殊指令,覆盖上面的常规规划流程)】:" + resolveTaskTimeoutWrapup("planner") } - // 本任务的工作目录 /,先建好。 + // 本任务的工作目录 /tasks/,先建好。 taskDir := ensureRunDir(p.workDir, taskID, 0) system, boundary := deferredSystem(plannerSystem(goal, p.workDir, taskDir), def) // planner 无自身墙钟预算;有 deadline 时把 MaxDuration 夹逼到剩余,让在跑的规划轮在 @@ -304,7 +304,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts TavilySearchAPIKey: p.webSearch.TavilyKey, WebSearchProxy: p.webSearch.Proxy, BashEnv: proxyEnv(p.proxyAddr, p.proxyCACert), // Bash 子命令默认走代理+信任 CA - WorkingDir: taskDir, // 本任务工作目录 / + WorkingDir: taskDir, // 本任务工作目录 /tasks/ ToolOutputDir: cmdOutDir(taskDir), MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management) MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余 diff --git a/agent/worker.go b/agent/worker.go index 4bd5f29..d21d246 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -167,11 +167,12 @@ func workerArtifactSpec(runDir string) string { } // ensureRunDir builds and creates an agent's working directory under base: -// / for planner/main; //i for a worker -// (intentID<=0 → task dir only). Best-effort mkdir — on failure, writes fail the -// same way an unwritable CWD would. +// /tasks/ for planner/main; /tasks//i for a +// worker (intentID<=0 → task dir only). The "tasks/" segment groups per-task dirs +// symmetrically with the chat agent's "sessions/". Best-effort mkdir — on +// failure, writes fail the same way an unwritable CWD would. func ensureRunDir(base string, taskID, intentID int64) string { - dir := filepath.Join(base, strconv.FormatInt(taskID, 10)) + dir := filepath.Join(base, "tasks", strconv.FormatInt(taskID, 10)) if intentID > 0 { dir = filepath.Join(dir, "i"+strconv.FormatInt(intentID, 10)) } @@ -260,7 +261,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db. // 意图 + 全局态势改放【启动 user 消息】(见下方 input),system 只留静态角色正文 // (段[A]/[B]/[C] + deferred 块)。与 planner 一致:把易变的运行期数据移出 system, // system 每 session 稳定、更利于缓存;代价是长 run 里这条 user 消息可能被 compaction - // 压缩。本次意图的专属工作目录 //i,引擎侧先建好。 + // 压缩。本次意图的专属工作目录 /tasks//i,引擎侧先建好。 runDir := ensureRunDir(w.workDir, taskID, intent.ID) overview := renderWorkerGraphOverview(tsx.graphOverviewData()) system, boundary := deferredSystem(workerSystem(w.proxyAddr, w.workDir, runDir), def) diff --git a/server/chatupload.go b/server/chatupload.go new file mode 100644 index 0000000..496007c --- /dev/null +++ b/server/chatupload.go @@ -0,0 +1,143 @@ +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=, multipart field "file" (repeatable). +// Returns {attachments:[{name,path,size}]}. Target dir mirrors the agent CWD layout: +// +// scope=task → /tasks//uploads/ +// scope=session → /sessions//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) + } +} diff --git a/server/server.go b/server/server.go index f00eef8..e25f59f 100644 --- a/server/server.go +++ b/server/server.go @@ -628,6 +628,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch) mux.HandleFunc("GET /api/report", s.getReport) mux.HandleFunc("POST /api/chat", s.chat) + mux.HandleFunc("POST /api/chat/upload", s.chatUpload) // 方式1 文件上传:落到会话/任务工作目录 uploads/ mux.HandleFunc("POST /api/tasks/{id}/chat/stop", s.stopChat) // --- 管理后台 API (PostgreSQL 数据源; 新版数据库与管理后台方案) --- @@ -2057,7 +2058,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { return } var req struct { - Message string `json:"message"` + Message string `json:"message"` + Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录) } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -2065,8 +2067,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { } // Persist + broadcast the human turn so the 主 Agent 编排会话 survives page // reloads and updates live: the conversation lives in the activity stream as - // worker="mainagent" (the per-task activity table, replayed via SSE). - s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "user", Summary: req.Message}) + // worker="mainagent" (the per-task activity table, replayed via SSE). With + // attachments, the activity's Detail carries {text, attachments} so the transcript + // renders attachment cards. + s.engine.emitActivity(t, userActivityWithAttachments("mainagent", req.Message, req.Attachments)) if ma := s.mainAgentRef(); ma != nil { // Serialize per task: one main-agent run at a time so concurrent messages // don't corrupt the shared exp-main transcript. If a prior turn is still @@ -2103,7 +2107,11 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) } maTaskID, _ := strconv.ParseInt(t.ID, 10, 64) resume := func() { s.reviveTask(t) } // set_goals 新增目标 → 把任务拉回 running - if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, req.Message, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil { + // 把上传附件的【绝对路径】清单拼进发给 agent 的消息,它据此用 Read/Bash 打开文件。 + // taskDir = agent 的工作目录(CWD),与 chatUpload 落盘、ensureRunDir 一致。 + taskDir := filepath.Join(s.m.dir, "tasks", t.ID) + agentMsg := composeAgentMessage(req.Message, req.Attachments, taskDir) + if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil { s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"}) } }() diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 7bd645e..5a225e9 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -16,7 +16,10 @@ import { ShieldAlertIcon, WifiOffIcon, RotateCwIcon, + PaperclipIcon, + XIcon, } from "lucide-react"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Transcript } from "@/components/transcript"; import { TodoPopover } from "@/components/todo-popover"; @@ -33,6 +36,7 @@ import { api, sseUrl } from "@/lib/api"; import { MOCK } from "@/lib/mock/enabled"; import type { Activity, + ChatAttachment, InterceptApprovalRow, Session, SessionStatus, @@ -40,6 +44,13 @@ import type { TokenTotal, } from "@/lib/types"; +// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx). +function fmtBytes(n: number): string { + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; + if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`; + return `${n} B`; +} + // ── Reliability model (see docs/task-session-history-sse-remediation.md) ────────── // The task's activity is NO LONGER one unbounded `allActivity` array replayed from // SSE since=0. Instead: @@ -284,6 +295,24 @@ export function SessionsTab({ taskId }: { taskId: string }) { const [input, setInput] = React.useState(""); const [sending, setSending] = React.useState(false); const [stopping, setStopping] = React.useState(false); + // 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。 + const [attachments, setAttachments] = React.useState([]); + const [uploading, setUploading] = React.useState(false); + const fileInputRef = React.useRef(null); + + async function pickFiles(files: FileList | null) { + if (!files || files.length === 0) return; + setUploading(true); + try { + const r = await api.chatUpload("task", taskId, Array.from(files)); + setAttachments((prev) => [...prev, ...r.attachments]); + } catch (e) { + toast.error("上传失败:" + (e as Error).message); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } // SSE connection state — surfaced so a dropped realtime link is visible, never // silently shown as "no messages". const [sseLive, setSseLive] = React.useState(false); @@ -795,7 +824,8 @@ export function SessionsTab({ taskId }: { taskId: string }) { function send() { const text = input.trim(); - if (!text || sending) return; + const atts = attachments; + if ((!text && atts.length === 0) || sending) return; const base = [...(store.main?.items ?? []), ...chatExtra]; const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1; const userMsg: Activity = { @@ -804,14 +834,17 @@ export function SessionsTab({ taskId }: { taskId: string }) { ts: new Date().toISOString(), kind: "user", summary: text, + // 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。 + detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined, }; // optimistic echo of the human turn only; the agent's steps + final answer // stream back live via SSE (worker="mainagent"), so we don't append the reply. setChatExtra((prev) => [...prev, userMsg]); setInput(""); + setAttachments([]); setSending(true); api - .chat(text, taskId) + .chat(text, taskId, atts.length > 0 ? atts : undefined) .catch(() => { setChatExtra((prev) => [ ...prev, @@ -1013,25 +1046,66 @@ export function SessionsTab({ taskId }: { taskId: string }) { {isMain ? ( -
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !mainLive) send(); - }} - disabled={mainLive} - /> - {mainLive ? ( - - ) : ( - +
+ {attachments.length > 0 && ( +
+ {attachments.map((a) => ( +
+ + {a.name} + {fmtBytes(a.size)} + +
+ ))} +
)} +
+ void pickFiles(e.target.files)} + /> + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !mainLive) send(); + }} + disabled={mainLive} + /> + {mainLive ? ( + + ) : ( + + )} +
) : (
diff --git a/web/src/components/transcript.tsx b/web/src/components/transcript.tsx index ec827d2..6fa21b2 100644 --- a/web/src/components/transcript.tsx +++ b/web/src/components/transcript.tsx @@ -8,6 +8,7 @@ import { CrosshairIcon, Flag, MessageSquare, + PaperclipIcon, ShieldAlertIcon, Terminal, UserIcon, @@ -445,11 +446,40 @@ function MessageBlock({ // it (intent=true) — same bubble, but a target icon instead of the human avatar. // summary is a truncated first line, so the full message is pulled from the detail // and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps). +// fmtBytes renders a human file size for attachment chips. +function fmtBytes(n: number): string { + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; + if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`; + return `${n} B`; +} + +type MsgAttachment = { name: string; path: string; size: number }; + +// parseUserBody splits a user turn's body into text + attachments. The backend stores +// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we +// parse defensively and fall back to treating the whole body as text. +function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } { + if (body.startsWith("{")) { + try { + const p = JSON.parse(body); + if (p && Array.isArray(p.attachments)) { + return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments }; + } + } catch { + /* plain text that merely starts with "{" */ + } + } + return { text: body, attachments: [] }; +} + function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise }) { const Icon = intent ? CrosshairIcon : UserIcon; const [ref, inView] = useInView(); - const [full, setFull] = React.useState(null); + // Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll. + const inline = step.detail && step.detail.length > 0 ? step.detail : null; + const [full, setFull] = React.useState(inline); React.useEffect(() => { + if (inline) return; // already have the body (optimistic echo) if (!inView) return; // fetch the full message only when the bubble nears view let live = true; getDetail(step.seq) @@ -462,11 +492,31 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean return () => { live = false; }; - }, [inView, step.seq, getDetail, step.summary]); + }, [inView, step.seq, getDetail, step.summary, inline]); + const { text, attachments } = parseUserBody(full ?? step.summary); return (
-
- {full ?? step.summary} +
+ {attachments.length > 0 && ( +
+ {attachments.map((a) => ( +
+ + {a.name} + {fmtBytes(a.size)} +
+ ))} +
+ )} + {text && ( +
+ {text} +
+ )}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a3d48b9..38a32c5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,7 +9,7 @@ import type { Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity, Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, - Tool, Conversation, AgentTrigger, + Tool, Conversation, AgentTrigger, ChatAttachment, InterceptRule, InterceptPending, InterceptApprovalRow, TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, WorkspaceListing, WorkspaceFile, @@ -315,7 +315,22 @@ export const api = { if (!r.ok) throw new Error(`report: ${r.status}`); return r.text(); }, - chat: (message: string, task?: string) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message }), + chat: (message: string, task?: string, attachments?: ChatAttachment[]) => + post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), + // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 + chatUpload: async (scope: "task" | "session", id: string, files: File[]) => { + if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size })) }; + const fd = new FormData(); + for (const f of files) fd.append("file", f); + const token = getToken(); + const r = await fetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, { + method: "POST", + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: fd, + }); + if (!r.ok) throw new Error(`上传失败: ${r.status} ${await r.text()}`); + return r.json() as Promise<{ attachments: ChatAttachment[] }>; + }, stopChat: (taskId: string) => post<{ status: string }>(`/tasks/${taskId}/chat/stop`, {}), gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}), diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 211d974..0d44a2a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -265,6 +265,13 @@ export type ActivityKind = | "usage" // live cumulative token usage (per model turn); not rendered | "intercept_request"; // user-approval request from the intercept layer +// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。 +export interface ChatAttachment { + name: string; + path: string; + size: number; +} + export interface Activity { seq: number; intent_id?: string;