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