Spaces:
Running
Running
update app
Browse files- Dockerfile +33 -0
- Home.tsx +439 -0
- index.css +15 -0
- index.html +26 -0
- index.tsx +12 -0
- metadata.json +5 -0
- package.json +25 -0
- server/package.json +20 -0
- server/public/service-worker.js +128 -0
- server/public/websocket-interceptor.js +65 -0
- server/server.js +350 -0
- tsconfig.json +29 -0
- vite.config.ts +23 -0
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,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
/* tslint:disable */
|
6 |
+
// FIX: Import `PersonGeneration` and `SafetyFilterLevel` enums from `@google/genai` to fix type errors.
|
7 |
+
import {
|
8 |
+
GoogleGenAI,
|
9 |
+
PersonGeneration,
|
10 |
+
SafetyFilterLevel,
|
11 |
+
} from '@google/genai';
|
12 |
+
import JSZip from 'jszip';
|
13 |
+
import {
|
14 |
+
Archive,
|
15 |
+
Download,
|
16 |
+
ImageIcon,
|
17 |
+
LoaderCircle,
|
18 |
+
SendHorizontal,
|
19 |
+
SlidersHorizontal,
|
20 |
+
Trash2,
|
21 |
+
X,
|
22 |
+
} from 'lucide-react';
|
23 |
+
import {useState} from 'react';
|
24 |
+
|
25 |
+
const ai = new GoogleGenAI({apiKey: process.env.API_KEY});
|
26 |
+
|
27 |
+
function parseError(error: string): React.ReactNode {
|
28 |
+
if (error.includes('429') && error.includes('RESOURCE_EXHAUSTED')) {
|
29 |
+
return (
|
30 |
+
<>
|
31 |
+
You've exceeded your current API quota (Rate Limit). This is a usage
|
32 |
+
limit on Google's servers.
|
33 |
+
<br />
|
34 |
+
<br />
|
35 |
+
Please check your plan and billing details, or try again after some
|
36 |
+
time. For more information, visit the{' '}
|
37 |
+
<a
|
38 |
+
href="https://ai.google.dev/gemini-api/docs/rate-limits"
|
39 |
+
target="_blank"
|
40 |
+
rel="noopener noreferrer"
|
41 |
+
className="text-blue-600 underline hover:text-blue-800"
|
42 |
+
>
|
43 |
+
Gemini API rate limits documentation
|
44 |
+
</a>
|
45 |
+
.
|
46 |
+
</>
|
47 |
+
);
|
48 |
+
}
|
49 |
+
const regex = /"message":\s*"(.*?)"/g;
|
50 |
+
const match = regex.exec(error);
|
51 |
+
if (match && match[1]) {
|
52 |
+
return match[1];
|
53 |
+
}
|
54 |
+
return error;
|
55 |
+
}
|
56 |
+
|
57 |
+
|
58 |
+
export default function Home() {
|
59 |
+
const [prompt, setPrompt] = useState('');
|
60 |
+
const [generatedImages, setGeneratedImages] = useState<string[]>([]);
|
61 |
+
const [isLoading, setIsLoading] = useState(false);
|
62 |
+
const [isZipping, setIsZipping] = useState(false);
|
63 |
+
const [showErrorModal, setShowErrorModal] = useState(false);
|
64 |
+
const [errorMessage, setErrorMessage] = useState<React.ReactNode>('');
|
65 |
+
const [numberOfImages, setNumberOfImages] = useState(2);
|
66 |
+
const [aspectRatio, setAspectRatio] = useState('1:1');
|
67 |
+
const [showSettings, setShowSettings] = useState(false);
|
68 |
+
|
69 |
+
// New state for advanced settings
|
70 |
+
const [model, setModel] = useState('imagen-4.0-fast-generate-001');
|
71 |
+
// FIX: Use the PersonGeneration enum for the personGeneration state to fix type errors.
|
72 |
+
const [personGeneration, setPersonGeneration] = useState<PersonGeneration>(
|
73 |
+
PersonGeneration.ALLOW_ALL,
|
74 |
+
);
|
75 |
+
|
76 |
+
const handleClear = () => {
|
77 |
+
setGeneratedImages([]);
|
78 |
+
setPrompt('');
|
79 |
+
setNumberOfImages(2);
|
80 |
+
setAspectRatio('1:1');
|
81 |
+
setModel('imagen-4.0-fast-generate-001');
|
82 |
+
// FIX: Use enum members to set state, resolving type errors.
|
83 |
+
setPersonGeneration(PersonGeneration.ALLOW_ALL);
|
84 |
+
};
|
85 |
+
|
86 |
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
87 |
+
e.preventDefault();
|
88 |
+
if (!prompt) {
|
89 |
+
setErrorMessage('Please enter a prompt to generate an image.');
|
90 |
+
setShowErrorModal(true);
|
91 |
+
return;
|
92 |
+
}
|
93 |
+
setIsLoading(true);
|
94 |
+
setGeneratedImages([]); // Clear previous results
|
95 |
+
|
96 |
+
try {
|
97 |
+
const response = await ai.models.generateImages({
|
98 |
+
model,
|
99 |
+
prompt,
|
100 |
+
config: {
|
101 |
+
numberOfImages: Number(numberOfImages),
|
102 |
+
aspectRatio,
|
103 |
+
personGeneration,
|
104 |
+
},
|
105 |
+
});
|
106 |
+
|
107 |
+
const imageUrls = response.generatedImages.map(
|
108 |
+
(img) => `data:image/png;base64,${img.image.imageBytes}`,
|
109 |
+
);
|
110 |
+
setGeneratedImages(imageUrls);
|
111 |
+
} catch (error) {
|
112 |
+
console.error('Error generating images:', error);
|
113 |
+
const rawMessage = (error as Error).message || 'An unexpected error occurred.';
|
114 |
+
setErrorMessage(parseError(rawMessage));
|
115 |
+
setShowErrorModal(true);
|
116 |
+
} finally {
|
117 |
+
setIsLoading(false);
|
118 |
+
}
|
119 |
+
};
|
120 |
+
|
121 |
+
const handleDownload = (src: string, index: number) => {
|
122 |
+
const link = document.createElement('a');
|
123 |
+
link.href = src;
|
124 |
+
link.download = `imagen4-studio-${index + 1}.png`;
|
125 |
+
document.body.appendChild(link);
|
126 |
+
link.click();
|
127 |
+
document.body.removeChild(link);
|
128 |
+
};
|
129 |
+
|
130 |
+
const handleDownloadAll = async () => {
|
131 |
+
if (generatedImages.length === 0) return;
|
132 |
+
|
133 |
+
setIsZipping(true);
|
134 |
+
try {
|
135 |
+
const zip = new JSZip();
|
136 |
+
for (let i = 0; i < generatedImages.length; i++) {
|
137 |
+
const src = generatedImages[i];
|
138 |
+
const base64Data = src.split(',')[1];
|
139 |
+
zip.file(`imagen4-studio-${i + 1}.png`, base64Data, {base64: true});
|
140 |
+
}
|
141 |
+
|
142 |
+
const content = await zip.generateAsync({type: 'blob'});
|
143 |
+
|
144 |
+
const link = document.createElement('a');
|
145 |
+
link.href = URL.createObjectURL(content);
|
146 |
+
link.download = 'imagen4-studio-images.zip';
|
147 |
+
document.body.appendChild(link);
|
148 |
+
link.click();
|
149 |
+
document.body.removeChild(link);
|
150 |
+
URL.revokeObjectURL(link.href);
|
151 |
+
} catch (error) {
|
152 |
+
console.error('Error creating zip file:', error);
|
153 |
+
setErrorMessage('Failed to create the zip file.');
|
154 |
+
setShowErrorModal(true);
|
155 |
+
} finally {
|
156 |
+
setIsZipping(false);
|
157 |
+
}
|
158 |
+
};
|
159 |
+
|
160 |
+
const closeErrorModal = () => {
|
161 |
+
setShowErrorModal(false);
|
162 |
+
};
|
163 |
+
|
164 |
+
const models = [
|
165 |
+
{id: 'imagen-4.0-fast-generate-001', name: 'Imagen 4 Fast'},
|
166 |
+
{id: 'imagen-4.0-generate-001', name: 'Imagen 4'},
|
167 |
+
{id: 'imagen-4.0-ultra-generate-001', name: 'Imagen 4 Ultra'},
|
168 |
+
];
|
169 |
+
|
170 |
+
// FIX: Use enum members for option IDs to ensure type safety.
|
171 |
+
const personGenerationOptions = [
|
172 |
+
{id: PersonGeneration.ALLOW_ALL, name: 'Allow All'},
|
173 |
+
{id: PersonGeneration.ALLOW_ADULT, name: 'Allow Adults'},
|
174 |
+
{id: PersonGeneration.DONT_ALLOW, name: "Don't Allow"},
|
175 |
+
];
|
176 |
+
|
177 |
+
const aspectRatios = ['1:1', '16:9', '4:3', '3:4', '9:16'];
|
178 |
+
|
179 |
+
const SettingButton = ({onClick, current, value, children}) => (
|
180 |
+
<button
|
181 |
+
type="button"
|
182 |
+
onClick={() => onClick(value)}
|
183 |
+
className={`px-3 py-1.5 text-sm font-semibold rounded-md transition-colors ${
|
184 |
+
current === value
|
185 |
+
? 'bg-black text-white'
|
186 |
+
: 'bg-gray-200 hover:bg-gray-300'
|
187 |
+
}`}>
|
188 |
+
{children}
|
189 |
+
</button>
|
190 |
+
);
|
191 |
+
|
192 |
+
return (
|
193 |
+
<>
|
194 |
+
<div className="min-h-screen text-gray-900 flex flex-col justify-start items-center">
|
195 |
+
<main className="container mx-auto px-3 sm:px-6 py-5 sm:py-10 pb-32 max-w-5xl w-full">
|
196 |
+
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 sm:mb-8 gap-2">
|
197 |
+
<div>
|
198 |
+
<h1 className="text-2xl sm:text-3xl font-bold mb-0 leading-tight">
|
199 |
+
IMAGEN4 AIO
|
200 |
+
</h1>
|
201 |
+
<p className="text-sm sm:text-base text-gray-500 mt-1">
|
202 |
+
Powered by the{' '}
|
203 |
+
<a
|
204 |
+
className="underline"
|
205 |
+
href="https://aistudio.google.com/app/apikey"
|
206 |
+
target="_blank"
|
207 |
+
rel="noopener noreferrer">
|
208 |
+
Google Gemini API
|
209 |
+
</a>
|
210 |
+
</p>
|
211 |
+
</div>
|
212 |
+
<div className="flex items-center gap-2 self-start sm:self-auto">
|
213 |
+
<button
|
214 |
+
type="button"
|
215 |
+
onClick={() => setShowSettings(!showSettings)}
|
216 |
+
className={`w-12 h-12 rounded-full flex items-center justify-center shadow-sm transition-all hover:scale-110 ${
|
217 |
+
showSettings ? 'bg-gray-200' : 'bg-white hover:bg-gray-50'
|
218 |
+
}`}
|
219 |
+
aria-label="Toggle Settings">
|
220 |
+
<SlidersHorizontal className="w-6 h-6 text-gray-700" />
|
221 |
+
</button>
|
222 |
+
<button
|
223 |
+
type="button"
|
224 |
+
onClick={handleDownloadAll}
|
225 |
+
disabled={isLoading || isZipping || generatedImages.length === 0}
|
226 |
+
className="w-12 h-12 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110 disabled:scale-100 disabled:cursor-not-allowed disabled:bg-gray-200"
|
227 |
+
aria-label="Download all images as a zip">
|
228 |
+
{isZipping ? (
|
229 |
+
<LoaderCircle className="w-6 h-6 animate-spin text-gray-700" />
|
230 |
+
) : (
|
231 |
+
<Archive className="w-6 h-6 text-gray-700" />
|
232 |
+
)}
|
233 |
+
</button>
|
234 |
+
<button
|
235 |
+
type="button"
|
236 |
+
onClick={handleClear}
|
237 |
+
className="w-12 h-12 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110"
|
238 |
+
aria-label="Clear Results">
|
239 |
+
<Trash2 className="w-6 h-6 text-gray-700" />
|
240 |
+
</button>
|
241 |
+
</div>
|
242 |
+
</div>
|
243 |
+
|
244 |
+
<div
|
245 |
+
className={`w-full mb-6 h-[60vh] bg-gray-200/50 rounded-lg flex justify-center p-4 border-2 border-dashed border-gray-300 overflow-y-auto ${
|
246 |
+
generatedImages.length > 0 ? 'items-start' : 'items-center'
|
247 |
+
}`}>
|
248 |
+
{isLoading ? (
|
249 |
+
<div className="text-center text-gray-600 self-center">
|
250 |
+
<LoaderCircle className="w-12 h-12 animate-spin mx-auto" />
|
251 |
+
<p className="mt-4 font-semibold">Generating images...</p>
|
252 |
+
<p className="text-sm text-gray-500">This may take a moment</p>
|
253 |
+
</div>
|
254 |
+
) : generatedImages.length > 0 ? (
|
255 |
+
<div
|
256 |
+
className={`grid gap-4 w-full ${
|
257 |
+
generatedImages.length > 1
|
258 |
+
? 'grid-cols-1 sm:grid-cols-2'
|
259 |
+
: 'grid-cols-1'
|
260 |
+
}`}>
|
261 |
+
{generatedImages.map((src, index) => (
|
262 |
+
<div
|
263 |
+
key={index}
|
264 |
+
className="relative group flex items-center justify-center bg-black/5 rounded-md overflow-hidden">
|
265 |
+
<img
|
266 |
+
src={src}
|
267 |
+
alt={`Generated image ${index + 1}`}
|
268 |
+
className="max-w-full max-h-full object-contain rounded-md"
|
269 |
+
/>
|
270 |
+
<button
|
271 |
+
onClick={() => handleDownload(src, index)}
|
272 |
+
className="absolute top-2 right-2 p-2 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-all duration-300 hover:scale-110"
|
273 |
+
aria-label="Download image">
|
274 |
+
<Download className="w-5 h-5" />
|
275 |
+
</button>
|
276 |
+
</div>
|
277 |
+
))}
|
278 |
+
</div>
|
279 |
+
) : (
|
280 |
+
<div className="text-center text-gray-500">
|
281 |
+
<ImageIcon className="w-12 h-12 mx-auto" />
|
282 |
+
<h3 className="font-semibold text-lg mt-4">
|
283 |
+
Your generated images will appear here
|
284 |
+
</h3>
|
285 |
+
<p>Enter a prompt below to get started</p>
|
286 |
+
</div>
|
287 |
+
)}
|
288 |
+
</div>
|
289 |
+
|
290 |
+
{/* Input form */}
|
291 |
+
<form onSubmit={handleSubmit} className="w-full">
|
292 |
+
<div className="relative">
|
293 |
+
<input
|
294 |
+
type="text"
|
295 |
+
value={prompt}
|
296 |
+
onChange={(e) => setPrompt(e.target.value)}
|
297 |
+
placeholder="Describe the image you want to create..."
|
298 |
+
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 h-14"
|
299 |
+
required
|
300 |
+
/>
|
301 |
+
<button
|
302 |
+
type="submit"
|
303 |
+
disabled={isLoading}
|
304 |
+
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"
|
305 |
+
aria-label="Submit">
|
306 |
+
{isLoading ? (
|
307 |
+
<LoaderCircle
|
308 |
+
className="w-5 sm:w-6 h-5 sm:h-6 animate-spin"
|
309 |
+
aria-label="Loading"
|
310 |
+
/>
|
311 |
+
) : (
|
312 |
+
<SendHorizontal className="w-5 sm:w-6 h-5 sm:h-6" />
|
313 |
+
)}
|
314 |
+
</button>
|
315 |
+
</div>
|
316 |
+
</form>
|
317 |
+
</main>
|
318 |
+
|
319 |
+
{/* Settings Modal */}
|
320 |
+
{showSettings && (
|
321 |
+
<div
|
322 |
+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4"
|
323 |
+
onClick={() => setShowSettings(false)}>
|
324 |
+
<div
|
325 |
+
className="bg-white rounded-lg shadow-2xl max-w-md w-full"
|
326 |
+
onClick={(e) => e.stopPropagation()}>
|
327 |
+
<div className="flex justify-between items-center p-4 border-b">
|
328 |
+
<h2 className="text-xl font-bold">Settings</h2>
|
329 |
+
<button
|
330 |
+
type="button"
|
331 |
+
onClick={() => setShowSettings(false)}
|
332 |
+
className="p-1 rounded-full hover:bg-gray-100"
|
333 |
+
aria-label="Close settings">
|
334 |
+
<X className="w-6 h-6" />
|
335 |
+
</button>
|
336 |
+
</div>
|
337 |
+
<div className="p-6 space-y-6 overflow-y-auto max-h-[70vh]">
|
338 |
+
<div>
|
339 |
+
<span className="block text-sm font-medium text-gray-700 mb-2">
|
340 |
+
Model
|
341 |
+
</span>
|
342 |
+
<div className="flex flex-wrap gap-2">
|
343 |
+
{models.map((m) => (
|
344 |
+
<SettingButton
|
345 |
+
key={m.id}
|
346 |
+
onClick={setModel}
|
347 |
+
current={model}
|
348 |
+
value={m.id}>
|
349 |
+
{m.name}
|
350 |
+
</SettingButton>
|
351 |
+
))}
|
352 |
+
</div>
|
353 |
+
</div>
|
354 |
+
|
355 |
+
<div>
|
356 |
+
<span className="block text-sm font-medium text-gray-700 mb-2">
|
357 |
+
Person Generation
|
358 |
+
</span>
|
359 |
+
<div className="flex flex-wrap gap-2">
|
360 |
+
{personGenerationOptions.map((opt) => (
|
361 |
+
<SettingButton
|
362 |
+
key={opt.id}
|
363 |
+
onClick={setPersonGeneration}
|
364 |
+
current={personGeneration}
|
365 |
+
value={opt.id}>
|
366 |
+
{opt.name}
|
367 |
+
</SettingButton>
|
368 |
+
))}
|
369 |
+
</div>
|
370 |
+
</div>
|
371 |
+
|
372 |
+
<div>
|
373 |
+
<label
|
374 |
+
htmlFor="num-images"
|
375 |
+
className="block text-sm font-medium text-gray-700 mb-2">
|
376 |
+
Number of Images
|
377 |
+
</label>
|
378 |
+
<input
|
379 |
+
type="number"
|
380 |
+
id="num-images"
|
381 |
+
min="1"
|
382 |
+
max="4"
|
383 |
+
value={numberOfImages}
|
384 |
+
onChange={(e) => {
|
385 |
+
const val = Math.max(
|
386 |
+
1,
|
387 |
+
Math.min(4, Number(e.target.value)),
|
388 |
+
);
|
389 |
+
setNumberOfImages(val);
|
390 |
+
}}
|
391 |
+
className="w-24 p-2 border border-gray-300 rounded-md shadow-sm focus:ring-gray-500 focus:border-gray-500"
|
392 |
+
/>
|
393 |
+
</div>
|
394 |
+
|
395 |
+
<div>
|
396 |
+
<span className="block text-sm font-medium text-gray-700 mb-2">
|
397 |
+
Aspect Ratio
|
398 |
+
</span>
|
399 |
+
<div className="flex flex-wrap gap-2">
|
400 |
+
{aspectRatios.map((ratio) => (
|
401 |
+
<SettingButton
|
402 |
+
key={ratio}
|
403 |
+
onClick={setAspectRatio}
|
404 |
+
current={aspectRatio}
|
405 |
+
value={ratio}>
|
406 |
+
{ratio}
|
407 |
+
</SettingButton>
|
408 |
+
))}
|
409 |
+
</div>
|
410 |
+
</div>
|
411 |
+
</div>
|
412 |
+
</div>
|
413 |
+
</div>
|
414 |
+
)}
|
415 |
+
|
416 |
+
{/* Error Modal */}
|
417 |
+
{showErrorModal && (
|
418 |
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
419 |
+
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
420 |
+
<div className="flex justify-between items-start mb-4">
|
421 |
+
<h3 className="text-xl font-bold text-gray-700">
|
422 |
+
Generation Failed
|
423 |
+
</h3>
|
424 |
+
<button
|
425 |
+
onClick={closeErrorModal}
|
426 |
+
className="text-gray-400 hover:text-gray-500">
|
427 |
+
<X className="w-5 h-5" />
|
428 |
+
</button>
|
429 |
+
</div>
|
430 |
+
<div className="font-medium text-gray-600">
|
431 |
+
{errorMessage}
|
432 |
+
</div>
|
433 |
+
</div>
|
434 |
+
</div>
|
435 |
+
)}
|
436 |
+
</div>
|
437 |
+
</>
|
438 |
+
);
|
439 |
+
}
|
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,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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>IMAGEN4 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 |
+
"jszip": "https://esm.sh/jszip@^3.10.1"
|
17 |
+
}
|
18 |
+
}
|
19 |
+
</script>
|
20 |
+
<link rel="stylesheet" href="/index.css">
|
21 |
+
</head>
|
22 |
+
<body>
|
23 |
+
<div id="root"></div>
|
24 |
+
<script type="module" src="/index.tsx"></script>
|
25 |
+
</body>
|
26 |
+
</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": "imagen4-aio",
|
3 |
+
"description": "An All-in-One image generation studio powered by Imagen 4.",
|
4 |
+
"requestFramePermissions": []
|
5 |
+
}
|
package.json
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "imagen4-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 |
+
"jszip": "^3.10.1"
|
18 |
+
},
|
19 |
+
"devDependencies": {
|
20 |
+
"@types/node": "^22.14.0",
|
21 |
+
"@vitejs/plugin-react": "^5.0.0",
|
22 |
+
"typescript": "~5.8.2",
|
23 |
+
"vite": "^6.2.0"
|
24 |
+
}
|
25 |
+
}
|
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,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import path from 'path';
|
2 |
+
import { defineConfig, loadEnv } from 'vite';
|
3 |
+
import react from '@vitejs/plugin-react';
|
4 |
+
|
5 |
+
export default defineConfig(({ mode }) => {
|
6 |
+
const env = loadEnv(mode, '.', '');
|
7 |
+
return {
|
8 |
+
server: {
|
9 |
+
port: 3000,
|
10 |
+
host: '0.0.0.0',
|
11 |
+
},
|
12 |
+
plugins: [react()],
|
13 |
+
define: {
|
14 |
+
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
15 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
16 |
+
},
|
17 |
+
resolve: {
|
18 |
+
alias: {
|
19 |
+
'@': path.resolve(__dirname, '.'),
|
20 |
+
}
|
21 |
+
}
|
22 |
+
};
|
23 |
+
});
|