prithivMLmods commited on
Commit
7a99b16
·
verified ·
1 Parent(s): b019fcb
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build the frontend, and install server dependencies
2
+ FROM node:22 AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy all files from the current directory
7
+ COPY . ./
8
+ RUN echo "API_KEY=PLACEHOLDER" > ./.env
9
+ RUN echo "GEMINI_API_KEY=PLACEHOLDER" >> ./.env
10
+
11
+ # Install server dependencies
12
+ WORKDIR /app/server
13
+ RUN npm install
14
+
15
+ # Install dependencies and build the frontend
16
+ WORKDIR /app
17
+ RUN mkdir dist
18
+ RUN bash -c 'if [ -f package.json ]; then npm install && npm run build; fi'
19
+
20
+
21
+ # Stage 2: Build the final server image
22
+ FROM node:22
23
+
24
+ WORKDIR /app
25
+
26
+ #Copy server files
27
+ COPY --from=builder /app/server .
28
+ # Copy built frontend assets from the builder stage
29
+ COPY --from=builder /app/dist ./dist
30
+
31
+ EXPOSE 3000
32
+
33
+ CMD ["node", "server.js"]
Home.tsx ADDED
@@ -0,0 +1,767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ /* tslint:disable */
6
+ import {Content, GoogleGenAI, Modality} from '@google/genai';
7
+ import {
8
+ ChevronDown,
9
+ Library,
10
+ LoaderCircle,
11
+ Paintbrush,
12
+ PictureInPicture,
13
+ Redo2,
14
+ SendHorizontal,
15
+ Sparkles,
16
+ Trash2,
17
+ Undo2,
18
+ X,
19
+ } from 'lucide-react';
20
+ import {useEffect, useRef, useState} from 'react';
21
+
22
+ const ai = new GoogleGenAI({apiKey: process.env.API_KEY});
23
+
24
+ function parseError(error: string) {
25
+ const regex = /{"error":(.*)}/gm;
26
+ const m = regex.exec(error);
27
+ try {
28
+ const e = m[1];
29
+ const err = JSON.parse(e);
30
+ return err.message || error;
31
+ } catch (e) {
32
+ return error;
33
+ }
34
+ }
35
+
36
+ export default function Home() {
37
+ const canvasRef = useRef(null);
38
+ const fileInputRef = useRef(null);
39
+ const backgroundImageRef = useRef(null);
40
+ const dropdownRef = useRef(null);
41
+ const [isDrawing, setIsDrawing] = useState(false);
42
+ const [prompt, setPrompt] = useState('');
43
+ const [generatedImage, setGeneratedImage] = useState<string | null>(null);
44
+ const [multiImages, setMultiImages] = useState<
45
+ {url: string; type: string}[]
46
+ >([]);
47
+ const [isLoading, setIsLoading] = useState(false);
48
+ const [showErrorModal, setShowErrorModal] = useState(false);
49
+ const [errorMessage, setErrorMessage] = useState('');
50
+ const [mode, setMode] = useState<
51
+ 'canvas' | 'editor' | 'imageGen' | 'multi-img-edit'
52
+ >('editor');
53
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
54
+
55
+ // State for canvas history
56
+ const [history, setHistory] = useState<string[]>([]);
57
+ const [historyIndex, setHistoryIndex] = useState(-1);
58
+
59
+ // When switching to canvas mode, initialize it and its history
60
+ useEffect(() => {
61
+ if (mode === 'canvas' && canvasRef.current) {
62
+ const canvas = canvasRef.current;
63
+ const ctx = canvas.getContext('2d');
64
+ ctx.fillStyle = '#FFFFFF';
65
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
66
+
67
+ // If an image already exists from another mode, draw it.
68
+ if (generatedImage) {
69
+ const img = new window.Image();
70
+ img.onload = () => {
71
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
72
+ // Save this as the initial state for this session
73
+ const dataUrl = canvas.toDataURL();
74
+ setHistory([dataUrl]);
75
+ setHistoryIndex(0);
76
+ };
77
+ img.src = generatedImage;
78
+ } else {
79
+ // Otherwise, save the blank state as initial
80
+ const dataUrl = canvas.toDataURL();
81
+ setHistory([dataUrl]);
82
+ setHistoryIndex(0);
83
+ }
84
+ }
85
+ }, [mode, generatedImage]);
86
+
87
+ // Load background image when generatedImage changes
88
+ useEffect(() => {
89
+ if (generatedImage && canvasRef.current) {
90
+ const img = new window.Image();
91
+ img.onload = () => {
92
+ backgroundImageRef.current = img;
93
+ drawImageToCanvas();
94
+ if (mode === 'canvas') {
95
+ // A small timeout to let the draw happen before saving
96
+ setTimeout(saveCanvasState, 50);
97
+ }
98
+ };
99
+ img.src = generatedImage;
100
+ }
101
+ }, [generatedImage, mode]);
102
+
103
+ // Handle clicks outside the dropdown to close it
104
+ useEffect(() => {
105
+ function handleClickOutside(event: MouseEvent) {
106
+ if (
107
+ dropdownRef.current &&
108
+ !dropdownRef.current.contains(event.target as Node)
109
+ ) {
110
+ setIsDropdownOpen(false);
111
+ }
112
+ }
113
+ document.addEventListener('mousedown', handleClickOutside);
114
+ return () => {
115
+ document.removeEventListener('mousedown', handleClickOutside);
116
+ };
117
+ }, [dropdownRef]);
118
+
119
+ // Initialize canvas with white background
120
+ const initializeCanvas = () => {
121
+ const canvas = canvasRef.current;
122
+ const ctx = canvas.getContext('2d');
123
+ ctx.fillStyle = '#FFFFFF';
124
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
125
+ };
126
+
127
+ // Draw the background image to the canvas
128
+ const drawImageToCanvas = () => {
129
+ if (!canvasRef.current || !backgroundImageRef.current) return;
130
+
131
+ const canvas = canvasRef.current;
132
+ const ctx = canvas.getContext('2d');
133
+ ctx.fillStyle = '#FFFFFF';
134
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
135
+ ctx.drawImage(
136
+ backgroundImageRef.current,
137
+ 0,
138
+ 0,
139
+ canvas.width,
140
+ canvas.height,
141
+ );
142
+ };
143
+
144
+ // Canvas history functions
145
+ const saveCanvasState = () => {
146
+ if (!canvasRef.current) return;
147
+ const canvas = canvasRef.current;
148
+ const dataUrl = canvas.toDataURL();
149
+ const newHistory = history.slice(0, historyIndex + 1);
150
+ newHistory.push(dataUrl);
151
+ setHistory(newHistory);
152
+ setHistoryIndex(newHistory.length - 1);
153
+ };
154
+
155
+ const restoreCanvasState = (index: number) => {
156
+ if (!canvasRef.current || !history[index]) return;
157
+ const canvas = canvasRef.current;
158
+ const ctx = canvas.getContext('2d');
159
+ const dataUrl = history[index];
160
+ const img = new window.Image();
161
+ img.onload = () => {
162
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
163
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
164
+ };
165
+ img.src = dataUrl;
166
+ };
167
+
168
+ const handleUndo = () => {
169
+ if (historyIndex > 0) {
170
+ const newIndex = historyIndex - 1;
171
+ setHistoryIndex(newIndex);
172
+ restoreCanvasState(newIndex);
173
+ }
174
+ };
175
+
176
+ const handleRedo = () => {
177
+ if (historyIndex < history.length - 1) {
178
+ const newIndex = historyIndex + 1;
179
+ setHistoryIndex(newIndex);
180
+ restoreCanvasState(newIndex);
181
+ }
182
+ };
183
+
184
+ // Get the correct coordinates based on canvas scaling
185
+ const getCoordinates = (e) => {
186
+ const canvas = canvasRef.current;
187
+ const rect = canvas.getBoundingClientRect();
188
+ const scaleX = canvas.width / rect.width;
189
+ const scaleY = canvas.height / rect.height;
190
+ return {
191
+ x:
192
+ (e.nativeEvent.offsetX ||
193
+ e.nativeEvent.touches?.[0]?.clientX - rect.left) * scaleX,
194
+ y:
195
+ (e.nativeEvent.offsetY ||
196
+ e.nativeEvent.touches?.[0]?.clientY - rect.top) * scaleY,
197
+ };
198
+ };
199
+
200
+ const startDrawing = (e) => {
201
+ const canvas = canvasRef.current;
202
+ const ctx = canvas.getContext('2d');
203
+ const {x, y} = getCoordinates(e);
204
+ if (e.type === 'touchstart') {
205
+ e.preventDefault();
206
+ }
207
+ ctx.beginPath();
208
+ ctx.moveTo(x, y);
209
+ setIsDrawing(true);
210
+ };
211
+
212
+ const draw = (e) => {
213
+ if (!isDrawing) return;
214
+ if (e.type === 'touchmove') {
215
+ e.preventDefault();
216
+ }
217
+ const canvas = canvasRef.current;
218
+ const ctx = canvas.getContext('2d');
219
+ const {x, y} = getCoordinates(e);
220
+ ctx.lineWidth = 5;
221
+ ctx.lineCap = 'round';
222
+ ctx.strokeStyle = '#000000';
223
+ ctx.lineTo(x, y);
224
+ ctx.stroke();
225
+ };
226
+
227
+ const stopDrawing = () => {
228
+ if (!isDrawing) return;
229
+ setIsDrawing(false);
230
+ saveCanvasState();
231
+ };
232
+
233
+ const handleClear = () => {
234
+ if (mode === 'canvas' && canvasRef.current) {
235
+ initializeCanvas();
236
+ const dataUrl = canvasRef.current.toDataURL();
237
+ setHistory([dataUrl]);
238
+ setHistoryIndex(0);
239
+ }
240
+ setGeneratedImage(null);
241
+ setMultiImages([]);
242
+ backgroundImageRef.current = null;
243
+ setPrompt('');
244
+ };
245
+
246
+ const processFiles = (files: FileList | null) => {
247
+ if (!files) return;
248
+ const fileArray = Array.from(files).filter((f) =>
249
+ f.type.startsWith('image/'),
250
+ );
251
+ if (fileArray.length === 0) return;
252
+
253
+ if (mode === 'multi-img-edit') {
254
+ const readers = fileArray.map((file) => {
255
+ return new Promise<{url: string; type: string}>((resolve, reject) => {
256
+ const reader = new FileReader();
257
+ reader.onload = () =>
258
+ resolve({url: reader.result as string, type: file.type});
259
+ reader.onerror = reject;
260
+ reader.readAsDataURL(file);
261
+ });
262
+ });
263
+ Promise.all(readers).then((newImages) => {
264
+ setMultiImages((prev) => [...prev, ...newImages]);
265
+ });
266
+ } else {
267
+ const file = fileArray[0];
268
+ const reader = new FileReader();
269
+ reader.onload = () => {
270
+ setGeneratedImage(reader.result as string);
271
+ };
272
+ reader.readAsDataURL(file);
273
+ }
274
+ };
275
+
276
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
277
+ processFiles(e.target.files);
278
+ // Reset file input to allow uploading the same file again
279
+ e.target.value = '';
280
+ };
281
+
282
+ const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
283
+ e.preventDefault();
284
+ e.stopPropagation();
285
+ e.currentTarget.classList.remove('border-blue-500');
286
+ processFiles(e.dataTransfer.files);
287
+ };
288
+
289
+ const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
290
+ e.preventDefault();
291
+ e.stopPropagation();
292
+ };
293
+ const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
294
+ e.currentTarget.classList.add('border-blue-500');
295
+ };
296
+ const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
297
+ e.currentTarget.classList.remove('border-blue-500');
298
+ };
299
+
300
+ const removeImage = (indexToRemove: number) => {
301
+ setMultiImages((prev) =>
302
+ prev.filter((_, index) => index !== indexToRemove),
303
+ );
304
+ };
305
+
306
+ const handleSubmit = async (e) => {
307
+ e.preventDefault();
308
+ setIsLoading(true);
309
+
310
+ try {
311
+ if (mode === 'editor' && !generatedImage) {
312
+ setErrorMessage('Please upload an image to edit.');
313
+ setShowErrorModal(true);
314
+ return;
315
+ }
316
+
317
+ if (mode === 'multi-img-edit' && multiImages.length === 0) {
318
+ setErrorMessage('Please upload at least one image to edit.');
319
+ setShowErrorModal(true);
320
+ return;
321
+ }
322
+
323
+ const parts: any[] = [];
324
+
325
+ if (mode === 'imageGen') {
326
+ const tempCanvas = document.createElement('canvas');
327
+ tempCanvas.width = 960;
328
+ tempCanvas.height = 540;
329
+ const tempCtx = tempCanvas.getContext('2d');
330
+ // Fill with white
331
+ tempCtx.fillStyle = '#FFFFFF';
332
+ tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
333
+ // Add a tiny, almost invisible pixel to encourage editing behavior
334
+ tempCtx.fillStyle = '#FEFEFE'; // A slightly off-white color
335
+ tempCtx.fillRect(0, 0, 1, 1); // At the top-left corner
336
+ const imageB64 = tempCanvas.toDataURL('image/png').split(',')[1];
337
+ parts.push({inlineData: {data: imageB64, mimeType: 'image/png'}});
338
+ } else if (mode === 'canvas') {
339
+ if (!canvasRef.current) return;
340
+ const canvas = canvasRef.current;
341
+ const tempCanvas = document.createElement('canvas');
342
+ tempCanvas.width = canvas.width;
343
+ tempCanvas.height = canvas.height;
344
+ const tempCtx = tempCanvas.getContext('2d');
345
+ tempCtx.fillStyle = '#FFFFFF';
346
+ tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
347
+ tempCtx.drawImage(canvas, 0, 0);
348
+ const imageB64 = tempCanvas.toDataURL('image/png').split(',')[1];
349
+ parts.push({inlineData: {data: imageB64, mimeType: 'image/png'}});
350
+ } else if (mode === 'editor' && generatedImage) {
351
+ const mimeType = generatedImage.substring(
352
+ generatedImage.indexOf(':') + 1,
353
+ generatedImage.indexOf(';'),
354
+ );
355
+ const imageB64 = generatedImage.split(',')[1];
356
+ parts.push({inlineData: {data: imageB64, mimeType}});
357
+ } else if (mode === 'multi-img-edit') {
358
+ multiImages.forEach((img) => {
359
+ parts.push({
360
+ inlineData: {data: img.url.split(',')[1], mimeType: img.type},
361
+ });
362
+ });
363
+ }
364
+
365
+ parts.push({text: prompt});
366
+
367
+ const contents: Content[] = [{role: 'USER', parts}];
368
+
369
+ const response = await ai.models.generateContent({
370
+ model: 'gemini-2.5-flash-image-preview',
371
+ contents,
372
+ config: {
373
+ responseModalities: [Modality.TEXT, Modality.IMAGE],
374
+ },
375
+ });
376
+
377
+ const data = {
378
+ success: true,
379
+ message: '',
380
+ imageData: null,
381
+ error: undefined,
382
+ };
383
+ for (const part of response.candidates[0].content.parts) {
384
+ if (part.text) {
385
+ data.message = part.text;
386
+ } else if (part.inlineData) {
387
+ data.imageData = part.inlineData.data;
388
+ }
389
+ }
390
+
391
+ if (data.imageData) {
392
+ const imageUrl = `data:image/png;base64,${data.imageData}`;
393
+ if (mode === 'multi-img-edit') {
394
+ setGeneratedImage(imageUrl);
395
+ setMultiImages([]);
396
+ setMode('editor');
397
+ } else {
398
+ setGeneratedImage(imageUrl);
399
+ }
400
+ } else {
401
+ setErrorMessage(
402
+ data.message || 'Failed to generate image. Please try again.',
403
+ );
404
+ setShowErrorModal(true);
405
+ }
406
+ } catch (error) {
407
+ console.error('Error submitting:', error);
408
+ setErrorMessage(error.message || 'An unexpected error occurred.');
409
+ setShowErrorModal(true);
410
+ } finally {
411
+ setIsLoading(false);
412
+ }
413
+ };
414
+
415
+ const closeErrorModal = () => {
416
+ setShowErrorModal(false);
417
+ };
418
+
419
+ useEffect(() => {
420
+ const canvas = canvasRef.current;
421
+ if (!canvas) return;
422
+
423
+ const preventTouchDefault = (e) => {
424
+ if (isDrawing) {
425
+ e.preventDefault();
426
+ }
427
+ };
428
+
429
+ canvas.addEventListener('touchstart', preventTouchDefault, {
430
+ passive: false,
431
+ });
432
+ canvas.addEventListener('touchmove', preventTouchDefault, {
433
+ passive: false,
434
+ });
435
+
436
+ return () => {
437
+ canvas.removeEventListener('touchstart', preventTouchDefault);
438
+ canvas.removeEventListener('touchmove', preventTouchDefault);
439
+ };
440
+ }, [isDrawing]);
441
+
442
+ const baseDisplayClass =
443
+ 'w-full sm:h-[60vh] h-[40vh] min-h-[320px] bg-white/90 touch-none flex items-center justify-center p-4 transition-colors';
444
+
445
+ return (
446
+ <>
447
+ <div className="min-h-screen text-gray-900 flex flex-col justify-start items-center">
448
+ <main className="container mx-auto px-3 sm:px-6 py-5 sm:py-10 pb-32 max-w-5xl w-full">
449
+ <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end mb-2 sm:mb-6 gap-2">
450
+ <div>
451
+ <h1 className="text-2xl sm:text-3xl font-bold mb-0 leading-tight">
452
+ Nano Banana AIO
453
+ </h1>
454
+ <p className="text-sm sm:text-base text-gray-500 mt-1">
455
+ constructed with the{' '}
456
+ <a
457
+ className="underline"
458
+ href="https://aistudio.google.com/app/apikey"
459
+ target="_blank"
460
+ rel="noopener noreferrer">
461
+ gemini api
462
+ </a>{' '}
463
+ by{' '}
464
+ <a
465
+ className="underline"
466
+ href="https://huggingface.co/prithivMLmods"
467
+ target="_blank"
468
+ rel="noopener noreferrer">
469
+ prithivsakthi-ur
470
+ </a>
471
+ </p>
472
+ </div>
473
+
474
+ <menu className="flex items-center bg-gray-300 rounded-full p-2 shadow-sm self-start sm:self-auto">
475
+ <div className="flex flex-wrap justify-center items-center bg-gray-200/80 rounded-full p-1 mr-2">
476
+ <div className="relative" ref={dropdownRef}>
477
+ <button
478
+ onClick={() => setIsDropdownOpen(!isDropdownOpen)}
479
+ className={`p-2 sm:px-3 sm:py-1.5 rounded-full text-sm font-medium flex items-center gap-2 transition-colors ${
480
+ mode === 'editor' || mode === 'multi-img-edit'
481
+ ? 'bg-white shadow'
482
+ : 'text-gray-600 hover:bg-gray-300/50'
483
+ }`}
484
+ aria-haspopup="true"
485
+ aria-expanded={isDropdownOpen}>
486
+ {mode === 'multi-img-edit' ? (
487
+ <>
488
+ <Library className="w-4 h-4" />
489
+ <span className="hidden sm:inline">Multi-Image</span>
490
+ </>
491
+ ) : (
492
+ <>
493
+ <PictureInPicture className="w-4 h-4" />
494
+ <span className="hidden sm:inline">Editor</span>
495
+ </>
496
+ )}
497
+ <ChevronDown className="w-4 h-4 opacity-70" />
498
+ </button>
499
+ {isDropdownOpen && (
500
+ <div className="absolute top-full mt-2 w-48 bg-white rounded-lg shadow-xl z-10 border border-gray-200 py-1">
501
+ <button
502
+ onClick={() => {
503
+ setMode('editor');
504
+ setIsDropdownOpen(false);
505
+ }}
506
+ className={`w-full text-left px-3 py-2 text-sm flex items-center gap-3 transition-colors ${
507
+ mode === 'editor'
508
+ ? 'bg-gray-100 text-gray-900'
509
+ : 'text-gray-700 hover:bg-gray-50'
510
+ }`}
511
+ aria-pressed={mode === 'editor'}>
512
+ <PictureInPicture className="w-4 h-4" />
513
+ <span>Single Image Edit</span>
514
+ </button>
515
+ <button
516
+ onClick={() => {
517
+ setMode('multi-img-edit');
518
+ setIsDropdownOpen(false);
519
+ }}
520
+ className={`w-full text-left px-3 py-2 text-sm flex items-center gap-3 transition-colors ${
521
+ mode === 'multi-img-edit'
522
+ ? 'bg-gray-100 text-gray-900'
523
+ : 'text-gray-700 hover:bg-gray-50'
524
+ }`}
525
+ aria-pressed={mode === 'multi-img-edit'}>
526
+ <Library className="w-4 h-4" />
527
+ <span>Multi-Image Edit</span>
528
+ </button>
529
+ </div>
530
+ )}
531
+ </div>
532
+
533
+ <button
534
+ onClick={() => setMode('canvas')}
535
+ className={`p-2 sm:px-3 sm:py-1.5 rounded-full text-sm font-medium flex items-center gap-2 transition-colors ${
536
+ mode === 'canvas'
537
+ ? 'bg-white shadow'
538
+ : 'text-gray-600 hover:bg-gray-300/50'
539
+ }`}
540
+ aria-pressed={mode === 'canvas'}>
541
+ <Paintbrush className="w-4 h-4" />
542
+ <span className="hidden sm:inline">Canvas</span>
543
+ </button>
544
+ <button
545
+ onClick={() => setMode('imageGen')}
546
+ className={`p-2 sm:px-3 sm:py-1.5 rounded-full text-sm font-medium flex items-center gap-2 transition-colors ${
547
+ mode === 'imageGen'
548
+ ? 'bg-white shadow'
549
+ : 'text-gray-600 hover:bg-gray-300/50'
550
+ }`}
551
+ aria-pressed={mode === 'imageGen'}>
552
+ <Sparkles className="w-4 h-4" />
553
+ <span className="hidden sm:inline">Image Gen</span>
554
+ </button>
555
+ </div>
556
+ <button
557
+ type="button"
558
+ onClick={handleClear}
559
+ className="w-10 h-10 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110">
560
+ <Trash2
561
+ className="w-5 h-5 text-gray-700"
562
+ aria-label="Clear Canvas"
563
+ />
564
+ </button>
565
+ </menu>
566
+ </div>
567
+
568
+ <div className="w-full mb-6">
569
+ <input
570
+ ref={fileInputRef}
571
+ type="file"
572
+ onChange={handleFileChange}
573
+ accept="image/*"
574
+ className="hidden"
575
+ aria-label="Upload image"
576
+ multiple={mode === 'multi-img-edit'}
577
+ />
578
+ {mode === 'canvas' ? (
579
+ <div className="relative w-full">
580
+ <canvas
581
+ ref={canvasRef}
582
+ width={960}
583
+ height={540}
584
+ onMouseDown={startDrawing}
585
+ onMouseMove={draw}
586
+ onMouseUp={stopDrawing}
587
+ onMouseLeave={stopDrawing}
588
+ onTouchStart={startDrawing}
589
+ onTouchMove={draw}
590
+ onTouchEnd={stopDrawing}
591
+ className="border-2 border-black w-full sm:h-[60vh] h-[40vh] min-h-[320px] bg-white/90 touch-none"
592
+ style={{
593
+ cursor:
594
+ "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%23FF0000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>') 12 12, crosshair",
595
+ }}
596
+ />
597
+ <div className="absolute top-2 right-2 flex gap-2">
598
+ <button
599
+ onClick={handleUndo}
600
+ disabled={historyIndex <= 0}
601
+ className="p-2 bg-white rounded-md shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors"
602
+ aria-label="Undo">
603
+ <Undo2 className="w-5 h-5" />
604
+ </button>
605
+ <button
606
+ onClick={handleRedo}
607
+ disabled={historyIndex >= history.length - 1}
608
+ className="p-2 bg-white rounded-md shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors"
609
+ aria-label="Redo">
610
+ <Redo2 className="w-5 h-5" />
611
+ </button>
612
+ </div>
613
+ </div>
614
+ ) : mode === 'editor' ? (
615
+ <div
616
+ className={`${baseDisplayClass} ${
617
+ generatedImage ? 'border-black' : 'border-gray-400'
618
+ } border-2 border-dashed`}
619
+ onDrop={handleDrop}
620
+ onDragOver={handleDragOver}
621
+ onDragEnter={handleDragEnter}
622
+ onDragLeave={handleDragLeave}>
623
+ {generatedImage ? (
624
+ <img
625
+ src={generatedImage}
626
+ alt="Current image"
627
+ className="max-w-full max-h-full object-contain"
628
+ />
629
+ ) : (
630
+ <button
631
+ type="button"
632
+ onClick={() => fileInputRef.current?.click()}
633
+ className="text-center text-gray-500 hover:text-gray-700 p-8 rounded-lg">
634
+ <h3 className="font-semibold text-lg">Upload Image</h3>
635
+ <p>Click to upload or drag & drop</p>
636
+ </button>
637
+ )}
638
+ </div>
639
+ ) : mode === 'multi-img-edit' ? (
640
+ <div
641
+ className={`${baseDisplayClass} ${
642
+ multiImages.length > 0
643
+ ? 'border-black items-start'
644
+ : 'border-gray-400'
645
+ } border-2 border-dashed flex-col`}
646
+ onDrop={handleDrop}
647
+ onDragOver={handleDragOver}
648
+ onDragEnter={handleDragEnter}
649
+ onDragLeave={handleDragLeave}>
650
+ {multiImages.length > 0 ? (
651
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4 overflow-y-auto w-full h-full">
652
+ {multiImages.map((image, index) => (
653
+ <div key={index} className="relative group aspect-square">
654
+ <img
655
+ src={image.url}
656
+ alt={`upload preview ${index + 1}`}
657
+ className="w-full h-full object-cover rounded-md"
658
+ />
659
+ <button
660
+ onClick={() => removeImage(index)}
661
+ className="absolute top-1 right-1 bg-black/50 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
662
+ aria-label={`Remove image ${index + 1}`}>
663
+ <X className="w-4 h-4" />
664
+ </button>
665
+ </div>
666
+ ))}
667
+ <button
668
+ type="button"
669
+ onClick={() => fileInputRef.current?.click()}
670
+ className="flex items-center justify-center border-2 border-dashed border-gray-300 rounded-md text-gray-400 hover:text-gray-600 hover:border-gray-400 transition-colors aspect-square">
671
+ + Add more
672
+ </button>
673
+ </div>
674
+ ) : (
675
+ <button
676
+ type="button"
677
+ onClick={() => fileInputRef.current?.click()}
678
+ className="text-center text-gray-500 hover:text-gray-700 p-8 rounded-lg m-auto">
679
+ <h3 className="font-semibold text-lg">
680
+ Upload one or multiple images
681
+ </h3>
682
+ <p>Click to upload or drag & drop</p>
683
+ </button>
684
+ )}
685
+ </div>
686
+ ) : (
687
+ // Image Gen mode display
688
+ <div
689
+ className={`relative ${baseDisplayClass} border-2 ${
690
+ generatedImage ? 'border-black' : 'border-gray-400'
691
+ }`}>
692
+ {generatedImage ? (
693
+ <img
694
+ src={generatedImage}
695
+ alt="Generated image"
696
+ className="max-w-full max-h-full object-contain"
697
+ />
698
+ ) : (
699
+ <div className="text-center text-gray-500">
700
+ <h3 className="font-semibold text-lg">Image Generation</h3>
701
+ <p>Enter a prompt below to create an image</p>
702
+ </div>
703
+ )}
704
+ </div>
705
+ )}
706
+ </div>
707
+
708
+ {/* Input form */}
709
+ <form onSubmit={handleSubmit} className="w-full">
710
+ <div className="relative">
711
+ <input
712
+ type="text"
713
+ value={prompt}
714
+ onChange={(e) => setPrompt(e.target.value)}
715
+ placeholder={
716
+ mode === 'imageGen'
717
+ ? 'Describe the image you want to create...'
718
+ : mode === 'multi-img-edit'
719
+ ? 'Describe how to edit or combine the images...'
720
+ : 'Add your change...'
721
+ }
722
+ className="w-full p-3 sm:p-4 pr-12 sm:pr-14 text-sm sm:text-base border-2 border-black bg-white text-gray-800 shadow-sm focus:ring-2 focus:ring-gray-200 focus:outline-none transition-all"
723
+ required
724
+ />
725
+ <button
726
+ type="submit"
727
+ disabled={isLoading}
728
+ className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 p-1.5 sm:p-2 rounded-none bg-black text-white hover:cursor-pointer hover:bg-gray-800 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
729
+ {isLoading ? (
730
+ <LoaderCircle
731
+ className="w-5 sm:w-6 h-5 sm:h-6 animate-spin"
732
+ aria-label="Loading"
733
+ />
734
+ ) : (
735
+ <SendHorizontal
736
+ className="w-5 sm:w-6 h-5 sm:h-6"
737
+ aria-label="Submit"
738
+ />
739
+ )}
740
+ </button>
741
+ </div>
742
+ </form>
743
+ </main>
744
+ {/* Error Modal */}
745
+ {showErrorModal && (
746
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
747
+ <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
748
+ <div className="flex justify-between items-start mb-4">
749
+ <h3 className="text-xl font-bold text-gray-700">
750
+ Failed to generate
751
+ </h3>
752
+ <button
753
+ onClick={closeErrorModal}
754
+ className="text-gray-400 hover:text-gray-500">
755
+ <X className="w-5 h-5" />
756
+ </button>
757
+ </div>
758
+ <p className="font-medium text-gray-600">
759
+ {parseError(errorMessage)}
760
+ </p>
761
+ </div>
762
+ </div>
763
+ )}
764
+ </div>
765
+ </>
766
+ );
767
+ }
index.css ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap');
3
+
4
+ :root {
5
+ --background: #f3f4f6; /* Tailwind gray-100 */
6
+ --foreground: #171717;
7
+ }
8
+
9
+
10
+
11
+ body {
12
+ color: var(--foreground);
13
+ font-family: 'Outfit', sans-serif;
14
+ background-color: var(--background);
15
+ }
index.html ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Nano Banana AIO</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "@google/genai": "https://esm.sh/@google/genai@^0.7.0",
11
+ "react": "https://esm.sh/react@^19.0.0",
12
+ "react/": "https://esm.sh/react@^19.0.0/",
13
+ "react-dom/": "https://esm.sh/react-dom@^19.0.0/",
14
+ "lucide-react": "https://esm.sh/lucide-react@^0.487.0",
15
+ "@tailwindcss/browser": "https://esm.sh/@tailwindcss/browser@^4.1.2"
16
+ }
17
+ }
18
+ </script>
19
+ <link rel="stylesheet" href="/index.css">
20
+ </head>
21
+ <body>
22
+ <div id="root"></div>
23
+ <script type="module" src="/index.tsx"></script>
24
+ </body>
25
+ </html>
index.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import '@tailwindcss/browser';
6
+ import './index.css';
7
+
8
+ import ReactDOM from 'react-dom/client';
9
+ import Home from './Home';
10
+
11
+ const root = ReactDOM.createRoot(document.getElementById('root'));
12
+ root.render(<Home />);
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "nano-banana-aio",
3
+ "description": "Sketch and prompt, Gemini brings your drawings to life! Co-create collaboratively with AI by sketching and prompting.",
4
+ "requestFramePermissions": []
5
+ }
package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nano-banana-aio",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@google/genai": "^0.7.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0",
15
+ "lucide-react": "^0.487.0",
16
+ "@tailwindcss/browser": "^4.1.2"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.14.0",
20
+ "typescript": "~5.8.2",
21
+ "vite": "^6.2.0"
22
+ }
23
+ }
server/package.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "appletserver",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "scripts": {
6
+ "start": "node server.js",
7
+ "dev": "nodemon server.js"
8
+ },
9
+ "dependencies": {
10
+ "axios": "^1.6.7",
11
+ "dotenv": "^16.4.5",
12
+ "express": "^4.18.2",
13
+ "express-rate-limit": "^7.5.0",
14
+ "ws": "^8.17.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.14.0",
18
+ "nodemon": "^3.1.0"
19
+ }
20
+ }
server/public/service-worker.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ // service-worker.js
7
+
8
+ // Define the target URL that we want to intercept and proxy.
9
+ const TARGET_URL_PREFIX = 'https://generativelanguage.googleapis.com';
10
+
11
+ // Installation event:
12
+ self.addEventListener('install', (event) => {
13
+ try {
14
+ console.log('Service Worker: Installing...');
15
+ event.waitUntil(self.skipWaiting());
16
+ } catch (error) {
17
+ console.error('Service Worker: Error during install event:', error);
18
+ // If skipWaiting fails, the new SW might get stuck in a waiting state.
19
+ }
20
+ });
21
+
22
+ // Activation event:
23
+ self.addEventListener('activate', (event) => {
24
+ try {
25
+ console.log('Service Worker: Activating...');
26
+ event.waitUntil(self.clients.claim());
27
+ } catch (error) {
28
+ console.error('Service Worker: Error during activate event:', error);
29
+ // If clients.claim() fails, the SW might not control existing pages until next nav.
30
+ }
31
+ });
32
+
33
+ // Fetch event:
34
+ self.addEventListener('fetch', (event) => {
35
+ try {
36
+ const requestUrl = event.request.url;
37
+
38
+ if (requestUrl.startsWith(TARGET_URL_PREFIX)) {
39
+ console.log(`Service Worker: Intercepting request to ${requestUrl}`);
40
+
41
+ const remainingPathAndQuery = requestUrl.substring(TARGET_URL_PREFIX.length);
42
+ const proxyUrl = `${self.location.origin}/api-proxy${remainingPathAndQuery}`;
43
+
44
+ console.log(`Service Worker: Proxying to ${proxyUrl}`);
45
+
46
+ // Construct headers for the request to the proxy
47
+ const newHeaders = new Headers();
48
+ // Copy essential headers from the original request
49
+ // For OPTIONS (preflight) requests, Access-Control-Request-* are critical.
50
+ // For actual requests (POST, GET), Content-Type, Accept etc.
51
+ const headersToCopy = [
52
+ 'Content-Type',
53
+ 'Accept',
54
+ 'Access-Control-Request-Method',
55
+ 'Access-Control-Request-Headers',
56
+ ];
57
+
58
+ for (const headerName of headersToCopy) {
59
+ if (event.request.headers.has(headerName)) {
60
+ newHeaders.set(headerName, event.request.headers.get(headerName));
61
+ }
62
+ }
63
+
64
+ if (event.request.method === 'POST') {
65
+
66
+ // Ensure Content-Type is set for POST requests to the proxy, defaulting to application/json
67
+ if (!newHeaders.has('Content-Type')) {
68
+ console.warn("Service Worker: POST request to proxy was missing Content-Type in newHeaders. Defaulting to application/json.");
69
+ newHeaders.set('Content-Type', 'application/json');
70
+ } else {
71
+ console.log(`Service Worker: POST request to proxy has Content-Type: ${newHeaders.get('Content-Type')}`);
72
+ }
73
+ }
74
+
75
+ const requestOptions = {
76
+ method: event.request.method,
77
+ headers: newHeaders, // Use simplified headers
78
+ body: event.request.body, // Still use the original body stream
79
+ mode: event.request.mode,
80
+ credentials: event.request.credentials,
81
+ cache: event.request.cache,
82
+ redirect: event.request.redirect,
83
+ referrer: event.request.referrer,
84
+ integrity: event.request.integrity,
85
+ };
86
+
87
+ // Only set duplex if there's a body and it's a relevant method
88
+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD' && event.request.body ) {
89
+ requestOptions.duplex = 'half';
90
+ }
91
+
92
+ const promise = fetch(new Request(proxyUrl, requestOptions))
93
+ .then((response) => {
94
+ console.log(`Service Worker: Successfully proxied request to ${proxyUrl}, Status: ${response.status}`);
95
+ return response;
96
+ })
97
+ .catch((error) => {
98
+ // Log more error details
99
+ console.error(`Service Worker: Error proxying request to ${proxyUrl}. Message: ${error.message}, Name: ${error.name}, Stack: ${error.stack}`);
100
+ return new Response(
101
+ JSON.stringify({ error: 'Proxying failed', details: error.message, name: error.name, proxiedUrl: proxyUrl }),
102
+ {
103
+ status: 502, // Bad Gateway is appropriate for proxy errors
104
+ headers: { 'Content-Type': 'application/json' }
105
+ }
106
+ );
107
+ });
108
+
109
+ event.respondWith(promise);
110
+
111
+ } else {
112
+ // If the request URL doesn't match our target, let it proceed as normal.
113
+ event.respondWith(fetch(event.request));
114
+ }
115
+ } catch (error) {
116
+ // Log more error details for unhandled errors too
117
+ console.error('Service Worker: Unhandled error in fetch event handler. Message:', error.message, 'Name:', error.name, 'Stack:', error.stack);
118
+ event.respondWith(
119
+ new Response(
120
+ JSON.stringify({ error: 'Service worker fetch handler failed', details: error.message, name: error.name }),
121
+ {
122
+ status: 500,
123
+ headers: { 'Content-Type': 'application/json' }
124
+ }
125
+ )
126
+ );
127
+ }
128
+ });
server/public/websocket-interceptor.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function() {
2
+ const TARGET_WS_HOST = 'generativelanguage.googleapis.com'; // Host to intercept
3
+ const originalWebSocket = window.WebSocket;
4
+
5
+ if (!originalWebSocket) {
6
+ console.error('[WebSocketInterceptor] Original window.WebSocket not found. Cannot apply interceptor.');
7
+ return;
8
+ }
9
+
10
+ const handler = {
11
+ construct(target, args) {
12
+ let [url, protocols] = args;
13
+ //stringify url's if necessary for parsing
14
+ let newUrlString = typeof url === 'string' ? url : (url && typeof url.toString === 'function' ? url.toString() : null);
15
+ //get ready to check for host to proxy
16
+ let isTarget = false;
17
+
18
+ if (newUrlString) {
19
+ try {
20
+ // For full URLs, parse string and check the host
21
+ if (newUrlString.startsWith('ws://') || newUrlString.startsWith('wss://')) {
22
+ //URL object again
23
+ const parsedUrl = new URL(newUrlString);
24
+ if (parsedUrl.host === TARGET_WS_HOST) {
25
+ isTarget = true;
26
+ //use wss if https, else ws
27
+ const proxyScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
28
+ const proxyHost = window.location.host;
29
+ newUrlString = `${proxyScheme}://${proxyHost}/api-proxy${parsedUrl.pathname}${parsedUrl.search}`;
30
+ }
31
+ }
32
+ } catch (e) {
33
+ console.warn('[WebSocketInterceptor-Proxy] Error parsing WebSocket URL, using original:', url, e);
34
+ }
35
+ } else {
36
+ console.warn('[WebSocketInterceptor-Proxy] WebSocket URL is not a string or stringifiable. Using original.');
37
+ }
38
+
39
+ if (isTarget) {
40
+ console.log('[WebSocketInterceptor-Proxy] Original WebSocket URL:', url);
41
+ console.log('[WebSocketInterceptor-Proxy] Redirecting to proxy URL:', newUrlString);
42
+ }
43
+
44
+ // Call the original constructor with potentially modified arguments
45
+ // Reflect.construct ensures 'new target(...)' behavior and correct prototype chain
46
+ if (protocols) {
47
+ return Reflect.construct(target, [newUrlString, protocols]);
48
+ } else {
49
+ return Reflect.construct(target, [newUrlString]);
50
+ }
51
+ },
52
+ get(target, prop, receiver) {
53
+ // Forward static property access (e.g., WebSocket.OPEN, WebSocket.CONNECTING)
54
+ // and prototype access to the original WebSocket constructor/prototype
55
+ if (prop === 'prototype') {
56
+ return target.prototype;
57
+ }
58
+ return Reflect.get(target, prop, receiver);
59
+ }
60
+ };
61
+
62
+ window.WebSocket = new Proxy(originalWebSocket, handler);
63
+
64
+ console.log('[WebSocketInterceptor-Proxy] Global WebSocket constructor has been wrapped using Proxy.');
65
+ })();
server/server.js ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ require('dotenv').config();
8
+ const express = require('express');
9
+ const fs = require('fs');
10
+ const axios = require('axios');
11
+ const https = require('https');
12
+ const path = require('path');
13
+ const WebSocket = require('ws');
14
+ const { URLSearchParams, URL } = require('url');
15
+ const rateLimit = require('express-rate-limit');
16
+
17
+ const app = express();
18
+ const port = process.env.PORT || 3000;
19
+ const externalApiBaseUrl = 'https://generativelanguage.googleapis.com';
20
+ const externalWsBaseUrl = 'wss://generativelanguage.googleapis.com';
21
+ // Support either API key env-var variant
22
+ const apiKey = process.env.GEMINI_API_KEY || process.env.API_KEY;
23
+
24
+ const staticPath = path.join(__dirname,'dist');
25
+ const publicPath = path.join(__dirname,'public');
26
+
27
+
28
+ if (!apiKey) {
29
+ // Only log an error, don't exit. The server will serve apps without proxy functionality
30
+ console.error("Warning: GEMINI_API_KEY or API_KEY environment variable is not set! Proxy functionality will be disabled.");
31
+ }
32
+ else {
33
+ console.log("API KEY FOUND (proxy will use this)")
34
+ }
35
+
36
+ // Limit body size to 50mb
37
+ app.use(express.json({ limit: '50mb' }));
38
+ app.use(express.urlencoded({extended: true, limit: '50mb'}));
39
+ app.set('trust proxy', 1 /* number of proxies between user and server */)
40
+
41
+ // Rate limiter for the proxy
42
+ const proxyLimiter = rateLimit({
43
+ windowMs: 15 * 60 * 1000, // Set ratelimit window at 15min (in ms)
44
+ max: 100, // Limit each IP to 100 requests per window
45
+ message: 'Too many requests from this IP, please try again after 15 minutes',
46
+ standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
47
+ legacyHeaders: false, // no `X-RateLimit-*` headers
48
+ handler: (req, res, next, options) => {
49
+ console.warn(`Rate limit exceeded for IP: ${req.ip}. Path: ${req.path}`);
50
+ res.status(options.statusCode).send(options.message);
51
+ }
52
+ });
53
+
54
+ // Apply the rate limiter to the /api-proxy route before the main proxy logic
55
+ app.use('/api-proxy', proxyLimiter);
56
+
57
+ // Proxy route for Gemini API calls (HTTP)
58
+ app.use('/api-proxy', async (req, res, next) => {
59
+ console.log(req.ip);
60
+ // If the request is an upgrade request, it's for WebSockets, so pass to next middleware/handler
61
+ if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
62
+ return next(); // Pass to the WebSocket upgrade handler
63
+ }
64
+
65
+ // Handle OPTIONS request for CORS preflight
66
+ if (req.method === 'OPTIONS') {
67
+ res.setHeader('Access-Control-Allow-Origin', '*'); // Adjust as needed for security
68
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
69
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Goog-Api-Key');
70
+ res.setHeader('Access-Control-Max-Age', '86400'); // Cache preflight response for 1 day
71
+ return res.sendStatus(200);
72
+ }
73
+
74
+ if (req.body) { // Only log body if it exists
75
+ console.log(" Request Body (from frontend):", req.body);
76
+ }
77
+ try {
78
+ // Construct the target URL by taking the part of the path after /api-proxy/
79
+ const targetPath = req.url.startsWith('/') ? req.url.substring(1) : req.url;
80
+ const apiUrl = `${externalApiBaseUrl}/${targetPath}`;
81
+ console.log(`HTTP Proxy: Forwarding request to ${apiUrl}`);
82
+
83
+ // Prepare headers for the outgoing request
84
+ const outgoingHeaders = {};
85
+ // Copy most headers from the incoming request
86
+ for (const header in req.headers) {
87
+ // Exclude host-specific headers and others that might cause issues upstream
88
+ if (!['host', 'connection', 'content-length', 'transfer-encoding', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions'].includes(header.toLowerCase())) {
89
+ outgoingHeaders[header] = req.headers[header];
90
+ }
91
+ }
92
+
93
+ // Set the actual API key in the appropriate header
94
+ outgoingHeaders['X-Goog-Api-Key'] = apiKey;
95
+
96
+ // Set Content-Type from original request if present (for relevant methods)
97
+ if (req.headers['content-type'] && ['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
98
+ outgoingHeaders['Content-Type'] = req.headers['content-type'];
99
+ } else if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
100
+ // Default Content-Type to application/json if no content type for post/put/patch
101
+ outgoingHeaders['Content-Type'] = 'application/json';
102
+ }
103
+
104
+ // For GET or DELETE requests, ensure Content-Type is NOT sent,
105
+ // even if the client erroneously included it.
106
+ if (['GET', 'DELETE'].includes(req.method.toUpperCase())) {
107
+ delete outgoingHeaders['Content-Type']; // Case-sensitive common practice
108
+ delete outgoingHeaders['content-type']; // Just in case
109
+ }
110
+
111
+ // Ensure 'accept' is reasonable if not set
112
+ if (!outgoingHeaders['accept']) {
113
+ outgoingHeaders['accept'] = '*/*';
114
+ }
115
+
116
+
117
+ const axiosConfig = {
118
+ method: req.method,
119
+ url: apiUrl,
120
+ headers: outgoingHeaders,
121
+ responseType: 'stream',
122
+ validateStatus: function (status) {
123
+ return true; // Accept any status code, we'll pipe it through
124
+ },
125
+ };
126
+
127
+ if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
128
+ axiosConfig.data = req.body;
129
+ }
130
+ // For GET, DELETE, etc., axiosConfig.data will remain undefined,
131
+ // and axios will not send a request body.
132
+
133
+ const apiResponse = await axios(axiosConfig);
134
+
135
+ // Pass through response headers from Gemini API to the client
136
+ for (const header in apiResponse.headers) {
137
+ res.setHeader(header, apiResponse.headers[header]);
138
+ }
139
+ res.status(apiResponse.status);
140
+
141
+
142
+ apiResponse.data.on('data', (chunk) => {
143
+ res.write(chunk);
144
+ });
145
+
146
+ apiResponse.data.on('end', () => {
147
+ res.end();
148
+ });
149
+
150
+ apiResponse.data.on('error', (err) => {
151
+ console.error('Error during streaming data from target API:', err);
152
+ if (!res.headersSent) {
153
+ res.status(500).json({ error: 'Proxy error during streaming from target' });
154
+ } else {
155
+ // If headers already sent, we can't send a JSON error, just end the response.
156
+ res.end();
157
+ }
158
+ });
159
+
160
+ } catch (error) {
161
+ console.error('Proxy error before request to target API:', error);
162
+ if (!res.headersSent) {
163
+ if (error.response) {
164
+ const errorData = {
165
+ status: error.response.status,
166
+ message: error.response.data?.error?.message || 'Proxy error from upstream API',
167
+ details: error.response.data?.error?.details || null
168
+ };
169
+ res.status(error.response.status).json(errorData);
170
+ } else {
171
+ res.status(500).json({ error: 'Proxy setup error', message: error.message });
172
+ }
173
+ }
174
+ }
175
+ });
176
+
177
+ const webSocketInterceptorScriptTag = `<script src="/public/websocket-interceptor.js" defer></script>`;
178
+
179
+ // Prepare service worker registration script content
180
+ const serviceWorkerRegistrationScript = `
181
+ <script>
182
+ if ('serviceWorker' in navigator) {
183
+ window.addEventListener('load' , () => {
184
+ navigator.serviceWorker.register('./service-worker.js')
185
+ .then(registration => {
186
+ console.log('Service Worker registered successfully with scope:', registration.scope);
187
+ })
188
+ .catch(error => {
189
+ console.error('Service Worker registration failed:', error);
190
+ });
191
+ });
192
+ } else {
193
+ console.log('Service workers are not supported in this browser.');
194
+ }
195
+ </script>
196
+ `;
197
+
198
+ // Serve index.html or placeholder based on API key and file availability
199
+ app.get('/', (req, res) => {
200
+ const placeholderPath = path.join(publicPath, 'placeholder.html');
201
+
202
+ // Try to serve index.html
203
+ console.log("LOG: Route '/' accessed. Attempting to serve index.html.");
204
+ const indexPath = path.join(staticPath, 'index.html');
205
+
206
+ fs.readFile(indexPath, 'utf8', (err, indexHtmlData) => {
207
+ if (err) {
208
+ // index.html not found or unreadable, serve the original placeholder
209
+ console.log('LOG: index.html not found or unreadable. Falling back to original placeholder.');
210
+ return res.sendFile(placeholderPath);
211
+ }
212
+
213
+ // If API key is not set, serve original HTML without injection
214
+ if (!apiKey) {
215
+ console.log("LOG: API key not set. Serving original index.html without script injections.");
216
+ return res.sendFile(indexPath);
217
+ }
218
+
219
+ // index.html found and apiKey set, inject scripts
220
+ console.log("LOG: index.html read successfully. Injecting scripts.");
221
+ let injectedHtml = indexHtmlData;
222
+
223
+
224
+ if (injectedHtml.includes('<head>')) {
225
+ // Inject WebSocket interceptor first, then service worker script
226
+ injectedHtml = injectedHtml.replace(
227
+ '<head>',
228
+ `<head>${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}`
229
+ );
230
+ console.log("LOG: Scripts injected into <head>.");
231
+ } else {
232
+ console.warn("WARNING: <head> tag not found in index.html. Prepending scripts to the beginning of the file as a fallback.");
233
+ injectedHtml = `${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}${indexHtmlData}`;
234
+ }
235
+ res.send(injectedHtml);
236
+ });
237
+ });
238
+
239
+ app.get('/service-worker.js', (req, res) => {
240
+ return res.sendFile(path.join(publicPath, 'service-worker.js'));
241
+ });
242
+
243
+ app.use('/public', express.static(publicPath));
244
+ app.use(express.static(staticPath));
245
+
246
+ // Start the HTTP server
247
+ const server = app.listen(port, () => {
248
+ console.log(`Server listening on port ${port}`);
249
+ console.log(`HTTP proxy active on /api-proxy/**`);
250
+ console.log(`WebSocket proxy active on /api-proxy/**`);
251
+ });
252
+
253
+ // Create WebSocket server and attach it to the HTTP server
254
+ const wss = new WebSocket.Server({ noServer: true });
255
+
256
+ server.on('upgrade', (request, socket, head) => {
257
+ const requestUrl = new URL(request.url, `http://${request.headers.host}`);
258
+ const pathname = requestUrl.pathname;
259
+
260
+ if (pathname.startsWith('/api-proxy/')) {
261
+ if (!apiKey) {
262
+ console.error("WebSocket proxy: API key not configured. Closing connection.");
263
+ socket.destroy();
264
+ return;
265
+ }
266
+
267
+ wss.handleUpgrade(request, socket, head, (clientWs) => {
268
+ console.log('Client WebSocket connected to proxy for path:', pathname);
269
+
270
+ const targetPathSegment = pathname.substring('/api-proxy'.length);
271
+ const clientQuery = new URLSearchParams(requestUrl.search);
272
+ clientQuery.set('key', apiKey);
273
+ const targetGeminiWsUrl = `${externalWsBaseUrl}${targetPathSegment}?${clientQuery.toString()}`;
274
+ console.log(`Attempting to connect to target WebSocket: ${targetGeminiWsUrl}`);
275
+
276
+ const geminiWs = new WebSocket(targetGeminiWsUrl, {
277
+ protocol: request.headers['sec-websocket-protocol'],
278
+ });
279
+
280
+ const messageQueue = [];
281
+
282
+ geminiWs.on('open', () => {
283
+ console.log('Proxy connected to Gemini WebSocket');
284
+ // Send any queued messages
285
+ while (messageQueue.length > 0) {
286
+ const message = messageQueue.shift();
287
+ if (geminiWs.readyState === WebSocket.OPEN) {
288
+ // console.log('Sending queued message from client -> Gemini');
289
+ geminiWs.send(message);
290
+ } else {
291
+ // Should not happen if we are in 'open' event, but good for safety
292
+ console.warn('Gemini WebSocket not open when trying to send queued message. Re-queuing.');
293
+ messageQueue.unshift(message); // Add it back to the front
294
+ break; // Stop processing queue for now
295
+ }
296
+ }
297
+ });
298
+
299
+ geminiWs.on('message', (message) => {
300
+ // console.log('Message from Gemini -> client');
301
+ if (clientWs.readyState === WebSocket.OPEN) {
302
+ clientWs.send(message);
303
+ }
304
+ });
305
+
306
+ geminiWs.on('close', (code, reason) => {
307
+ console.log(`Gemini WebSocket closed: ${code} ${reason.toString()}`);
308
+ if (clientWs.readyState === WebSocket.OPEN || clientWs.readyState === WebSocket.CONNECTING) {
309
+ clientWs.close(code, reason.toString());
310
+ }
311
+ });
312
+
313
+ geminiWs.on('error', (error) => {
314
+ console.error('Error on Gemini WebSocket connection:', error);
315
+ if (clientWs.readyState === WebSocket.OPEN || clientWs.readyState === WebSocket.CONNECTING) {
316
+ clientWs.close(1011, 'Upstream WebSocket error');
317
+ }
318
+ });
319
+
320
+ clientWs.on('message', (message) => {
321
+ if (geminiWs.readyState === WebSocket.OPEN) {
322
+ // console.log('Message from client -> Gemini');
323
+ geminiWs.send(message);
324
+ } else if (geminiWs.readyState === WebSocket.CONNECTING) {
325
+ // console.log('Queueing message from client -> Gemini (Gemini still connecting)');
326
+ messageQueue.push(message);
327
+ } else {
328
+ console.warn('Client sent message but Gemini WebSocket is not open or connecting. Message dropped.');
329
+ }
330
+ });
331
+
332
+ clientWs.on('close', (code, reason) => {
333
+ console.log(`Client WebSocket closed: ${code} ${reason.toString()}`);
334
+ if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) {
335
+ geminiWs.close(code, reason.toString());
336
+ }
337
+ });
338
+
339
+ clientWs.on('error', (error) => {
340
+ console.error('Error on client WebSocket connection:', error);
341
+ if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) {
342
+ geminiWs.close(1011, 'Client WebSocket error');
343
+ }
344
+ });
345
+ });
346
+ } else {
347
+ console.log(`WebSocket upgrade request for non-proxy path: ${pathname}. Closing connection.`);
348
+ socket.destroy();
349
+ }
350
+ });
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "types": [
14
+ "node"
15
+ ],
16
+ "moduleResolution": "bundler",
17
+ "isolatedModules": true,
18
+ "moduleDetection": "force",
19
+ "allowJs": true,
20
+ "jsx": "react-jsx",
21
+ "paths": {
22
+ "@/*": [
23
+ "./*"
24
+ ]
25
+ },
26
+ "allowImportingTsExtensions": true,
27
+ "noEmit": true
28
+ }
29
+ }
vite.config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+
4
+ export default defineConfig(({ mode }) => {
5
+ const env = loadEnv(mode, '.', '');
6
+ return {
7
+ define: {
8
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
9
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
10
+ },
11
+ resolve: {
12
+ alias: {
13
+ '@': path.resolve(__dirname, '.'),
14
+ }
15
+ }
16
+ };
17
+ });