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:
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">
|
||||
|
||||
Reference in new issue
Block a user