From 57287cbc670369b5d86f36a0c9da37bd2c845dba Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 9 May 2026 17:34:24 +0800 Subject: [PATCH] add api key settings and agent docs --- .env.example | 3 + API图片修改-Agent.md | 208 +++++++++++++++++++++++++++++++++++++++++++ README.md | 19 ++++ server/index.ts | 98 +++++++++++++++++--- src/App.tsx | 160 ++++++++++++++++++++++++++------- 5 files changed, 444 insertions(+), 44 deletions(-) create mode 100644 API图片修改-Agent.md diff --git a/.env.example b/.env.example index 7e33f8d..1f03db6 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ API_PORT="3002" # When set, send Authorization: Bearer or x-api-key: . API_AUTH_TOKEN="" +# API callers can also send a temporary Gemini key per request with: +# x-gemini-api-key: + # Gemini model defaults used by the API service. GEMINI_IMAGE_MODEL="gemini-3.1-flash-image-preview" GEMINI_TEXT_MODEL="gemini-2.5-flash" diff --git a/API图片修改-Agent.md b/API图片修改-Agent.md new file mode 100644 index 0000000..222018c --- /dev/null +++ b/API图片修改-Agent.md @@ -0,0 +1,208 @@ +# API 图片修改 Agent 调用说明 + +本服务提供一个本地 HTTP API,用于让其他 Agent、脚本或自动化系统上传已有图片/文档,并按文字指令生成或修改图片。 + +默认地址: + +- 本机:`http://localhost:3002` +- 局域网:`http://192.168.31.204:3002` + +## 认证与 API Key + +Gemini API Key 有三种传入方式,按优先级从高到低: + +1. 单次请求 Header:`x-gemini-api-key: YOUR_GEMINI_API_KEY` +2. 单次请求 JSON/Form 字段:`apiKey=YOUR_GEMINI_API_KEY` +3. 服务端环境变量:`.env.local` 中的 `GEMINI_API_KEY` + +运行中更新服务端 Key: + +```bash +curl -X POST http://localhost:3002/api/config/api-key \ + -H "Content-Type: application/json" \ + -d "{\"apiKey\":\"YOUR_GEMINI_API_KEY\",\"persist\":true}" +``` + +`persist:true` 会写入本项目的 `.env.local`,该文件不会提交到 git。 + +如果服务设置了 `API_AUTH_TOKEN`,还需要在每次请求中加入: + +```txt +Authorization: Bearer YOUR_API_AUTH_TOKEN +``` + +或: + +```txt +x-api-key: YOUR_API_AUTH_TOKEN +``` + +## 健康检查 + +```bash +curl http://localhost:3002/api/health +``` + +返回示例: + +```json +{ + "ok": true, + "apiPort": 3002, + "hasGeminiApiKey": true, + "authEnabled": false, + "acceptsPerRequestApiKey": true +} +``` + +## 修改已有图片 + +使用 `multipart/form-data` 上传图片,字段名可以叫 `files`、`image` 或任意名称;服务会读取所有上传文件。 + +```bash +curl -X POST http://localhost:3002/api/edit-image \ + -H "x-gemini-api-key: YOUR_GEMINI_API_KEY" \ + -F "prompt=保留主体不变,把背景改成干净的白色摄影棚,增强产品质感" \ + -F "imageSize=1K" \ + -F "aspectRatio=1:1" \ + -F "files=@input.png" +``` + +支持的图片常见格式: + +- `image/png` +- `image/jpeg` +- `image/webp` + +可选参数: + +- `prompt` 或 `instruction`:修改指令,必填 +- `imageSize`:`1K`、`2K`、`4K`,默认 `1K` +- `aspectRatio`:`1:1`、`4:3`、`3:4`、`16:9`、`9:16`,默认 `1:1` +- `model`:默认 `gemini-3.1-flash-image-preview` +- `apiKey`:单次调用使用的 Gemini API Key + +成功响应示例: + +```json +{ + "ok": true, + "model": "gemini-3.1-flash-image-preview", + "images": [ + { + "mimeType": "image/png", + "data": "BASE64_IMAGE_DATA", + "dataUrl": "data:image/png;base64,BASE64_IMAGE_DATA" + } + ], + "texts": [] +} +``` + +调用方可以直接使用 `images[0].dataUrl` 预览图片,或把 `images[0].data` 解码保存为 PNG 文件。 + +## JSON/Base64 方式修改图片 + +如果 Agent 已经拿到了图片 base64,可以不用 multipart: + +```bash +curl -X POST http://localhost:3002/api/generate \ + -H "Content-Type: application/json" \ + -H "x-gemini-api-key: YOUR_GEMINI_API_KEY" \ + -d "{ + \"prompt\":\"把这张图改成赛博朋克夜景风格,但保留人物脸部特征\", + \"imageSize\":\"1K\", + \"aspectRatio\":\"1:1\", + \"images\":[ + { + \"mimeType\":\"image/png\", + \"base64\":\"BASE64_IMAGE_DATA\" + } + ] + }" +``` + +也可以传 `dataUrl`: + +```json +{ + "prompt": "把背景改成浅灰色", + "images": [ + { + "dataUrl": "data:image/png;base64,BASE64_IMAGE_DATA" + } + ] +} +``` + +或传公开可访问 URL: + +```json +{ + "prompt": "生成一张更适合电商首图的版本", + "images": [ + { + "url": "https://example.com/input.png" + } + ] +} +``` + +## 上传文档分析 + +用于 PDF、Word 转出的文本文件、图片文档等分析任务。这个接口默认使用文本模型。 + +```bash +curl -X POST http://localhost:3002/api/analyze-document \ + -H "x-gemini-api-key: YOUR_GEMINI_API_KEY" \ + -F "prompt=用中文总结这份文档,提取关键结论和待办事项" \ + -F "files=@report.pdf" +``` + +响应中的 `texts` 是模型返回的文字结果: + +```json +{ + "ok": true, + "model": "gemini-2.5-flash", + "texts": ["文档总结内容..."], + "images": [] +} +``` + +## 多文件输入 + +一个请求可以同时上传多张图片或多个文档: + +```bash +curl -X POST http://localhost:3002/api/edit-image \ + -H "x-gemini-api-key: YOUR_GEMINI_API_KEY" \ + -F "prompt=参考第二张图的色调,修改第一张图" \ + -F "files=@source.png" \ + -F "files=@style-reference.jpg" +``` + +## 错误格式 + +失败时统一返回: + +```json +{ + "ok": false, + "error": "错误原因" +} +``` + +常见错误: + +- `prompt or instruction is required.`:缺少修改指令 +- `Gemini API key is required.`:没有配置或传入 Gemini API Key +- `Remote file is too large.`:URL 文件超过大小限制 + +## Agent 调用建议 + +1. 优先使用 `POST /api/edit-image` + multipart 上传真实图片文件。 +2. 如果图片已经是 base64,使用 `POST /api/generate` 的 `images[].base64`。 +3. 每次调用都带 `x-gemini-api-key`,可避免依赖服务端环境。 +4. 取返回的 `images[0].dataUrl` 给用户预览;保存文件时解码 `images[0].data`。 +5. 修改指令尽量包含保留内容、需要改变的内容、输出用途和画幅比例。 diff --git a/README.md b/README.md index e78cc6d..652d477 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,23 @@ The UI runs on port `3000`. The HTTP API runs separately on port `3002`. 3. Check the API: `http://localhost:3002/api/health` +You can change the server API key without restarting: + +```bash +curl -X POST http://localhost:3002/api/config/api-key \ + -H "Content-Type: application/json" \ + -d "{\"apiKey\":\"YOUR_GEMINI_API_KEY\",\"persist\":true}" +``` + +You can also pass a temporary Gemini key for one call: + +```bash +curl -X POST http://localhost:3002/api/generate \ + -H "Content-Type: application/json" \ + -H "x-gemini-api-key: YOUR_GEMINI_API_KEY" \ + -d "{\"prompt\":\"Create a clean product poster\"}" +``` + ### API examples Generate or edit with JSON/base64: @@ -58,3 +75,5 @@ curl -X POST http://localhost:3002/api/analyze-document \ ``` Optional API auth: set `API_AUTH_TOKEN`, then send either `Authorization: Bearer ` or `x-api-key: `. + +For Agent-facing image editing instructions, see `API图片修改-Agent.md`. diff --git a/server/index.ts b/server/index.ts index 85434be..3079c9e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,8 +1,13 @@ -import 'dotenv/config'; import express from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; +import dotenv from 'dotenv'; import multer from 'multer'; import { GoogleGenAI } from '@google/genai'; +dotenv.config({ path: '.env.local' }); +dotenv.config(); + type InlineInput = { name?: string; mimeType?: string; @@ -13,6 +18,7 @@ type InlineInput = { }; type GenerateRequest = { + apiKey?: string; prompt?: string; instruction?: string; model?: string; @@ -40,22 +46,20 @@ const upload = multer({ }, }); -const apiKey = process.env.GEMINI_API_KEY || process.env.API_KEY; +let runtimeApiKey = 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) { +if (!runtimeApiKey) { 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'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,x-api-key,x-gemini-api-key'); if (req.method === 'OPTIONS') { res.status(204).end(); return; @@ -86,6 +90,29 @@ function requireAuth(req: express.Request, res: express.Response, next: express. }); } +function getRequestApiKey(req: express.Request, body?: GenerateRequest) { + const headerKey = req.header('x-gemini-api-key') || ''; + return body?.apiKey || headerKey || runtimeApiKey; +} + +function persistApiKeyToEnvLocal(apiKey: string) { + const envPath = path.resolve(process.cwd(), '.env.local'); + let content = ''; + if (fs.existsSync(envPath)) { + content = fs.readFileSync(envPath, 'utf8'); + } + + const escaped = apiKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const line = `GEMINI_API_KEY="${escaped}"`; + if (/^GEMINI_API_KEY=.*$/m.test(content)) { + content = content.replace(/^GEMINI_API_KEY=.*$/m, line); + } else { + content = content ? `${content.trimEnd()}\n${line}\n` : `${line}\n`; + } + + fs.writeFileSync(envPath, content, 'utf8'); +} + function stripDataPrefix(value: string) { const match = value.match(/^data:([^;]+);base64,(.+)$/); if (!match) { @@ -194,6 +221,7 @@ async function buildParts(body: GenerateRequest, files: Express.Multer.File[] = function normalizeMultipartBody(req: express.Request): GenerateRequest { return { + apiKey: req.body.apiKey, prompt: String(req.body.prompt || req.body.instruction || ''), model: req.body.model, imageSize: req.body.imageSize, @@ -224,8 +252,14 @@ function collectResponseParts(response: any) { return { images, texts }; } -async function runGemini(body: GenerateRequest, files: Express.Multer.File[] = [], textOnly = false) { +async function runGemini(req: express.Request, body: GenerateRequest, files: Express.Multer.File[] = [], textOnly = false) { const parts = await buildParts(body, files); + const requestApiKey = getRequestApiKey(req, body); + if (!requestApiKey) { + throw new Error('Gemini API key is required. Set GEMINI_API_KEY, call POST /api/config/api-key, or send x-gemini-api-key.'); + } + + const ai = new GoogleGenAI({ apiKey: requestApiKey }); const model = body.model || (textOnly ? defaultTextModel : defaultImageModel); const config: Record = {}; @@ -247,6 +281,7 @@ async function runGemini(body: GenerateRequest, files: Express.Multer.File[] = [ return { ok: true, model, + usedRequestApiKey: Boolean(body.apiKey || req.header('x-gemini-api-key')), input: { fileCount: files.length + (body.images?.length || 0) + (body.documents?.length || 0) + (body.files?.length || 0), partCount: parts.length, @@ -273,6 +308,8 @@ app.get('/api', (_req, res) => { name: 'Gemini Draw API', endpoints: [ 'GET /api/health', + 'GET /api/config', + 'POST /api/config/api-key', 'POST /api/generate', 'POST /api/generate/upload', 'POST /api/edit-image', @@ -285,16 +322,53 @@ app.get('/api/health', (_req, res) => { res.json({ ok: true, apiPort, - hasGeminiApiKey: Boolean(apiKey), + hasGeminiApiKey: Boolean(runtimeApiKey), + authEnabled: Boolean(apiAuthToken), + acceptsPerRequestApiKey: true, + defaultImageModel, + defaultTextModel, + }); +}); + +app.get('/api/config', requireAuth, (_req, res) => { + res.json({ + ok: true, + apiPort, + hasGeminiApiKey: Boolean(runtimeApiKey), + apiKeyPreview: runtimeApiKey ? `${runtimeApiKey.slice(0, 6)}...${runtimeApiKey.slice(-4)}` : '', authEnabled: Boolean(apiAuthToken), defaultImageModel, defaultTextModel, }); }); +app.post('/api/config/api-key', requireAuth, async (req, res) => { + try { + const nextApiKey = String(req.body.apiKey || '').trim(); + const persist = Boolean(req.body.persist); + if (!nextApiKey) { + throw new Error('apiKey is required.'); + } + + runtimeApiKey = nextApiKey; + process.env.GEMINI_API_KEY = nextApiKey; + if (persist) { + persistApiKeyToEnvLocal(nextApiKey); + } + + res.json({ + ok: true, + persisted: persist, + apiKeyPreview: `${runtimeApiKey.slice(0, 6)}...${runtimeApiKey.slice(-4)}`, + }); + } catch (error) { + handleError(res, error); + } +}); + app.post('/api/generate', requireAuth, async (req, res) => { try { - res.json(await runGemini(req.body)); + res.json(await runGemini(req, req.body)); } catch (error) { handleError(res, error); } @@ -302,7 +376,7 @@ app.post('/api/generate', requireAuth, async (req, res) => { app.post('/api/generate/upload', requireAuth, upload.any(), async (req, res) => { try { - res.json(await runGemini(normalizeMultipartBody(req), (req.files as Express.Multer.File[]) || [])); + res.json(await runGemini(req, normalizeMultipartBody(req), (req.files as Express.Multer.File[]) || [])); } catch (error) { handleError(res, error); } @@ -312,7 +386,7 @@ 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)); + res.json(await runGemini(req, body, files)); } catch (error) { handleError(res, error); } @@ -322,7 +396,7 @@ 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)); + res.json(await runGemini(req, body, files, true)); } catch (error) { handleError(res, error); } diff --git a/src/App.tsx b/src/App.tsx index b3ef302..72c6c97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Upload, Image as ImageIcon, Wand2, Loader2, AlertCircle, X, Download, Lock, User, LogOut, Settings } from 'lucide-react'; +import { Upload, Image as ImageIcon, Wand2, Loader2, AlertCircle, X, Download, Lock, User, LogOut, Settings, KeyRound } from 'lucide-react'; import { GoogleGenAI } from '@google/genai'; declare global { @@ -14,6 +14,7 @@ declare global { function useApiKey() { const [hasKey, setHasKey] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [apiKey, setApiKey] = useState(''); useEffect(() => { checkKey(); @@ -21,14 +22,20 @@ function useApiKey() { const checkKey = async () => { try { + const localApiKey = localStorage.getItem('geminiApiKey') || ''; + const envApiKey = process.env.API_KEY || process.env.GEMINI_API_KEY || ''; + const nextApiKey = localApiKey || envApiKey; + setApiKey(nextApiKey); + if (window.aistudio?.hasSelectedApiKey) { const result = await window.aistudio.hasSelectedApiKey(); - setHasKey(result); + setHasKey(result || Boolean(nextApiKey)); } else { - setHasKey(true); + setHasKey(Boolean(nextApiKey)); } } catch (e) { console.error(e); + setHasKey(Boolean(localStorage.getItem('geminiApiKey') || process.env.API_KEY || process.env.GEMINI_API_KEY)); } finally { setIsLoading(false); } @@ -39,6 +46,8 @@ function useApiKey() { if (window.aistudio?.openSelectKey) { await window.aistudio.openSelectKey(); setHasKey(true); + } else { + checkKey(); } } catch (e) { console.error(e); @@ -48,7 +57,18 @@ function useApiKey() { } }; - return { hasKey, isLoading, selectKey }; + const saveKey = (nextApiKey: string) => { + const trimmed = nextApiKey.trim(); + if (trimmed) { + localStorage.setItem('geminiApiKey', trimmed); + } else { + localStorage.removeItem('geminiApiKey'); + } + setApiKey(trimmed); + setHasKey(Boolean(trimmed || process.env.API_KEY || process.env.GEMINI_API_KEY)); + }; + + return { hasKey, isLoading, apiKey, selectKey, saveKey }; } function useAuth() { @@ -229,10 +249,62 @@ function ChangePasswordModal({ onClose, onChange }: { onClose: () => void, onCha ); } +function ApiKeyModal({ currentKey, onClose, onSave }: { currentKey: string, onClose: () => void, onSave: (apiKey: string) => void }) { + const [apiKey, setApiKey] = useState(currentKey); + const [saved, setSaved] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(apiKey); + setSaved(true); + setTimeout(onClose, 500); + }; + + return ( +
+
+
+

