diff --git a/assistant/src/daemon/handlers/sessions.ts b/assistant/src/daemon/handlers/sessions.ts index 5457f6ee820..60a1b0efdce 100644 --- a/assistant/src/daemon/handlers/sessions.ts +++ b/assistant/src/daemon/handlers/sessions.ts @@ -512,10 +512,12 @@ export function handleHistoryRequest( // the client, so non-video attachments always keep their inline data. const MAX_INLINE_B64_SIZE = 512 * 1024; attachments = linked.map((a) => { - const omit = a.mimeType.startsWith('video/') && a.dataBase64.length > MAX_INLINE_B64_SIZE; + const isFileBacked = !a.dataBase64; // empty string = file-backed attachment + const omit = isFileBacked || (a.mimeType.startsWith('video/') && a.dataBase64.length > MAX_INLINE_B64_SIZE); // Lazily generate thumbnails for existing video attachments on first history load. - if (a.mimeType.startsWith('video/') && !a.thumbnailBase64) { + // Skip for file-backed attachments — there is no in-memory base64 to generate from. + if (a.mimeType.startsWith('video/') && !a.thumbnailBase64 && a.dataBase64) { const attachmentId = a.id; const base64 = a.dataBase64; silentlyWithLog( diff --git a/assistant/src/memory/attachments-store.ts b/assistant/src/memory/attachments-store.ts index 73bf9bcc71c..80d8c0f2392 100644 --- a/assistant/src/memory/attachments-store.ts +++ b/assistant/src/memory/attachments-store.ts @@ -7,7 +7,7 @@ import { eq } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; -import { getDb, rawRun } from './db.js'; +import { getDb, rawRun, rawGet } from './db.js'; import { attachments, messageAttachments } from './schema.js'; export interface StoredAttachment { @@ -209,6 +209,22 @@ export function uploadFileBackedAttachment( }; } +/** + * Returns the file_path for a file-backed attachment, or null if not file-backed. + * Uses raw SQL since file_path is added via runtime migration and is not in the Drizzle schema. + */ +export function getFilePathForAttachment(attachmentId: string): string | null { + if (!filePathColumnEnsured) { + ensureFilePathColumn(); + filePathColumnEnsured = true; + } + const row = rawGet<{ file_path: string | null }>( + 'SELECT file_path FROM attachments WHERE id = ?', + attachmentId, + ); + return row?.file_path ?? null; +} + export function uploadAttachment( filename: string, mimeType: string, diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index a0162cf5233..ec7dc0053c3 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -27,6 +27,7 @@ import { handleUploadAttachment, handleDeleteAttachment, handleGetAttachment, + handleGetAttachmentContent, } from './routes/attachment-routes.js'; import { handleCreateRun, @@ -581,6 +582,9 @@ export class RuntimeHttpServer { if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req); if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req); + const attachmentContentMatch = endpoint.match(/^attachments\/([^/]+)\/content$/); + if (attachmentContentMatch && req.method === 'GET') return handleGetAttachmentContent(attachmentContentMatch[1], req); + const attachmentMatch = endpoint.match(/^attachments\/([^/]+)$/); if (attachmentMatch && req.method === 'GET') return handleGetAttachment(attachmentMatch[1]); diff --git a/assistant/src/runtime/routes/attachment-routes.ts b/assistant/src/runtime/routes/attachment-routes.ts index 2471c36b9ca..05d75165599 100644 --- a/assistant/src/runtime/routes/attachment-routes.ts +++ b/assistant/src/runtime/routes/attachment-routes.ts @@ -1,8 +1,9 @@ /** * Route handlers for attachment upload, download, and deletion. */ +import { existsSync } from 'node:fs'; import * as attachmentsStore from '../../memory/attachments-store.js'; -import { validateAttachmentUpload, AttachmentUploadError } from '../../memory/attachments-store.js'; +import { validateAttachmentUpload, AttachmentUploadError, getFilePathForAttachment } from '../../memory/attachments-store.js'; /** 30 MB — base64-encoded 20 MB attachment ≈ 27 MB plus JSON wrapper overhead. */ const MAX_UPLOAD_BODY_BYTES = 30 * 1024 * 1024; @@ -122,6 +123,8 @@ export function handleGetAttachment(attachmentId: string): Response { return Response.json({ error: 'Attachment not found' }, { status: 404 }); } + const isFileBacked = !attachment.dataBase64; + return Response.json({ id: attachment.id, filename: attachment.originalFilename, @@ -129,5 +132,103 @@ export function handleGetAttachment(attachmentId: string): Response { sizeBytes: attachment.sizeBytes, kind: attachment.kind, data: attachment.dataBase64, + // Signal to clients that they should fetch content via the /content endpoint + ...(isFileBacked ? { fileBacked: true } : {}), + }); +} + +/** + * Serve raw file bytes for an attachment. For file-backed attachments this + * streams from disk; for inline attachments it decodes the base64 data. + * Supports Range headers for video seeking. + */ +export function handleGetAttachmentContent(attachmentId: string, req: Request): Response { + const attachment = attachmentsStore.getAttachmentById(attachmentId); + if (!attachment) { + return Response.json({ error: 'Attachment not found' }, { status: 404 }); + } + + // Check for file-backed attachment + const filePath = getFilePathForAttachment(attachmentId); + if (filePath) { + if (!existsSync(filePath)) { + return Response.json({ error: 'Recording file not found on disk' }, { status: 404 }); + } + + const file = Bun.file(filePath); + const rangeHeader = req.headers.get('Range'); + + if (rangeHeader) { + const fileSize = attachment.sizeBytes; + let start: number; + let end: number; + + // Parse suffix range: bytes=-N (last N bytes) + const suffixMatch = rangeHeader.match(/bytes=-(\d+)/); + if (suffixMatch) { + const suffixLen = parseInt(suffixMatch[1]); + start = Math.max(0, fileSize - suffixLen); + end = fileSize - 1; + } else { + // Parse standard range: bytes=start-end + const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if (!match) { + // Unparseable range — return full file + return new Response(file, { + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(fileSize), + 'Accept-Ranges': 'bytes', + }, + }); + } + start = parseInt(match[1]); + end = match[2] ? parseInt(match[2]) : fileSize - 1; + } + + // Clamp end to file size + end = Math.min(end, fileSize - 1); + + // Reject invalid ranges + if (start > end || start >= fileSize) { + return new Response(null, { + status: 416, + headers: { 'Content-Range': `bytes */${fileSize}` }, + }); + } + + const slice = file.slice(start, end + 1); + return new Response(slice, { + status: 206, + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': String(end - start + 1), + }, + }); + } + + return new Response(file, { + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(attachment.sizeBytes), + 'Accept-Ranges': 'bytes', + }, + }); + } + + // Fall back to base64-decoded content for inline attachments + if (!attachment.dataBase64) { + return Response.json({ error: 'No content available' }, { status: 404 }); + } + + const buffer = Buffer.from(attachment.dataBase64, 'base64'); + return new Response(buffer, { + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(buffer.length), + 'Accept-Ranges': 'bytes', + }, }); }