Skip to content

Commit b13588e

Browse files
authored
Support media in chat session (#358)
* ✨ (storage): add local file storage option and base64 image conversion for Claude prompts * ♻️ (storage.ts): remove redundant useLocalStorage variable and ensure non-null assertions for S3 credentials and bucket name. * ♻️ (chat-sessions): refactor image handling to use URL-based images instead of base64 conversion and remove local storage support
1 parent bf28472 commit b13588e

File tree

10 files changed

+68
-17
lines changed

10 files changed

+68
-17
lines changed

.env.local

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ SENTRY_ENVIRONMENT=
128128

129129
# S3: storage configuration
130130
# ------------------------------------------------------------------------------------
131+
# S3 Storage (Digital Ocean Spaces)
132+
S3_ENABLED=false
131133
S3_REGION=fra1
132134
S3_ENDPOINT=https://fra1.digitaloceanspaces.com
133135
S3_BUCKET=your-bucket-name

.env.prod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ SENTRY_ENVIRONMENT=production
7676

7777
# S3: storage configuration
7878
# ------------------------------------------------------------------------------------
79+
# S3 Storage (Digital Ocean Spaces)
80+
S3_ENABLED=true
7981
S3_REGION=fra1
8082
S3_ENDPOINT=https://fra1.digitaloceanspaces.com
8183
S3_BUCKET=${S3_BUCKET}

.env.stage

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ SENTRY_ENVIRONMENT=stage
7676

7777
# S3: storage configuration
7878
# ------------------------------------------------------------------------------------
79+
# S3 Storage (Digital Ocean Spaces)
80+
S3_ENABLED=true
7981
S3_REGION=fra1
8082
S3_ENDPOINT=https://fra1.digitaloceanspaces.com
8183
S3_BUCKET=${S3_BUCKET}

docker-compose.local.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ services:
5454
- .:/app:cached
5555
- /app/node_modules
5656
- /app/.next
57+
- /dev/null:/app/.env.local:ro # Shadow .env.local with empty file
5758
- /var/run/docker.sock:/var/run/docker.sock
5859
depends_on:
5960
postgres:
@@ -70,6 +71,7 @@ services:
7071
- .:/app:cached
7172
- /app/node_modules
7273
- /app/.next
74+
- /dev/null:/app/.env.local:ro # Shadow .env.local with empty file
7375
- /var/run/docker.sock:/var/run/docker.sock
7476
depends_on:
7577
redis:

knip.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ const knipConfig: KnipConfig = {
3030
'@radix-ui/*',
3131
'embla-carousel-react',
3232
'input-otp',
33-
'react-resizable-panels',
3433
'vaul',
3534
'ts-node',
3635
'react-hook-form',

src/app/api/projects/[id]/chat-sessions/[sessionId]/route.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ import { buildQueue } from '@/lib/queue';
1818
import { getSandboxConfig, getSandboxManager, SandboxClient } from '@/lib/sandbox';
1919
import { getSandboxDatabaseUrl } from '@/lib/sandbox/database';
2020
import { MessageAttachmentPayload, uploadFile } from '@/lib/storage';
21+
import type { ImageUrlContent } from '@/lib/types';
2122
import * as Sentry from '@sentry/nextjs';
2223
import { eq } from 'drizzle-orm';
2324

25+
// Supported image media types for Claude multipart prompts
26+
const IMAGE_MEDIA_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const;
27+
2428
// Schema for updating a chat session
2529
const updateChatSessionSchema = z.object({
2630
title: z.string().min(1).max(100).optional(),
@@ -65,7 +69,7 @@ async function saveUploadedFile(file: File, projectId: string): Promise<MessageA
6569
}
6670

6771
/**
68-
* Process a FormData request and extract the content and attachment
72+
* Process a FormData request and extract the content and attachments
6973
*/
7074
async function processFormDataRequest(
7175
req: NextRequest,
@@ -89,6 +93,7 @@ async function processFormDataRequest(
8993
for (let i = 0; i < attachmentCount; i++) {
9094
const attachmentFile = formData.get(`attachment_${i}`) as File | null;
9195
if (attachmentFile) {
96+
// Upload to S3 for storage/display
9297
const attachment = await saveUploadedFile(attachmentFile, projectId);
9398
attachments.push(attachment);
9499
}
@@ -358,6 +363,7 @@ export async function POST(
358363
const contentType = req.headers.get('content-type') || '';
359364
let messageContent: string;
360365
let attachmentPayloads: MessageAttachmentPayload[] = [];
366+
let imageUrls: ImageUrlContent[] = []; // URL-based images for Claude (fetched by CLI)
361367

362368
if (contentType.includes('multipart/form-data')) {
363369
// Process FormData request (for file uploads)
@@ -366,12 +372,27 @@ export async function POST(
366372
messageContent = formData.content;
367373
attachmentPayloads = formData.attachments;
368374

375+
// Build URL-based image objects from uploaded attachments
376+
// CLI will fetch these URLs and convert to base64 for Claude
377+
imageUrls = attachmentPayloads
378+
.filter(a =>
379+
IMAGE_MEDIA_TYPES.includes(a.upload.mediaType as (typeof IMAGE_MEDIA_TYPES)[number])
380+
)
381+
.map(a => ({
382+
mediaType: a.upload.mediaType as ImageUrlContent['mediaType'],
383+
url: a.upload.fileUrl,
384+
}));
385+
369386
if (attachmentPayloads.length > 0) {
370-
console.log(`⬆️ ${attachmentPayloads.length} file(s) uploaded`);
387+
console.log(`📎 ${attachmentPayloads.length} file(s) attached`);
371388
attachmentPayloads.forEach((attachment, index) => {
372-
console.log(`⬆️ Attachment [${index + 1}] uploaded: ${attachment.upload.fileUrl}`);
389+
console.log(`📎 Attachment [${index + 1}]: ${attachment.upload.fileUrl}`);
373390
});
374391
}
392+
393+
if (imageUrls.length > 0) {
394+
console.log(`🖼️ ${imageUrls.length} image URL(s) prepared for Claude`);
395+
}
375396
} else {
376397
// Process JSON request for text messages
377398
console.log('Processing JSON request for streaming');
@@ -533,6 +554,7 @@ export async function POST(
533554
// Stream events from kosuke serve /api/plan (plan phase only)
534555
const planStream = sandboxClient.streamPlan(messageContent, '/app/project', {
535556
resume: claudeSessionId, // Resume previous conversation if exists
557+
...(imageUrls.length > 0 && { images: imageUrls }), // Include image URLs (fetched by CLI)
536558
});
537559

538560
for await (const event of planStream) {

src/instrumentation.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as Sentry from '@sentry/nextjs';
66
*/
77
function validateEnvironmentVariables() {
88
const sentryEnabled = process.env.SENTRY_ENABLED !== 'false';
9+
const s3Enabled = process.env.S3_ENABLED === 'true';
910

1011
const requiredEnvVars = [
1112
// Database
@@ -56,13 +57,6 @@ function validateEnvironmentVariables() {
5657
// Domain Configuration
5758
{ key: 'TRAEFIK_ENABLED', description: 'Enable Traefik reverse proxy' },
5859

59-
// Digital Ocean Spaces (Storage)
60-
{ key: 'S3_REGION', description: 'Digital Ocean Spaces region' },
61-
{ key: 'S3_ENDPOINT', description: 'Digital Ocean Spaces endpoint URL' },
62-
{ key: 'S3_BUCKET', description: 'Digital Ocean Spaces bucket name' },
63-
{ key: 'S3_ACCESS_KEY_ID', description: 'Digital Ocean Spaces access key' },
64-
{ key: 'S3_SECRET_ACCESS_KEY', description: 'Digital Ocean Spaces secret key' },
65-
6660
// Redis Configuration
6761
{ key: 'REDIS_PASSWORD', description: 'Redis password' },
6862
{ key: 'REDIS_URL', description: 'Redis connection URL for job queue' },
@@ -85,6 +79,17 @@ function validateEnvironmentVariables() {
8579
...(sentryEnabled
8680
? [{ key: 'SENTRY_AUTH_TOKEN', description: 'Sentry authentication token' }]
8781
: []),
82+
83+
// Digital Ocean Spaces (S3) - only required when S3_ENABLED=true
84+
...(s3Enabled
85+
? [
86+
{ key: 'S3_REGION', description: 'Digital Ocean Spaces region' },
87+
{ key: 'S3_ENDPOINT', description: 'Digital Ocean Spaces endpoint URL' },
88+
{ key: 'S3_BUCKET', description: 'Digital Ocean Spaces bucket name' },
89+
{ key: 'S3_ACCESS_KEY_ID', description: 'Digital Ocean Spaces access key' },
90+
{ key: 'S3_SECRET_ACCESS_KEY', description: 'Digital Ocean Spaces secret key' },
91+
]
92+
: []),
8893
];
8994

9095
const missingVars = requiredEnvVars.filter(({ key }) => !process.env[key]);

src/lib/sandbox/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* HTTP client for communicating with sandbox containers
44
*/
55

6+
import type { ImageInput } from '@/lib/types';
7+
68
import { getSandboxConfig } from './config';
79
import { getSandboxManager } from './manager';
810
import type { FileInfo, GitPullResponse, GitRevertResponse } from './types';
@@ -180,6 +182,7 @@ export class SandboxClient {
180182
options?: {
181183
noTest?: boolean;
182184
resume?: string | null;
185+
images?: ImageInput[]; // Optional images (base64 or URL - CLI will normalize)
183186
}
184187
): AsyncGenerator<Record<string, unknown>> {
185188
const config = getSandboxConfig();
@@ -198,6 +201,7 @@ export class SandboxClient {
198201
cwd,
199202
noTest,
200203
resume: options?.resume,
204+
images: options?.images,
201205
}),
202206
});
203207

src/lib/storage.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@ import { Readable } from 'node:stream';
66
import type { FileType } from './db/schema';
77

88
// S3 Client configuration for Digital Ocean Spaces
9-
// Used for both development and production environments
109
const s3Client = new S3Client({
1110
endpoint: process.env.S3_ENDPOINT,
1211
region: process.env.S3_REGION,
1312
credentials: {
14-
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
15-
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
13+
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
14+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
1615
},
1716
forcePathStyle: false, // Digital Ocean Spaces uses virtual-hosted-style
1817
});
1918

20-
const S3_BUCKET = process.env.S3_BUCKET || '';
19+
const S3_BUCKET = process.env.S3_BUCKET;
2120

2221
export interface MessageAttachmentPayload {
2322
upload: UploadResult;
@@ -126,8 +125,7 @@ async function uploadFileToS3(
126125
}
127126

128127
/**
129-
* Generic file upload function
130-
* Uploads files to Digital Ocean Spaces in both development and production
128+
* Upload file to S3 storage
131129
* @param file File to upload
132130
* @param prefix Optional prefix for organizing files (e.g., 'documents/', 'images/')
133131
* @returns Upload result with file metadata

src/lib/types/chat.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@ export interface Attachment {
161161
createdAt: Date;
162162
}
163163

164+
// Base64-encoded image content for Claude multipart prompts (internal, used in ImageInput union)
165+
interface ImageContent {
166+
mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
167+
data: string; // base64-encoded image data
168+
}
169+
170+
// URL-based image content (fetched and converted to base64 by CLI)
171+
export interface ImageUrlContent {
172+
mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
173+
url: string; // S3 or public URL to fetch
174+
}
175+
176+
// Union type for image input - supports both base64 and URL formats
177+
export type ImageInput = ImageContent | ImageUrlContent;
178+
164179
// Streaming Event Types (kosuke-cli format)
165180
export interface StreamingEvent {
166181
// Event types from kosuke-cli

0 commit comments

Comments
 (0)