feat: 主 Agent 对话支持文件上传(方式1:落盘 + Read/Bash 读取)

- 前置: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 上传待后续补。
This commit is contained in:
Autumn-27 committed 2026-08-17 11:10:03 -04:00
1 parent 6348ec9d21
commit 071414063e
9 files changed
+337 -39

No files matched your search

@@ -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<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(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 }) {
</div>
</ScrollArea>
{isMain ? (
<div className="flex items-center gap-2 border-t p-3">
<Input
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !mainLive) send();
}}
disabled={mainLive}
/>
{mainLive ? (
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</Button>
) : (
<Button size="icon" onClick={send} disabled={!input.trim() || sending}>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</Button>
<div className="border-t p-3">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a) => (
<div
key={a.path}
className="flex items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs"
title={a.path}
>
<PaperclipIcon className="size-3 shrink-0 text-primary" />
<span className="max-w-[160px] truncate">{a.name}</span>
<span className="text-muted-foreground">{fmtBytes(a.size)}</span>
<button
type="button"
className="ml-0.5 text-muted-foreground hover:text-foreground"
onClick={() => setAttachments((p) => p.filter((x) => x.path !== a.path))}
title="移除"
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
size="icon"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
title="上传文件"
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</Button>
<Input
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !mainLive) send();
}}
disabled={mainLive}
/>
{mainLive ? (
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</Button>
) : (
<Button size="icon" onClick={send} disabled={(!input.trim() && attachments.length === 0) || sending}>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</Button>
)}
</div>
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
+54 -4
View File
@@ -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<string> }) {
const Icon = intent ? CrosshairIcon : UserIcon;
const [ref, inView] = useInView();
const [full, setFull] = React.useState<string | null>(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<string | null>(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 (
<div ref={ref} className="mt-3 mb-2 flex justify-end gap-2">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{full ?? step.summary}
<div className="flex max-w-[85%] flex-col items-end gap-1.5">
{attachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-1.5">
{attachments.map((a) => (
<div
key={a.path}
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
title={a.path}
>
<PaperclipIcon className="size-3 shrink-0 text-primary" />
<span className="max-w-[180px] truncate font-medium">{a.name}</span>
<span className="shrink-0 text-muted-foreground">{fmtBytes(a.size)}</span>
</div>
))}
</div>
)}
{text && (
<div className="whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{text}
</div>
)}
</div>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-3.5 text-primary" />
+17 -2
View File
@@ -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}`, {}),
+7
View File
@@ -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;