From 2563ab8f704fa72cd56bf0b1eabd59cb0ff90998 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 9 May 2026 17:13:53 +0800 Subject: [PATCH] add http api server --- .env.example | 16 +++ README.md | 40 ++++++ package-lock.json | 83 ++++++++++++ package.json | 17 ++- server/index.ts | 333 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 7 deletions(-) create mode 100644 server/index.ts diff --git a/.env.example b/.env.example index 6df2675..7e33f8d 100644 --- a/.env.example +++ b/.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. # Used for self-referential links, OAuth callbacks, and API endpoints. 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 or x-api-key: . +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" diff --git a/README.md b/README.md index b863763..e78cc6d 100644 --- a/README.md +++ b/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 3. Run the app: `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 ` or `x-api-key: `. diff --git a/package-lock.json b/package-lock.json index ebada0b..9e1e77b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,14 @@ "express": "^4.21.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", + "multer": "^2.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "vite": "^6.2.0" }, "devDependencies": { "@types/express": "^4.17.21", + "@types/multer": "^2.1.0", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "tailwindcss": "^4.1.14", @@ -1537,6 +1539,16 @@ "dev": true, "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": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -1641,6 +1653,12 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1861,6 +1879,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1925,6 +1960,21 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "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": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3215,6 +3265,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "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": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3901,6 +3970,14 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4042,6 +4119,12 @@ "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": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/package.json b/package.json index 3c7b140..cf1fe3e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", + "api": "tsx server/index.ts", "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", @@ -14,22 +15,24 @@ "@google/genai": "^1.29.0", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", + "better-sqlite3": "^12.4.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "multer": "^2.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "vite": "^6.2.0", - "express": "^4.21.2", - "dotenv": "^17.2.3", - "better-sqlite3": "^12.4.1", - "motion": "^12.23.24" + "vite": "^6.2.0" }, "devDependencies": { + "@types/express": "^4.17.21", + "@types/multer": "^2.1.0", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.0", - "@types/express": "^4.17.21" + "vite": "^6.2.0" } } diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..85434be --- /dev/null +++ b/server/index.ts @@ -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 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 { + 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 = {}; + + 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}`); +});