diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 01b608f2..54b212d0 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -84,7 +84,7 @@ use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; #[cfg(target_os = "macos")] static NEEDS_TRAFFIC_LIGHT_FIX: AtomicBool = AtomicBool::new(false); -const PROXY_BODY_LIMIT: usize = 32 * 1024 * 1024; // 32MB +const PROXY_BODY_LIMIT: usize = 50 * 1024 * 1024; // 50MB const CLIENT_RELOAD_DELAY_MS: u64 = 800; const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; const MODELS_METADATA_CACHE_TTL: Duration = Duration::from_secs(5 * 60); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 63f21470..849046fb 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -519,6 +519,14 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo normalized.includes('gateway timeout') || normalized === 'failed to send message'; + if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) { + toast.error('Attachments are too large to send. Please try reducing the number or size of images.'); + if (allAttachments.length > 0) { + useFileStore.setState({ attachedFiles: allAttachments }); + } + return; + } + if (isSoftNetworkError) { return; } diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index bc64a9db..08b3390b 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -432,6 +432,56 @@ class OpencodeService { return lowerMime === 'image/heic' || lowerMime === 'image/heif'; } + /** + * Compress and resize image using Canvas. + */ + private async compressImage(dataUrl: string, mimeType: string, quality = 0.8, maxWidth = 2048): Promise { + if (typeof document === 'undefined') return dataUrl; + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + let width = img.width; + let height = img.height; + + if (width > maxWidth || height > maxWidth) { + if (width > height) { + height = Math.round(height * (maxWidth / width)); + width = maxWidth; + } else { + width = Math.round(width * (maxWidth / height)); + height = maxWidth; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(dataUrl); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + let targetMime = mimeType; + // Convert large PNGs to JPEG to save space + if (mimeType === 'image/png' && dataUrl.length > 2.5 * 1024 * 1024) { + targetMime = 'image/jpeg'; + } + + try { + resolve(canvas.toDataURL(targetMime, quality)); + } catch { + resolve(dataUrl); + } + }; + img.onerror = () => resolve(dataUrl); + img.src = dataUrl; + }); + } + /** * Convert HEIC image to JPEG. * Returns the original file if conversion fails. @@ -496,6 +546,23 @@ class OpencodeService { return this.convertHeicToJpeg(file); } + // Handle large image compression (Resize > 2048px or > 1MB) + if (file.mime.startsWith('image/') && (file.mime === 'image/jpeg' || file.mime === 'image/png' || file.mime === 'image/webp')) { + // > ~1MB base64 + if (file.url.length > 1.33 * 1024 * 1024) { + const compressedUrl = await this.compressImage(file.url, file.mime); + const newMime = compressedUrl.startsWith('data:image/jpeg') ? 'image/jpeg' : file.mime; + // Update the file object with compressed data + // We return a new object to avoid mutating the original file ref if used elsewhere, + // but here we just return the part. + return { + ...file, + mime: newMime, + url: compressedUrl + }; + } + } + // Handle text MIME normalization if (!this.shouldNormalizeToTextPlain(file.mime)) { return file; diff --git a/packages/ui/src/stores/fileStore.ts b/packages/ui/src/stores/fileStore.ts index e1e37678..4e0d1139 100644 --- a/packages/ui/src/stores/fileStore.ts +++ b/packages/ui/src/stores/fileStore.ts @@ -17,7 +17,7 @@ interface FileActions { type FileStore = FileState & FileActions; -const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; +const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024; const guessMimeTypeFromName = (filename: string): string => { const name = (filename || "").toLowerCase(); @@ -135,7 +135,7 @@ export const useFileStore = create()( const maxSize = MAX_ATTACHMENT_SIZE; if (file.size > maxSize) { - throw new Error(`File "${file.name}" is too large. Maximum size is 10MB.`); + throw new Error(`File "${file.name}" is too large. Maximum size is 50MB.`); } const allowedTypes = [ @@ -251,7 +251,7 @@ export const useFileStore = create()( : new TextEncoder().encode(fileContent || "").length; if (sizeBytes > MAX_ATTACHMENT_SIZE) { - throw new Error(`File "${name}" is too large. Maximum size is 10MB.`); + throw new Error(`File "${name}" is too large. Maximum size is 50MB.`); } const file = new File([], name, { type: safeMimeType }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 69a8cbbf..84828763 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1851,6 +1851,7 @@ function setupProxy(app) { }, onProxyReq: (proxyReq, req, res) => { console.log(`Proxying ${req.method} ${req.path} to OpenCode`); + if (req.headers.accept && req.headers.accept.includes('text/event-stream')) { console.log(`[SSE] Setting up SSE proxy for ${req.method} ${req.path}`); proxyReq.setHeader('Accept', 'text/event-stream'); @@ -1997,16 +1998,16 @@ async function main(options = {}) { req.path.startsWith('/api/opencode') ) { - express.json()(req, res, next); + express.json({ limit: '50mb' })(req, res, next); } else if (req.path.startsWith('/api')) { next(); } else { - express.json()(req, res, next); + express.json({ limit: '50mb' })(req, res, next); } }); - app.use(express.urlencoded({ extended: true })); + app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);