add api key settings and agent docs
This commit is contained in:
@@ -18,6 +18,9 @@ API_PORT="3002"
|
|||||||
# When set, send Authorization: Bearer <token> or x-api-key: <token>.
|
# When set, send Authorization: Bearer <token> or x-api-key: <token>.
|
||||||
API_AUTH_TOKEN=""
|
API_AUTH_TOKEN=""
|
||||||
|
|
||||||
|
# API callers can also send a temporary Gemini key per request with:
|
||||||
|
# x-gemini-api-key: <Gemini API Key>
|
||||||
|
|
||||||
# Gemini model defaults used by the API service.
|
# Gemini model defaults used by the API service.
|
||||||
GEMINI_IMAGE_MODEL="gemini-3.1-flash-image-preview"
|
GEMINI_IMAGE_MODEL="gemini-3.1-flash-image-preview"
|
||||||
GEMINI_TEXT_MODEL="gemini-2.5-flash"
|
GEMINI_TEXT_MODEL="gemini-2.5-flash"
|
||||||
|
|||||||
208
API图片修改-Agent.md
Normal file
208
API图片修改-Agent.md
Normal file
@@ -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. 修改指令尽量包含保留内容、需要改变的内容、输出用途和画幅比例。
|
||||||
19
README.md
19
README.md
@@ -29,6 +29,23 @@ The UI runs on port `3000`. The HTTP API runs separately on port `3002`.
|
|||||||
3. Check the API:
|
3. Check the API:
|
||||||
`http://localhost:3002/api/health`
|
`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
|
### API examples
|
||||||
|
|
||||||
Generate or edit with JSON/base64:
|
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 <token>` or `x-api-key: <token>`.
|
Optional API auth: set `API_AUTH_TOKEN`, then send either `Authorization: Bearer <token>` or `x-api-key: <token>`.
|
||||||
|
|
||||||
|
For Agent-facing image editing instructions, see `API图片修改-Agent.md`.
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import 'dotenv/config';
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
type InlineInput = {
|
type InlineInput = {
|
||||||
name?: string;
|
name?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
@@ -13,6 +18,7 @@ type InlineInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GenerateRequest = {
|
type GenerateRequest = {
|
||||||
|
apiKey?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
instruction?: string;
|
instruction?: string;
|
||||||
model?: 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 apiPort = Number(process.env.API_PORT || 3002);
|
||||||
const defaultImageModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-3.1-flash-image-preview';
|
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 defaultTextModel = process.env.GEMINI_TEXT_MODEL || 'gemini-2.5-flash';
|
||||||
const apiAuthToken = process.env.API_AUTH_TOKEN || '';
|
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.');
|
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) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', process.env.API_CORS_ORIGIN || '*');
|
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-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') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
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) {
|
function stripDataPrefix(value: string) {
|
||||||
const match = value.match(/^data:([^;]+);base64,(.+)$/);
|
const match = value.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -194,6 +221,7 @@ async function buildParts(body: GenerateRequest, files: Express.Multer.File[] =
|
|||||||
|
|
||||||
function normalizeMultipartBody(req: express.Request): GenerateRequest {
|
function normalizeMultipartBody(req: express.Request): GenerateRequest {
|
||||||
return {
|
return {
|
||||||
|
apiKey: req.body.apiKey,
|
||||||
prompt: String(req.body.prompt || req.body.instruction || ''),
|
prompt: String(req.body.prompt || req.body.instruction || ''),
|
||||||
model: req.body.model,
|
model: req.body.model,
|
||||||
imageSize: req.body.imageSize,
|
imageSize: req.body.imageSize,
|
||||||
@@ -224,8 +252,14 @@ function collectResponseParts(response: any) {
|
|||||||
return { images, texts };
|
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 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 model = body.model || (textOnly ? defaultTextModel : defaultImageModel);
|
||||||
const config: Record<string, unknown> = {};
|
const config: Record<string, unknown> = {};
|
||||||
|
|
||||||
@@ -247,6 +281,7 @@ async function runGemini(body: GenerateRequest, files: Express.Multer.File[] = [
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
model,
|
model,
|
||||||
|
usedRequestApiKey: Boolean(body.apiKey || req.header('x-gemini-api-key')),
|
||||||
input: {
|
input: {
|
||||||
fileCount: files.length + (body.images?.length || 0) + (body.documents?.length || 0) + (body.files?.length || 0),
|
fileCount: files.length + (body.images?.length || 0) + (body.documents?.length || 0) + (body.files?.length || 0),
|
||||||
partCount: parts.length,
|
partCount: parts.length,
|
||||||
@@ -273,6 +308,8 @@ app.get('/api', (_req, res) => {
|
|||||||
name: 'Gemini Draw API',
|
name: 'Gemini Draw API',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
'GET /api/health',
|
'GET /api/health',
|
||||||
|
'GET /api/config',
|
||||||
|
'POST /api/config/api-key',
|
||||||
'POST /api/generate',
|
'POST /api/generate',
|
||||||
'POST /api/generate/upload',
|
'POST /api/generate/upload',
|
||||||
'POST /api/edit-image',
|
'POST /api/edit-image',
|
||||||
@@ -285,16 +322,53 @@ app.get('/api/health', (_req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
apiPort,
|
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),
|
authEnabled: Boolean(apiAuthToken),
|
||||||
defaultImageModel,
|
defaultImageModel,
|
||||||
defaultTextModel,
|
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) => {
|
app.post('/api/generate', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await runGemini(req.body));
|
res.json(await runGemini(req, req.body));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(res, 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) => {
|
app.post('/api/generate/upload', requireAuth, upload.any(), async (req, res) => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
handleError(res, error);
|
handleError(res, error);
|
||||||
}
|
}
|
||||||
@@ -312,7 +386,7 @@ app.post('/api/edit-image', requireAuth, upload.any(), async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const files = (req.files as Express.Multer.File[]) || [];
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
const body = req.is('multipart/form-data') ? normalizeMultipartBody(req) : req.body;
|
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) {
|
} catch (error) {
|
||||||
handleError(res, error);
|
handleError(res, error);
|
||||||
}
|
}
|
||||||
@@ -322,7 +396,7 @@ app.post('/api/analyze-document', requireAuth, upload.any(), async (req, res) =>
|
|||||||
try {
|
try {
|
||||||
const files = (req.files as Express.Multer.File[]) || [];
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
const body = req.is('multipart/form-data') ? normalizeMultipartBody(req) : req.body;
|
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) {
|
} catch (error) {
|
||||||
handleError(res, error);
|
handleError(res, error);
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/App.tsx
160
src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
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';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -14,6 +14,7 @@ declare global {
|
|||||||
function useApiKey() {
|
function useApiKey() {
|
||||||
const [hasKey, setHasKey] = useState(false);
|
const [hasKey, setHasKey] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkKey();
|
checkKey();
|
||||||
@@ -21,14 +22,20 @@ function useApiKey() {
|
|||||||
|
|
||||||
const checkKey = async () => {
|
const checkKey = async () => {
|
||||||
try {
|
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) {
|
if (window.aistudio?.hasSelectedApiKey) {
|
||||||
const result = await window.aistudio.hasSelectedApiKey();
|
const result = await window.aistudio.hasSelectedApiKey();
|
||||||
setHasKey(result);
|
setHasKey(result || Boolean(nextApiKey));
|
||||||
} else {
|
} else {
|
||||||
setHasKey(true);
|
setHasKey(Boolean(nextApiKey));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
setHasKey(Boolean(localStorage.getItem('geminiApiKey') || process.env.API_KEY || process.env.GEMINI_API_KEY));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,6 +46,8 @@ function useApiKey() {
|
|||||||
if (window.aistudio?.openSelectKey) {
|
if (window.aistudio?.openSelectKey) {
|
||||||
await window.aistudio.openSelectKey();
|
await window.aistudio.openSelectKey();
|
||||||
setHasKey(true);
|
setHasKey(true);
|
||||||
|
} else {
|
||||||
|
checkKey();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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() {
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="max-w-lg w-full bg-zinc-900 border border-zinc-800 rounded-2xl p-8 space-y-6 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white">Gemini API Key</h2>
|
||||||
|
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wider ml-1">API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => 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..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saved && (
|
||||||
|
<div className="text-emerald-400 text-sm bg-emerald-400/10 p-3 rounded-xl border border-emerald-400/20">
|
||||||
|
Saved
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-bold transition-all"
|
||||||
|
>
|
||||||
|
Save API Key
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isLoggedIn, isLoading: isAuthLoading, login, logout, changePassword } = useAuth();
|
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 [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
|
|
||||||
if (isAuthLoading || isKeyLoading) {
|
if (isAuthLoading || isKeyLoading) {
|
||||||
return <div className="min-h-screen flex items-center justify-center bg-zinc-950 text-zinc-200"><Loader2 className="animate-spin" /></div>;
|
return <div className="min-h-screen flex items-center justify-center bg-zinc-950 text-zinc-200"><Loader2 className="animate-spin" /></div>;
|
||||||
@@ -244,35 +316,45 @@ export default function App() {
|
|||||||
|
|
||||||
if (!hasKey) {
|
if (!hasKey) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-950 text-zinc-200 p-4">
|
<>
|
||||||
<div className="max-w-md w-full bg-zinc-900 border border-zinc-800 rounded-2xl p-8 text-center space-y-6">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-zinc-950 text-zinc-200 p-4">
|
||||||
<div className="w-16 h-16 bg-zinc-800 rounded-full flex items-center justify-center mx-auto">
|
<div className="max-w-md w-full bg-zinc-900 border border-zinc-800 rounded-2xl p-8 text-center space-y-6">
|
||||||
<Wand2 className="w-8 h-8 text-emerald-400" />
|
<div className="w-16 h-16 bg-zinc-800 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<Wand2 className="w-8 h-8 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">API Key Required</h1>
|
||||||
|
<p className="text-zinc-400">
|
||||||
|
To use the Gemini 3.1 Flash Image model, you need to set a Google AI Studio API key.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowApiKey(true)}
|
||||||
|
className="w-full py-3 px-4 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Set API Key
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
<a href="https://ai.google.dev/gemini-api/docs/billing" target="_blank" rel="noreferrer" className="underline hover:text-zinc-300">
|
||||||
|
Billing documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-white">API Key Required</h1>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
To use the Gemini 3.1 Flash Image model, you need to select a paid Google Cloud API key.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={selectKey}
|
|
||||||
className="w-full py-3 px-4 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Select API Key
|
|
||||||
</button>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
<a href="https://ai.google.dev/gemini-api/docs/billing" target="_blank" rel="noreferrer" className="underline hover:text-zinc-300">
|
|
||||||
Billing documentation
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{showApiKey && (
|
||||||
|
<ApiKeyModal
|
||||||
|
currentKey={apiKey}
|
||||||
|
onClose={() => setShowApiKey(false)}
|
||||||
|
onSave={saveKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ImageEditor
|
<ImageEditor
|
||||||
onSelectKey={selectKey}
|
apiKey={apiKey}
|
||||||
|
onOpenApiKey={() => setShowApiKey(true)}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onChangePassword={() => setShowChangePassword(true)}
|
onChangePassword={() => setShowChangePassword(true)}
|
||||||
/>
|
/>
|
||||||
@@ -282,11 +364,18 @@ export default function App() {
|
|||||||
onChange={changePassword}
|
onChange={changePassword}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showApiKey && (
|
||||||
|
<ApiKeyModal
|
||||||
|
currentKey={apiKey}
|
||||||
|
onClose={() => setShowApiKey(false)}
|
||||||
|
onSave={saveKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey: () => Promise<void>, onLogout: () => void, onChangePassword: () => void }) {
|
function ImageEditor({ apiKey, onOpenApiKey, onLogout, onChangePassword }: { apiKey: string, onOpenApiKey: () => void, onLogout: () => void, onChangePassword: () => void }) {
|
||||||
const [image, setImage] = useState<string | null>(null);
|
const [image, setImage] = useState<string | null>(null);
|
||||||
const [mimeType, setMimeType] = useState<string>('');
|
const [mimeType, setMimeType] = useState<string>('');
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
@@ -347,7 +436,11 @@ function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey:
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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 ai = new GoogleGenAI({ apiKey });
|
||||||
|
|
||||||
const parts: any[] = [];
|
const parts: any[] = [];
|
||||||
@@ -423,10 +516,13 @@ function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey:
|
|||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSelectKey}
|
onClick={onOpenApiKey}
|
||||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded-lg text-sm font-medium transition-colors border border-zinc-700"
|
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded-lg text-sm font-medium transition-colors border border-zinc-700"
|
||||||
>
|
>
|
||||||
Restart / Change API Key
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
API Key
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -545,10 +641,10 @@ function ImageEditor({ onSelectKey, onLogout, onChangePassword }: { onSelectKey:
|
|||||||
</div>
|
</div>
|
||||||
{(error.includes("API Key") || error.includes("permission") || error.includes("403")) && (
|
{(error.includes("API Key") || error.includes("permission") || error.includes("403")) && (
|
||||||
<button
|
<button
|
||||||
onClick={onSelectKey}
|
onClick={onOpenApiKey}
|
||||||
className="self-start px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-300 rounded-lg text-sm font-medium transition-colors"
|
className="self-start px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-300 rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Reselect API Key
|
Set API Key
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user