add http api server
This commit is contained in:
16
.env.example
16
.env.example
@@ -10,3 +10,19 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
|||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
APP_URL="MY_APP_URL"
|
APP_URL="MY_APP_URL"
|
||||||
|
|
||||||
|
# API_PORT: HTTP API server port. Frontend still runs on 3000 by default.
|
||||||
|
API_PORT="3002"
|
||||||
|
|
||||||
|
# API_AUTH_TOKEN: Optional token for API calls. Leave empty to disable auth.
|
||||||
|
# When set, send Authorization: Bearer <token> or x-api-key: <token>.
|
||||||
|
API_AUTH_TOKEN=""
|
||||||
|
|
||||||
|
# Gemini model defaults used by the API service.
|
||||||
|
GEMINI_IMAGE_MODEL="gemini-3.1-flash-image-preview"
|
||||||
|
GEMINI_TEXT_MODEL="gemini-2.5-flash"
|
||||||
|
|
||||||
|
# Upload limits.
|
||||||
|
API_MAX_FILE_MB="25"
|
||||||
|
API_MAX_FILES="20"
|
||||||
|
API_JSON_LIMIT="50mb"
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -18,3 +18,43 @@ View your app in AI Studio: https://ai.studio/apps/96002e3b-5eec-4566-85e8-71871
|
|||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
3. Run the app:
|
3. Run the app:
|
||||||
`npm run dev`
|
`npm run dev`
|
||||||
|
|
||||||
|
## Run the API server
|
||||||
|
|
||||||
|
The UI runs on port `3000`. The HTTP API runs separately on port `3002`.
|
||||||
|
|
||||||
|
1. Set `GEMINI_API_KEY` in `.env.local` or your shell.
|
||||||
|
2. Start the API:
|
||||||
|
`npm run api`
|
||||||
|
3. Check the API:
|
||||||
|
`http://localhost:3002/api/health`
|
||||||
|
|
||||||
|
### API examples
|
||||||
|
|
||||||
|
Generate or edit with JSON/base64:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3002/api/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"prompt\":\"Create a clean product poster for a white coffee mug\",\"imageSize\":\"1K\",\"aspectRatio\":\"1:1\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload an image or document with a prompt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3002/api/generate/upload \
|
||||||
|
-F "prompt=Change the background to a bright studio scene" \
|
||||||
|
-F "imageSize=1K" \
|
||||||
|
-F "aspectRatio=1:1" \
|
||||||
|
-F "files=@input.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
Analyze a document:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3002/api/analyze-document \
|
||||||
|
-F "prompt=Summarize this document in Chinese" \
|
||||||
|
-F "files=@report.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional API auth: set `API_AUTH_TOKEN`, then send either `Authorization: Bearer <token>` or `x-api-key: <token>`.
|
||||||
|
|||||||
83
package-lock.json
generated
83
package-lock.json
generated
@@ -16,12 +16,14 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
@@ -1537,6 +1539,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/multer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "22.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
@@ -1641,6 +1653,12 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -1861,6 +1879,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1925,6 +1960,21 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -3215,6 +3265,25 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -3901,6 +3970,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -4042,6 +4119,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"api": "tsx server/index.ts",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
@@ -14,22 +15,24 @@
|
|||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"better-sqlite3": "^12.4.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0"
|
||||||
"express": "^4.21.2",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"better-sqlite3": "^12.4.1",
|
|
||||||
"motion": "^12.23.24"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0"
|
||||||
"@types/express": "^4.17.21"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
333
server/index.ts
Normal file
333
server/index.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
|
||||||
|
type InlineInput = {
|
||||||
|
name?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
data?: string;
|
||||||
|
base64?: string;
|
||||||
|
dataUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GenerateRequest = {
|
||||||
|
prompt?: string;
|
||||||
|
instruction?: string;
|
||||||
|
model?: string;
|
||||||
|
imageSize?: '1K' | '2K' | '4K';
|
||||||
|
aspectRatio?: '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
|
||||||
|
images?: InlineInput[];
|
||||||
|
documents?: InlineInput[];
|
||||||
|
files?: InlineInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeminiPart = {
|
||||||
|
text?: string;
|
||||||
|
inlineData?: {
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: Number(process.env.API_MAX_FILE_MB || 25) * 1024 * 1024,
|
||||||
|
files: Number(process.env.API_MAX_FILES || 20),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY || process.env.API_KEY;
|
||||||
|
const apiPort = Number(process.env.API_PORT || 3002);
|
||||||
|
const defaultImageModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-3.1-flash-image-preview';
|
||||||
|
const defaultTextModel = process.env.GEMINI_TEXT_MODEL || 'gemini-2.5-flash';
|
||||||
|
const apiAuthToken = process.env.API_AUTH_TOKEN || '';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('GEMINI_API_KEY/API_KEY is not set. API calls will fail until it is configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey });
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', process.env.API_CORS_ORIGIN || '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,x-api-key');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.json({ limit: process.env.API_JSON_LIMIT || '50mb' }));
|
||||||
|
|
||||||
|
function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
|
if (!apiAuthToken) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.header('authorization') || '';
|
||||||
|
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
||||||
|
const headerToken = req.header('x-api-key') || '';
|
||||||
|
|
||||||
|
if (bearerToken === apiAuthToken || headerToken === apiAuthToken) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({
|
||||||
|
ok: false,
|
||||||
|
error: 'Unauthorized. Send Authorization: Bearer <API_AUTH_TOKEN> or x-api-key.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDataPrefix(value: string) {
|
||||||
|
const match = value.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mimeType: match[1],
|
||||||
|
data: match[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAsBase64(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(response.headers.get('content-length') || 0);
|
||||||
|
const maxBytes = Number(process.env.API_MAX_URL_MB || 25) * 1024 * 1024;
|
||||||
|
if (contentLength > maxBytes) {
|
||||||
|
throw new Error(`Remote file is too large. Max size is ${process.env.API_MAX_URL_MB || 25}MB.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buffer.length > maxBytes) {
|
||||||
|
throw new Error(`Remote file is too large. Max size is ${process.env.API_MAX_URL_MB || 25}MB.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: buffer.toString('base64'),
|
||||||
|
mimeType: response.headers.get('content-type')?.split(';')[0] || 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inlineInputToPart(input: InlineInput): Promise<GeminiPart> {
|
||||||
|
if (input.dataUrl) {
|
||||||
|
const parsed = stripDataPrefix(input.dataUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error(`Invalid dataUrl${input.name ? ` for ${input.name}` : ''}.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
inlineData: {
|
||||||
|
data: parsed.data,
|
||||||
|
mimeType: input.mimeType || parsed.mimeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.url) {
|
||||||
|
const fetched = await fetchAsBase64(input.url);
|
||||||
|
return {
|
||||||
|
inlineData: {
|
||||||
|
data: fetched.data,
|
||||||
|
mimeType: input.mimeType || fetched.mimeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = input.base64 || input.data;
|
||||||
|
if (!rawData) {
|
||||||
|
throw new Error(`File input${input.name ? ` ${input.name}` : ''} is missing data, base64, dataUrl, or url.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = stripDataPrefix(rawData);
|
||||||
|
return {
|
||||||
|
inlineData: {
|
||||||
|
data: parsed?.data || rawData,
|
||||||
|
mimeType: input.mimeType || parsed?.mimeType || 'application/octet-stream',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedFileToPart(file: Express.Multer.File): GeminiPart {
|
||||||
|
return {
|
||||||
|
inlineData: {
|
||||||
|
data: file.buffer.toString('base64'),
|
||||||
|
mimeType: file.mimetype || 'application/octet-stream',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildParts(body: GenerateRequest, files: Express.Multer.File[] = []) {
|
||||||
|
const prompt = (body.prompt || body.instruction || '').trim();
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error('prompt or instruction is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineInputs = [
|
||||||
|
...(body.images || []),
|
||||||
|
...(body.documents || []),
|
||||||
|
...(body.files || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const parts: GeminiPart[] = [];
|
||||||
|
for (const input of inlineInputs) {
|
||||||
|
parts.push(await inlineInputToPart(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
parts.push(uploadedFileToPart(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({ text: prompt });
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMultipartBody(req: express.Request): GenerateRequest {
|
||||||
|
return {
|
||||||
|
prompt: String(req.body.prompt || req.body.instruction || ''),
|
||||||
|
model: req.body.model,
|
||||||
|
imageSize: req.body.imageSize,
|
||||||
|
aspectRatio: req.body.aspectRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectResponseParts(response: any) {
|
||||||
|
const images: Array<{ mimeType: string; data: string; dataUrl: string }> = [];
|
||||||
|
const texts: string[] = [];
|
||||||
|
|
||||||
|
for (const candidate of response.candidates || []) {
|
||||||
|
for (const part of candidate.content?.parts || []) {
|
||||||
|
if (part.inlineData?.data) {
|
||||||
|
const mimeType = part.inlineData.mimeType || 'image/png';
|
||||||
|
images.push({
|
||||||
|
mimeType,
|
||||||
|
data: part.inlineData.data,
|
||||||
|
dataUrl: `data:${mimeType};base64,${part.inlineData.data}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (part.text) {
|
||||||
|
texts.push(part.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { images, texts };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGemini(body: GenerateRequest, files: Express.Multer.File[] = [], textOnly = false) {
|
||||||
|
const parts = await buildParts(body, files);
|
||||||
|
const model = body.model || (textOnly ? defaultTextModel : defaultImageModel);
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (!textOnly) {
|
||||||
|
config.imageConfig = {
|
||||||
|
imageSize: body.imageSize || '1K',
|
||||||
|
aspectRatio: body.aspectRatio || '1:1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model,
|
||||||
|
contents: { parts } as any,
|
||||||
|
config,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { images, texts } = collectResponseParts(response);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
model,
|
||||||
|
input: {
|
||||||
|
fileCount: files.length + (body.images?.length || 0) + (body.documents?.length || 0) + (body.files?.length || 0),
|
||||||
|
partCount: parts.length,
|
||||||
|
imageSize: textOnly ? undefined : body.imageSize || '1K',
|
||||||
|
aspectRatio: textOnly ? undefined : body.aspectRatio || '1:1',
|
||||||
|
},
|
||||||
|
images,
|
||||||
|
texts,
|
||||||
|
usageMetadata: response.usageMetadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(res: express.Response, error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
name: 'Gemini Draw API',
|
||||||
|
endpoints: [
|
||||||
|
'GET /api/health',
|
||||||
|
'POST /api/generate',
|
||||||
|
'POST /api/generate/upload',
|
||||||
|
'POST /api/edit-image',
|
||||||
|
'POST /api/analyze-document',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
apiPort,
|
||||||
|
hasGeminiApiKey: Boolean(apiKey),
|
||||||
|
authEnabled: Boolean(apiAuthToken),
|
||||||
|
defaultImageModel,
|
||||||
|
defaultTextModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/generate', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await runGemini(req.body));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/generate/upload', requireAuth, upload.any(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await runGemini(normalizeMultipartBody(req), (req.files as Express.Multer.File[]) || []));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/edit-image', requireAuth, upload.any(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
|
const body = req.is('multipart/form-data') ? normalizeMultipartBody(req) : req.body;
|
||||||
|
res.json(await runGemini(body, files));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/analyze-document', requireAuth, upload.any(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
|
const body = req.is('multipart/form-data') ? normalizeMultipartBody(req) : req.body;
|
||||||
|
res.json(await runGemini(body, files, true));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(apiPort, '0.0.0.0', () => {
|
||||||
|
console.log(`Gemini Draw API listening on http://localhost:${apiPort}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user