Gemini API Key

+ +
+ +
+
+ + setApiKey(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl py-3 px-4 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500/50 transition-all" + placeholder="AIza..." + /> +
+ + {saved && ( +
+ Saved +
+ )} + + +
+
+
+ ); +} + export default function App() { const { isLoggedIn, isLoading: isAuthLoading, login, logout, changePassword } = useAuth(); - const { hasKey, isLoading: isKeyLoading, selectKey } = useApiKey(); + const { hasKey, isLoading: isKeyLoading, apiKey, saveKey } = useApiKey(); const [showChangePassword, setShowChangePassword] = useState(false); + const [showApiKey, setShowApiKey] = useState(false); if (isAuthLoading || isKeyLoading) { return
; @@ -244,35 +316,45 @@ export default function App() { if (!hasKey) { return ( -
-
-
- + <> +
+
+
+ +
+

API Key Required

+

+ To use the Gemini 3.1 Flash Image model, you need to set a Google AI Studio API key. +

+ +

+ + Billing documentation + +

-

API Key Required

-

- To use the Gemini 3.1 Flash Image model, you need to select a paid Google Cloud API key. -

- -

- - Billing documentation - -

-
+ {showApiKey && ( + setShowApiKey(false)} + onSave={saveKey} + /> + )} + ); } return ( <> setShowApiKey(true)} onLogout={logout} onChangePassword={() => setShowChangePassword(true)} /> @@ -282,11 +364,18 @@ export default function App() { onChange={changePassword} /> )} + {showApiKey && ( + setShowApiKey(false)} + onSave={saveKey} + /> + )} ); } -function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey: () => Promise, onLogout: () => void, onChangePassword: () => void }) { +function ImageEditor({ apiKey, onOpenApiKey, onLogout, onChangePassword }: { apiKey: string, onOpenApiKey: () => void, onLogout: () => void, onChangePassword: () => void }) { const [image, setImage] = useState(null); const [mimeType, setMimeType] = useState(''); const [prompt, setPrompt] = useState(''); @@ -347,7 +436,11 @@ function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey: setError(null); try { - const apiKey = process.env.API_KEY || process.env.GEMINI_API_KEY; + if (!apiKey) { + setError("Gemini API Key is missing. Please set it first."); + return; + } + const ai = new GoogleGenAI({ apiKey }); const parts: any[] = []; @@ -423,10 +516,13 @@ function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey:
@@ -545,10 +641,10 @@ function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey:
{(error.includes("API Key") || error.includes("permission") || error.includes("403")) && ( )}