Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions assistant/src/daemon/handlers/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 17 additions & 1 deletion assistant/src/memory/attachments-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions assistant/src/runtime/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
handleUploadAttachment,
handleDeleteAttachment,
handleGetAttachment,
handleGetAttachmentContent,
} from './routes/attachment-routes.js';
import {
handleCreateRun,
Expand Down Expand Up @@ -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]);

Expand Down
103 changes: 102 additions & 1 deletion assistant/src/runtime/routes/attachment-routes.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -122,12 +123,112 @@ 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,
mimeType: attachment.mimeType,
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',
},
});
}