From 58082f6606e532b53cd2824b30b8bb444ab7df3f Mon Sep 17 00:00:00 2001 From: Vellum Assistant Date: Mon, 23 Feb 2026 10:49:50 -0500 Subject: [PATCH] feat: add file-backed attachment storage and content streaming endpoint Co-Authored-By: Claude --- .../attachment-content-route.test.ts | 210 ++++++++++++ .../__tests__/file-backed-attachments.test.ts | 319 ++++++++++++++++++ .../fixtures/media-reuse-fixtures.ts | 12 + assistant/src/memory/attachments-store.ts | 114 ++++++- assistant/src/memory/db.ts | 4 + assistant/src/memory/schema.ts | 4 + assistant/src/runtime/http-server.ts | 7 + .../src/runtime/routes/attachment-routes.ts | 99 ++++++ assistant/src/tools/assets/search.ts | 23 +- 9 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 assistant/src/__tests__/attachment-content-route.test.ts create mode 100644 assistant/src/__tests__/file-backed-attachments.test.ts diff --git a/assistant/src/__tests__/attachment-content-route.test.ts b/assistant/src/__tests__/attachment-content-route.test.ts new file mode 100644 index 00000000000..57168a19116 --- /dev/null +++ b/assistant/src/__tests__/attachment-content-route.test.ts @@ -0,0 +1,210 @@ +import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const testDir = mkdtempSync(join(tmpdir(), 'attach-content-test-')); + +mock.module('../util/platform.js', () => ({ + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + getRootDir: () => testDir, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../config/loader.js', () => ({ + getConfig: () => ({ + model: 'test', + provider: 'test', + apiKeys: {}, + memory: { enabled: false }, + rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 }, + }), +})); + +import { initializeDb, getDb, resetDb } from '../memory/db.js'; +import { + uploadAttachment, + createFileBackedAttachment, +} from '../memory/attachments-store.js'; +import { handleGetAttachmentContent } from '../runtime/routes/attachment-routes.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +function resetTables() { + const db = getDb(); + db.run('DELETE FROM message_attachments'); + db.run('DELETE FROM attachments'); +} + +// --------------------------------------------------------------------------- +// handleGetAttachmentContent — full file content +// --------------------------------------------------------------------------- + +describe('handleGetAttachmentContent — file-backed', () => { + beforeEach(resetTables); + + test('returns full file content for non-range request', async () => { + const filePath = join(testDir, 'test-video.mp4'); + const content = Buffer.from('fake video content for testing'); + writeFileSync(filePath, content); + + const attachment = createFileBackedAttachment({ + filename: 'test-video.mp4', + mimeType: 'video/mp4', + sizeBytes: content.length, + filePath, + }); + + const req = new Request('http://localhost/v1/attachments/' + attachment.id + '/content'); + const res = handleGetAttachmentContent(attachment.id, req); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('video/mp4'); + expect(res.headers.get('Content-Length')).toBe(String(content.length)); + expect(res.headers.get('Accept-Ranges')).toBe('bytes'); + expect(res.headers.get('Content-Disposition')).toBe('inline'); + + const body = await res.arrayBuffer(); + expect(Buffer.from(body).toString()).toBe(content.toString()); + }); + + test('returns partial content for range request', async () => { + const filePath = join(testDir, 'test-range.mp4'); + const content = Buffer.from('0123456789abcdef'); + writeFileSync(filePath, content); + + const attachment = createFileBackedAttachment({ + filename: 'test-range.mp4', + mimeType: 'video/mp4', + sizeBytes: content.length, + filePath, + }); + + const req = new Request('http://localhost/v1/attachments/' + attachment.id + '/content', { + headers: { Range: 'bytes=4-9' }, + }); + const res = handleGetAttachmentContent(attachment.id, req); + + expect(res.status).toBe(206); + expect(res.headers.get('Content-Range')).toBe(`bytes 4-9/${content.length}`); + expect(res.headers.get('Content-Length')).toBe('6'); + + const body = await res.arrayBuffer(); + expect(Buffer.from(body).toString()).toBe('456789'); + }); + + test('returns range with open-ended range header', async () => { + const filePath = join(testDir, 'test-open-range.mp4'); + const content = Buffer.from('abcdefghij'); + writeFileSync(filePath, content); + + const attachment = createFileBackedAttachment({ + filename: 'test-open-range.mp4', + mimeType: 'video/mp4', + sizeBytes: content.length, + filePath, + }); + + const req = new Request('http://localhost/v1/attachments/' + attachment.id + '/content', { + headers: { Range: 'bytes=5-' }, + }); + const res = handleGetAttachmentContent(attachment.id, req); + + expect(res.status).toBe(206); + expect(res.headers.get('Content-Range')).toBe(`bytes 5-9/${content.length}`); + expect(res.headers.get('Content-Length')).toBe('5'); + + const body = await res.arrayBuffer(); + expect(Buffer.from(body).toString()).toBe('fghij'); + }); + + test('returns 416 for unsatisfiable range', () => { + const filePath = join(testDir, 'test-416.mp4'); + const content = Buffer.from('short'); + writeFileSync(filePath, content); + + const attachment = createFileBackedAttachment({ + filename: 'test-416.mp4', + mimeType: 'video/mp4', + sizeBytes: content.length, + filePath, + }); + + const req = new Request('http://localhost/v1/attachments/' + attachment.id + '/content', { + headers: { Range: 'bytes=100-200' }, + }); + const res = handleGetAttachmentContent(attachment.id, req); + + expect(res.status).toBe(416); + }); + + test('returns 404 when file is missing from disk', () => { + const attachment = createFileBackedAttachment({ + filename: 'missing.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/nonexistent/path/missing.mp4', + }); + + const req = new Request('http://localhost/v1/attachments/' + attachment.id + '/content'); + const res = handleGetAttachmentContent(attachment.id, req); + + expect(res.status).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// handleGetAttachmentContent — 404 for non-existent +// --------------------------------------------------------------------------- + +describe('handleGetAttachmentContent — 404', () => { + beforeEach(resetTables); + + test('returns 404 for non-existent attachment', () => { + const req = new Request('http://localhost/v1/attachments/nonexistent/content'); + const res = handleGetAttachmentContent('nonexistent', req); + expect(res.status).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// handleGetAttachmentContent — inline_base64 fallback +// --------------------------------------------------------------------------- + +describe('handleGetAttachmentContent — inline_base64 fallback', () => { + beforeEach(resetTables); + + test('decodes and returns inline base64 content', async () => { + const originalText = 'hello world'; + const base64 = Buffer.from(originalText).toString('base64'); + const stored = uploadAttachment('hello.txt', 'text/plain', base64); + + const req = new Request('http://localhost/v1/attachments/' + stored.id + '/content'); + const res = handleGetAttachmentContent(stored.id, req); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('text/plain'); + expect(res.headers.get('Accept-Ranges')).toBe('bytes'); + + const body = await res.text(); + expect(body).toBe(originalText); + }); +}); diff --git a/assistant/src/__tests__/file-backed-attachments.test.ts b/assistant/src/__tests__/file-backed-attachments.test.ts new file mode 100644 index 00000000000..06ed23015de --- /dev/null +++ b/assistant/src/__tests__/file-backed-attachments.test.ts @@ -0,0 +1,319 @@ +import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const testDir = mkdtempSync(join(tmpdir(), 'file-backed-attach-test-')); + +mock.module('../util/platform.js', () => ({ + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + getRootDir: () => testDir, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../config/loader.js', () => ({ + getConfig: () => ({ + model: 'test', + provider: 'test', + apiKeys: {}, + memory: { enabled: false }, + rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 }, + }), +})); + +import { initializeDb, getDb, resetDb } from '../memory/db.js'; +import { + uploadAttachment, + getAttachmentById, + getAttachmentsByIds, + createFileBackedAttachment, + getExpiredFileAttachments, + deleteFileBackedAttachment, +} from '../memory/attachments-store.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +function resetTables() { + const db = getDb(); + db.run('DELETE FROM message_attachments'); + db.run('DELETE FROM attachments'); +} + +// --------------------------------------------------------------------------- +// createFileBackedAttachment +// --------------------------------------------------------------------------- + +describe('createFileBackedAttachment', () => { + beforeEach(resetTables); + + test('creates correct DB record with file metadata', () => { + const result = createFileBackedAttachment({ + filename: 'recording.mp4', + mimeType: 'video/mp4', + sizeBytes: 50_000_000, + filePath: '/data/recordings/recording.mp4', + sha256: 'abc123def456', + expiresAt: Date.now() + 86400000, + }); + + expect(result.id).toBeDefined(); + expect(result.originalFilename).toBe('recording.mp4'); + expect(result.mimeType).toBe('video/mp4'); + expect(result.sizeBytes).toBe(50_000_000); + expect(result.kind).toBe('video'); + expect(result.storageKind).toBe('file'); + expect(result.filePath).toBe('/data/recordings/recording.mp4'); + expect(result.sha256).toBe('abc123def456'); + expect(result.expiresAt).toBeGreaterThan(0); + expect(result.createdAt).toBeGreaterThan(0); + }); + + test('handles optional fields gracefully', () => { + const result = createFileBackedAttachment({ + filename: 'screenshot.png', + mimeType: 'image/png', + sizeBytes: 1024, + filePath: '/data/screenshots/shot.png', + }); + + expect(result.sha256).toBeNull(); + expect(result.expiresAt).toBeNull(); + expect(result.thumbnailBase64).toBeNull(); + }); + + test('stores thumbnail when provided', () => { + const result = createFileBackedAttachment({ + filename: 'video.mp4', + mimeType: 'video/mp4', + sizeBytes: 10_000, + filePath: '/data/video.mp4', + thumbnailBase64: 'iVBORw0KGgoAAAANSUh', + }); + + expect(result.thumbnailBase64).toBe('iVBORw0KGgoAAAANSUh'); + }); + + test('classifies kind from mime type', () => { + const image = createFileBackedAttachment({ + filename: 'pic.png', + mimeType: 'image/png', + sizeBytes: 100, + filePath: '/data/pic.png', + }); + expect(image.kind).toBe('image'); + + const video = createFileBackedAttachment({ + filename: 'clip.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/data/clip.mp4', + }); + expect(video.kind).toBe('video'); + + const doc = createFileBackedAttachment({ + filename: 'doc.pdf', + mimeType: 'application/pdf', + sizeBytes: 100, + filePath: '/data/doc.pdf', + }); + expect(doc.kind).toBe('document'); + }); +}); + +// --------------------------------------------------------------------------- +// getAttachmentById returns file metadata for file-backed attachments +// --------------------------------------------------------------------------- + +describe('getAttachmentById with file-backed attachments', () => { + beforeEach(resetTables); + + test('returns file metadata for file-backed attachments', () => { + const created = createFileBackedAttachment({ + filename: 'recording.mp4', + mimeType: 'video/mp4', + sizeBytes: 50_000_000, + filePath: '/data/recording.mp4', + sha256: 'abc123', + expiresAt: 1234567890, + }); + + const fetched = getAttachmentById(created.id); + expect(fetched).not.toBeNull(); + expect(fetched!.storageKind).toBe('file'); + expect(fetched!.filePath).toBe('/data/recording.mp4'); + expect(fetched!.sha256).toBe('abc123'); + expect(fetched!.expiresAt).toBe(1234567890); + expect(fetched!.dataBase64).toBe(''); + }); + + test('returns inline_base64 for traditional attachments', () => { + const created = uploadAttachment('chart.png', 'image/png', 'iVBORw0K'); + + const fetched = getAttachmentById(created.id); + expect(fetched).not.toBeNull(); + expect(fetched!.storageKind).toBe('inline_base64'); + expect(fetched!.filePath).toBeNull(); + expect(fetched!.sha256).toBeNull(); + expect(fetched!.expiresAt).toBeNull(); + expect(fetched!.dataBase64).toBe('iVBORw0K'); + }); +}); + +// --------------------------------------------------------------------------- +// getAttachmentsByIds with mixed types +// --------------------------------------------------------------------------- + +describe('getAttachmentsByIds with mixed types', () => { + beforeEach(resetTables); + + test('returns both inline and file-backed attachments', () => { + const inline = uploadAttachment('doc.pdf', 'application/pdf', 'JVBER'); + const fileBacked = createFileBackedAttachment({ + filename: 'video.mp4', + mimeType: 'video/mp4', + sizeBytes: 5000, + filePath: '/data/video.mp4', + }); + + const results = getAttachmentsByIds([inline.id, fileBacked.id]); + expect(results).toHaveLength(2); + + const inlineResult = results.find((r) => r.id === inline.id); + expect(inlineResult!.storageKind).toBe('inline_base64'); + expect(inlineResult!.dataBase64).toBe('JVBER'); + + const fileResult = results.find((r) => r.id === fileBacked.id); + expect(fileResult!.storageKind).toBe('file'); + expect(fileResult!.filePath).toBe('/data/video.mp4'); + }); +}); + +// --------------------------------------------------------------------------- +// getExpiredFileAttachments +// --------------------------------------------------------------------------- + +describe('getExpiredFileAttachments', () => { + beforeEach(resetTables); + + test('returns only expired file attachments', () => { + const now = Date.now(); + + // Expired + createFileBackedAttachment({ + filename: 'old.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/data/old.mp4', + expiresAt: now - 10000, + }); + + // Not expired + createFileBackedAttachment({ + filename: 'new.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/data/new.mp4', + expiresAt: now + 86400000, + }); + + // No expiry + createFileBackedAttachment({ + filename: 'permanent.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/data/permanent.mp4', + }); + + // Inline base64 (should never be returned) + uploadAttachment('inline.txt', 'text/plain', 'AAAA'); + + const expired = getExpiredFileAttachments(); + expect(expired).toHaveLength(1); + expect(expired[0].filePath).toBe('/data/old.mp4'); + }); + + test('returns empty when no expired attachments', () => { + createFileBackedAttachment({ + filename: 'future.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/data/future.mp4', + expiresAt: Date.now() + 86400000, + }); + + const expired = getExpiredFileAttachments(); + expect(expired).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// deleteFileBackedAttachment +// --------------------------------------------------------------------------- + +describe('deleteFileBackedAttachment', () => { + beforeEach(resetTables); + + test('deletes existing file-backed attachment', () => { + const created = createFileBackedAttachment({ + filename: 'recording.mp4', + mimeType: 'video/mp4', + sizeBytes: 100, + filePath: '/data/recording.mp4', + }); + + const result = deleteFileBackedAttachment(created.id); + expect(result).toBe('deleted'); + + const fetched = getAttachmentById(created.id); + expect(fetched).toBeNull(); + }); + + test('returns not_found for nonexistent attachment', () => { + const result = deleteFileBackedAttachment('nonexistent-id'); + expect(result).toBe('not_found'); + }); +}); + +// --------------------------------------------------------------------------- +// Backward compatibility: existing base64 attachments still work +// --------------------------------------------------------------------------- + +describe('backward compatibility', () => { + beforeEach(resetTables); + + test('existing uploadAttachment still works correctly', () => { + const stored = uploadAttachment('chart.png', 'image/png', 'iVBORw0K'); + expect(stored.id).toBeDefined(); + expect(stored.storageKind).toBe('inline_base64'); + expect(stored.filePath).toBeNull(); + + const fetched = getAttachmentById(stored.id); + expect(fetched).not.toBeNull(); + expect(fetched!.dataBase64).toBe('iVBORw0K'); + expect(fetched!.storageKind).toBe('inline_base64'); + }); + + test('deduplication still works for inline base64', () => { + const first = uploadAttachment('a.png', 'image/png', 'DUPEDATA'); + const second = uploadAttachment('b.png', 'image/png', 'DUPEDATA'); + expect(first.id).toBe(second.id); + }); +}); diff --git a/assistant/src/__tests__/fixtures/media-reuse-fixtures.ts b/assistant/src/__tests__/fixtures/media-reuse-fixtures.ts index fa16c2c9717..44fb0ca0898 100644 --- a/assistant/src/__tests__/fixtures/media-reuse-fixtures.ts +++ b/assistant/src/__tests__/fixtures/media-reuse-fixtures.ts @@ -36,6 +36,10 @@ export const FAKE_SELFIE_ATTACHMENT: StoredAttachment = { sizeBytes: Buffer.from(TINY_PNG_BASE64, 'base64').length, kind: 'image', thumbnailBase64: null, + storageKind: 'inline_base64', + filePath: null, + sha256: null, + expiresAt: null, createdAt: NOW, }; @@ -47,6 +51,10 @@ export const FAKE_DOCUMENT_ATTACHMENT: StoredAttachment = { sizeBytes: 4096, kind: 'document', thumbnailBase64: null, + storageKind: 'inline_base64', + filePath: null, + sha256: null, + expiresAt: null, createdAt: NOW, }; @@ -58,6 +66,10 @@ export const FAKE_PHOTO_ATTACHMENT: StoredAttachment = { sizeBytes: Buffer.from(TINY_JPEG_BASE64, 'base64').length, kind: 'image', thumbnailBase64: null, + storageKind: 'inline_base64', + filePath: null, + sha256: null, + expiresAt: null, createdAt: NOW, }; diff --git a/assistant/src/memory/attachments-store.ts b/assistant/src/memory/attachments-store.ts index 8aa9cd91aad..fc51e39bf82 100644 --- a/assistant/src/memory/attachments-store.ts +++ b/assistant/src/memory/attachments-store.ts @@ -17,6 +17,10 @@ export interface StoredAttachment { sizeBytes: number; kind: string; thumbnailBase64: string | null; + storageKind: 'inline_base64' | 'file'; + filePath: string | null; + sha256: string | null; + expiresAt: number | null; createdAt: number; } @@ -182,6 +186,10 @@ export function uploadAttachment( sizeBytes: attachments.sizeBytes, kind: attachments.kind, thumbnailBase64: attachments.thumbnailBase64, + storageKind: attachments.storageKind, + filePath: attachments.filePath, + sha256: attachments.sha256, + expiresAt: attachments.expiresAt, createdAt: attachments.createdAt, }) .from(attachments) @@ -189,7 +197,7 @@ export function uploadAttachment( .get(); if (existing) { - return existing; + return { ...existing, storageKind: existing.storageKind as 'inline_base64' | 'file' }; } const now = Date.now(); @@ -215,6 +223,10 @@ export function uploadAttachment( sizeBytes, kind, thumbnailBase64: null, + storageKind: 'inline_base64' as const, + filePath: null, + sha256: null, + expiresAt: null, createdAt: now, }; } @@ -281,6 +293,10 @@ export function getAttachmentsByIds( sizeBytes: row.sizeBytes, kind: row.kind, thumbnailBase64: row.thumbnailBase64, + storageKind: row.storageKind as 'inline_base64' | 'file', + filePath: row.filePath, + sha256: row.sha256, + expiresAt: row.expiresAt, dataBase64: row.dataBase64, createdAt: row.createdAt, }); @@ -356,12 +372,16 @@ export function getAttachmentMetadataForMessage( sizeBytes: attachments.sizeBytes, kind: attachments.kind, thumbnailBase64: attachments.thumbnailBase64, + storageKind: attachments.storageKind, + filePath: attachments.filePath, + sha256: attachments.sha256, + expiresAt: attachments.expiresAt, createdAt: attachments.createdAt, }) .from(attachments) .where(eq(attachments.id, link.attachmentId)) .get(); - if (row) results.push(row); + if (row) results.push({ ...row, storageKind: row.storageKind as 'inline_base64' | 'file' }); } return results; } @@ -395,3 +415,93 @@ export function deleteOrphanAttachments(candidateIds: string[]): number { const result = stmt.run(...candidateIds); return result.changes; } + +// --------------------------------------------------------------------------- +// File-backed attachment operations +// --------------------------------------------------------------------------- + +/** + * Create a file-backed attachment record. The actual file content lives on + * disk at `filePath`; the DB row stores only metadata. + */ +export function createFileBackedAttachment(params: { + filename: string; + mimeType: string; + sizeBytes: number; + filePath: string; + sha256?: string; + expiresAt?: number; + thumbnailBase64?: string; +}): StoredAttachment { + const db = getDb(); + const now = Date.now(); + const kind = classifyKind(params.mimeType); + const id = uuid(); + + const record = { + id, + originalFilename: params.filename, + mimeType: params.mimeType, + sizeBytes: params.sizeBytes, + kind, + dataBase64: '', + storageKind: 'file' as const, + filePath: params.filePath, + sha256: params.sha256 ?? null, + expiresAt: params.expiresAt ?? null, + thumbnailBase64: params.thumbnailBase64 ?? null, + createdAt: now, + }; + + db.insert(attachments).values(record).run(); + + return { + id, + originalFilename: params.filename, + mimeType: params.mimeType, + sizeBytes: params.sizeBytes, + kind, + thumbnailBase64: params.thumbnailBase64 ?? null, + storageKind: 'file', + filePath: params.filePath, + sha256: params.sha256 ?? null, + expiresAt: params.expiresAt ?? null, + createdAt: now, + }; +} + +/** + * Return file-backed attachments whose retention period has elapsed. + */ +export function getExpiredFileAttachments(): Array<{ id: string; filePath: string }> { + const db = getDb(); + const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client; + const now = Date.now(); + const rows = raw + .prepare( + `SELECT id, file_path FROM attachments WHERE storage_kind = 'file' AND expires_at IS NOT NULL AND expires_at < ?`, + ) + .all(now) as Array<{ id: string; file_path: string }>; + return rows.map((r) => ({ id: r.id, filePath: r.file_path })); +} + +/** + * Delete a file-backed attachment's DB row. The caller is responsible for + * removing the file on disk. + */ +export function deleteFileBackedAttachment(attachmentId: string): 'deleted' | 'not_found' { + const db = getDb(); + const existing = db + .select({ id: attachments.id }) + .from(attachments) + .where(eq(attachments.id, attachmentId)) + .get(); + + if (!existing) return 'not_found'; + + db.delete(attachments) + .where(eq(attachments.id, attachmentId)) + .run(); + + return 'deleted'; +} diff --git a/assistant/src/memory/db.ts b/assistant/src/memory/db.ts index 5d6e42580ef..e54e2c4352b 100644 --- a/assistant/src/memory/db.ts +++ b/assistant/src/memory/db.ts @@ -540,6 +540,10 @@ export function initializeDb(): void { try { database.run(/*sql*/ `ALTER TABLE conversations ADD COLUMN thread_type TEXT NOT NULL DEFAULT 'standard'`); } catch { /* already exists */ } try { database.run(/*sql*/ `ALTER TABLE conversations ADD COLUMN memory_scope_id TEXT NOT NULL DEFAULT 'default'`); } catch { /* already exists */ } try { database.run(/*sql*/ `ALTER TABLE attachments ADD COLUMN thumbnail_base64 TEXT`); } catch { /* already exists */ } + try { database.run(/*sql*/ `ALTER TABLE attachments ADD COLUMN storage_kind TEXT NOT NULL DEFAULT 'inline_base64'`); } catch { /* already exists */ } + try { database.run(/*sql*/ `ALTER TABLE attachments ADD COLUMN file_path TEXT`); } catch { /* already exists */ } + try { database.run(/*sql*/ `ALTER TABLE attachments ADD COLUMN sha256 TEXT`); } catch { /* already exists */ } + try { database.run(/*sql*/ `ALTER TABLE attachments ADD COLUMN expires_at INTEGER`); } catch { /* already exists */ } try { database.run(/*sql*/ `ALTER TABLE cron_jobs ADD COLUMN schedule_syntax TEXT NOT NULL DEFAULT 'cron'`); } catch { /* already exists */ } try { database.run(/*sql*/ `ALTER TABLE messages ADD COLUMN metadata TEXT`); } catch { /* already exists */ } diff --git a/assistant/src/memory/schema.ts b/assistant/src/memory/schema.ts index c98843e25ac..0bbba8a8e0e 100644 --- a/assistant/src/memory/schema.ts +++ b/assistant/src/memory/schema.ts @@ -164,6 +164,10 @@ export const attachments = sqliteTable('attachments', { dataBase64: text('data_base64').notNull(), contentHash: text('content_hash'), thumbnailBase64: text('thumbnail_base64'), + storageKind: text('storage_kind').notNull().default('inline_base64'), + filePath: text('file_path'), + sha256: text('sha256'), + expiresAt: integer('expires_at'), createdAt: integer('created_at').notNull(), }); diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index 23230e47931..5a8e6a4e607 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, @@ -688,6 +689,12 @@ export class RuntimeHttpServer { return await handleDeleteAttachment(req); } + // Match attachments/:attachmentId/content — must come before the generic attachments/:attachmentId + const attachmentContentMatch = endpoint.match(/^attachments\/([^/]+)\/content$/); + if (attachmentContentMatch && req.method === 'GET') { + return handleGetAttachmentContent(attachmentContentMatch[1], req); + } + // Match attachments/:attachmentId const attachmentMatch = endpoint.match(/^attachments\/([^/]+)$/); if (attachmentMatch && req.method === 'GET') { diff --git a/assistant/src/runtime/routes/attachment-routes.ts b/assistant/src/runtime/routes/attachment-routes.ts index 2471c36b9ca..88c90723fbe 100644 --- a/assistant/src/runtime/routes/attachment-routes.ts +++ b/assistant/src/runtime/routes/attachment-routes.ts @@ -1,6 +1,7 @@ /** * Route handlers for attachment upload, download, and deletion. */ +import { existsSync, statSync } from 'node:fs'; import * as attachmentsStore from '../../memory/attachments-store.js'; import { validateAttachmentUpload, AttachmentUploadError } from '../../memory/attachments-store.js'; @@ -131,3 +132,101 @@ export function handleGetAttachment(attachmentId: string): Response { data: attachment.dataBase64, }); } + +/** + * Stream attachment content as binary. Supports Range requests for video seek. + * + * For file-backed attachments: reads from disk with optional partial content. + * For inline_base64 attachments: decodes the base64 data and returns it. + */ +export function handleGetAttachmentContent(attachmentId: string, req: Request): Response { + const attachment = attachmentsStore.getAttachmentById(attachmentId); + if (!attachment) { + return Response.json({ error: 'Attachment not found' }, { status: 404 }); + } + + if (attachment.storageKind === 'file') { + return handleFileContent(attachment, req); + } + + // inline_base64 fallback — decode and return full content + const buffer = Buffer.from(attachment.dataBase64, 'base64'); + return new Response(buffer, { + status: 200, + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(buffer.length), + 'Accept-Ranges': 'bytes', + 'Content-Disposition': 'inline', + }, + }); +} + +/** + * Serve file-backed attachment content with Range header support. + */ +function handleFileContent( + attachment: attachmentsStore.StoredAttachment & { dataBase64: string }, + req: Request, +): Response { + const filePath = attachment.filePath; + if (!filePath || !existsSync(filePath)) { + return Response.json({ error: 'Attachment file not found on disk' }, { status: 404 }); + } + + let fileSize: number; + try { + fileSize = statSync(filePath).size; + } catch { + return Response.json({ error: 'Failed to read attachment file' }, { status: 500 }); + } + + const rangeHeader = req.headers.get('range'); + if (!rangeHeader) { + // Full file response + const file = Bun.file(filePath); + return new Response(file, { + status: 200, + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(fileSize), + 'Accept-Ranges': 'bytes', + 'Content-Disposition': 'inline', + }, + }); + } + + // Parse Range header (only supports single byte ranges) + const rangeMatch = rangeHeader.match(/^bytes=(\d+)-(\d*)$/); + if (!rangeMatch) { + return new Response('Invalid Range header', { + status: 416, + headers: { 'Content-Range': `bytes */${fileSize}` }, + }); + } + + const start = parseInt(rangeMatch[1], 10); + const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : fileSize - 1; + + if (start >= fileSize || end >= fileSize || start > end) { + return new Response('Range not satisfiable', { + status: 416, + headers: { 'Content-Range': `bytes */${fileSize}` }, + }); + } + + const contentLength = end - start + 1; + const file = Bun.file(filePath); + const slice = file.slice(start, end + 1); + + return new Response(slice, { + status: 206, + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(contentLength), + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Disposition': 'inline', + }, + }); +} diff --git a/assistant/src/tools/assets/search.ts b/assistant/src/tools/assets/search.ts index 16cf91d20dc..e3e2a02ac7a 100644 --- a/assistant/src/tools/assets/search.ts +++ b/assistant/src/tools/assets/search.ts @@ -183,7 +183,7 @@ export function searchAttachments(params: AssetSearchParams): StoredAttachment[] } const limit = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_RESULTS); const stmt = raw.prepare( - `SELECT a.id, a.original_filename, a.mime_type, a.size_bytes, a.kind, a.thumbnail_base64, a.created_at + `SELECT a.id, a.original_filename, a.mime_type, a.size_bytes, a.kind, a.thumbnail_base64, a.storage_kind, a.file_path, a.sha256, a.expires_at, a.created_at FROM attachments a WHERE ${whereParts.join(' AND ')} ORDER BY a.created_at DESC @@ -197,6 +197,10 @@ export function searchAttachments(params: AssetSearchParams): StoredAttachment[] size_bytes: number; kind: string; thumbnail_base64: string | null; + storage_kind: string; + file_path: string | null; + sha256: string | null; + expires_at: number | null; created_at: number; }>; @@ -207,6 +211,10 @@ export function searchAttachments(params: AssetSearchParams): StoredAttachment[] sizeBytes: r.size_bytes, kind: r.kind, thumbnailBase64: r.thumbnail_base64, + storageKind: r.storage_kind as 'inline_base64' | 'file', + filePath: r.file_path, + sha256: r.sha256, + expiresAt: r.expires_at, createdAt: r.created_at, })); } @@ -223,16 +231,25 @@ export function searchAttachments(params: AssetSearchParams): StoredAttachment[] sizeBytes: attachments.sizeBytes, kind: attachments.kind, thumbnailBase64: attachments.thumbnailBase64, + storageKind: attachments.storageKind, + filePath: attachments.filePath, + sha256: attachments.sha256, + expiresAt: attachments.expiresAt, createdAt: attachments.createdAt, }) .from(attachments) .orderBy(desc(attachments.createdAt)) .limit(limit); + const castRow = (r: { storageKind: string } & Omit): StoredAttachment => ({ + ...r, + storageKind: r.storageKind as 'inline_base64' | 'file', + }); + if (where) { - return query.where(where).all(); + return query.where(where).all().map(castRow); } - return query.all(); + return query.all().map(castRow); } // ---------------------------------------------------------------------------