Mark Rachapoom c73acb731f fix: send image attachments to gateway and prefix attached file paths
Two issues with image handling in chat:

1. Images pasted/dropped in chat were uploaded to disk but only file
   paths were sent as plain text. The model never received actual image
   bytes. Now sends base64 image data as `attachments` in the chat.send
   RPC so vision-capable models can see images directly.

2. Attached file paths (e.g. assets/screenshot.png) were not prefixed
   with the workspace root, unlike [Context: workspace file '...'] paths.
   The agent couldn't resolve relative paths. Now both patterns get the
   workspace prefix.

Files changed:
- chat-panel.tsx: read images as base64 via FileReader, send as FileUIPart
- chat/route.ts: extract image file parts, prefix attached file paths
- gateway/chat/route.ts: accept attachments in request body
- active-runs.ts: thread attachments through startRun
- agent-runner.ts: forward attachments to chat.send RPC
- chat-message.tsx: render inline image previews in user messages

Made-with: Cursor
2026-03-20 21:39:07 -07:00

93 lines
2.3 KiB
TypeScript

import {
startSubscribeRun,
getActiveRun,
subscribeToRun,
reactivateSubscribeRun,
type SseEvent,
} from "@/lib/active-runs";
export const runtime = "nodejs";
export async function POST(req: Request) {
const { sessionKey, message, attachments }: {
sessionKey: string;
message: string;
attachments?: Array<{ mediaType: string; data: string }>;
} = await req.json();
if (!sessionKey || !message?.trim()) {
return new Response("sessionKey and message are required", { status: 400 });
}
let run = getActiveRun(sessionKey);
if (run?.status === "running") {
return new Response("Active run already in progress for this session", { status: 409 });
}
if (run) {
reactivateSubscribeRun(sessionKey, message);
} else {
const sessionLabel = sessionKey.split(":").slice(2).join(":");
run = startSubscribeRun({
sessionKey,
parentSessionId: sessionKey,
task: message.slice(0, 200),
label: sessionLabel,
});
reactivateSubscribeRun(sessionKey, message);
}
const encoder = new TextEncoder();
let closed = false;
let unsubscribe: (() => void) | null = null;
let keepalive: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
start(controller) {
keepalive = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch { /* ignore */ }
}, 15_000);
unsubscribe = subscribeToRun(
sessionKey,
(event: SseEvent | null) => {
if (closed) return;
if (event === null) {
closed = true;
if (keepalive) { clearInterval(keepalive); keepalive = null; }
try { controller.close(); } catch { /* already closed */ }
return;
}
try {
const json = JSON.stringify(event);
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
} catch { /* ignore */ }
},
{ replay: false },
);
if (!unsubscribe) {
closed = true;
if (keepalive) { clearInterval(keepalive); keepalive = null; }
controller.close();
}
},
cancel() {
closed = true;
if (keepalive) { clearInterval(keepalive); keepalive = null; }
unsubscribe?.();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}