Compare commits
No commits in common. "6944e6b750d433cb3dda8251b55f0b31b363198b" and "1ea8492e21746ed9a6cdfa75bf457264d0764504" have entirely different histories.
6944e6b750
...
1ea8492e21
15
.mcp.json
15
.mcp.json
|
|
@ -13,22 +13,17 @@
|
|||
},
|
||||
"brave-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-brave-search"
|
||||
],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "BSAcRGGikEzY4B2j3NZ8Qy5NYh9l4HZ"
|
||||
}
|
||||
},
|
||||
"postgres": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"DATABASE_URI",
|
||||
"crystaldba/postgres-mcp",
|
||||
"--access-mode=unrestricted"
|
||||
],
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
||||
"env": {
|
||||
"DATABASE_URI": "postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.next
|
||||
out
|
||||
.turbo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Data and storage
|
||||
data/
|
||||
data/postgres/**
|
||||
*.sql
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Lock files
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Coverage and test reports
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -115,7 +115,6 @@ Shared Drizzle ORM package used by API service and future apps:
|
|||
- **Configuration**: `drizzle.config.ts` - Drizzle Kit configuration
|
||||
|
||||
Key table: `api_keys`
|
||||
|
||||
- Stores hashed API keys (SHA-256)
|
||||
- Two types: `master` (admin, never expires) and `project` (90-day expiration)
|
||||
- Soft delete via `is_active` flag
|
||||
|
|
@ -128,7 +127,6 @@ Key table: `api_keys`
|
|||
### Root `.env` (Docker Compose Infrastructure)
|
||||
|
||||
Used by Docker Compose services (MinIO, Postgres, API container). Key differences from local:
|
||||
|
||||
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@postgres:5432/banatie_db` (Docker network hostname)
|
||||
- `MINIO_ENDPOINT=minio:9000` (Docker network hostname)
|
||||
- `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` - MinIO admin credentials
|
||||
|
|
@ -137,7 +135,6 @@ Used by Docker Compose services (MinIO, Postgres, API container). Key difference
|
|||
### API Service `.env` (Local Development Only)
|
||||
|
||||
Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` locally:
|
||||
|
||||
- `DATABASE_URL=postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db` (port-forwarded)
|
||||
- `MINIO_ENDPOINT=localhost:9000` (port-forwarded)
|
||||
- **NOTE**: This file is excluded from Docker builds (see Dockerfile.mono)
|
||||
|
|
@ -200,20 +197,19 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
|||
## API Endpoints (API Service)
|
||||
|
||||
### Public Endpoints (No Authentication)
|
||||
|
||||
- `GET /health` - Health check with uptime and status
|
||||
- `GET /api/info` - API information and limits
|
||||
- `POST /api/bootstrap/initial-key` - Create first master key (one-time only)
|
||||
|
||||
### Admin Endpoints (Master Key Required)
|
||||
|
||||
- `POST /api/admin/keys` - Create new API keys (master or project)
|
||||
- `GET /api/admin/keys` - List all API keys
|
||||
- `DELETE /api/admin/keys/:keyId` - Revoke an API key
|
||||
|
||||
### Protected Endpoints (API Key Required)
|
||||
|
||||
- `POST /api/generate` - Generate images from text + optional reference images
|
||||
- `POST /api/text-to-image` - Generate images from text only (JSON)
|
||||
- `POST /api/enhance` - Enhance and optimize text prompts
|
||||
- `GET /api/images` - List generated images
|
||||
|
||||
**Authentication**: All protected endpoints require `X-API-Key` header
|
||||
|
|
@ -224,11 +220,9 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
|||
|
||||
1. **Start Services**: `docker compose up -d`
|
||||
2. **Create Master Key**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/bootstrap/initial-key
|
||||
```
|
||||
|
||||
Save the returned key securely!
|
||||
|
||||
3. **Create Project Key** (for testing):
|
||||
|
|
@ -243,13 +237,10 @@ Located at `apps/api-service/.env` - used ONLY when running `pnpm dev:api` local
|
|||
|
||||
```bash
|
||||
# Image generation with project key
|
||||
curl -X POST http://localhost:3000/api/text-to-image \
|
||||
curl -X POST http://localhost:3000/api/generate \
|
||||
-H "X-API-Key: YOUR_PROJECT_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "a sunset",
|
||||
"filename": "test_image"
|
||||
}'
|
||||
-F "prompt=a sunset" \
|
||||
-F "filename=test_image"
|
||||
```
|
||||
|
||||
### Key Management
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -19,28 +19,24 @@ banatie-service/
|
|||
## Applications
|
||||
|
||||
### 🚀 API Service (`apps/api-service`)
|
||||
|
||||
- **Port**: 3000
|
||||
- **Tech**: Express.js, TypeScript, Gemini AI
|
||||
- **Purpose**: Core REST API for AI image generation
|
||||
- **Features**: Image generation, file upload, rate limiting, logging
|
||||
|
||||
### 🌐 Landing Page (`apps/landing`)
|
||||
|
||||
- **Port**: 3001
|
||||
- **Tech**: Next.js 14, Tailwind CSS
|
||||
- **Purpose**: Public landing page with demo
|
||||
- **Features**: Service overview, image generation demo
|
||||
|
||||
### 🏢 Studio (`apps/studio`)
|
||||
|
||||
- **Port**: 3002
|
||||
- **Tech**: Next.js 14, Supabase, Stripe
|
||||
- **Purpose**: SaaS platform for paid users
|
||||
- **Features**: Authentication, billing, subscriptions, usage tracking
|
||||
|
||||
### 🔧 Admin (`apps/admin`)
|
||||
|
||||
- **Port**: 3003
|
||||
- **Tech**: Next.js 14, Dashboard components
|
||||
- **Purpose**: Service administration and monitoring
|
||||
|
|
@ -49,7 +45,6 @@ banatie-service/
|
|||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- pnpm >= 8.0.0
|
||||
- Docker & Docker Compose
|
||||
|
|
@ -130,7 +125,7 @@ See individual app README files for specific environment variables.
|
|||
## Services & Ports
|
||||
|
||||
| Service | Port | Description |
|
||||
| --------------- | ---- | ---------------- |
|
||||
|---------|------|-------------|
|
||||
| API Service | 3000 | Core REST API |
|
||||
| Landing Page | 3001 | Public website |
|
||||
| Studio Platform | 3002 | SaaS application |
|
||||
|
|
@ -142,16 +137,16 @@ See individual app README files for specific environment variables.
|
|||
## API Usage
|
||||
|
||||
### Generate Image
|
||||
|
||||
```bash
|
||||
# Text-to-image
|
||||
curl -X POST http://localhost:3000/api/text-to-image \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-d '{
|
||||
"prompt": "A magical forest with glowing mushrooms",
|
||||
"filename": "magical_forest"
|
||||
}'
|
||||
# Basic text-to-image
|
||||
curl -X POST http://localhost:3000/api/generate \
|
||||
-F "prompt=A magical forest with glowing mushrooms" \
|
||||
-F "filename=magical_forest"
|
||||
|
||||
# With reference images
|
||||
curl -X POST http://localhost:3000/api/generate \
|
||||
-F "prompt=Character in medieval armor like the reference" \
|
||||
-F "referenceImages=@./reference.jpg"
|
||||
```
|
||||
|
||||
See `apps/api-service/README.md` for detailed API documentation.
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals", "prettier"],
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,6 @@ const nextConfig = {
|
|||
POSTGRES_URL: process.env.POSTGRES_URL,
|
||||
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = nextConfig
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
import type { Metadata } from 'next';
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Banatie Admin - Service Administration',
|
||||
description: 'Administration dashboard for managing the Banatie AI image generation service',
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-gray-100">
|
||||
<div className="min-h-screen">{children}</div>
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
@ -6,7 +6,9 @@ export default function AdminDashboard() {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Banatie Admin</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Banatie Admin
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500">System Status: Online</span>
|
||||
|
|
@ -19,6 +21,7 @@ export default function AdminDashboard() {
|
|||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
|
|
@ -30,8 +33,12 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">API Requests</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">1,234</dd>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
API Requests
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
1,234
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -51,7 +58,9 @@ export default function AdminDashboard() {
|
|||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Images Generated
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">567</dd>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
567
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,8 +77,12 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">89</dd>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Active Users
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
89
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -86,8 +99,12 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Errors (24h)</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">3</dd>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Errors (24h)
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
3
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,7 +116,9 @@ export default function AdminDashboard() {
|
|||
<div className="mt-8">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Service Status</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
Service Status
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">API Service</span>
|
||||
|
|
@ -140,8 +159,8 @@ export default function AdminDashboard() {
|
|||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>
|
||||
This admin dashboard is being developed to provide comprehensive monitoring
|
||||
and management capabilities for the Banatie service.
|
||||
This admin dashboard is being developed to provide comprehensive
|
||||
monitoring and management capabilities for the Banatie service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,5 +170,5 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
# Network Error Detection
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds intelligent network error detection to the Banatie API service. It follows best practices by **only checking connectivity when errors occur** (zero overhead on successful requests) and provides clear, actionable error messages to users.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Lazy Validation
|
||||
- Network checks **only trigger on failures**
|
||||
- Zero performance overhead on successful requests
|
||||
- Follows the fail-fast pattern
|
||||
|
||||
### ✅ Error Classification
|
||||
Automatically detects and classifies:
|
||||
- **DNS Resolution Failures** (`ENOTFOUND`, `EAI_AGAIN`)
|
||||
- **Connection Timeouts** (`ETIMEDOUT`)
|
||||
- **Connection Refused** (`ECONNREFUSED`)
|
||||
- **Connection Resets** (`ECONNRESET`, `ENETUNREACH`)
|
||||
- **Generic Network Errors** (fetch failed, etc.)
|
||||
|
||||
### ✅ Clear User Messages
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Gemini AI generation failed: exception TypeError: fetch failed sending request
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Network error: Unable to connect to Gemini API. Please check your internet connection and firewall settings.
|
||||
```
|
||||
|
||||
### ✅ Detailed Logging
|
||||
|
||||
Logs contain both user-friendly messages and technical details:
|
||||
```
|
||||
[NETWORK ERROR - DNS] DNS resolution failed: Unable to resolve Gemini API hostname | Technical: Error code: ENOTFOUND
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Core Utility
|
||||
|
||||
**Location:** `src/utils/NetworkErrorDetector.ts`
|
||||
|
||||
**Key Methods:**
|
||||
- `classifyError(error, serviceName)` - Analyzes an error and determines if it's network-related
|
||||
- `formatErrorForLogging(result)` - Formats errors for logging with technical details
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **ImageGenService** (`src/services/ImageGenService.ts`)
|
||||
- Enhanced error handling in `generateImageWithAI()` method
|
||||
- Provides clear network diagnostics when image generation fails
|
||||
|
||||
2. **PromptEnhancementService** (`src/services/promptEnhancement/PromptEnhancementService.ts`)
|
||||
- Enhanced error handling in `enhancePrompt()` method
|
||||
- Helps users diagnose connectivity issues during prompt enhancement
|
||||
|
||||
## How It Works
|
||||
|
||||
### Normal Operation (No Overhead)
|
||||
```
|
||||
User Request → Service → Gemini API → Success ✓
|
||||
```
|
||||
No network checks performed - zero overhead.
|
||||
|
||||
### Error Scenario (Smart Detection)
|
||||
```
|
||||
User Request → Service → Gemini API → Error ✗
|
||||
↓
|
||||
NetworkErrorDetector.classifyError()
|
||||
↓
|
||||
1. Check error code/message for network patterns
|
||||
2. If network-related: Quick DNS check (2s timeout)
|
||||
3. Return classification + user-friendly message
|
||||
```
|
||||
|
||||
## Error Types
|
||||
|
||||
| Error Type | Trigger | User Message |
|
||||
|-----------|---------|--------------|
|
||||
| `dns` | ENOTFOUND, EAI_AGAIN | DNS resolution failed: Unable to resolve Gemini API hostname |
|
||||
| `timeout` | ETIMEDOUT | Connection timeout: Gemini API did not respond in time |
|
||||
| `refused` | ECONNREFUSED | Connection refused: Service may be down or blocked by firewall |
|
||||
| `reset` | ECONNRESET, ENETUNREACH | Connection lost: Network connection was interrupted |
|
||||
| `connection` | General connectivity failure | Network connection failed: Unable to reach Gemini API |
|
||||
| `unknown` | Network keywords detected | Network error: Unable to connect to Gemini API |
|
||||
|
||||
## Testing
|
||||
|
||||
Run the manual test to see error detection in action:
|
||||
|
||||
```bash
|
||||
npx tsx test-network-error-detector.ts
|
||||
```
|
||||
|
||||
This demonstrates:
|
||||
- DNS errors
|
||||
- Timeout errors
|
||||
- Fetch failures (actual error from your logs)
|
||||
- Non-network errors (no false positives)
|
||||
|
||||
## Example Error Logs
|
||||
|
||||
### Before Implementation
|
||||
```
|
||||
[2025-10-09T16:56:29.228Z] [fmfnz0zp7] Text-to-image generation completed: {
|
||||
success: false,
|
||||
error: 'Gemini AI generation failed: exception TypeError: fetch failed sending request'
|
||||
}
|
||||
```
|
||||
|
||||
### After Implementation
|
||||
```
|
||||
[ImageGenService] [NETWORK ERROR - UNKNOWN] Network error: Unable to connect to Gemini API. Please check your internet connection and firewall settings. | Technical: exception TypeError: fetch failed sending request
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Better UX**: Users get actionable error messages
|
||||
2. **Faster Debugging**: Developers immediately know if it's a network issue
|
||||
3. **Zero Overhead**: No performance impact on successful requests
|
||||
4. **Production-Ready**: Follows industry best practices (AWS SDK, Stripe, Google Cloud)
|
||||
5. **Comprehensive**: Detects all major network error types
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Retry logic with exponential backoff for transient network errors
|
||||
- Circuit breaker pattern for repeated failures
|
||||
- Metrics/alerting for network error rates
|
||||
- Health check endpoint with connectivity status
|
||||
|
|
@ -13,7 +13,6 @@ module.exports = [
|
|||
'@typescript-eslint': require('@typescript-eslint/eslint-plugin'),
|
||||
prettier: require('eslint-plugin-prettier'),
|
||||
},
|
||||
extends: [require('eslint-config-prettier')],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
|
@ -28,6 +27,12 @@ module.exports = [
|
|||
},
|
||||
},
|
||||
{
|
||||
ignores: ['node_modules/**', 'dist/**', '*.js', '*.mjs', 'eslint.config.js'],
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'*.js',
|
||||
'*.mjs',
|
||||
'eslint.config.js'
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,22 +1,24 @@
|
|||
import express, { Application } from 'express';
|
||||
import cors from 'cors';
|
||||
import { config } from 'dotenv';
|
||||
import { Config } from './types/api';
|
||||
import { textToImageRouter } from './routes/textToImage';
|
||||
import { imagesRouter } from './routes/images';
|
||||
import bootstrapRoutes from './routes/bootstrap';
|
||||
import adminKeysRoutes from './routes/admin/keys';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
import express, { Application } from "express";
|
||||
import cors from "cors";
|
||||
import { config } from "dotenv";
|
||||
import { Config } from "./types/api";
|
||||
import { generateRouter } from "./routes/generate";
|
||||
import { enhanceRouter } from "./routes/enhance";
|
||||
import { textToImageRouter } from "./routes/textToImage";
|
||||
import { imagesRouter } from "./routes/images";
|
||||
import bootstrapRoutes from "./routes/bootstrap";
|
||||
import adminKeysRoutes from "./routes/admin/keys";
|
||||
import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
// Application configuration
|
||||
export const appConfig: Config = {
|
||||
port: parseInt(process.env['PORT'] || '3000'),
|
||||
geminiApiKey: process.env['GEMINI_API_KEY'] || '',
|
||||
resultsDir: './results',
|
||||
uploadsDir: './uploads/temp',
|
||||
port: parseInt(process.env["PORT"] || "3000"),
|
||||
geminiApiKey: process.env["GEMINI_API_KEY"] || "",
|
||||
resultsDir: "./results",
|
||||
uploadsDir: "./uploads/temp",
|
||||
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 3,
|
||||
};
|
||||
|
|
@ -30,30 +32,30 @@ export const createApp = (): Application => {
|
|||
cors({
|
||||
origin: true, // Allow all origins
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
|
||||
exposedHeaders: ['X-Request-ID'],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"],
|
||||
exposedHeaders: ["X-Request-ID"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// Request ID middleware for logging
|
||||
app.use((req, res, next) => {
|
||||
req.requestId = Math.random().toString(36).substr(2, 9);
|
||||
res.setHeader('X-Request-ID', req.requestId);
|
||||
res.setHeader("X-Request-ID", req.requestId);
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (_req, res) => {
|
||||
app.get("/health", (_req, res) => {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env['NODE_ENV'] || 'development',
|
||||
version: process.env['npm_package_version'] || '1.0.0',
|
||||
environment: process.env["NODE_ENV"] || "development",
|
||||
version: process.env["npm_package_version"] || "1.0.0",
|
||||
};
|
||||
|
||||
console.log(`[${health.timestamp}] Health check - ${health.status}`);
|
||||
|
|
@ -61,29 +63,34 @@ export const createApp = (): Application => {
|
|||
});
|
||||
|
||||
// API info endpoint
|
||||
app.get('/api/info', async (req: any, res) => {
|
||||
app.get("/api/info", async (req: any, res) => {
|
||||
const info: any = {
|
||||
name: 'Banatie - Nano Banana Image Generation API',
|
||||
version: '1.0.0',
|
||||
name: "Banatie - Nano Banana Image Generation API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
'REST API service for AI-powered image generation using Gemini Flash Image model',
|
||||
"REST API service for AI-powered image generation using Gemini Flash Image model",
|
||||
endpoints: {
|
||||
'GET /health': 'Health check',
|
||||
'GET /api/info': 'API information',
|
||||
'POST /api/text-to-image': 'Generate images from text prompt only (JSON)',
|
||||
"GET /health": "Health check",
|
||||
"GET /api/info": "API information",
|
||||
"POST /api/generate":
|
||||
"Generate images from text prompt with optional reference images",
|
||||
"POST /api/text-to-image":
|
||||
"Generate images from text prompt only (JSON)",
|
||||
"POST /api/enhance":
|
||||
"Enhance and optimize prompts for better image generation",
|
||||
},
|
||||
limits: {
|
||||
maxFileSize: `${appConfig.maxFileSize / (1024 * 1024)}MB`,
|
||||
maxFiles: appConfig.maxFiles,
|
||||
supportedFormats: ['PNG', 'JPEG', 'JPG', 'WebP'],
|
||||
supportedFormats: ["PNG", "JPEG", "JPG", "WebP"],
|
||||
},
|
||||
};
|
||||
|
||||
// If API key is provided, validate and return key info
|
||||
const providedKey = req.headers['x-api-key'] as string;
|
||||
const providedKey = req.headers["x-api-key"] as string;
|
||||
if (providedKey) {
|
||||
try {
|
||||
const { ApiKeyService } = await import('./services/ApiKeyService');
|
||||
const { ApiKeyService } = await import("./services/ApiKeyService");
|
||||
const apiKeyService = new ApiKeyService();
|
||||
const apiKey = await apiKeyService.validateKey(providedKey);
|
||||
|
||||
|
|
@ -110,14 +117,16 @@ export const createApp = (): Application => {
|
|||
|
||||
// Public routes (no authentication)
|
||||
// Bootstrap route (no auth, but works only once)
|
||||
app.use('/api/bootstrap', bootstrapRoutes);
|
||||
app.use("/api/bootstrap", bootstrapRoutes);
|
||||
|
||||
// Admin routes (require master key)
|
||||
app.use('/api/admin/keys', adminKeysRoutes);
|
||||
app.use("/api/admin/keys", adminKeysRoutes);
|
||||
|
||||
// Protected API routes (require valid API key)
|
||||
app.use('/api', textToImageRouter);
|
||||
app.use('/api', imagesRouter);
|
||||
app.use("/api", generateRouter);
|
||||
app.use("/api", enhanceRouter);
|
||||
app.use("/api", textToImageRouter);
|
||||
app.use("/api", imagesRouter);
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(notFoundHandler);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { createDbClient } from '@banatie/database';
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env['DATABASE_URL'] ||
|
||||
const DATABASE_URL = process.env['DATABASE_URL'] ||
|
||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
||||
|
||||
export const db = createDbClient(DATABASE_URL);
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`,
|
||||
);
|
||||
console.log(`[${new Date().toISOString()}] Database client initialized - ${new URL(DATABASE_URL).host}`);
|
||||
|
|
@ -37,7 +37,7 @@ class RateLimiter {
|
|||
return {
|
||||
allowed: true,
|
||||
remaining: this.limit - record.count,
|
||||
resetAt: record.resetAt,
|
||||
resetAt: record.resetAt
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +57,11 @@ const rateLimiter = new RateLimiter(100, 60 * 60 * 1000); // 100 requests per ho
|
|||
* Rate limiting middleware
|
||||
* Must be used AFTER validateApiKey middleware
|
||||
*/
|
||||
export function rateLimitByApiKey(req: Request, res: Response, next: NextFunction): void {
|
||||
export function rateLimitByApiKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (!req.apiKey) {
|
||||
next();
|
||||
return;
|
||||
|
|
@ -73,12 +77,9 @@ export function rateLimitByApiKey(req: Request, res: Response, next: NextFunctio
|
|||
if (!result.allowed) {
|
||||
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
|
||||
|
||||
console.warn(
|
||||
`[${new Date().toISOString()}] Rate limit exceeded: ${req.apiKey.id} (${req.apiKey.keyType}) - reset: ${new Date(result.resetAt).toISOString()}`,
|
||||
);
|
||||
console.warn(`[${new Date().toISOString()}] Rate limit exceeded: ${req.apiKey.id} (${req.apiKey.keyType}) - reset: ${new Date(result.resetAt).toISOString()}`);
|
||||
|
||||
res
|
||||
.status(429)
|
||||
res.status(429)
|
||||
.setHeader('Retry-After', retryAfter.toString())
|
||||
.json({
|
||||
error: 'Rate limit exceeded',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { Request, Response, NextFunction } from 'express';
|
|||
* Middleware to ensure the API key is a master key
|
||||
* Must be used AFTER validateApiKey middleware
|
||||
*/
|
||||
export function requireMasterKey(req: Request, res: Response, next: NextFunction): void {
|
||||
export function requireMasterKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (!req.apiKey) {
|
||||
res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
|
|
@ -14,9 +18,7 @@ export function requireMasterKey(req: Request, res: Response, next: NextFunction
|
|||
}
|
||||
|
||||
if (req.apiKey.keyType !== 'master') {
|
||||
console.warn(
|
||||
`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`,
|
||||
);
|
||||
console.warn(`[${new Date().toISOString()}] Non-master key attempted admin action: ${req.apiKey.id} (${req.apiKey.keyType}) - ${req.path}`);
|
||||
|
||||
res.status(403).json({
|
||||
error: 'Master key required',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { Request, Response, NextFunction } from 'express';
|
|||
* Middleware to ensure only project keys can access generation endpoints
|
||||
* Master keys are for admin purposes only
|
||||
*/
|
||||
export function requireProjectKey(req: Request, res: Response, next: NextFunction): void {
|
||||
export function requireProjectKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
// This middleware assumes validateApiKey has already run and attached req.apiKey
|
||||
if (!req.apiKey) {
|
||||
res.status(401).json({
|
||||
|
|
@ -18,8 +22,7 @@ export function requireProjectKey(req: Request, res: Response, next: NextFunctio
|
|||
if (req.apiKey.keyType === 'master') {
|
||||
res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
||||
message: 'Master keys cannot be used for image generation. Please use a project-specific API key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -33,9 +36,7 @@ export function requireProjectKey(req: Request, res: Response, next: NextFunctio
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Project key validated for generation: ${req.apiKey.id}`,
|
||||
);
|
||||
console.log(`[${new Date().toISOString()}] Project key validated for generation: ${req.apiKey.id}`);
|
||||
|
||||
next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const apiKeyService = new ApiKeyService();
|
|||
export async function validateApiKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const providedKey = req.headers['x-api-key'] as string;
|
||||
|
||||
|
|
@ -44,9 +44,7 @@ export async function validateApiKey(
|
|||
// Attach to request for use in routes
|
||||
req.apiKey = apiKey;
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] API key validated: ${apiKey.id} (${apiKey.keyType})`,
|
||||
);
|
||||
console.log(`[${new Date().toISOString()}] API key validated: ${apiKey.id} (${apiKey.keyType})`);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { GenerateImageResponse } from '../types/api';
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { GenerateImageResponse } from "../types/api";
|
||||
|
||||
/**
|
||||
* Global error handler for the Express application
|
||||
*/
|
||||
export const errorHandler = (error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
export const errorHandler = (
|
||||
error: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const requestId = req.requestId || 'unknown';
|
||||
const requestId = req.requestId || "unknown";
|
||||
|
||||
// Log the error
|
||||
console.error(`[${timestamp}] [${requestId}] ERROR:`, {
|
||||
|
|
@ -25,40 +30,52 @@ export const errorHandler = (error: Error, req: Request, res: Response, next: Ne
|
|||
|
||||
// Determine error type and status code
|
||||
let statusCode = 500;
|
||||
let errorMessage = 'Internal server error';
|
||||
let errorType = 'INTERNAL_ERROR';
|
||||
let errorMessage = "Internal server error";
|
||||
let errorType = "INTERNAL_ERROR";
|
||||
|
||||
if (error.name === 'ValidationError') {
|
||||
if (error.name === "ValidationError") {
|
||||
statusCode = 400;
|
||||
errorMessage = error.message;
|
||||
errorType = 'VALIDATION_ERROR';
|
||||
} else if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||
errorType = "VALIDATION_ERROR";
|
||||
} else if (
|
||||
error.message.includes("API key") ||
|
||||
error.message.includes("authentication")
|
||||
) {
|
||||
statusCode = 401;
|
||||
errorMessage = 'Authentication failed';
|
||||
errorType = 'AUTH_ERROR';
|
||||
} else if (error.message.includes('not found') || error.message.includes('404')) {
|
||||
errorMessage = "Authentication failed";
|
||||
errorType = "AUTH_ERROR";
|
||||
} else if (
|
||||
error.message.includes("not found") ||
|
||||
error.message.includes("404")
|
||||
) {
|
||||
statusCode = 404;
|
||||
errorMessage = 'Resource not found';
|
||||
errorType = 'NOT_FOUND';
|
||||
} else if (error.message.includes('timeout') || error.message.includes('503')) {
|
||||
errorMessage = "Resource not found";
|
||||
errorType = "NOT_FOUND";
|
||||
} else if (
|
||||
error.message.includes("timeout") ||
|
||||
error.message.includes("503")
|
||||
) {
|
||||
statusCode = 503;
|
||||
errorMessage = 'Service temporarily unavailable';
|
||||
errorType = 'SERVICE_UNAVAILABLE';
|
||||
} else if (error.message.includes('overloaded') || error.message.includes('rate limit')) {
|
||||
errorMessage = "Service temporarily unavailable";
|
||||
errorType = "SERVICE_UNAVAILABLE";
|
||||
} else if (
|
||||
error.message.includes("overloaded") ||
|
||||
error.message.includes("rate limit")
|
||||
) {
|
||||
statusCode = 429;
|
||||
errorMessage = 'Service overloaded, please try again later';
|
||||
errorType = 'RATE_LIMITED';
|
||||
errorMessage = "Service overloaded, please try again later";
|
||||
errorType = "RATE_LIMITED";
|
||||
}
|
||||
|
||||
// Create error response
|
||||
const errorResponse: GenerateImageResponse = {
|
||||
success: false,
|
||||
message: 'Request failed',
|
||||
message: "Request failed",
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
// Add additional debug info in development
|
||||
if (process.env['NODE_ENV'] === 'development') {
|
||||
if (process.env["NODE_ENV"] === "development") {
|
||||
(errorResponse as any).debug = {
|
||||
originalError: error.message,
|
||||
errorType,
|
||||
|
|
@ -79,13 +96,15 @@ export const errorHandler = (error: Error, req: Request, res: Response, next: Ne
|
|||
*/
|
||||
export const notFoundHandler = (req: Request, res: Response) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const requestId = req.requestId || 'unknown';
|
||||
const requestId = req.requestId || "unknown";
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] 404 - Route not found: ${req.method} ${req.path}`,
|
||||
);
|
||||
|
||||
const notFoundResponse: GenerateImageResponse = {
|
||||
success: false,
|
||||
message: 'Route not found',
|
||||
message: "Route not found",
|
||||
error: `The requested endpoint ${req.method} ${req.path} does not exist`,
|
||||
};
|
||||
|
||||
|
|
@ -95,6 +114,7 @@ export const notFoundHandler = (req: Request, res: Response) => {
|
|||
/**
|
||||
* Async error wrapper to catch errors in async route handlers
|
||||
*/
|
||||
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
|
||||
export const asyncHandler =
|
||||
(fn: Function) => (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Response, NextFunction } from 'express';
|
||||
import { sanitizeFilename } from './validation';
|
||||
import { Response, NextFunction } from "express";
|
||||
import { sanitizeFilename } from "./validation";
|
||||
|
||||
// Validation rules (same as existing validation but for JSON)
|
||||
const VALIDATION_RULES = {
|
||||
|
|
@ -18,16 +18,16 @@ const VALIDATION_RULES = {
|
|||
|
||||
// Valid aspect ratios supported by Gemini SDK
|
||||
const VALID_ASPECT_RATIOS = [
|
||||
'1:1', // Square (1024x1024)
|
||||
'2:3', // Portrait (832x1248)
|
||||
'3:2', // Landscape (1248x832)
|
||||
'3:4', // Portrait (864x1184)
|
||||
'4:3', // Landscape (1184x864)
|
||||
'4:5', // Portrait (896x1152)
|
||||
'5:4', // Landscape (1152x896)
|
||||
'9:16', // Vertical (768x1344)
|
||||
'16:9', // Widescreen (1344x768)
|
||||
'21:9', // Ultrawide (1536x672)
|
||||
"1:1", // Square (1024x1024)
|
||||
"2:3", // Portrait (832x1248)
|
||||
"3:2", // Landscape (1248x832)
|
||||
"3:4", // Portrait (864x1184)
|
||||
"4:3", // Landscape (1184x864)
|
||||
"4:5", // Portrait (896x1152)
|
||||
"5:4", // Landscape (1152x896)
|
||||
"9:16", // Vertical (768x1344)
|
||||
"16:9", // Widescreen (1344x768)
|
||||
"21:9", // Ultrawide (1536x672)
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
|
@ -42,14 +42,16 @@ export const validateTextToImageRequest = (
|
|||
const { prompt, filename, aspectRatio, autoEnhance, enhancementOptions } = req.body;
|
||||
const errors: string[] = [];
|
||||
|
||||
console.log(`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`,
|
||||
);
|
||||
|
||||
// Validate that request body exists
|
||||
if (!req.body || typeof req.body !== 'object') {
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Request body must be valid JSON',
|
||||
message: 'Invalid request format',
|
||||
error: "Request body must be valid JSON",
|
||||
message: "Invalid request format",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -61,54 +63,67 @@ export const validateTextToImageRequest = (
|
|||
|
||||
// Default template to "photorealistic" in enhancementOptions
|
||||
if (req.body.enhancementOptions && !req.body.enhancementOptions.template) {
|
||||
req.body.enhancementOptions.template = 'photorealistic';
|
||||
req.body.enhancementOptions.template = "photorealistic";
|
||||
} else if (!req.body.enhancementOptions && req.body.autoEnhance !== false) {
|
||||
// If autoEnhance is true (default) and no enhancementOptions, create it with default template
|
||||
req.body.enhancementOptions = { template: 'photorealistic' };
|
||||
req.body.enhancementOptions = { template: "photorealistic" };
|
||||
}
|
||||
|
||||
// Validate prompt
|
||||
if (!prompt) {
|
||||
errors.push('Prompt is required');
|
||||
} else if (typeof prompt !== 'string') {
|
||||
errors.push('Prompt must be a string');
|
||||
errors.push("Prompt is required");
|
||||
} else if (typeof prompt !== "string") {
|
||||
errors.push("Prompt must be a string");
|
||||
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
||||
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
|
||||
errors.push(
|
||||
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
|
||||
);
|
||||
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
||||
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
|
||||
errors.push(
|
||||
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
if (!filename) {
|
||||
errors.push('Filename is required');
|
||||
} else if (typeof filename !== 'string') {
|
||||
errors.push('Filename must be a string');
|
||||
errors.push("Filename is required");
|
||||
} else if (typeof filename !== "string") {
|
||||
errors.push("Filename must be a string");
|
||||
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
||||
errors.push('Filename cannot be empty');
|
||||
errors.push("Filename cannot be empty");
|
||||
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
||||
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
|
||||
errors.push(
|
||||
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
|
||||
);
|
||||
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
||||
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
|
||||
errors.push(
|
||||
"Filename can only contain letters, numbers, underscores, and hyphens",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate aspectRatio (optional, defaults to "1:1")
|
||||
if (aspectRatio !== undefined) {
|
||||
if (typeof aspectRatio !== 'string') {
|
||||
errors.push('aspectRatio must be a string');
|
||||
if (typeof aspectRatio !== "string") {
|
||||
errors.push("aspectRatio must be a string");
|
||||
} else if (!VALID_ASPECT_RATIOS.includes(aspectRatio as any)) {
|
||||
errors.push(`Invalid aspectRatio. Must be one of: ${VALID_ASPECT_RATIOS.join(', ')}`);
|
||||
errors.push(
|
||||
`Invalid aspectRatio. Must be one of: ${VALID_ASPECT_RATIOS.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate autoEnhance (optional boolean)
|
||||
if (autoEnhance !== undefined && typeof autoEnhance !== 'boolean') {
|
||||
errors.push('autoEnhance must be a boolean');
|
||||
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
|
||||
errors.push("autoEnhance must be a boolean");
|
||||
}
|
||||
|
||||
// Validate enhancementOptions (optional object)
|
||||
if (enhancementOptions !== undefined) {
|
||||
if (typeof enhancementOptions !== 'object' || Array.isArray(enhancementOptions)) {
|
||||
errors.push('enhancementOptions must be an object');
|
||||
if (
|
||||
typeof enhancementOptions !== "object" ||
|
||||
Array.isArray(enhancementOptions)
|
||||
) {
|
||||
errors.push("enhancementOptions must be an object");
|
||||
} else {
|
||||
const { template } = enhancementOptions;
|
||||
|
||||
|
|
@ -116,17 +131,17 @@ export const validateTextToImageRequest = (
|
|||
if (
|
||||
template !== undefined &&
|
||||
![
|
||||
'photorealistic',
|
||||
'illustration',
|
||||
'minimalist',
|
||||
'sticker',
|
||||
'product',
|
||||
'comic',
|
||||
'general',
|
||||
"photorealistic",
|
||||
"illustration",
|
||||
"minimalist",
|
||||
"sticker",
|
||||
"product",
|
||||
"comic",
|
||||
"general",
|
||||
].includes(template)
|
||||
) {
|
||||
errors.push(
|
||||
'Invalid template in enhancementOptions. Must be one of: photorealistic, illustration, minimalist, sticker, product, comic, general',
|
||||
"Invalid template in enhancementOptions. Must be one of: photorealistic, illustration, minimalist, sticker, product, comic, general",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -134,16 +149,19 @@ export const validateTextToImageRequest = (
|
|||
|
||||
// Validate meta (optional object)
|
||||
if (req.body.meta !== undefined) {
|
||||
if (typeof req.body.meta !== 'object' || Array.isArray(req.body.meta)) {
|
||||
errors.push('meta must be an object');
|
||||
if (
|
||||
typeof req.body.meta !== "object" ||
|
||||
Array.isArray(req.body.meta)
|
||||
) {
|
||||
errors.push("meta must be an object");
|
||||
} else if (req.body.meta.tags !== undefined) {
|
||||
if (!Array.isArray(req.body.meta.tags)) {
|
||||
errors.push('meta.tags must be an array');
|
||||
errors.push("meta.tags must be an array");
|
||||
} else {
|
||||
// Validate each tag is a string
|
||||
for (const tag of req.body.meta.tags) {
|
||||
if (typeof tag !== 'string') {
|
||||
errors.push('Each tag in meta.tags must be a string');
|
||||
if (typeof tag !== "string") {
|
||||
errors.push("Each tag in meta.tags must be a string");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -152,19 +170,28 @@ export const validateTextToImageRequest = (
|
|||
}
|
||||
|
||||
// Check for XSS attempts in prompt
|
||||
const xssPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i, /<object/i, /<embed/i];
|
||||
const xssPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+\s*=/i,
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
];
|
||||
|
||||
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
|
||||
errors.push('Invalid characters detected in prompt');
|
||||
errors.push("Invalid characters detected in prompt");
|
||||
}
|
||||
|
||||
// Log validation results
|
||||
if (errors.length > 0) {
|
||||
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
message: errors.join(', '),
|
||||
error: "Validation failed",
|
||||
message: errors.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -190,23 +217,40 @@ export const validateTextToImageRequest = (
|
|||
/**
|
||||
* Log text-to-image request details for debugging
|
||||
*/
|
||||
export const logTextToImageRequest = (req: any, _res: Response, next: NextFunction): void => {
|
||||
export const logTextToImageRequest = (
|
||||
req: any,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): void => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
|
||||
|
||||
console.log(`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`,
|
||||
);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Content-Type: ${req.get('Content-Type')}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`,
|
||||
`[${timestamp}] [${req.requestId}] Content-Type: ${req.get("Content-Type")}`,
|
||||
);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
|
||||
);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
|
||||
);
|
||||
if (enhancementOptions) {
|
||||
console.log(`[${timestamp}] [${req.requestId}] Enhancement options:`, enhancementOptions);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Enhancement options:`,
|
||||
enhancementOptions,
|
||||
);
|
||||
}
|
||||
console.log(`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`);
|
||||
console.log(`[${timestamp}] [${req.requestId}] =====================================`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`,
|
||||
);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] =====================================`,
|
||||
);
|
||||
next();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { PromptEnhancementService } from '../services/promptEnhancement';
|
||||
import { EnhancedGenerateImageRequest } from '../types/api';
|
||||
import { Request, Response } from "express";
|
||||
import { PromptEnhancementService } from "../services/promptEnhancement";
|
||||
import { EnhancedGenerateImageRequest } from "../types/api";
|
||||
|
||||
let promptEnhancementService: PromptEnhancementService | null = null;
|
||||
|
||||
|
|
@ -28,15 +28,19 @@ export const autoEnhancePrompt = async (
|
|||
const shouldEnhance = autoEnhance !== false;
|
||||
|
||||
if (!shouldEnhance) {
|
||||
console.log(`[${timestamp}] [${requestId}] Auto-enhancement explicitly disabled, skipping`);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Auto-enhancement explicitly disabled, skipping`,
|
||||
);
|
||||
return next();
|
||||
}
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] Auto-enhancement enabled, processing prompt`);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Auto-enhancement enabled, processing prompt`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (!promptEnhancementService) {
|
||||
const apiKey = process.env['GEMINI_API_KEY'];
|
||||
const apiKey = process.env["GEMINI_API_KEY"];
|
||||
if (!apiKey) {
|
||||
console.error(
|
||||
`[${timestamp}] [${requestId}] Cannot initialize prompt enhancement: Missing API key`,
|
||||
|
|
@ -47,8 +51,8 @@ export const autoEnhancePrompt = async (
|
|||
}
|
||||
|
||||
// Extract orgId and projectId from validated API key
|
||||
const orgId = req.apiKey?.organizationSlug || 'unknown';
|
||||
const projectId = req.apiKey?.projectSlug || 'unknown';
|
||||
const orgId = req.apiKey?.organizationSlug || "unknown";
|
||||
const projectId = req.apiKey?.projectSlug || "unknown";
|
||||
|
||||
const result = await promptEnhancementService.enhancePrompt(
|
||||
prompt,
|
||||
|
|
@ -65,7 +69,9 @@ export const autoEnhancePrompt = async (
|
|||
|
||||
if (result.success && result.enhancedPrompt) {
|
||||
console.log(`[${timestamp}] [${requestId}] Prompt enhanced successfully`);
|
||||
console.log(`[${timestamp}] [${requestId}] Original: "${prompt.substring(0, 50)}..."`);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Original: "${prompt.substring(0, 50)}..."`,
|
||||
);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Enhanced: "${result.enhancedPrompt.substring(0, 50)}..."`,
|
||||
);
|
||||
|
|
@ -84,12 +90,21 @@ export const autoEnhancePrompt = async (
|
|||
|
||||
req.body.prompt = result.enhancedPrompt;
|
||||
} else {
|
||||
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
|
||||
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
||||
console.warn(
|
||||
`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`,
|
||||
);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Proceeding with original prompt`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${timestamp}] [${requestId}] Error during auto-enhancement:`, error);
|
||||
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
|
||||
console.error(
|
||||
`[${timestamp}] [${requestId}] Error during auto-enhancement:`,
|
||||
error,
|
||||
);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Proceeding with original prompt`,
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import multer from 'multer';
|
||||
import { Request, RequestHandler } from 'express';
|
||||
import multer from "multer";
|
||||
import { Request, RequestHandler } from "express";
|
||||
|
||||
// Configure multer for memory storage (we'll process files in memory)
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
// File filter for image types only
|
||||
const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
const fileFilter = (
|
||||
_req: Request,
|
||||
file: Express.Multer.File,
|
||||
cb: multer.FileFilterCallback,
|
||||
) => {
|
||||
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
console.log(
|
||||
|
|
@ -17,7 +21,11 @@ const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFil
|
|||
console.log(
|
||||
`[${new Date().toISOString()}] Rejected file: ${file.originalname} (${file.mimetype})`,
|
||||
);
|
||||
cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`));
|
||||
cb(
|
||||
new Error(
|
||||
`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPEG, WebP`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -36,50 +44,59 @@ export const upload = multer({
|
|||
});
|
||||
|
||||
// Middleware for handling reference images
|
||||
export const uploadReferenceImages: RequestHandler = upload.array('referenceImages', MAX_FILES);
|
||||
export const uploadReferenceImages: RequestHandler = upload.array(
|
||||
"referenceImages",
|
||||
MAX_FILES,
|
||||
);
|
||||
|
||||
// Error handler for multer errors
|
||||
export const handleUploadErrors = (error: any, _req: Request, res: any, next: any) => {
|
||||
export const handleUploadErrors = (
|
||||
error: any,
|
||||
_req: Request,
|
||||
res: any,
|
||||
next: any,
|
||||
) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.error(`[${timestamp}] Multer error:`, error.message);
|
||||
|
||||
switch (error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
case "LIMIT_FILE_SIZE":
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `File too large. Maximum size: ${MAX_FILE_SIZE / (1024 * 1024)}MB`,
|
||||
message: 'File upload failed',
|
||||
message: "File upload failed",
|
||||
});
|
||||
|
||||
case 'LIMIT_FILE_COUNT':
|
||||
case "LIMIT_FILE_COUNT":
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Too many files. Maximum: ${MAX_FILES} files`,
|
||||
message: 'File upload failed',
|
||||
message: "File upload failed",
|
||||
});
|
||||
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
case "LIMIT_UNEXPECTED_FILE":
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Unexpected file field. Use "referenceImages" for image uploads',
|
||||
message: 'File upload failed',
|
||||
error:
|
||||
'Unexpected file field. Use "referenceImages" for image uploads',
|
||||
message: "File upload failed",
|
||||
});
|
||||
|
||||
default:
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: 'File upload failed',
|
||||
message: "File upload failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message.includes('Unsupported file type')) {
|
||||
if (error.message.includes("Unsupported file type")) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: 'File validation failed',
|
||||
message: "File validation failed",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Response, NextFunction } from 'express';
|
||||
import { Response, NextFunction } from "express";
|
||||
|
||||
// Validation rules
|
||||
const VALIDATION_RULES = {
|
||||
|
|
@ -20,9 +20,9 @@ const VALIDATION_RULES = {
|
|||
*/
|
||||
export const sanitizeFilename = (filename: string): string => {
|
||||
return filename
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace invalid chars with underscore
|
||||
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
||||
.replace(/^_+|_+$/g, '') // Remove leading/trailing underscores
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_") // Replace invalid chars with underscore
|
||||
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
|
||||
.replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
|
||||
.substring(0, 100); // Limit length
|
||||
};
|
||||
|
||||
|
|
@ -39,17 +39,17 @@ export const validateGenerateRequest = (
|
|||
const errors: string[] = [];
|
||||
|
||||
// Convert string values from multipart form data
|
||||
if (typeof autoEnhance === 'string') {
|
||||
autoEnhance = autoEnhance.toLowerCase() === 'true';
|
||||
if (typeof autoEnhance === "string") {
|
||||
autoEnhance = autoEnhance.toLowerCase() === "true";
|
||||
req.body.autoEnhance = autoEnhance;
|
||||
}
|
||||
|
||||
if (typeof enhancementOptions === 'string') {
|
||||
if (typeof enhancementOptions === "string") {
|
||||
try {
|
||||
enhancementOptions = JSON.parse(enhancementOptions);
|
||||
req.body.enhancementOptions = enhancementOptions;
|
||||
} catch {
|
||||
errors.push('enhancementOptions must be valid JSON');
|
||||
errors.push("enhancementOptions must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,79 +57,111 @@ export const validateGenerateRequest = (
|
|||
|
||||
// Validate prompt
|
||||
if (!prompt) {
|
||||
errors.push('Prompt is required');
|
||||
} else if (typeof prompt !== 'string') {
|
||||
errors.push('Prompt must be a string');
|
||||
errors.push("Prompt is required");
|
||||
} else if (typeof prompt !== "string") {
|
||||
errors.push("Prompt must be a string");
|
||||
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
|
||||
errors.push(`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`);
|
||||
errors.push(
|
||||
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
|
||||
);
|
||||
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
|
||||
errors.push(`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`);
|
||||
errors.push(
|
||||
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
if (!filename) {
|
||||
errors.push('Filename is required');
|
||||
} else if (typeof filename !== 'string') {
|
||||
errors.push('Filename must be a string');
|
||||
errors.push("Filename is required");
|
||||
} else if (typeof filename !== "string") {
|
||||
errors.push("Filename must be a string");
|
||||
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
|
||||
errors.push('Filename cannot be empty');
|
||||
errors.push("Filename cannot be empty");
|
||||
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
|
||||
errors.push(`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`);
|
||||
errors.push(
|
||||
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
|
||||
);
|
||||
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
|
||||
errors.push('Filename can only contain letters, numbers, underscores, and hyphens');
|
||||
errors.push(
|
||||
"Filename can only contain letters, numbers, underscores, and hyphens",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate autoEnhance (optional boolean)
|
||||
if (autoEnhance !== undefined && typeof autoEnhance !== 'boolean') {
|
||||
errors.push('autoEnhance must be a boolean');
|
||||
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
|
||||
errors.push("autoEnhance must be a boolean");
|
||||
}
|
||||
|
||||
// Validate enhancementOptions (optional object)
|
||||
if (enhancementOptions !== undefined) {
|
||||
if (typeof enhancementOptions !== 'object' || Array.isArray(enhancementOptions)) {
|
||||
errors.push('enhancementOptions must be an object');
|
||||
if (
|
||||
typeof enhancementOptions !== "object" ||
|
||||
Array.isArray(enhancementOptions)
|
||||
) {
|
||||
errors.push("enhancementOptions must be an object");
|
||||
} else {
|
||||
const { imageStyle, aspectRatio, mood, lighting, cameraAngle, negativePrompts } =
|
||||
enhancementOptions;
|
||||
const {
|
||||
imageStyle,
|
||||
aspectRatio,
|
||||
mood,
|
||||
lighting,
|
||||
cameraAngle,
|
||||
negativePrompts,
|
||||
} = enhancementOptions;
|
||||
|
||||
if (
|
||||
imageStyle !== undefined &&
|
||||
!['photorealistic', 'illustration', 'minimalist', 'sticker', 'product', 'comic'].includes(
|
||||
imageStyle,
|
||||
)
|
||||
![
|
||||
"photorealistic",
|
||||
"illustration",
|
||||
"minimalist",
|
||||
"sticker",
|
||||
"product",
|
||||
"comic",
|
||||
].includes(imageStyle)
|
||||
) {
|
||||
errors.push('Invalid imageStyle in enhancementOptions');
|
||||
errors.push("Invalid imageStyle in enhancementOptions");
|
||||
}
|
||||
|
||||
if (
|
||||
aspectRatio !== undefined &&
|
||||
!['square', 'portrait', 'landscape', 'wide', 'ultrawide'].includes(aspectRatio)
|
||||
!["square", "portrait", "landscape", "wide", "ultrawide"].includes(
|
||||
aspectRatio,
|
||||
)
|
||||
) {
|
||||
errors.push('Invalid aspectRatio in enhancementOptions');
|
||||
errors.push("Invalid aspectRatio in enhancementOptions");
|
||||
}
|
||||
|
||||
if (mood !== undefined && (typeof mood !== 'string' || mood.length > 100)) {
|
||||
errors.push('mood must be a string with max 100 characters');
|
||||
if (
|
||||
mood !== undefined &&
|
||||
(typeof mood !== "string" || mood.length > 100)
|
||||
) {
|
||||
errors.push("mood must be a string with max 100 characters");
|
||||
}
|
||||
|
||||
if (lighting !== undefined && (typeof lighting !== 'string' || lighting.length > 100)) {
|
||||
errors.push('lighting must be a string with max 100 characters');
|
||||
if (
|
||||
lighting !== undefined &&
|
||||
(typeof lighting !== "string" || lighting.length > 100)
|
||||
) {
|
||||
errors.push("lighting must be a string with max 100 characters");
|
||||
}
|
||||
|
||||
if (
|
||||
cameraAngle !== undefined &&
|
||||
(typeof cameraAngle !== 'string' || cameraAngle.length > 100)
|
||||
(typeof cameraAngle !== "string" || cameraAngle.length > 100)
|
||||
) {
|
||||
errors.push('cameraAngle must be a string with max 100 characters');
|
||||
errors.push("cameraAngle must be a string with max 100 characters");
|
||||
}
|
||||
|
||||
if (negativePrompts !== undefined) {
|
||||
if (!Array.isArray(negativePrompts) || negativePrompts.length > 10) {
|
||||
errors.push('negativePrompts must be an array with max 10 items');
|
||||
errors.push("negativePrompts must be an array with max 10 items");
|
||||
} else {
|
||||
for (const item of negativePrompts) {
|
||||
if (typeof item !== 'string' || item.length > 100) {
|
||||
errors.push('Each negative prompt must be a string with max 100 characters');
|
||||
if (typeof item !== "string" || item.length > 100) {
|
||||
errors.push(
|
||||
"Each negative prompt must be a string with max 100 characters",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -139,19 +171,28 @@ export const validateGenerateRequest = (
|
|||
}
|
||||
|
||||
// Check for XSS attempts in prompt
|
||||
const xssPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i, /<object/i, /<embed/i];
|
||||
const xssPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+\s*=/i,
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
];
|
||||
|
||||
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
|
||||
errors.push('Invalid characters detected in prompt');
|
||||
errors.push("Invalid characters detected in prompt");
|
||||
}
|
||||
|
||||
// Log validation results
|
||||
if (errors.length > 0) {
|
||||
console.log(`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(', ')}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
message: errors.join(', '),
|
||||
error: "Validation failed",
|
||||
message: errors.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +218,11 @@ export const validateGenerateRequest = (
|
|||
/**
|
||||
* Log request details for debugging
|
||||
*/
|
||||
export const logRequestDetails = (req: any, _res: Response, next: NextFunction): void => {
|
||||
export const logRequestDetails = (
|
||||
req: any,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): void => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
|
|
@ -186,14 +231,21 @@ export const logRequestDetails = (req: any, _res: Response, next: NextFunction):
|
|||
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}"`,
|
||||
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
|
||||
);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
|
||||
console.log(`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
|
||||
);
|
||||
if (enhancementOptions) {
|
||||
console.log(`[${timestamp}] [${req.requestId}] Enhancement options:`, enhancementOptions);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Enhancement options:`,
|
||||
enhancementOptions,
|
||||
);
|
||||
}
|
||||
console.log(`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${req.requestId}] Reference files: ${files.length}`,
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
files.forEach((file, index) => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ router.post('/', async (req, res) => {
|
|||
organizationName,
|
||||
projectName,
|
||||
name,
|
||||
expiresInDays,
|
||||
expiresInDays
|
||||
} = req.body;
|
||||
|
||||
// Validation
|
||||
|
|
@ -73,13 +73,11 @@ router.post('/', async (req, res) => {
|
|||
finalOrgId,
|
||||
name,
|
||||
req.apiKey!.id,
|
||||
expiresInDays || 90,
|
||||
expiresInDays || 90
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`,
|
||||
);
|
||||
console.log(`[${new Date().toISOString()}] New API key created by admin: ${result.metadata.id} (${result.metadata.keyType}) - by: ${req.apiKey!.id}`);
|
||||
|
||||
res.status(201).json({
|
||||
apiKey: result.key,
|
||||
|
|
@ -113,7 +111,7 @@ router.get('/', async (req, res) => {
|
|||
const keys = await apiKeyService.listKeys();
|
||||
|
||||
// Don't expose key hashes
|
||||
const safeKeys = keys.map((key) => ({
|
||||
const safeKeys = keys.map(key => ({
|
||||
id: key.id,
|
||||
type: key.keyType,
|
||||
projectId: key.projectId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import express from 'express';
|
||||
import { ApiKeyService } from '../services/ApiKeyService';
|
||||
import express from "express";
|
||||
import { ApiKeyService } from "../services/ApiKeyService";
|
||||
|
||||
const router = express.Router();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
|
@ -10,21 +10,25 @@ const apiKeyService = new ApiKeyService();
|
|||
*
|
||||
* POST /api/bootstrap/initial-key
|
||||
*/
|
||||
router.post('/initial-key', async (req, res) => {
|
||||
router.post("/initial-key", async (req, res) => {
|
||||
try {
|
||||
// Check if any keys already exist
|
||||
const hasKeys = await apiKeyService.hasAnyKeys();
|
||||
|
||||
if (hasKeys) {
|
||||
console.warn(`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`);
|
||||
console.warn(
|
||||
`[${new Date().toISOString()}] Bootstrap attempt when keys already exist`,
|
||||
);
|
||||
return res.status(403).json({
|
||||
error: 'Bootstrap not allowed',
|
||||
message: 'API keys already exist. Use /api/admin/keys to create new keys.',
|
||||
error: "Bootstrap not allowed",
|
||||
message:
|
||||
"API keys already exist. Use /api/admin/keys to create new keys.",
|
||||
});
|
||||
}
|
||||
|
||||
// Create first master key
|
||||
const { key, metadata } = await apiKeyService.createMasterKey('Initial Master Key');
|
||||
const { key, metadata } =
|
||||
await apiKeyService.createMasterKey("Initial Master Key");
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Initial master key created via bootstrap: ${metadata.id}`,
|
||||
|
|
@ -35,13 +39,13 @@ router.post('/initial-key', async (req, res) => {
|
|||
type: metadata.keyType,
|
||||
name: metadata.name,
|
||||
expiresAt: metadata.expiresAt,
|
||||
message: 'IMPORTANT: Save this key securely. You will not see it again!',
|
||||
message: "IMPORTANT: Save this key securely. You will not see it again!",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[${new Date().toISOString()}] Bootstrap error:`, error);
|
||||
res.status(500).json({
|
||||
error: 'Bootstrap failed',
|
||||
message: 'Failed to create initial API key',
|
||||
error: "Bootstrap failed",
|
||||
message: "Failed to create initial API key",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import { Request, Response, Router } from "express";
|
||||
import type { Router as RouterType } from "express";
|
||||
import { PromptEnhancementService } from "../services/promptEnhancement";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import {
|
||||
PromptEnhancementRequest,
|
||||
PromptEnhancementResponse,
|
||||
} from "../types/api";
|
||||
import { body, validationResult } from "express-validator";
|
||||
import { validateApiKey } from "../middleware/auth/validateApiKey";
|
||||
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
|
||||
|
||||
export const enhanceRouter: RouterType = Router();
|
||||
|
||||
let promptEnhancementService: PromptEnhancementService;
|
||||
|
||||
const validateEnhanceRequest = [
|
||||
body("prompt")
|
||||
.notEmpty()
|
||||
.withMessage("Prompt is required")
|
||||
.isLength({ min: 1, max: 5000 })
|
||||
.withMessage("Prompt must be between 1 and 5000 characters")
|
||||
.trim(),
|
||||
|
||||
body("options.imageStyle")
|
||||
.optional()
|
||||
.isIn([
|
||||
"photorealistic",
|
||||
"illustration",
|
||||
"minimalist",
|
||||
"sticker",
|
||||
"product",
|
||||
"comic",
|
||||
])
|
||||
.withMessage("Invalid image style"),
|
||||
|
||||
body("options.aspectRatio")
|
||||
.optional()
|
||||
.isIn(["square", "portrait", "landscape", "wide", "ultrawide"])
|
||||
.withMessage("Invalid aspect ratio"),
|
||||
|
||||
body("options.mood")
|
||||
.optional()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("Mood description too long")
|
||||
.trim(),
|
||||
|
||||
body("options.lighting")
|
||||
.optional()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("Lighting description too long")
|
||||
.trim(),
|
||||
|
||||
body("options.cameraAngle")
|
||||
.optional()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("Camera angle description too long")
|
||||
.trim(),
|
||||
|
||||
body("options.outputFormat")
|
||||
.optional()
|
||||
.isIn(["text", "markdown", "detailed"])
|
||||
.withMessage("Invalid output format"),
|
||||
|
||||
body("options.negativePrompts")
|
||||
.optional()
|
||||
.isArray({ max: 10 })
|
||||
.withMessage("Too many negative prompts (max 10)"),
|
||||
|
||||
body("options.negativePrompts.*")
|
||||
.optional()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage("Negative prompt too long")
|
||||
.trim(),
|
||||
];
|
||||
|
||||
const logEnhanceRequest = (req: Request, _res: Response, next: Function) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const requestId = req.requestId;
|
||||
const { prompt, options } = req.body as PromptEnhancementRequest;
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] POST /api/enhance`);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Prompt length: ${prompt?.length || 0} characters`,
|
||||
);
|
||||
console.log(`[${timestamp}] [${requestId}] Options:`, options || "none");
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
enhanceRouter.post(
|
||||
"/enhance",
|
||||
// Authentication middleware
|
||||
validateApiKey,
|
||||
rateLimitByApiKey,
|
||||
|
||||
validateEnhanceRequest,
|
||||
logEnhanceRequest,
|
||||
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!promptEnhancementService) {
|
||||
const apiKey = process.env["GEMINI_API_KEY"];
|
||||
if (!apiKey) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
originalPrompt: "",
|
||||
error: "Server configuration error: GEMINI_API_KEY not configured",
|
||||
} as PromptEnhancementResponse);
|
||||
}
|
||||
promptEnhancementService = new PromptEnhancementService(apiKey);
|
||||
}
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] [${req.requestId}] Validation failed:`,
|
||||
errors.array(),
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
originalPrompt: req.body.prompt || "",
|
||||
error: `Validation failed: ${errors
|
||||
.array()
|
||||
.map((e) => e.msg)
|
||||
.join(", ")}`,
|
||||
} as PromptEnhancementResponse);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const requestId = req.requestId;
|
||||
const { prompt, options } = req.body as PromptEnhancementRequest;
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] Starting prompt enhancement`);
|
||||
|
||||
try {
|
||||
// Extract orgId and projectId from validated API key
|
||||
const orgId = req.apiKey?.organizationSlug || "unknown";
|
||||
const projectId = req.apiKey?.projectSlug || "unknown";
|
||||
|
||||
const result = await promptEnhancementService.enhancePrompt(
|
||||
prompt,
|
||||
options || {},
|
||||
{
|
||||
orgId,
|
||||
projectId,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] Enhancement completed:`, {
|
||||
success: result.success,
|
||||
detectedLanguage: result.detectedLanguage,
|
||||
appliedTemplate: result.appliedTemplate,
|
||||
enhancementsCount: result.metadata?.enhancements.length || 0,
|
||||
hasError: !!result.error,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const successResponse: PromptEnhancementResponse = {
|
||||
success: true,
|
||||
originalPrompt: result.originalPrompt,
|
||||
enhancedPrompt: result.enhancedPrompt!,
|
||||
...(result.detectedLanguage && {
|
||||
detectedLanguage: result.detectedLanguage,
|
||||
}),
|
||||
...(result.appliedTemplate && {
|
||||
appliedTemplate: result.appliedTemplate,
|
||||
}),
|
||||
...(result.metadata && { metadata: result.metadata }),
|
||||
};
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] Sending success response`);
|
||||
return res.status(200).json(successResponse);
|
||||
} else {
|
||||
const errorResponse: PromptEnhancementResponse = {
|
||||
success: false,
|
||||
originalPrompt: result.originalPrompt,
|
||||
error: result.error || "Unknown error occurred",
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
|
||||
);
|
||||
return res.status(500).json(errorResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${timestamp}] [${requestId}] Unhandled error in enhance endpoint:`,
|
||||
error,
|
||||
);
|
||||
|
||||
const errorResponse: PromptEnhancementResponse = {
|
||||
success: false,
|
||||
originalPrompt: prompt,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
};
|
||||
|
||||
return res.status(500).json(errorResponse);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import { Response, Router } from "express";
|
||||
import type { Router as RouterType } from "express";
|
||||
import { ImageGenService } from "../services/ImageGenService";
|
||||
import {
|
||||
uploadReferenceImages,
|
||||
handleUploadErrors,
|
||||
} from "../middleware/upload";
|
||||
import {
|
||||
validateGenerateRequest,
|
||||
logRequestDetails,
|
||||
} from "../middleware/validation";
|
||||
import {
|
||||
autoEnhancePrompt,
|
||||
logEnhancementResult,
|
||||
} from "../middleware/promptEnhancement";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { validateApiKey } from "../middleware/auth/validateApiKey";
|
||||
import { requireProjectKey } from "../middleware/auth/requireProjectKey";
|
||||
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
|
||||
import { GenerateImageResponse } from "../types/api";
|
||||
// Create router
|
||||
export const generateRouter: RouterType = Router();
|
||||
|
||||
// Initialize ImageGenService (will be created in the route handler to avoid circular dependency)
|
||||
let imageGenService: ImageGenService;
|
||||
|
||||
/**
|
||||
* POST /api/generate - Generate image from text prompt with optional reference images
|
||||
*/
|
||||
generateRouter.post(
|
||||
"/generate",
|
||||
// Authentication middleware
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
|
||||
// File upload middleware
|
||||
uploadReferenceImages,
|
||||
handleUploadErrors,
|
||||
|
||||
// Validation middleware
|
||||
logRequestDetails,
|
||||
validateGenerateRequest,
|
||||
|
||||
// Auto-enhancement middleware (optional)
|
||||
autoEnhancePrompt,
|
||||
logEnhancementResult,
|
||||
|
||||
// Main handler
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
// Initialize service if not already done
|
||||
if (!imageGenService) {
|
||||
const apiKey = process.env["GEMINI_API_KEY"];
|
||||
if (!apiKey) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Server configuration error",
|
||||
error: "GEMINI_API_KEY not configured",
|
||||
} as GenerateImageResponse);
|
||||
}
|
||||
imageGenService = new ImageGenService(apiKey);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const requestId = req.requestId;
|
||||
const { prompt, filename, aspectRatio, meta } = req.body;
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
|
||||
// Extract org/project slugs from validated API key
|
||||
const orgId = req.apiKey?.organizationSlug || undefined;
|
||||
const projectId = req.apiKey?.projectSlug!; // Guaranteed by requireProjectKey middleware
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Starting image generation process for org:${orgId}, project:${projectId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate reference images if provided
|
||||
if (files.length > 0) {
|
||||
const validation = ImageGenService.validateReferenceImages(files);
|
||||
if (!validation.valid) {
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Reference image validation failed: ${validation.error}`,
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Reference image validation failed",
|
||||
error: validation.error,
|
||||
} as GenerateImageResponse);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Reference images validation passed`,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert files to reference images
|
||||
const referenceImages =
|
||||
files.length > 0
|
||||
? ImageGenService.convertFilesToReferenceImages(files)
|
||||
: undefined;
|
||||
|
||||
// Generate the image
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Calling ImageGenService.generateImage()`,
|
||||
);
|
||||
|
||||
const result = await imageGenService.generateImage({
|
||||
prompt,
|
||||
filename,
|
||||
...(aspectRatio && { aspectRatio }),
|
||||
orgId,
|
||||
projectId,
|
||||
...(referenceImages && { referenceImages }),
|
||||
...(meta && { meta }),
|
||||
});
|
||||
|
||||
// Log the result
|
||||
console.log(`[${timestamp}] [${requestId}] Image generation completed:`, {
|
||||
success: result.success,
|
||||
model: result.model,
|
||||
filename: result.filename,
|
||||
hasError: !!result.error,
|
||||
});
|
||||
|
||||
// Send response
|
||||
if (result.success) {
|
||||
const successResponse: GenerateImageResponse = {
|
||||
success: true,
|
||||
message: "Image generated successfully",
|
||||
data: {
|
||||
filename: result.filename!,
|
||||
filepath: result.filepath!,
|
||||
...(result.description && { description: result.description }),
|
||||
model: result.model,
|
||||
generatedAt: timestamp,
|
||||
...(req.enhancedPrompt && {
|
||||
promptEnhancement: {
|
||||
originalPrompt: req.originalPrompt,
|
||||
enhancedPrompt: req.enhancedPrompt,
|
||||
detectedLanguage: req.enhancementMetadata?.detectedLanguage,
|
||||
appliedTemplate: req.enhancementMetadata?.appliedTemplate,
|
||||
enhancements: req.enhancementMetadata?.enhancements || [],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] Sending success response`);
|
||||
return res.status(200).json(successResponse);
|
||||
} else {
|
||||
const errorResponse: GenerateImageResponse = {
|
||||
success: false,
|
||||
message: "Image generation failed",
|
||||
error: result.error || "Unknown error occurred",
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
|
||||
);
|
||||
return res.status(500).json(errorResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${timestamp}] [${requestId}] Unhandled error in generate endpoint:`,
|
||||
error,
|
||||
);
|
||||
|
||||
const errorResponse: GenerateImageResponse = {
|
||||
success: false,
|
||||
message: "Image generation failed",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
};
|
||||
|
||||
return res.status(500).json(errorResponse);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { StorageFactory } from '../services/StorageFactory';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { Router, Request, Response } from "express";
|
||||
import { StorageFactory } from "../services/StorageFactory";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
|
||||
export const imagesRouter = Router();
|
||||
|
||||
|
|
@ -9,15 +9,15 @@ export const imagesRouter = Router();
|
|||
* Serves images via presigned URLs (redirect approach)
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/images/:orgId/:projectId/:category/:filename',
|
||||
"/images/:orgId/:projectId/:category/:filename",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { orgId, projectId, category, filename } = req.params;
|
||||
|
||||
// Validate category
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
if (!["uploads", "generated", "references"].includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid category',
|
||||
message: "Invalid category",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -28,36 +28,36 @@ imagesRouter.get(
|
|||
const exists = await storageService.fileExists(
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
category as "uploads" | "generated" | "references",
|
||||
filename,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found',
|
||||
message: "File not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Determine content type from filename
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
const ext = filename.toLowerCase().split(".").pop();
|
||||
const contentType =
|
||||
{
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
}[ext || ''] || 'application/octet-stream';
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
}[ext || ""] || "application/octet-stream";
|
||||
|
||||
// Set headers for optimal caching and performance
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); // 24 hours + immutable
|
||||
res.setHeader('ETag', `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400, immutable"); // 24 hours + immutable
|
||||
res.setHeader("ETag", `"${orgId}-${projectId}-${filename}"`); // Simple ETag
|
||||
|
||||
// Handle conditional requests (304 Not Modified)
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
const ifNoneMatch = req.headers["if-none-match"];
|
||||
if (ifNoneMatch === `"${orgId}-${projectId}-${filename}"`) {
|
||||
return res.status(304).end(); // Not Modified
|
||||
}
|
||||
|
|
@ -66,17 +66,17 @@ imagesRouter.get(
|
|||
const fileStream = await storageService.streamFile(
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
category as "uploads" | "generated" | "references",
|
||||
filename,
|
||||
);
|
||||
|
||||
// Handle stream errors
|
||||
fileStream.on('error', (streamError) => {
|
||||
console.error('Stream error:', streamError);
|
||||
fileStream.on("error", (streamError) => {
|
||||
console.error("Stream error:", streamError);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Error streaming file',
|
||||
message: "Error streaming file",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -84,10 +84,10 @@ imagesRouter.get(
|
|||
// Stream the file without loading into memory
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream file:', error);
|
||||
console.error("Failed to stream file:", error);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found',
|
||||
message: "File not found",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
|
@ -98,15 +98,15 @@ imagesRouter.get(
|
|||
* Returns a presigned URL instead of redirecting
|
||||
*/
|
||||
imagesRouter.get(
|
||||
'/images/url/:orgId/:projectId/:category/:filename',
|
||||
"/images/url/:orgId/:projectId/:category/:filename",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { orgId, projectId, category, filename } = req.params;
|
||||
const { expiry = '3600' } = req.query; // Default 1 hour
|
||||
const { expiry = "3600" } = req.query; // Default 1 hour
|
||||
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
if (!["uploads", "generated", "references"].includes(category)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid category',
|
||||
message: "Invalid category",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ imagesRouter.get(
|
|||
const presignedUrl = await storageService.getPresignedDownloadUrl(
|
||||
orgId,
|
||||
projectId,
|
||||
category as 'uploads' | 'generated' | 'references',
|
||||
category as "uploads" | "generated" | "references",
|
||||
filename,
|
||||
parseInt(expiry as string, 10),
|
||||
);
|
||||
|
|
@ -127,10 +127,10 @@ imagesRouter.get(
|
|||
expiresIn: parseInt(expiry as string, 10),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned URL:', error);
|
||||
console.error("Failed to generate presigned URL:", error);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'File not found or access denied',
|
||||
message: "File not found or access denied",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { ImageGenService } from '../services/ImageGenService';
|
||||
import { validateTextToImageRequest, logTextToImageRequest } from '../middleware/jsonValidation';
|
||||
import { autoEnhancePrompt, logEnhancementResult } from '../middleware/promptEnhancement';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { validateApiKey } from '../middleware/auth/validateApiKey';
|
||||
import { requireProjectKey } from '../middleware/auth/requireProjectKey';
|
||||
import { rateLimitByApiKey } from '../middleware/auth/rateLimiter';
|
||||
import { GenerateImageResponse } from '../types/api';
|
||||
import { Response, Router } from "express";
|
||||
import type { Router as RouterType } from "express";
|
||||
import { ImageGenService } from "../services/ImageGenService";
|
||||
import {
|
||||
validateTextToImageRequest,
|
||||
logTextToImageRequest,
|
||||
} from "../middleware/jsonValidation";
|
||||
import {
|
||||
autoEnhancePrompt,
|
||||
logEnhancementResult,
|
||||
} from "../middleware/promptEnhancement";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { validateApiKey } from "../middleware/auth/validateApiKey";
|
||||
import { requireProjectKey } from "../middleware/auth/requireProjectKey";
|
||||
import { rateLimitByApiKey } from "../middleware/auth/rateLimiter";
|
||||
import { GenerateImageResponse } from "../types/api";
|
||||
|
||||
export const textToImageRouter: RouterType = Router();
|
||||
|
||||
|
|
@ -17,7 +23,7 @@ let imageGenService: ImageGenService;
|
|||
* POST /api/text-to-image - Generate image from text prompt only (JSON)
|
||||
*/
|
||||
textToImageRouter.post(
|
||||
'/text-to-image',
|
||||
"/text-to-image",
|
||||
// Authentication middleware
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
|
|
@ -35,12 +41,12 @@ textToImageRouter.post(
|
|||
asyncHandler(async (req: any, res: Response) => {
|
||||
// Initialize service if not already done
|
||||
if (!imageGenService) {
|
||||
const apiKey = process.env['GEMINI_API_KEY'];
|
||||
const apiKey = process.env["GEMINI_API_KEY"];
|
||||
if (!apiKey) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server configuration error',
|
||||
error: 'GEMINI_API_KEY not configured',
|
||||
message: "Server configuration error",
|
||||
error: "GEMINI_API_KEY not configured",
|
||||
} as GenerateImageResponse);
|
||||
}
|
||||
imageGenService = new ImageGenService(apiKey);
|
||||
|
|
@ -74,18 +80,21 @@ textToImageRouter.post(
|
|||
});
|
||||
|
||||
// Log the result
|
||||
console.log(`[${timestamp}] [${requestId}] Text-to-image generation completed:`, {
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Text-to-image generation completed:`,
|
||||
{
|
||||
success: result.success,
|
||||
model: result.model,
|
||||
filename: result.filename,
|
||||
hasError: !!result.error,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Send response
|
||||
if (result.success) {
|
||||
const successResponse: GenerateImageResponse = {
|
||||
success: true,
|
||||
message: 'Image generated successfully',
|
||||
message: "Image generated successfully",
|
||||
data: {
|
||||
filename: result.filename!,
|
||||
filepath: result.filepath!,
|
||||
|
|
@ -111,11 +120,13 @@ textToImageRouter.post(
|
|||
} else {
|
||||
const errorResponse: GenerateImageResponse = {
|
||||
success: false,
|
||||
message: 'Image generation failed',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
message: "Image generation failed",
|
||||
error: result.error || "Unknown error occurred",
|
||||
};
|
||||
|
||||
console.log(`[${timestamp}] [${requestId}] Sending error response: ${result.error}`);
|
||||
console.log(
|
||||
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
|
||||
);
|
||||
return res.status(500).json(errorResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -126,8 +137,9 @@ textToImageRouter.post(
|
|||
|
||||
const errorResponse: GenerateImageResponse = {
|
||||
success: false,
|
||||
message: 'Image generation failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
message: "Image generation failed",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
};
|
||||
|
||||
return res.status(500).json(errorResponse);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import fs from 'fs';
|
|||
|
||||
// Ensure required directories exist
|
||||
const ensureDirectoriesExist = () => {
|
||||
const directories = [appConfig.resultsDir, appConfig.uploadsDir, './logs'];
|
||||
const directories = [
|
||||
appConfig.resultsDir,
|
||||
appConfig.uploadsDir,
|
||||
'./logs'
|
||||
];
|
||||
|
||||
directories.forEach((dir) => {
|
||||
directories.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`[${new Date().toISOString()}] Created directory: ${dir}`);
|
||||
|
|
@ -28,10 +32,7 @@ const validateEnvironment = () => {
|
|||
}
|
||||
|
||||
log('INFO', 'Environment validation passed');
|
||||
log(
|
||||
'INFO',
|
||||
`Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`,
|
||||
);
|
||||
log('INFO', `Server configuration: port=${appConfig.port}, maxFileSize=${appConfig.maxFileSize}bytes, maxFiles=${appConfig.maxFiles}`);
|
||||
};
|
||||
|
||||
// Graceful shutdown
|
||||
|
|
@ -75,6 +76,7 @@ const startServer = async () => {
|
|||
|
||||
// Setup graceful shutdown
|
||||
setupGracefulShutdown(server);
|
||||
|
||||
} catch (error) {
|
||||
log('ERROR', `Failed to start server: ${error instanceof Error ? error.message : error}`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import crypto from 'crypto';
|
||||
import { db } from '../db';
|
||||
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import crypto from "crypto";
|
||||
import { db } from "../db";
|
||||
import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from "@banatie/database";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
|
||||
// Extended API key type with slugs for storage paths
|
||||
export interface ApiKeyWithSlugs extends ApiKey {
|
||||
|
|
@ -19,12 +19,12 @@ export class ApiKeyService {
|
|||
keyHash: string;
|
||||
keyPrefix: string;
|
||||
} {
|
||||
const secret = crypto.randomBytes(32).toString('hex'); // 64 chars
|
||||
const keyPrefix = 'bnt_';
|
||||
const secret = crypto.randomBytes(32).toString("hex"); // 64 chars
|
||||
const keyPrefix = "bnt_";
|
||||
const fullKey = keyPrefix + secret;
|
||||
|
||||
// Hash for storage (SHA-256)
|
||||
const keyHash = crypto.createHash('sha256').update(fullKey).digest('hex');
|
||||
const keyHash = crypto.createHash("sha256").update(fullKey).digest("hex");
|
||||
|
||||
return { fullKey, keyHash, keyPrefix };
|
||||
}
|
||||
|
|
@ -43,10 +43,10 @@ export class ApiKeyService {
|
|||
.values({
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
keyType: 'master',
|
||||
keyType: "master",
|
||||
projectId: null,
|
||||
scopes: ['*'], // Full access
|
||||
name: name || 'Master Key',
|
||||
scopes: ["*"], // Full access
|
||||
name: name || "Master Key",
|
||||
expiresAt: null, // Never expires
|
||||
createdBy: createdBy || null,
|
||||
})
|
||||
|
|
@ -79,10 +79,10 @@ export class ApiKeyService {
|
|||
.values({
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
keyType: 'project',
|
||||
keyType: "project",
|
||||
projectId,
|
||||
organizationId: organizationId || null,
|
||||
scopes: ['generate', 'read'],
|
||||
scopes: ["generate", "read"],
|
||||
name: name || `Project Key - ${projectId}`,
|
||||
expiresAt,
|
||||
createdBy: createdBy || null,
|
||||
|
|
@ -102,12 +102,15 @@ export class ApiKeyService {
|
|||
* Returns API key with organization and project slugs for storage paths
|
||||
*/
|
||||
async validateKey(providedKey: string): Promise<ApiKeyWithSlugs | null> {
|
||||
if (!providedKey || !providedKey.startsWith('bnt_')) {
|
||||
if (!providedKey || !providedKey.startsWith("bnt_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the provided key
|
||||
const keyHash = crypto.createHash('sha256').update(providedKey).digest('hex');
|
||||
const keyHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(providedKey)
|
||||
.digest("hex");
|
||||
|
||||
// Find in database with left joins to get slugs
|
||||
const [result] = await db
|
||||
|
|
@ -157,7 +160,10 @@ export class ApiKeyService {
|
|||
.where(eq(apiKeys.id, result.id))
|
||||
.execute()
|
||||
.catch((err) =>
|
||||
console.error(`[${new Date().toISOString()}] Failed to update lastUsedAt:`, err),
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] Failed to update lastUsedAt:`,
|
||||
err,
|
||||
),
|
||||
);
|
||||
|
||||
return result as ApiKeyWithSlugs;
|
||||
|
|
@ -201,7 +207,11 @@ export class ApiKeyService {
|
|||
* Get or create organization by slug
|
||||
* If organization doesn't exist, create it with provided name (or use slug as name)
|
||||
*/
|
||||
async getOrCreateOrganization(slug: string, name?: string, email?: string): Promise<string> {
|
||||
async getOrCreateOrganization(
|
||||
slug: string,
|
||||
name?: string,
|
||||
email?: string,
|
||||
): Promise<string> {
|
||||
// Try to find existing organization
|
||||
const [existing] = await db
|
||||
.select({ id: organizations.id })
|
||||
|
|
@ -223,7 +233,9 @@ export class ApiKeyService {
|
|||
})
|
||||
.returning({ id: organizations.id });
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`);
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`,
|
||||
);
|
||||
|
||||
return newOrg!.id;
|
||||
}
|
||||
|
|
@ -232,12 +244,21 @@ export class ApiKeyService {
|
|||
* Get or create project by slug within an organization
|
||||
* If project doesn't exist, create it with provided name (or use slug as name)
|
||||
*/
|
||||
async getOrCreateProject(organizationId: string, slug: string, name?: string): Promise<string> {
|
||||
async getOrCreateProject(
|
||||
organizationId: string,
|
||||
slug: string,
|
||||
name?: string,
|
||||
): Promise<string> {
|
||||
// Try to find existing project
|
||||
const [existing] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.organizationId, organizationId), eq(projects.slug, slug)))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.organizationId, organizationId),
|
||||
eq(projects.slug, slug),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,23 @@
|
|||
import { GoogleGenAI } from '@google/genai';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mime = require('mime') as any;
|
||||
const mime = require("mime") as any;
|
||||
import {
|
||||
ImageGenerationOptions,
|
||||
ImageGenerationResult,
|
||||
ReferenceImage,
|
||||
GeneratedImageData,
|
||||
GeminiParams,
|
||||
} from '../types/api';
|
||||
import { StorageFactory } from './StorageFactory';
|
||||
import { TTILogger, TTILogEntry } from './TTILogger';
|
||||
import { NetworkErrorDetector } from '../utils/NetworkErrorDetector';
|
||||
} from "../types/api";
|
||||
import { StorageFactory } from "./StorageFactory";
|
||||
import { TTILogger, TTILogEntry } from "./TTILogger";
|
||||
|
||||
export class ImageGenService {
|
||||
private ai: GoogleGenAI;
|
||||
private primaryModel = 'gemini-2.5-flash-image';
|
||||
private primaryModel = "gemini-2.5-flash-image";
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('Gemini API key is required');
|
||||
throw new Error("Gemini API key is required");
|
||||
}
|
||||
this.ai = new GoogleGenAI({ apiKey });
|
||||
}
|
||||
|
|
@ -27,13 +26,16 @@ export class ImageGenService {
|
|||
* Generate an image from text prompt with optional reference images
|
||||
* This method separates image generation from storage for clear error handling
|
||||
*/
|
||||
async generateImage(options: ImageGenerationOptions): Promise<ImageGenerationResult> {
|
||||
async generateImage(
|
||||
options: ImageGenerationOptions,
|
||||
): Promise<ImageGenerationResult> {
|
||||
const { prompt, filename, referenceImages, aspectRatio, orgId, projectId, meta } = options;
|
||||
|
||||
// Use default values if not provided
|
||||
const finalOrgId = orgId || process.env['DEFAULT_ORG_ID'] || 'default';
|
||||
const finalProjectId = projectId || process.env['DEFAULT_PROJECT_ID'] || 'main';
|
||||
const finalAspectRatio = aspectRatio || '1:1'; // Default to square
|
||||
const finalOrgId = orgId || process.env["DEFAULT_ORG_ID"] || "default";
|
||||
const finalProjectId =
|
||||
projectId || process.env["DEFAULT_PROJECT_ID"] || "main";
|
||||
const finalAspectRatio = aspectRatio || "1:1"; // Default to square
|
||||
|
||||
// Step 1: Generate image from Gemini AI
|
||||
let generatedData: GeneratedImageData;
|
||||
|
|
@ -54,8 +56,9 @@ export class ImageGenService {
|
|||
return {
|
||||
success: false,
|
||||
model: this.primaryModel,
|
||||
error: error instanceof Error ? error.message : 'Image generation failed',
|
||||
errorType: 'generation',
|
||||
error:
|
||||
error instanceof Error ? error.message : "Image generation failed",
|
||||
errorType: "generation",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +69,7 @@ export class ImageGenService {
|
|||
const uploadResult = await storageService.uploadFile(
|
||||
finalOrgId,
|
||||
finalProjectId,
|
||||
'generated',
|
||||
"generated",
|
||||
finalFilename,
|
||||
generatedData.buffer,
|
||||
generatedData.mimeType,
|
||||
|
|
@ -90,8 +93,8 @@ export class ImageGenService {
|
|||
success: false,
|
||||
model: this.primaryModel,
|
||||
geminiParams,
|
||||
error: `Image generated successfully but storage failed: ${uploadResult.error || 'Unknown storage error'}`,
|
||||
errorType: 'storage',
|
||||
error: `Image generated successfully but storage failed: ${uploadResult.error || "Unknown storage error"}`,
|
||||
errorType: "storage",
|
||||
generatedImageData: generatedData,
|
||||
...(generatedData.description && {
|
||||
description: generatedData.description,
|
||||
|
|
@ -104,8 +107,8 @@ export class ImageGenService {
|
|||
success: false,
|
||||
model: this.primaryModel,
|
||||
geminiParams,
|
||||
error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : 'Unknown storage error'}`,
|
||||
errorType: 'storage',
|
||||
error: `Image generated successfully but storage failed: ${error instanceof Error ? error.message : "Unknown storage error"}`,
|
||||
errorType: "storage",
|
||||
generatedImageData: generatedData,
|
||||
...(generatedData.description && {
|
||||
description: generatedData.description,
|
||||
|
|
@ -137,7 +140,7 @@ export class ImageGenService {
|
|||
contentParts.push({
|
||||
inlineData: {
|
||||
mimeType: refImage.mimetype,
|
||||
data: refImage.buffer.toString('base64'),
|
||||
data: refImage.buffer.toString("base64"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -152,13 +155,13 @@ export class ImageGenService {
|
|||
// These exact objects will be passed to both SDK and logger
|
||||
const contents = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
role: "user" as const,
|
||||
parts: contentParts,
|
||||
},
|
||||
];
|
||||
|
||||
const config = {
|
||||
responseModalities: ['IMAGE', 'TEXT'],
|
||||
responseModalities: ["IMAGE", "TEXT"],
|
||||
imageConfig: {
|
||||
aspectRatio,
|
||||
},
|
||||
|
|
@ -169,7 +172,7 @@ export class ImageGenService {
|
|||
model: this.primaryModel,
|
||||
config,
|
||||
contentsStructure: {
|
||||
role: 'user',
|
||||
role: "user",
|
||||
partsCount: contentParts.length,
|
||||
hasReferenceImages: !!(referenceImages && referenceImages.length > 0),
|
||||
},
|
||||
|
|
@ -206,8 +209,12 @@ export class ImageGenService {
|
|||
});
|
||||
|
||||
// Parse response
|
||||
if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) {
|
||||
throw new Error('No response received from Gemini AI');
|
||||
if (
|
||||
!response.candidates ||
|
||||
!response.candidates[0] ||
|
||||
!response.candidates[0].content
|
||||
) {
|
||||
throw new Error("No response received from Gemini AI");
|
||||
}
|
||||
|
||||
const content = response.candidates[0].content;
|
||||
|
|
@ -217,8 +224,8 @@ export class ImageGenService {
|
|||
// Extract image data and description from response
|
||||
for (const part of content.parts || []) {
|
||||
if (part.inlineData) {
|
||||
const buffer = Buffer.from(part.inlineData.data || '', 'base64');
|
||||
const mimeType = part.inlineData.mimeType || 'image/png';
|
||||
const buffer = Buffer.from(part.inlineData.data || "", "base64");
|
||||
const mimeType = part.inlineData.mimeType || "image/png";
|
||||
imageData = { buffer, mimeType };
|
||||
} else if (part.text) {
|
||||
generatedDescription = part.text;
|
||||
|
|
@ -226,10 +233,10 @@ export class ImageGenService {
|
|||
}
|
||||
|
||||
if (!imageData) {
|
||||
throw new Error('No image data received from Gemini AI');
|
||||
throw new Error("No image data received from Gemini AI");
|
||||
}
|
||||
|
||||
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
|
||||
const fileExtension = mime.getExtension(imageData.mimeType) || "png";
|
||||
|
||||
const generatedData: GeneratedImageData = {
|
||||
buffer: imageData.buffer,
|
||||
|
|
@ -243,18 +250,11 @@ export class ImageGenService {
|
|||
geminiParams,
|
||||
};
|
||||
} catch (error) {
|
||||
// Enhanced error detection with network diagnostics
|
||||
// Re-throw with clear error message
|
||||
if (error instanceof Error) {
|
||||
// Classify the error and check for network issues (only on failure)
|
||||
const errorAnalysis = await NetworkErrorDetector.classifyError(error, 'Gemini API');
|
||||
|
||||
// Log the detailed error for debugging
|
||||
console.error(`[ImageGenService] ${NetworkErrorDetector.formatErrorForLogging(errorAnalysis)}`);
|
||||
|
||||
// Throw user-friendly error message
|
||||
throw new Error(errorAnalysis.userMessage);
|
||||
throw new Error(`Gemini AI generation failed: ${error.message}`);
|
||||
}
|
||||
throw new Error('Gemini AI generation failed: Unknown error');
|
||||
throw new Error("Gemini AI generation failed: Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,10 +263,10 @@ export class ImageGenService {
|
|||
error?: string;
|
||||
} {
|
||||
if (files.length > 3) {
|
||||
return { valid: false, error: 'Maximum 3 reference images allowed' };
|
||||
return { valid: false, error: "Maximum 3 reference images allowed" };
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
||||
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
for (const file of files) {
|
||||
|
|
@ -288,7 +288,9 @@ export class ImageGenService {
|
|||
return { valid: true };
|
||||
}
|
||||
|
||||
static convertFilesToReferenceImages(files: Express.Multer.File[]): ReferenceImage[] {
|
||||
static convertFilesToReferenceImages(
|
||||
files: Express.Multer.File[],
|
||||
): ReferenceImage[] {
|
||||
return files.map((file) => ({
|
||||
buffer: file.buffer,
|
||||
mimetype: file.mimetype,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Client as MinioClient } from 'minio';
|
||||
import { StorageService, FileMetadata, UploadResult } from './StorageService';
|
||||
import { Client as MinioClient } from "minio";
|
||||
import { StorageService, FileMetadata, UploadResult } from "./StorageService";
|
||||
|
||||
export class MinioStorageService implements StorageService {
|
||||
private client: MinioClient;
|
||||
|
|
@ -11,12 +11,12 @@ export class MinioStorageService implements StorageService {
|
|||
accessKey: string,
|
||||
secretKey: string,
|
||||
useSSL: boolean = false,
|
||||
bucketName: string = 'banatie',
|
||||
bucketName: string = "banatie",
|
||||
publicUrl?: string,
|
||||
) {
|
||||
// Parse endpoint to separate hostname and port
|
||||
const cleanEndpoint = endpoint.replace(/^https?:\/\//, '');
|
||||
const [hostname, portStr] = cleanEndpoint.split(':');
|
||||
const cleanEndpoint = endpoint.replace(/^https?:\/\//, "");
|
||||
const [hostname, portStr] = cleanEndpoint.split(":");
|
||||
const port = portStr ? parseInt(portStr, 10) : useSSL ? 443 : 9000;
|
||||
|
||||
if (!hostname) {
|
||||
|
|
@ -31,13 +31,13 @@ export class MinioStorageService implements StorageService {
|
|||
secretKey,
|
||||
});
|
||||
this.bucketName = bucketName;
|
||||
this.publicUrl = publicUrl || `${useSSL ? 'https' : 'http'}://${endpoint}`;
|
||||
this.publicUrl = publicUrl || `${useSSL ? "https" : "http"}://${endpoint}`;
|
||||
}
|
||||
|
||||
private getFilePath(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): string {
|
||||
// Simplified path without date folder for now
|
||||
|
|
@ -50,9 +50,11 @@ export class MinioStorageService implements StorageService {
|
|||
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const ext = sanitized.includes('.') ? sanitized.substring(sanitized.lastIndexOf('.')) : '';
|
||||
const name = sanitized.includes('.')
|
||||
? sanitized.substring(0, sanitized.lastIndexOf('.'))
|
||||
const ext = sanitized.includes(".")
|
||||
? sanitized.substring(sanitized.lastIndexOf("."))
|
||||
: "";
|
||||
const name = sanitized.includes(".")
|
||||
? sanitized.substring(0, sanitized.lastIndexOf("."))
|
||||
: sanitized;
|
||||
|
||||
return `${name}-${timestamp}-${random}${ext}`;
|
||||
|
|
@ -61,9 +63,9 @@ export class MinioStorageService implements StorageService {
|
|||
private sanitizeFilename(filename: string): string {
|
||||
// Remove dangerous characters and path traversal attempts
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove dangerous chars
|
||||
.replace(/\.\./g, '') // Remove path traversal
|
||||
.replace(/^\.+/, '') // Remove leading dots
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "") // Remove dangerous chars
|
||||
.replace(/\.\./g, "") // Remove path traversal
|
||||
.replace(/^\.+/, "") // Remove leading dots
|
||||
.trim()
|
||||
.substring(0, 255); // Limit length
|
||||
}
|
||||
|
|
@ -77,42 +79,54 @@ export class MinioStorageService implements StorageService {
|
|||
// Validate orgId
|
||||
if (!orgId || !/^[a-zA-Z0-9_-]+$/.test(orgId) || orgId.length > 50) {
|
||||
throw new Error(
|
||||
'Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||
"Invalid organization ID: must be alphanumeric with dashes/underscores, max 50 chars",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate projectId
|
||||
if (!projectId || !/^[a-zA-Z0-9_-]+$/.test(projectId) || projectId.length > 50) {
|
||||
if (
|
||||
!projectId ||
|
||||
!/^[a-zA-Z0-9_-]+$/.test(projectId) ||
|
||||
projectId.length > 50
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars',
|
||||
"Invalid project ID: must be alphanumeric with dashes/underscores, max 50 chars",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate category
|
||||
if (!['uploads', 'generated', 'references'].includes(category)) {
|
||||
throw new Error('Invalid category: must be uploads, generated, or references');
|
||||
if (!["uploads", "generated", "references"].includes(category)) {
|
||||
throw new Error(
|
||||
"Invalid category: must be uploads, generated, or references",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
if (!filename || filename.length === 0 || filename.length > 255) {
|
||||
throw new Error('Invalid filename: must be 1-255 characters');
|
||||
throw new Error("Invalid filename: must be 1-255 characters");
|
||||
}
|
||||
|
||||
// Check for path traversal and dangerous patterns
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw new Error('Invalid characters in filename: path traversal not allowed');
|
||||
if (
|
||||
filename.includes("..") ||
|
||||
filename.includes("/") ||
|
||||
filename.includes("\\")
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid characters in filename: path traversal not allowed",
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent null bytes and control characters
|
||||
if (/[\x00-\x1f]/.test(filename)) {
|
||||
throw new Error('Invalid filename: control characters not allowed');
|
||||
throw new Error("Invalid filename: control characters not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
async createBucket(): Promise<void> {
|
||||
const exists = await this.client.bucketExists(this.bucketName);
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucketName, 'us-east-1');
|
||||
await this.client.makeBucket(this.bucketName, "us-east-1");
|
||||
console.log(`Created bucket: ${this.bucketName}`);
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +141,7 @@ export class MinioStorageService implements StorageService {
|
|||
async uploadFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
|
|
@ -136,11 +150,11 @@ export class MinioStorageService implements StorageService {
|
|||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Buffer cannot be empty');
|
||||
throw new Error("Buffer cannot be empty");
|
||||
}
|
||||
|
||||
if (!contentType || contentType.trim().length === 0) {
|
||||
throw new Error('Content type is required');
|
||||
throw new Error("Content type is required");
|
||||
}
|
||||
|
||||
// Ensure bucket exists
|
||||
|
|
@ -148,15 +162,20 @@ export class MinioStorageService implements StorageService {
|
|||
|
||||
// Generate unique filename to avoid conflicts
|
||||
const uniqueFilename = this.generateUniqueFilename(filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, uniqueFilename);
|
||||
const filePath = this.getFilePath(
|
||||
orgId,
|
||||
projectId,
|
||||
category,
|
||||
uniqueFilename,
|
||||
);
|
||||
|
||||
const metadata = {
|
||||
'Content-Type': contentType,
|
||||
'X-Amz-Meta-Original-Name': filename,
|
||||
'X-Amz-Meta-Category': category,
|
||||
'X-Amz-Meta-Project': projectId,
|
||||
'X-Amz-Meta-Organization': orgId,
|
||||
'X-Amz-Meta-Upload-Time': new Date().toISOString(),
|
||||
"Content-Type": contentType,
|
||||
"X-Amz-Meta-Original-Name": filename,
|
||||
"X-Amz-Meta-Category": category,
|
||||
"X-Amz-Meta-Project": projectId,
|
||||
"X-Amz-Meta-Organization": orgId,
|
||||
"X-Amz-Meta-Upload-Time": new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`Uploading file to: ${this.bucketName}/${filePath}`);
|
||||
|
|
@ -186,7 +205,7 @@ export class MinioStorageService implements StorageService {
|
|||
async downloadFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<Buffer> {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
|
@ -196,18 +215,18 @@ export class MinioStorageService implements StorageService {
|
|||
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
stream.on("data", (chunk) => chunks.push(chunk));
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async streamFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<import('stream').Readable> {
|
||||
): Promise<import("stream").Readable> {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
|
||||
|
|
@ -218,7 +237,7 @@ export class MinioStorageService implements StorageService {
|
|||
async deleteFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
|
@ -229,19 +248,19 @@ export class MinioStorageService implements StorageService {
|
|||
getPublicUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): string {
|
||||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
// Production-ready: Return API URL for presigned URL access
|
||||
const apiBaseUrl = process.env['API_BASE_URL'] || 'http://localhost:3000';
|
||||
const apiBaseUrl = process.env["API_BASE_URL"] || "http://localhost:3000";
|
||||
return `${apiBaseUrl}/api/images/${orgId}/${projectId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
async getPresignedUploadUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
contentType: string,
|
||||
|
|
@ -249,17 +268,21 @@ export class MinioStorageService implements StorageService {
|
|||
this.validateFilePath(orgId, projectId, category, filename);
|
||||
|
||||
if (!contentType || contentType.trim().length === 0) {
|
||||
throw new Error('Content type is required for presigned upload URL');
|
||||
throw new Error("Content type is required for presigned upload URL");
|
||||
}
|
||||
|
||||
const filePath = this.getFilePath(orgId, projectId, category, filename);
|
||||
return await this.client.presignedPutObject(this.bucketName, filePath, expirySeconds);
|
||||
return await this.client.presignedPutObject(
|
||||
this.bucketName,
|
||||
filePath,
|
||||
expirySeconds,
|
||||
);
|
||||
}
|
||||
|
||||
async getPresignedDownloadUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
expirySeconds: number = 86400, // 24 hours default
|
||||
): Promise<string> {
|
||||
|
|
@ -273,10 +296,14 @@ export class MinioStorageService implements StorageService {
|
|||
|
||||
// Replace internal Docker hostname with public URL if configured
|
||||
if (this.publicUrl) {
|
||||
const clientEndpoint = this.client.host + (this.client.port ? `:${this.client.port}` : '');
|
||||
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, '');
|
||||
const clientEndpoint =
|
||||
this.client.host + (this.client.port ? `:${this.client.port}` : "");
|
||||
const publicEndpoint = this.publicUrl.replace(/^https?:\/\//, "");
|
||||
|
||||
return presignedUrl.replace(`${this.client.protocol}//${clientEndpoint}`, this.publicUrl);
|
||||
return presignedUrl.replace(
|
||||
`${this.client.protocol}//${clientEndpoint}`,
|
||||
this.publicUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return presignedUrl;
|
||||
|
|
@ -285,24 +312,32 @@ export class MinioStorageService implements StorageService {
|
|||
async listProjectFiles(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category?: 'uploads' | 'generated' | 'references',
|
||||
category?: "uploads" | "generated" | "references",
|
||||
): Promise<FileMetadata[]> {
|
||||
const prefix = category ? `${orgId}/${projectId}/${category}/` : `${orgId}/${projectId}/`;
|
||||
const prefix = category
|
||||
? `${orgId}/${projectId}/${category}/`
|
||||
: `${orgId}/${projectId}/`;
|
||||
|
||||
const files: FileMetadata[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = this.client.listObjects(this.bucketName, prefix, true);
|
||||
|
||||
stream.on('data', async (obj) => {
|
||||
stream.on("data", async (obj) => {
|
||||
try {
|
||||
if (!obj.name) return;
|
||||
|
||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
||||
const metadata = await this.client.statObject(
|
||||
this.bucketName,
|
||||
obj.name,
|
||||
);
|
||||
|
||||
const pathParts = obj.name.split('/');
|
||||
const pathParts = obj.name.split("/");
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
const categoryFromPath = pathParts[2] as 'uploads' | 'generated' | 'references';
|
||||
const categoryFromPath = pathParts[2] as
|
||||
| "uploads"
|
||||
| "generated"
|
||||
| "references";
|
||||
|
||||
if (!filename || !categoryFromPath) {
|
||||
return;
|
||||
|
|
@ -311,23 +346,29 @@ export class MinioStorageService implements StorageService {
|
|||
files.push({
|
||||
key: `${this.bucketName}/${obj.name}`,
|
||||
filename,
|
||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
||||
contentType:
|
||||
metadata.metaData?.["content-type"] || "application/octet-stream",
|
||||
size: obj.size || 0,
|
||||
url: this.getPublicUrl(orgId, projectId, categoryFromPath, filename),
|
||||
url: this.getPublicUrl(
|
||||
orgId,
|
||||
projectId,
|
||||
categoryFromPath,
|
||||
filename,
|
||||
),
|
||||
createdAt: obj.lastModified || new Date(),
|
||||
});
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
stream.on('end', () => resolve(files));
|
||||
stream.on('error', reject);
|
||||
stream.on("end", () => resolve(files));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
parseKey(key: string): {
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
category: 'uploads' | 'generated' | 'references';
|
||||
category: "uploads" | "generated" | "references";
|
||||
filename: string;
|
||||
} | null {
|
||||
try {
|
||||
|
|
@ -348,7 +389,7 @@ export class MinioStorageService implements StorageService {
|
|||
return {
|
||||
orgId,
|
||||
projectId,
|
||||
category: category as 'uploads' | 'generated' | 'references',
|
||||
category: category as "uploads" | "generated" | "references",
|
||||
filename,
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -359,7 +400,7 @@ export class MinioStorageService implements StorageService {
|
|||
async fileExists(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -375,10 +416,10 @@ export class MinioStorageService implements StorageService {
|
|||
async listFiles(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
prefix?: string,
|
||||
): Promise<FileMetadata[]> {
|
||||
this.validateFilePath(orgId, projectId, category, 'dummy.txt');
|
||||
this.validateFilePath(orgId, projectId, category, "dummy.txt");
|
||||
|
||||
const basePath = `${orgId}/${projectId}/${category}/`;
|
||||
const searchPrefix = prefix ? `${basePath}${prefix}` : basePath;
|
||||
|
|
@ -386,23 +427,31 @@ export class MinioStorageService implements StorageService {
|
|||
const files: FileMetadata[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = this.client.listObjects(this.bucketName, searchPrefix, true);
|
||||
const stream = this.client.listObjects(
|
||||
this.bucketName,
|
||||
searchPrefix,
|
||||
true,
|
||||
);
|
||||
|
||||
stream.on('data', async (obj) => {
|
||||
stream.on("data", async (obj) => {
|
||||
if (!obj.name || !obj.size) return;
|
||||
|
||||
try {
|
||||
const pathParts = obj.name.split('/');
|
||||
const pathParts = obj.name.split("/");
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
|
||||
if (!filename) return;
|
||||
|
||||
const metadata = await this.client.statObject(this.bucketName, obj.name);
|
||||
const metadata = await this.client.statObject(
|
||||
this.bucketName,
|
||||
obj.name,
|
||||
);
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: obj.size,
|
||||
contentType: metadata.metaData?.['content-type'] || 'application/octet-stream',
|
||||
contentType:
|
||||
metadata.metaData?.["content-type"] || "application/octet-stream",
|
||||
lastModified: obj.lastModified || new Date(),
|
||||
etag: metadata.etag,
|
||||
path: obj.name,
|
||||
|
|
@ -410,8 +459,8 @@ export class MinioStorageService implements StorageService {
|
|||
} catch (error) {}
|
||||
});
|
||||
|
||||
stream.on('end', () => resolve(files));
|
||||
stream.on('error', reject);
|
||||
stream.on("end", () => resolve(files));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { StorageService } from './StorageService';
|
||||
import { MinioStorageService } from './MinioStorageService';
|
||||
import { StorageService } from "./StorageService";
|
||||
import { MinioStorageService } from "./MinioStorageService";
|
||||
|
||||
export class StorageFactory {
|
||||
private static instance: StorageService | null = null;
|
||||
|
|
@ -30,7 +30,9 @@ export class StorageFactory {
|
|||
try {
|
||||
this.instance = this.createStorageService();
|
||||
} catch (error) {
|
||||
throw new Error('Storage service unavailable. Please check MinIO configuration.');
|
||||
throw new Error(
|
||||
"Storage service unavailable. Please check MinIO configuration.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.instance;
|
||||
|
|
@ -51,7 +53,7 @@ export class StorageFactory {
|
|||
if (attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Failed to initialize storage service after ${maxRetries} attempts. ` +
|
||||
`Last error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
`Last error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +62,7 @@ export class StorageFactory {
|
|||
}
|
||||
}
|
||||
|
||||
throw new Error('Unexpected error in storage service creation');
|
||||
throw new Error("Unexpected error in storage service creation");
|
||||
}
|
||||
|
||||
private static sleep(ms: number): Promise<void> {
|
||||
|
|
@ -68,21 +70,21 @@ export class StorageFactory {
|
|||
}
|
||||
|
||||
private static createStorageService(): StorageService {
|
||||
const storageType = process.env['STORAGE_TYPE'] || 'minio';
|
||||
const storageType = process.env["STORAGE_TYPE"] || "minio";
|
||||
|
||||
try {
|
||||
switch (storageType.toLowerCase()) {
|
||||
case 'minio': {
|
||||
const endpoint = process.env['MINIO_ENDPOINT'];
|
||||
const accessKey = process.env['MINIO_ACCESS_KEY'];
|
||||
const secretKey = process.env['MINIO_SECRET_KEY'];
|
||||
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
||||
const bucketName = process.env['MINIO_BUCKET_NAME'] || 'banatie';
|
||||
const publicUrl = process.env['MINIO_PUBLIC_URL'];
|
||||
case "minio": {
|
||||
const endpoint = process.env["MINIO_ENDPOINT"];
|
||||
const accessKey = process.env["MINIO_ACCESS_KEY"];
|
||||
const secretKey = process.env["MINIO_SECRET_KEY"];
|
||||
const useSSL = process.env["MINIO_USE_SSL"] === "true";
|
||||
const bucketName = process.env["MINIO_BUCKET_NAME"] || "banatie";
|
||||
const publicUrl = process.env["MINIO_PUBLIC_URL"];
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey) {
|
||||
throw new Error(
|
||||
'MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY',
|
||||
"MinIO configuration missing. Required: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Readable } from 'stream';
|
||||
import { Readable } from "stream";
|
||||
|
||||
export interface FileMetadata {
|
||||
filename: string;
|
||||
|
|
@ -42,7 +42,7 @@ export interface StorageService {
|
|||
uploadFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
|
|
@ -58,7 +58,7 @@ export interface StorageService {
|
|||
downloadFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<Buffer>;
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ export interface StorageService {
|
|||
streamFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<Readable>;
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ export interface StorageService {
|
|||
getPresignedDownloadUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
): Promise<string>;
|
||||
|
|
@ -104,7 +104,7 @@ export interface StorageService {
|
|||
getPresignedUploadUrl(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
expirySeconds: number,
|
||||
contentType: string,
|
||||
|
|
@ -120,7 +120,7 @@ export interface StorageService {
|
|||
listFiles(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
prefix?: string,
|
||||
): Promise<FileMetadata[]>;
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ export interface StorageService {
|
|||
deleteFile(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<void>;
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ export interface StorageService {
|
|||
fileExists(
|
||||
orgId: string,
|
||||
projectId: string,
|
||||
category: 'uploads' | 'generated' | 'references',
|
||||
category: "uploads" | "generated" | "references",
|
||||
filename: string,
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
export interface TTILogEntry {
|
||||
timestamp: string;
|
||||
|
|
@ -24,7 +24,7 @@ export class TTILogger {
|
|||
private isEnabled: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
const ttiLogPath = process.env['TTI_LOG'];
|
||||
const ttiLogPath = process.env["TTI_LOG"];
|
||||
|
||||
if (ttiLogPath) {
|
||||
this.logFilePath = ttiLogPath;
|
||||
|
|
@ -51,8 +51,8 @@ export class TTILogger {
|
|||
}
|
||||
|
||||
// Reset/clear the log file on service start
|
||||
writeFileSync(this.logFilePath, '# Text-to-Image Generation Log\n\n', {
|
||||
encoding: 'utf-8',
|
||||
writeFileSync(this.logFilePath, "# Text-to-Image Generation Log\n\n", {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
console.log(`[TTILogger] Log file initialized: ${this.logFilePath}`);
|
||||
|
|
@ -72,15 +72,19 @@ export class TTILogger {
|
|||
|
||||
// Read existing content
|
||||
const existingContent = existsSync(this.logFilePath)
|
||||
? readFileSync(this.logFilePath, 'utf-8')
|
||||
: '# Text-to-Image Generation Log\n\n';
|
||||
? readFileSync(this.logFilePath, "utf-8")
|
||||
: "# Text-to-Image Generation Log\n\n";
|
||||
|
||||
// Insert new entry AFTER header but BEFORE old entries
|
||||
const headerEnd = existingContent.indexOf('\n\n') + 2;
|
||||
const headerEnd = existingContent.indexOf("\n\n") + 2;
|
||||
const header = existingContent.slice(0, headerEnd);
|
||||
const oldEntries = existingContent.slice(headerEnd);
|
||||
|
||||
writeFileSync(this.logFilePath, header + newLogEntry + oldEntries, 'utf-8');
|
||||
writeFileSync(
|
||||
this.logFilePath,
|
||||
header + newLogEntry + oldEntries,
|
||||
"utf-8",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[TTILogger] Failed to write log entry:`, error);
|
||||
}
|
||||
|
|
@ -91,7 +95,7 @@ export class TTILogger {
|
|||
|
||||
// Format date from ISO timestamp
|
||||
const date = new Date(timestamp);
|
||||
const formattedDate = date.toISOString().replace('T', ' ').slice(0, 19);
|
||||
const formattedDate = date.toISOString().replace("T", " ").slice(0, 19);
|
||||
|
||||
let logText = `## ${formattedDate}\n`;
|
||||
logText += `${orgId}/${projectId}\n\n`;
|
||||
|
|
@ -99,16 +103,16 @@ export class TTILogger {
|
|||
|
||||
// Add tags if present
|
||||
if (meta?.tags && meta.tags.length > 0) {
|
||||
logText += `**Tags:** ${meta.tags.join(', ')}\n\n`;
|
||||
logText += `**Tags:** ${meta.tags.join(", ")}\n\n`;
|
||||
}
|
||||
|
||||
if (referenceImages && referenceImages.length > 0) {
|
||||
logText += `**Reference Images:** ${referenceImages.length} image${referenceImages.length > 1 ? 's' : ''}\n`;
|
||||
logText += `**Reference Images:** ${referenceImages.length} image${referenceImages.length > 1 ? "s" : ""}\n`;
|
||||
for (const img of referenceImages) {
|
||||
const sizeMB = (img.size / (1024 * 1024)).toFixed(2);
|
||||
logText += `- ${img.originalname} (${img.mimetype}, ${sizeMB} MB)\n`;
|
||||
}
|
||||
logText += '\n';
|
||||
logText += "\n";
|
||||
}
|
||||
|
||||
logText += `**Model:** ${model}\n`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { EnhancementLogEntry } from './types';
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import { EnhancementLogEntry } from "./types";
|
||||
|
||||
export class EnhancementLogger {
|
||||
private static instance: EnhancementLogger | null = null;
|
||||
|
|
@ -8,7 +8,7 @@ export class EnhancementLogger {
|
|||
private isEnabled: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
const enhLogPath = process.env['ENH_LOG'];
|
||||
const enhLogPath = process.env["ENH_LOG"];
|
||||
|
||||
if (enhLogPath) {
|
||||
this.logFilePath = enhLogPath;
|
||||
|
|
@ -35,13 +35,18 @@ export class EnhancementLogger {
|
|||
}
|
||||
|
||||
// Reset/clear the log file on service start
|
||||
writeFileSync(this.logFilePath, '# Prompt Enhancement Log\n\n', {
|
||||
encoding: 'utf-8',
|
||||
writeFileSync(this.logFilePath, "# Prompt Enhancement Log\n\n", {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
console.log(`[EnhancementLogger] Log file initialized: ${this.logFilePath}`);
|
||||
console.log(
|
||||
`[EnhancementLogger] Log file initialized: ${this.logFilePath}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[EnhancementLogger] Failed to initialize log file:`, error);
|
||||
console.error(
|
||||
`[EnhancementLogger] Failed to initialize log file:`,
|
||||
error,
|
||||
);
|
||||
this.isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -56,15 +61,19 @@ export class EnhancementLogger {
|
|||
|
||||
// Read existing content
|
||||
const existingContent = existsSync(this.logFilePath)
|
||||
? readFileSync(this.logFilePath, 'utf-8')
|
||||
: '# Prompt Enhancement Log\n\n';
|
||||
? readFileSync(this.logFilePath, "utf-8")
|
||||
: "# Prompt Enhancement Log\n\n";
|
||||
|
||||
// Insert new entry AFTER header but BEFORE old entries
|
||||
const headerEnd = existingContent.indexOf('\n\n') + 2;
|
||||
const headerEnd = existingContent.indexOf("\n\n") + 2;
|
||||
const header = existingContent.slice(0, headerEnd);
|
||||
const oldEntries = existingContent.slice(headerEnd);
|
||||
|
||||
writeFileSync(this.logFilePath, header + newLogEntry + oldEntries, 'utf-8');
|
||||
writeFileSync(
|
||||
this.logFilePath,
|
||||
header + newLogEntry + oldEntries,
|
||||
"utf-8",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[EnhancementLogger] Failed to write log entry:`, error);
|
||||
}
|
||||
|
|
@ -86,7 +95,7 @@ export class EnhancementLogger {
|
|||
|
||||
// Format date from ISO timestamp
|
||||
const date = new Date(timestamp);
|
||||
const formattedDate = date.toISOString().replace('T', ' ').slice(0, 19);
|
||||
const formattedDate = date.toISOString().replace("T", " ").slice(0, 19);
|
||||
|
||||
let logText = `## ${formattedDate}\n`;
|
||||
logText += `${orgId}/${projectId}\n\n`;
|
||||
|
|
@ -95,7 +104,7 @@ export class EnhancementLogger {
|
|||
|
||||
// Add tags if present
|
||||
if (meta?.tags && meta.tags.length > 0) {
|
||||
logText += `**Tags:** ${meta.tags.join(', ')}\n\n`;
|
||||
logText += `**Tags:** ${meta.tags.join(", ")}\n\n`;
|
||||
}
|
||||
|
||||
logText += `**Template:** ${template}\n`;
|
||||
|
|
@ -103,7 +112,7 @@ export class EnhancementLogger {
|
|||
logText += `**Language:** ${detectedLanguage}\n`;
|
||||
}
|
||||
if (enhancements.length > 0) {
|
||||
logText += `**Enhancements:** ${enhancements.join(', ')}\n`;
|
||||
logText += `**Enhancements:** ${enhancements.join(", ")}\n`;
|
||||
}
|
||||
logText += `**Model:** ${model}\n\n`;
|
||||
logText += `---\n\n`;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,18 @@ import {
|
|||
PromptEnhancementOptions,
|
||||
PromptEnhancementContext,
|
||||
PromptEnhancementResult,
|
||||
} from './types';
|
||||
import { getAgent } from './agents';
|
||||
import { validatePromptLength } from './validators';
|
||||
import { EnhancementLogger } from './EnhancementLogger';
|
||||
import { NetworkErrorDetector } from '../../utils/NetworkErrorDetector';
|
||||
} from "./types";
|
||||
import { getAgent } from "./agents";
|
||||
import { validatePromptLength } from "./validators";
|
||||
import { EnhancementLogger } from "./EnhancementLogger";
|
||||
|
||||
export class PromptEnhancementService {
|
||||
private apiKey: string;
|
||||
private model = 'gemini-2.5-flash';
|
||||
private model = "gemini-2.5-flash";
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('Gemini API key is required');
|
||||
throw new Error("Gemini API key is required");
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
|
@ -29,9 +28,11 @@ export class PromptEnhancementService {
|
|||
console.log(
|
||||
`[${timestamp}] Starting prompt enhancement for: "${rawPrompt.substring(0, 50)}..."`,
|
||||
);
|
||||
console.log(`[${timestamp}] Template: ${options.template || 'general (auto-select)'}`);
|
||||
console.log(
|
||||
`[${timestamp}] Template: ${options.template || "general (auto-select)"}`,
|
||||
);
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
console.log(`[${timestamp}] Tags: ${options.tags.join(', ')}`);
|
||||
console.log(`[${timestamp}] Tags: ${options.tags.join(", ")}`);
|
||||
}
|
||||
|
||||
// Pre-validate input prompt
|
||||
|
|
@ -40,7 +41,7 @@ export class PromptEnhancementService {
|
|||
return {
|
||||
success: false,
|
||||
originalPrompt: rawPrompt,
|
||||
error: inputValidation.error || 'Validation failed',
|
||||
error: inputValidation.error || "Validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -55,17 +56,23 @@ export class PromptEnhancementService {
|
|||
return {
|
||||
success: false,
|
||||
originalPrompt: rawPrompt,
|
||||
error: agentResult.error || 'Enhancement failed',
|
||||
error: agentResult.error || "Enhancement failed",
|
||||
};
|
||||
}
|
||||
|
||||
// Post-validate enhanced prompt length
|
||||
const outputValidation = validatePromptLength(agentResult.enhancedPrompt, 2000);
|
||||
const outputValidation = validatePromptLength(
|
||||
agentResult.enhancedPrompt,
|
||||
2000,
|
||||
);
|
||||
if (!outputValidation.valid) {
|
||||
console.warn(
|
||||
`[${timestamp}] Enhanced prompt exceeds 2000 characters (${agentResult.enhancedPrompt.length}), truncating...`,
|
||||
);
|
||||
agentResult.enhancedPrompt = agentResult.enhancedPrompt.substring(0, 2000);
|
||||
agentResult.enhancedPrompt = agentResult.enhancedPrompt.substring(
|
||||
0,
|
||||
2000,
|
||||
);
|
||||
}
|
||||
|
||||
const result: PromptEnhancementResult = {
|
||||
|
|
@ -75,9 +82,10 @@ export class PromptEnhancementService {
|
|||
...(agentResult.detectedLanguage && {
|
||||
detectedLanguage: agentResult.detectedLanguage,
|
||||
}),
|
||||
appliedTemplate: agentResult.appliedTemplate || options.template || 'general',
|
||||
appliedTemplate:
|
||||
agentResult.appliedTemplate || options.template || "general",
|
||||
metadata: {
|
||||
style: agentResult.appliedTemplate || options.template || 'general',
|
||||
style: agentResult.appliedTemplate || options.template || "general",
|
||||
enhancements: agentResult.enhancements,
|
||||
},
|
||||
};
|
||||
|
|
@ -91,7 +99,8 @@ export class PromptEnhancementService {
|
|||
originalPrompt: rawPrompt,
|
||||
enhancedPrompt: agentResult.enhancedPrompt,
|
||||
...(context.meta && { meta: context.meta }),
|
||||
template: agentResult.appliedTemplate || options.template || 'general',
|
||||
template:
|
||||
agentResult.appliedTemplate || options.template || "general",
|
||||
...(agentResult.detectedLanguage && {
|
||||
detectedLanguage: agentResult.detectedLanguage,
|
||||
}),
|
||||
|
|
@ -103,28 +112,11 @@ export class PromptEnhancementService {
|
|||
console.log(`[${timestamp}] Enhancement completed successfully`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Enhanced error detection with network diagnostics
|
||||
if (error instanceof Error) {
|
||||
// Classify the error and check for network issues (only on failure)
|
||||
const errorAnalysis = await NetworkErrorDetector.classifyError(error, 'Gemini API');
|
||||
|
||||
// Log the detailed error for debugging
|
||||
console.error(
|
||||
`[${timestamp}] [PromptEnhancementService] ${NetworkErrorDetector.formatErrorForLogging(errorAnalysis)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
originalPrompt: rawPrompt,
|
||||
error: errorAnalysis.userMessage,
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`[${timestamp}] Prompt enhancement failed:`, error);
|
||||
return {
|
||||
success: false,
|
||||
originalPrompt: rawPrompt,
|
||||
error: 'Enhancement failed',
|
||||
error: error instanceof Error ? error.message : "Enhancement failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PromptEnhancementService } from '../PromptEnhancementService';
|
||||
import type { AgentResult } from '../types';
|
||||
|
||||
// Mock the agents module
|
||||
vi.mock('../agents', () => ({
|
||||
getAgent: vi.fn(() => ({
|
||||
templateType: 'general',
|
||||
enhance: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('PromptEnhancementService', () => {
|
||||
let service: PromptEnhancementService;
|
||||
const mockApiKey = 'test-gemini-api-key';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PromptEnhancementService(mockApiKey);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should throw error when API key is missing', () => {
|
||||
expect(() => new PromptEnhancementService('')).toThrow('Gemini API key is required');
|
||||
});
|
||||
|
||||
it('should create instance with valid API key', () => {
|
||||
expect(service).toBeInstanceOf(PromptEnhancementService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enhancePrompt', () => {
|
||||
it('should reject empty prompts', async () => {
|
||||
const result = await service.enhancePrompt('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Prompt cannot be empty');
|
||||
});
|
||||
|
||||
it('should reject prompts exceeding 5000 characters', async () => {
|
||||
const longPrompt = 'a'.repeat(5001);
|
||||
const result = await service.enhancePrompt(longPrompt);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('exceeds maximum length');
|
||||
});
|
||||
|
||||
it('should successfully enhance valid prompt', async () => {
|
||||
const { getAgent } = await import('../agents');
|
||||
const mockAgent = {
|
||||
templateType: 'general',
|
||||
enhance: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
enhancedPrompt: 'A beautiful sunset over the ocean with vibrant colors',
|
||||
appliedTemplate: 'general',
|
||||
enhancements: ['Added detailed descriptions'],
|
||||
} as AgentResult),
|
||||
};
|
||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
||||
|
||||
const result = await service.enhancePrompt('sunset');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.enhancedPrompt).toBe('A beautiful sunset over the ocean with vibrant colors');
|
||||
expect(result.appliedTemplate).toBe('general');
|
||||
});
|
||||
|
||||
it('should handle agent enhancement failure', async () => {
|
||||
const { getAgent } = await import('../agents');
|
||||
const mockAgent = {
|
||||
templateType: 'general',
|
||||
enhance: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Enhancement failed',
|
||||
enhancements: [],
|
||||
} as AgentResult),
|
||||
};
|
||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
||||
|
||||
const result = await service.enhancePrompt('sunset');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Enhancement failed');
|
||||
});
|
||||
|
||||
it('should truncate enhanced prompt exceeding 2000 characters', async () => {
|
||||
const { getAgent } = await import('../agents');
|
||||
const longEnhancedPrompt = 'enhanced '.repeat(300); // > 2000 chars
|
||||
const mockAgent = {
|
||||
templateType: 'general',
|
||||
enhance: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
enhancedPrompt: longEnhancedPrompt,
|
||||
appliedTemplate: 'general',
|
||||
enhancements: [],
|
||||
} as AgentResult),
|
||||
};
|
||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
||||
|
||||
const result = await service.enhancePrompt('sunset');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.enhancedPrompt?.length).toBe(2000);
|
||||
});
|
||||
|
||||
it('should pass options to agent', async () => {
|
||||
const { getAgent } = await import('../agents');
|
||||
const mockEnhance = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
enhancedPrompt: 'Enhanced',
|
||||
appliedTemplate: 'photorealistic',
|
||||
enhancements: [],
|
||||
} as AgentResult);
|
||||
const mockAgent = {
|
||||
templateType: 'photorealistic',
|
||||
enhance: mockEnhance,
|
||||
};
|
||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
||||
|
||||
await service.enhancePrompt('sunset', {
|
||||
template: 'photorealistic',
|
||||
tags: ['landscape', 'nature'],
|
||||
});
|
||||
|
||||
expect(mockEnhance).toHaveBeenCalledWith('sunset', {
|
||||
template: 'photorealistic',
|
||||
tags: ['landscape', 'nature'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should include detected language in result', async () => {
|
||||
const { getAgent } = await import('../agents');
|
||||
const mockAgent = {
|
||||
templateType: 'general',
|
||||
enhance: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
enhancedPrompt: 'Enhanced prompt',
|
||||
detectedLanguage: 'Spanish',
|
||||
appliedTemplate: 'general',
|
||||
enhancements: [],
|
||||
} as AgentResult),
|
||||
};
|
||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
||||
|
||||
const result = await service.enhancePrompt('hermosa puesta de sol');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.detectedLanguage).toBe('Spanish');
|
||||
});
|
||||
|
||||
it('should handle exceptions gracefully', async () => {
|
||||
const { getAgent } = await import('../agents');
|
||||
const mockAgent = {
|
||||
templateType: 'general',
|
||||
enhance: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
};
|
||||
vi.mocked(getAgent).mockReturnValue(mockAgent);
|
||||
|
||||
const result = await service.enhancePrompt('sunset');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
|
||||
it('should return original prompt in all results', async () => {
|
||||
const originalPrompt = 'test prompt';
|
||||
const result = await service.enhancePrompt(originalPrompt);
|
||||
expect(result.originalPrompt).toBe(originalPrompt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getAgent } from '../agents';
|
||||
import {
|
||||
GeneralAgent,
|
||||
PhotorealisticAgent,
|
||||
IllustrationAgent,
|
||||
MinimalistAgent,
|
||||
StickerAgent,
|
||||
ProductAgent,
|
||||
ComicAgent,
|
||||
} from '../agents';
|
||||
|
||||
describe('getAgent', () => {
|
||||
const mockApiKey = 'test-api-key';
|
||||
|
||||
it('should return GeneralAgent when no template provided', () => {
|
||||
const agent = getAgent(mockApiKey);
|
||||
expect(agent).toBeInstanceOf(GeneralAgent);
|
||||
});
|
||||
|
||||
it('should return PhotorealisticAgent for photorealistic template', () => {
|
||||
const agent = getAgent(mockApiKey, 'photorealistic');
|
||||
expect(agent).toBeInstanceOf(PhotorealisticAgent);
|
||||
});
|
||||
|
||||
it('should return IllustrationAgent for illustration template', () => {
|
||||
const agent = getAgent(mockApiKey, 'illustration');
|
||||
expect(agent).toBeInstanceOf(IllustrationAgent);
|
||||
});
|
||||
|
||||
it('should return MinimalistAgent for minimalist template', () => {
|
||||
const agent = getAgent(mockApiKey, 'minimalist');
|
||||
expect(agent).toBeInstanceOf(MinimalistAgent);
|
||||
});
|
||||
|
||||
it('should return StickerAgent for sticker template', () => {
|
||||
const agent = getAgent(mockApiKey, 'sticker');
|
||||
expect(agent).toBeInstanceOf(StickerAgent);
|
||||
});
|
||||
|
||||
it('should return ProductAgent for product template', () => {
|
||||
const agent = getAgent(mockApiKey, 'product');
|
||||
expect(agent).toBeInstanceOf(ProductAgent);
|
||||
});
|
||||
|
||||
it('should return ComicAgent for comic template', () => {
|
||||
const agent = getAgent(mockApiKey, 'comic');
|
||||
expect(agent).toBeInstanceOf(ComicAgent);
|
||||
});
|
||||
|
||||
it('should return GeneralAgent for general template', () => {
|
||||
const agent = getAgent(mockApiKey, 'general');
|
||||
expect(agent).toBeInstanceOf(GeneralAgent);
|
||||
});
|
||||
|
||||
it('should fallback to GeneralAgent for unknown template', () => {
|
||||
const agent = getAgent(mockApiKey, 'unknown-template' as any);
|
||||
expect(agent).toBeInstanceOf(GeneralAgent);
|
||||
});
|
||||
|
||||
it('should return agent with correct templateType', () => {
|
||||
const agent = getAgent(mockApiKey, 'photorealistic');
|
||||
expect(agent.templateType).toBe('photorealistic');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { detectLanguage, detectEnhancements } from '../utils';
|
||||
|
||||
describe('detectLanguage', () => {
|
||||
it('should detect English', () => {
|
||||
expect(detectLanguage('a sunset over the ocean')).toBe('English');
|
||||
});
|
||||
|
||||
it('should detect Chinese', () => {
|
||||
expect(detectLanguage('美丽的日落')).toBe('Chinese');
|
||||
});
|
||||
|
||||
it('should detect Japanese', () => {
|
||||
expect(detectLanguage('こんにちは')).toBe('Japanese');
|
||||
});
|
||||
|
||||
it('should detect Korean', () => {
|
||||
expect(detectLanguage('아름다운 일몰')).toBe('Korean');
|
||||
});
|
||||
|
||||
it('should detect Romance Language', () => {
|
||||
expect(detectLanguage('hermosa puesta de sól')).toBe('Romance Language');
|
||||
expect(detectLanguage('beau coucher de soléil')).toBe('Romance Language');
|
||||
});
|
||||
|
||||
it('should detect Russian', () => {
|
||||
expect(detectLanguage('красивый закат')).toBe('Russian');
|
||||
});
|
||||
|
||||
it('should detect Greek', () => {
|
||||
expect(detectLanguage('όμορφο ηλιοβασίλεμα')).toBe('Greek');
|
||||
});
|
||||
|
||||
it('should detect Arabic', () => {
|
||||
expect(detectLanguage('غروب جميل')).toBe('Arabic');
|
||||
});
|
||||
|
||||
it('should detect Hebrew', () => {
|
||||
expect(detectLanguage('שקיעה יפה')).toBe('Hebrew');
|
||||
});
|
||||
|
||||
it('should default to English for unknown scripts', () => {
|
||||
expect(detectLanguage('123')).toBe('English');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEnhancements', () => {
|
||||
it('should detect added detailed descriptions', () => {
|
||||
const original = 'sunset';
|
||||
const enhanced = 'A breathtaking sunset over the ocean with vibrant orange and pink hues';
|
||||
const enhancements = detectEnhancements(original, enhanced);
|
||||
expect(enhancements).toContain('Added detailed descriptions');
|
||||
});
|
||||
|
||||
it('should detect photography terminology', () => {
|
||||
const original = 'sunset';
|
||||
const enhanced = 'A photorealistic sunset shot with 50mm lens';
|
||||
const enhancements = detectEnhancements(original, enhanced);
|
||||
expect(enhancements).toContain('Applied photography terminology');
|
||||
});
|
||||
|
||||
it('should detect lighting descriptions', () => {
|
||||
const original = 'room';
|
||||
const enhanced = 'A room with soft lighting and warm illuminated corners';
|
||||
const enhancements = detectEnhancements(original, enhanced);
|
||||
expect(enhancements).toContain('Enhanced lighting description');
|
||||
});
|
||||
|
||||
it('should detect texture details', () => {
|
||||
const original = 'wall';
|
||||
const enhanced = 'A wall with rough texture and weathered surface';
|
||||
const enhancements = detectEnhancements(original, enhanced);
|
||||
expect(enhancements).toContain('Added texture details');
|
||||
});
|
||||
|
||||
it('should detect multiple enhancements', () => {
|
||||
const original = 'sunset';
|
||||
const enhanced =
|
||||
'A photorealistic sunset with dramatic lighting, beautiful texture in the clouds, and soft illuminated sky';
|
||||
const enhancements = detectEnhancements(original, enhanced);
|
||||
expect(enhancements.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should return empty array when no enhancements detected', () => {
|
||||
const original = 'sunset';
|
||||
const enhanced = 'sunset';
|
||||
const enhancements = detectEnhancements(original, enhanced);
|
||||
expect(enhancements).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { validatePromptLength } from '../validators';
|
||||
|
||||
describe('validatePromptLength', () => {
|
||||
it('should reject empty prompts', () => {
|
||||
const result = validatePromptLength('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Prompt cannot be empty');
|
||||
});
|
||||
|
||||
it('should reject whitespace-only prompts', () => {
|
||||
const result = validatePromptLength(' ');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Prompt cannot be empty');
|
||||
});
|
||||
|
||||
it('should accept valid prompt within max length', () => {
|
||||
const result = validatePromptLength('a sunset over the ocean', 2000);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject prompts exceeding max length', () => {
|
||||
const longPrompt = 'a'.repeat(2001);
|
||||
const result = validatePromptLength(longPrompt, 2000);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('exceeds maximum length');
|
||||
expect(result.error).toContain('2000');
|
||||
});
|
||||
|
||||
it('should use custom max length', () => {
|
||||
const prompt = 'a'.repeat(150);
|
||||
const result = validatePromptLength(prompt, 100);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('100');
|
||||
});
|
||||
|
||||
it('should accept prompt at exact max length', () => {
|
||||
const prompt = 'a'.repeat(100);
|
||||
const result = validatePromptLength(prompt, 100);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
import { GoogleGenAI } from '@google/genai';
|
||||
import { IPromptAgent, PromptEnhancementOptions, AgentResult } from '../types';
|
||||
import { detectLanguage, detectEnhancements } from '../utils';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import {
|
||||
IPromptAgent,
|
||||
PromptEnhancementOptions,
|
||||
AgentResult,
|
||||
} from "../types";
|
||||
import { detectLanguage, detectEnhancements } from "../utils";
|
||||
|
||||
export abstract class BaseAgent implements IPromptAgent {
|
||||
protected ai: GoogleGenAI;
|
||||
protected model = 'gemini-2.5-flash';
|
||||
protected model = "gemini-2.5-flash";
|
||||
|
||||
abstract readonly templateType: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('Gemini API key is required');
|
||||
throw new Error("Gemini API key is required");
|
||||
}
|
||||
this.ai = new GoogleGenAI({ apiKey });
|
||||
}
|
||||
|
|
@ -18,7 +22,10 @@ export abstract class BaseAgent implements IPromptAgent {
|
|||
protected abstract getSystemPrompt(): string;
|
||||
protected abstract getTemplate(): string;
|
||||
|
||||
async enhance(rawPrompt: string, _options: PromptEnhancementOptions): Promise<AgentResult> {
|
||||
async enhance(
|
||||
rawPrompt: string,
|
||||
_options: PromptEnhancementOptions,
|
||||
): Promise<AgentResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
console.log(
|
||||
|
|
@ -31,20 +38,26 @@ export abstract class BaseAgent implements IPromptAgent {
|
|||
|
||||
const response = await this.ai.models.generateContent({
|
||||
model: this.model,
|
||||
config: { responseModalities: ['TEXT'] },
|
||||
config: { responseModalities: ["TEXT"] },
|
||||
contents: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
role: "user" as const,
|
||||
parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (response.candidates && response.candidates[0] && response.candidates[0].content) {
|
||||
if (
|
||||
response.candidates &&
|
||||
response.candidates[0] &&
|
||||
response.candidates[0].content
|
||||
) {
|
||||
const content = response.candidates[0].content;
|
||||
const enhancedPrompt = content.parts?.[0]?.text?.trim() || '';
|
||||
const enhancedPrompt = content.parts?.[0]?.text?.trim() || "";
|
||||
|
||||
console.log(`[${timestamp}] [${this.templateType}Agent] Enhancement successful`);
|
||||
console.log(
|
||||
`[${timestamp}] [${this.templateType}Agent] Enhancement successful`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -57,14 +70,17 @@ export abstract class BaseAgent implements IPromptAgent {
|
|||
|
||||
return {
|
||||
success: false,
|
||||
error: 'No enhanced prompt received from API',
|
||||
error: "No enhanced prompt received from API",
|
||||
enhancements: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[${timestamp}] [${this.templateType}Agent] Enhancement failed:`, error);
|
||||
console.error(
|
||||
`[${timestamp}] [${this.templateType}Agent] Enhancement failed:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Enhancement failed',
|
||||
error: error instanceof Error ? error.message : "Enhancement failed",
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
|
||||
export const COMIC_TEMPLATE = `A single comic book panel in a [art style] style. In the foreground, [character description and action]. In the background, [setting details]. The panel has a [dialogue/caption box] with the text "[Text]". The lighting creates a [mood] mood. [Aspect ratio].
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Example:
|
|||
A single comic book panel in a gritty, noir art style with high-contrast black and white inks. In the foreground, a detective in a trench coat stands under a flickering streetlamp, rain soaking his shoulders. In the background, the neon sign of a desolate bar reflects in a puddle. A caption box at the top reads "The city was a tough place to keep secrets." The lighting is harsh, creating a dramatic, somber mood. Landscape.`;
|
||||
|
||||
export class ComicAgent extends BaseAgent {
|
||||
readonly templateType = 'comic';
|
||||
readonly templateType = "comic";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return COMIC_TEMPLATE;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { PHOTOREALISTIC_TEMPLATE } from './PhotorealisticAgent';
|
||||
import { ILLUSTRATION_TEMPLATE } from './IllustrationAgent';
|
||||
import { MINIMALIST_TEMPLATE } from './MinimalistAgent';
|
||||
import { STICKER_TEMPLATE } from './StickerAgent';
|
||||
import { PRODUCT_TEMPLATE } from './ProductAgent';
|
||||
import { COMIC_TEMPLATE } from './ComicAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
import { PHOTOREALISTIC_TEMPLATE } from "./PhotorealisticAgent";
|
||||
import { ILLUSTRATION_TEMPLATE } from "./IllustrationAgent";
|
||||
import { MINIMALIST_TEMPLATE } from "./MinimalistAgent";
|
||||
import { STICKER_TEMPLATE } from "./StickerAgent";
|
||||
import { PRODUCT_TEMPLATE } from "./ProductAgent";
|
||||
import { COMIC_TEMPLATE } from "./ComicAgent";
|
||||
|
||||
export class GeneralAgent extends BaseAgent {
|
||||
readonly templateType = 'general';
|
||||
readonly templateType = "general";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return `AVAILABLE TEMPLATES:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
|
||||
export const ILLUSTRATION_TEMPLATE = `A [style] illustration of [subject], featuring [key characteristics] with [color palette]. The art style is [art technique description], with [line work style] and [shading technique]. The composition includes [composition details].
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Example:
|
|||
A watercolor illustration of a magical forest clearing at twilight, featuring glowing fireflies and an ancient stone archway covered in luminescent moss. The art style is whimsical and dreamlike, with soft, flowing brushstrokes and gentle color bleeding. The color palette consists of deep purples, soft blues, and warm golden yellows. The illustration uses delicate line work for fine details and subtle wet-on-wet shading to create atmospheric depth.`;
|
||||
|
||||
export class IllustrationAgent extends BaseAgent {
|
||||
readonly templateType = 'illustration';
|
||||
readonly templateType = "illustration";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return ILLUSTRATION_TEMPLATE;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
|
||||
export const MINIMALIST_TEMPLATE = `A minimalist composition featuring a single [subject] positioned in the [position in frame] of the frame. The background is a vast, empty [color/description] canvas, creating significant negative space. Soft, subtle lighting. [Aspect ratio].
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Example:
|
|||
A minimalist composition featuring a single, delicate red maple leaf positioned in the bottom-right of the frame. The background is a vast, empty off-white canvas, creating significant negative space for text. Soft, diffused lighting from the top left. Square image.`;
|
||||
|
||||
export class MinimalistAgent extends BaseAgent {
|
||||
readonly templateType = 'minimalist';
|
||||
readonly templateType = "minimalist";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return MINIMALIST_TEMPLATE;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
|
||||
export const PHOTOREALISTIC_TEMPLATE = `A photorealistic [shot type] of [subject], [action or expression], set in [environment]. The scene is illuminated by [lighting description], creating a [mood] atmosphere. Captured with a [camera/lens details], emphasizing [key textures and details]. The image should be in a [aspect ratio] format.
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Example:
|
|||
A photorealistic close-up portrait of an elderly Japanese ceramicist with deep, sun-etched wrinkles and a warm, knowing smile. He is carefully inspecting a freshly glazed tea bowl. The setting is his rustic, sun-drenched workshop. The scene is illuminated by soft, golden hour light streaming through a window, highlighting the fine texture of the clay. Captured with an 85mm portrait lens, resulting in a soft, blurred background (bokeh). The overall mood is serene and masterful. Vertical portrait orientation.`;
|
||||
|
||||
export class PhotorealisticAgent extends BaseAgent {
|
||||
readonly templateType = 'photorealistic';
|
||||
readonly templateType = "photorealistic";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return PHOTOREALISTIC_TEMPLATE;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
|
||||
export const PRODUCT_TEMPLATE = `A high-resolution, studio-lit product photograph of a [product description] on a [background surface/description]. The lighting is a [lighting setup, e.g., three-point softbox setup] to [lighting purpose]. The camera angle is a [angle type] to showcase [specific feature]. Ultra-realistic, with sharp focus on [key detail]. [Aspect ratio].
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Example:
|
|||
A high-resolution, studio-lit product photograph of a minimalist ceramic coffee mug in matte black, presented on a polished concrete surface. The lighting is a three-point softbox setup designed to create soft, diffused highlights and eliminate harsh shadows. The camera angle is a slightly elevated 45-degree shot to showcase its clean lines. Ultra-realistic, with sharp focus on the steam rising from the coffee. Square image.`;
|
||||
|
||||
export class ProductAgent extends BaseAgent {
|
||||
readonly templateType = 'product';
|
||||
readonly templateType = "product";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return PRODUCT_TEMPLATE;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseAgent } from './BaseAgent';
|
||||
import { BaseAgent } from "./BaseAgent";
|
||||
|
||||
export const STICKER_TEMPLATE = `A [style] sticker of a [subject], featuring [key characteristics] and a [color palette]. The design should have [line style] and [shading style]. The background must be transparent.
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Example:
|
|||
A kawaii-style sticker of a happy red panda wearing a tiny bamboo hat. It's munching on a green bamboo leaf. The design features bold, clean outlines, simple cel-shading, and a vibrant color palette. The background must be white.`;
|
||||
|
||||
export class StickerAgent extends BaseAgent {
|
||||
readonly templateType = 'sticker';
|
||||
readonly templateType = "sticker";
|
||||
|
||||
protected getTemplate(): string {
|
||||
return STICKER_TEMPLATE;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { IPromptAgent } from '../types';
|
||||
import { PhotorealisticAgent } from './PhotorealisticAgent';
|
||||
import { IllustrationAgent } from './IllustrationAgent';
|
||||
import { MinimalistAgent } from './MinimalistAgent';
|
||||
import { StickerAgent } from './StickerAgent';
|
||||
import { ProductAgent } from './ProductAgent';
|
||||
import { ComicAgent } from './ComicAgent';
|
||||
import { GeneralAgent } from './GeneralAgent';
|
||||
import { IPromptAgent } from "../types";
|
||||
import { PhotorealisticAgent } from "./PhotorealisticAgent";
|
||||
import { IllustrationAgent } from "./IllustrationAgent";
|
||||
import { MinimalistAgent } from "./MinimalistAgent";
|
||||
import { StickerAgent } from "./StickerAgent";
|
||||
import { ProductAgent } from "./ProductAgent";
|
||||
import { ComicAgent } from "./ComicAgent";
|
||||
import { GeneralAgent } from "./GeneralAgent";
|
||||
|
||||
type AgentConstructor = new (apiKey: string) => IPromptAgent;
|
||||
|
||||
|
|
@ -26,7 +26,9 @@ export function getAgent(apiKey: string, template?: string): IPromptAgent {
|
|||
|
||||
const AgentClass = AGENT_REGISTRY[template];
|
||||
if (!AgentClass) {
|
||||
console.warn(`Unknown template "${template}", falling back to GeneralAgent`);
|
||||
console.warn(
|
||||
`Unknown template "${template}", falling back to GeneralAgent`,
|
||||
);
|
||||
return new GeneralAgent(apiKey);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { PromptEnhancementService } from './PromptEnhancementService';
|
||||
export { EnhancementLogger } from './EnhancementLogger';
|
||||
export { PromptEnhancementService } from "./PromptEnhancementService";
|
||||
export { EnhancementLogger } from "./EnhancementLogger";
|
||||
|
||||
export type {
|
||||
PromptEnhancementOptions,
|
||||
|
|
@ -9,4 +9,4 @@ export type {
|
|||
IPromptAgent,
|
||||
AgentResult,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
} from "./types";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
export interface PromptEnhancementOptions {
|
||||
template?:
|
||||
| 'photorealistic'
|
||||
| 'illustration'
|
||||
| 'minimalist'
|
||||
| 'sticker'
|
||||
| 'product'
|
||||
| 'comic'
|
||||
| 'general';
|
||||
| "photorealistic"
|
||||
| "illustration"
|
||||
| "minimalist"
|
||||
| "sticker"
|
||||
| "product"
|
||||
| "comic"
|
||||
| "general";
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,10 @@ export interface AgentResult {
|
|||
|
||||
export interface IPromptAgent {
|
||||
readonly templateType: string;
|
||||
enhance(prompt: string, options: PromptEnhancementOptions): Promise<AgentResult>;
|
||||
enhance(
|
||||
prompt: string,
|
||||
options: PromptEnhancementOptions,
|
||||
): Promise<AgentResult>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
|
|
|
|||
|
|
@ -1,36 +1,46 @@
|
|||
export function detectLanguage(text: string): string {
|
||||
if (/[\u4e00-\u9fff]/.test(text)) return 'Chinese';
|
||||
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'Japanese';
|
||||
if (/[\uac00-\ud7af]/.test(text)) return 'Korean';
|
||||
if (/[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(text)) return 'Romance Language';
|
||||
if (/[а-яё]/.test(text.toLowerCase())) return 'Russian';
|
||||
if (/[α-ωΑ-Ω]/.test(text)) return 'Greek';
|
||||
if (/[أ-ي]/.test(text)) return 'Arabic';
|
||||
if (/[א-ת]/.test(text)) return 'Hebrew';
|
||||
return 'English';
|
||||
if (/[\u4e00-\u9fff]/.test(text)) return "Chinese";
|
||||
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return "Japanese";
|
||||
if (/[\uac00-\ud7af]/.test(text)) return "Korean";
|
||||
if (/[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(text))
|
||||
return "Romance Language";
|
||||
if (/[а-яё]/.test(text.toLowerCase())) return "Russian";
|
||||
if (/[α-ωΑ-Ω]/.test(text)) return "Greek";
|
||||
if (/[أ-ي]/.test(text)) return "Arabic";
|
||||
if (/[א-ת]/.test(text)) return "Hebrew";
|
||||
return "English";
|
||||
}
|
||||
|
||||
export function detectEnhancements(originalPrompt: string, enhancedPrompt: string): string[] {
|
||||
export function detectEnhancements(
|
||||
originalPrompt: string,
|
||||
enhancedPrompt: string,
|
||||
): string[] {
|
||||
const enhancements: string[] = [];
|
||||
|
||||
if (enhancedPrompt.length > originalPrompt.length * 1.5) {
|
||||
enhancements.push('Added detailed descriptions');
|
||||
enhancements.push("Added detailed descriptions");
|
||||
}
|
||||
|
||||
if (
|
||||
enhancedPrompt.includes('photorealistic') ||
|
||||
enhancedPrompt.includes('shot') ||
|
||||
enhancedPrompt.includes('lens')
|
||||
enhancedPrompt.includes("photorealistic") ||
|
||||
enhancedPrompt.includes("shot") ||
|
||||
enhancedPrompt.includes("lens")
|
||||
) {
|
||||
enhancements.push('Applied photography terminology');
|
||||
enhancements.push("Applied photography terminology");
|
||||
}
|
||||
|
||||
if (enhancedPrompt.includes('lighting') || enhancedPrompt.includes('illuminated')) {
|
||||
enhancements.push('Enhanced lighting description');
|
||||
if (
|
||||
enhancedPrompt.includes("lighting") ||
|
||||
enhancedPrompt.includes("illuminated")
|
||||
) {
|
||||
enhancements.push("Enhanced lighting description");
|
||||
}
|
||||
|
||||
if (enhancedPrompt.includes('texture') || enhancedPrompt.includes('surface')) {
|
||||
enhancements.push('Added texture details');
|
||||
if (
|
||||
enhancedPrompt.includes("texture") ||
|
||||
enhancedPrompt.includes("surface")
|
||||
) {
|
||||
enhancements.push("Added texture details");
|
||||
}
|
||||
|
||||
return enhancements;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { ValidationResult } from './types';
|
||||
import { ValidationResult } from "./types";
|
||||
|
||||
export function validatePromptLength(prompt: string, maxLength: number = 2000): ValidationResult {
|
||||
export function validatePromptLength(
|
||||
prompt: string,
|
||||
maxLength: number = 2000,
|
||||
): ValidationResult {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Prompt cannot be empty',
|
||||
error: "Prompt cannot be empty",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Request } from 'express';
|
||||
import { Request } from "express";
|
||||
|
||||
// API Request/Response types
|
||||
export interface GenerateImageRequest {
|
||||
|
|
@ -13,13 +13,13 @@ export interface TextToImageRequest {
|
|||
autoEnhance?: boolean; // Defaults to true
|
||||
enhancementOptions?: {
|
||||
template?:
|
||||
| 'photorealistic'
|
||||
| 'illustration'
|
||||
| 'minimalist'
|
||||
| 'sticker'
|
||||
| 'product'
|
||||
| 'comic'
|
||||
| 'general'; // Defaults to "photorealistic"
|
||||
| "photorealistic"
|
||||
| "illustration"
|
||||
| "minimalist"
|
||||
| "sticker"
|
||||
| "product"
|
||||
| "comic"
|
||||
| "general"; // Defaults to "photorealistic"
|
||||
};
|
||||
meta?: {
|
||||
tags?: string[]; // Optional array of tags for tracking/grouping (not stored, only logged)
|
||||
|
|
@ -98,7 +98,7 @@ export interface ImageGenerationResult {
|
|||
model: string;
|
||||
geminiParams?: GeminiParams; // Gemini SDK parameters used for generation
|
||||
error?: string;
|
||||
errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors
|
||||
errorType?: "generation" | "storage"; // Distinguish between generation and storage errors
|
||||
generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails
|
||||
}
|
||||
|
||||
|
|
@ -123,13 +123,13 @@ export interface PromptEnhancementRequest {
|
|||
prompt: string;
|
||||
options?: {
|
||||
template?:
|
||||
| 'photorealistic'
|
||||
| 'illustration'
|
||||
| 'minimalist'
|
||||
| 'sticker'
|
||||
| 'product'
|
||||
| 'comic'
|
||||
| 'general'; // Defaults to "photorealistic"
|
||||
| "photorealistic"
|
||||
| "illustration"
|
||||
| "minimalist"
|
||||
| "sticker"
|
||||
| "product"
|
||||
| "comic"
|
||||
| "general"; // Defaults to "photorealistic"
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -153,13 +153,13 @@ export interface EnhancedGenerateImageRequest extends GenerateImageRequest {
|
|||
autoEnhance?: boolean; // Defaults to true
|
||||
enhancementOptions?: {
|
||||
template?:
|
||||
| 'photorealistic'
|
||||
| 'illustration'
|
||||
| 'minimalist'
|
||||
| 'sticker'
|
||||
| 'product'
|
||||
| 'comic'
|
||||
| 'general'; // Defaults to "photorealistic"
|
||||
| "photorealistic"
|
||||
| "illustration"
|
||||
| "minimalist"
|
||||
| "sticker"
|
||||
| "product"
|
||||
| "comic"
|
||||
| "general"; // Defaults to "photorealistic"
|
||||
};
|
||||
meta?: {
|
||||
tags?: string[];
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
import * as dns from 'dns';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const dnsLookup = promisify(dns.lookup);
|
||||
|
||||
export interface NetworkErrorResult {
|
||||
isNetworkError: boolean;
|
||||
errorType?: 'connection' | 'dns' | 'timeout' | 'refused' | 'reset' | 'unknown';
|
||||
userMessage: string;
|
||||
technicalDetails?: string;
|
||||
}
|
||||
|
||||
export class NetworkErrorDetector {
|
||||
private static NETWORK_ERROR_CODES = [
|
||||
'ENOTFOUND', // DNS resolution failed
|
||||
'ETIMEDOUT', // Connection timeout
|
||||
'ECONNREFUSED', // Connection refused
|
||||
'ECONNRESET', // Connection reset
|
||||
'ENETUNREACH', // Network unreachable
|
||||
'EHOSTUNREACH', // Host unreachable
|
||||
'EAI_AGAIN', // DNS temporary failure
|
||||
];
|
||||
|
||||
/**
|
||||
* Classify an error and determine if it's network-related
|
||||
* This is called ONLY when an error occurs (lazy validation)
|
||||
*/
|
||||
static async classifyError(error: Error, serviceName: string = 'API'): Promise<NetworkErrorResult> {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
const errorCode = (error as any).code?.toUpperCase();
|
||||
|
||||
// Check if it's a known network error
|
||||
if (this.isNetworkErrorCode(errorCode) || this.containsNetworkKeywords(errorMessage)) {
|
||||
// Perform a quick connectivity check to confirm
|
||||
const isOnline = await this.quickConnectivityCheck();
|
||||
|
||||
if (!isOnline) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'connection',
|
||||
userMessage: `Network connection failed: Unable to reach ${serviceName}. Please check your internet connection.`,
|
||||
technicalDetails: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Network is up, but specific error occurred
|
||||
const specificError = this.getSpecificNetworkError(errorCode, errorMessage, serviceName);
|
||||
return specificError;
|
||||
}
|
||||
|
||||
// Not a network error - return original error
|
||||
return {
|
||||
isNetworkError: false,
|
||||
userMessage: error.message,
|
||||
technicalDetails: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick connectivity check - only called when an error occurs
|
||||
* Tests DNS resolution to a reliable endpoint (Google DNS)
|
||||
*/
|
||||
private static async quickConnectivityCheck(): Promise<boolean> {
|
||||
try {
|
||||
// Use Google's public DNS as a reliable test endpoint
|
||||
await Promise.race([
|
||||
dnsLookup('dns.google'),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error code is a known network error
|
||||
*/
|
||||
private static isNetworkErrorCode(code?: string): boolean {
|
||||
if (!code) return false;
|
||||
return this.NETWORK_ERROR_CODES.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error message contains network-related keywords
|
||||
*/
|
||||
private static containsNetworkKeywords(message: string): boolean {
|
||||
const keywords = [
|
||||
'fetch failed',
|
||||
'network',
|
||||
'connection',
|
||||
'timeout',
|
||||
'dns',
|
||||
'unreachable',
|
||||
'refused',
|
||||
'reset',
|
||||
'enotfound',
|
||||
'etimedout',
|
||||
'econnrefused',
|
||||
'econnreset',
|
||||
];
|
||||
|
||||
return keywords.some((keyword) => message.includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific error details based on error code/message
|
||||
*/
|
||||
private static getSpecificNetworkError(
|
||||
code: string | undefined,
|
||||
message: string,
|
||||
serviceName: string,
|
||||
): NetworkErrorResult {
|
||||
// DNS resolution failures
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || message.includes('dns')) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'dns',
|
||||
userMessage: `DNS resolution failed: Unable to resolve ${serviceName} hostname. Check your DNS settings or internet connection.`,
|
||||
technicalDetails: `Error code: ${code || 'unknown'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Connection timeout
|
||||
if (code === 'ETIMEDOUT' || message.includes('timeout')) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'timeout',
|
||||
userMessage: `Connection timeout: ${serviceName} did not respond in time. This may be due to slow internet or firewall blocking.`,
|
||||
technicalDetails: `Error code: ${code || 'timeout'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Connection refused
|
||||
if (code === 'ECONNREFUSED' || message.includes('refused')) {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'refused',
|
||||
userMessage: `Connection refused: ${serviceName} is not accepting connections. Service may be down or blocked by firewall.`,
|
||||
technicalDetails: `Error code: ${code || 'refused'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Connection reset
|
||||
if (code === 'ECONNRESET' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'reset',
|
||||
userMessage: `Connection lost: Network connection to ${serviceName} was interrupted. Check your internet stability.`,
|
||||
technicalDetails: `Error code: ${code || 'reset'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Generic network error
|
||||
return {
|
||||
isNetworkError: true,
|
||||
errorType: 'unknown',
|
||||
userMessage: `Network error: Unable to connect to ${serviceName}. Please check your internet connection and firewall settings.`,
|
||||
technicalDetails: message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for logging with both user-friendly and technical details
|
||||
*/
|
||||
static formatErrorForLogging(result: NetworkErrorResult): string {
|
||||
if (result.isNetworkError) {
|
||||
return `[NETWORK ERROR - ${result.errorType?.toUpperCase()}] ${result.userMessage}${
|
||||
result.technicalDetails ? ` | Technical: ${result.technicalDetails}` : ''
|
||||
}`;
|
||||
}
|
||||
return result.userMessage;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
/**
|
||||
* Manual test script for NetworkErrorDetector
|
||||
* Run with: npx tsx test-network-error-detector.ts
|
||||
*/
|
||||
|
||||
import { NetworkErrorDetector } from './src/utils/NetworkErrorDetector';
|
||||
|
||||
async function testNetworkErrorDetector() {
|
||||
console.log('🧪 Testing NetworkErrorDetector\n');
|
||||
|
||||
// Test 1: DNS Error
|
||||
console.log('Test 1: DNS Resolution Error (ENOTFOUND)');
|
||||
const dnsError = new Error('getaddrinfo ENOTFOUND api.example.com');
|
||||
(dnsError as any).code = 'ENOTFOUND';
|
||||
const dnsResult = await NetworkErrorDetector.classifyError(dnsError, 'Gemini API');
|
||||
console.log(' Is Network Error:', dnsResult.isNetworkError);
|
||||
console.log(' Error Type:', dnsResult.errorType);
|
||||
console.log(' User Message:', dnsResult.userMessage);
|
||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(dnsResult));
|
||||
console.log('');
|
||||
|
||||
// Test 2: Timeout Error
|
||||
console.log('Test 2: Connection Timeout (ETIMEDOUT)');
|
||||
const timeoutError = new Error('connect ETIMEDOUT 142.250.185.10:443');
|
||||
(timeoutError as any).code = 'ETIMEDOUT';
|
||||
const timeoutResult = await NetworkErrorDetector.classifyError(timeoutError, 'Gemini API');
|
||||
console.log(' Is Network Error:', timeoutResult.isNetworkError);
|
||||
console.log(' Error Type:', timeoutResult.errorType);
|
||||
console.log(' User Message:', timeoutResult.userMessage);
|
||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(timeoutResult));
|
||||
console.log('');
|
||||
|
||||
// Test 3: Fetch Failed (actual error from logs)
|
||||
console.log('Test 3: Fetch Failed Error (from your logs)');
|
||||
const fetchError = new Error('exception TypeError: fetch failed sending request');
|
||||
const fetchResult = await NetworkErrorDetector.classifyError(fetchError, 'Gemini API');
|
||||
console.log(' Is Network Error:', fetchResult.isNetworkError);
|
||||
console.log(' Error Type:', fetchResult.errorType);
|
||||
console.log(' User Message:', fetchResult.userMessage);
|
||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(fetchResult));
|
||||
console.log('');
|
||||
|
||||
// Test 4: Non-network Error
|
||||
console.log('Test 4: Non-Network Error (Invalid API Key)');
|
||||
const apiError = new Error('Invalid API key provided');
|
||||
const apiResult = await NetworkErrorDetector.classifyError(apiError, 'Gemini API');
|
||||
console.log(' Is Network Error:', apiResult.isNetworkError);
|
||||
console.log(' User Message:', apiResult.userMessage);
|
||||
console.log(' Formatted Log:', NetworkErrorDetector.formatErrorForLogging(apiResult));
|
||||
console.log('');
|
||||
|
||||
console.log('✅ Tests completed!');
|
||||
console.log('\n📝 Summary:');
|
||||
console.log(' - Network errors are detected and classified');
|
||||
console.log(' - User-friendly messages are provided');
|
||||
console.log(' - Technical details are preserved for logging');
|
||||
console.log(' - No overhead on successful requests (only runs on failures)');
|
||||
}
|
||||
|
||||
testNetworkErrorDetector().catch(console.error);
|
||||
|
|
@ -35,8 +35,14 @@
|
|||
"@/utils/*": ["src/utils/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests/**/*"],
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"tests/**/*"
|
||||
],
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals", "prettier"],
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,13 +7,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
## Components Architecture
|
||||
|
||||
### 1. MinimizedApiKey Component
|
||||
|
||||
**Location:** `src/components/demo/MinimizedApiKey.tsx`
|
||||
|
||||
**Purpose:** Minimizes API key section to a badge after validation, freeing up valuable screen space.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Fixed position in top-right corner (z-index: 40)
|
||||
- Collapsed state: Shows `org/project` slugs with green status indicator
|
||||
- Expanded state: Full card with API key visibility toggle and revoke button
|
||||
|
|
@ -22,13 +20,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- ARIA labels for screen readers
|
||||
|
||||
**Design Patterns:**
|
||||
|
||||
- Badge: `px-4 py-2 bg-slate-900/95 backdrop-blur-sm border border-slate-700 rounded-full`
|
||||
- Green indicator: `w-2 h-2 rounded-full bg-green-400`
|
||||
- Hover states with amber accent
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- `aria-label` on all buttons
|
||||
- Focus ring on interactions: `focus:ring-2 focus:ring-amber-500`
|
||||
- Keyboard navigation support
|
||||
|
|
@ -37,13 +33,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
---
|
||||
|
||||
### 2. PromptReuseButton Component
|
||||
|
||||
**Location:** `src/components/demo/PromptReuseButton.tsx`
|
||||
|
||||
**Purpose:** Allows users to quickly reuse prompts from previous generations.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Small, compact button next to prompt text
|
||||
- Visual feedback on click (changes to "Inserted" state)
|
||||
- Auto-resets after 1 second
|
||||
|
|
@ -51,14 +45,12 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Icon + text label for clarity
|
||||
|
||||
**Design Patterns:**
|
||||
|
||||
- Compact size: `px-2 py-1 text-xs`
|
||||
- Slate background with amber hover: `bg-slate-800/50 hover:bg-amber-600/20`
|
||||
- Border transition: `border-slate-700 hover:border-amber-600/50`
|
||||
- Refresh icon (↻) for "reuse" action
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Descriptive `aria-label` with context
|
||||
- Title attribute for tooltip
|
||||
- Focus indicator
|
||||
|
|
@ -67,18 +59,15 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
---
|
||||
|
||||
### 3. GenerationTimer Component
|
||||
|
||||
**Location:** `src/components/demo/GenerationTimer.tsx`
|
||||
|
||||
**Purpose:** Shows live generation time during API calls and final duration on results.
|
||||
|
||||
**Components:**
|
||||
|
||||
- `GenerationTimer`: Live timer during generation (updates every 100ms)
|
||||
- `CompletedTimerBadge`: Static badge showing final duration
|
||||
|
||||
**Features:**
|
||||
|
||||
- Live updates during generation with spinning icon
|
||||
- Format: "⏱️ 2.3s"
|
||||
- Two variants: `inline` (with spinner) and `badge` (compact)
|
||||
|
|
@ -86,14 +75,12 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Green badge for completed generations
|
||||
|
||||
**Design Patterns:**
|
||||
|
||||
- Inline: `text-sm text-gray-400` with amber clock icon
|
||||
- Badge: `bg-slate-900/80 border border-slate-700 rounded-md`
|
||||
- Completed: `bg-green-900/20 border border-green-700/50 text-green-400`
|
||||
- Spinning animation on clock icon during generation
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Live region for screen readers (implicit via state updates)
|
||||
- Clear visual distinction between active/completed states
|
||||
- Sufficient color contrast (WCAG AA compliant)
|
||||
|
|
@ -101,13 +88,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
---
|
||||
|
||||
### 4. InspectMode Component
|
||||
|
||||
**Location:** `src/components/demo/InspectMode.tsx`
|
||||
|
||||
**Purpose:** Developer tool to inspect raw API request/response data and Gemini parameters.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Two-column layout (left: original, right: enhanced)
|
||||
- Three collapsible sections per column:
|
||||
- API Request
|
||||
|
|
@ -119,7 +104,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Max height with scroll for long data
|
||||
|
||||
**Design Patterns:**
|
||||
|
||||
- Grid layout: `grid md:grid-cols-2 gap-4`
|
||||
- Collapsible headers: `bg-slate-900/50 hover:bg-slate-900/70`
|
||||
- JSON container: `bg-slate-950/50 border border-slate-700 rounded-lg`
|
||||
|
|
@ -130,7 +114,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Booleans/null: `text-purple-400`
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- `aria-expanded` on collapsible buttons
|
||||
- Descriptive `aria-label` for each section
|
||||
- Keyboard navigation (Enter/Space to toggle)
|
||||
|
|
@ -138,7 +121,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Scrollable with overflow for long content
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
- JSON escaping for safe HTML rendering
|
||||
- `dangerouslySetInnerHTML` used ONLY for pre-sanitized content
|
||||
- Each section independently collapsible
|
||||
|
|
@ -147,13 +129,11 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
---
|
||||
|
||||
### 5. ResultCard Component
|
||||
|
||||
**Location:** `src/components/demo/ResultCard.tsx`
|
||||
|
||||
**Purpose:** Enhanced result display with preview/inspect modes and code examples.
|
||||
|
||||
**Features:**
|
||||
|
||||
- **View Mode Toggle:** Switch between Preview (images) and Inspect (data)
|
||||
- **Image Preview Mode:**
|
||||
- Side-by-side image comparison (horizontal scroll)
|
||||
|
|
@ -171,7 +151,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Terminal-style UI with traffic light dots
|
||||
|
||||
**Design Patterns:**
|
||||
|
||||
- Card: `p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl`
|
||||
- Mode toggle: `bg-slate-950/50 border border-slate-700 rounded-lg`
|
||||
- Active tab: `bg-amber-600 text-white`
|
||||
|
|
@ -179,7 +158,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Code block: Terminal UI with red/yellow/green dots
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- `aria-pressed` on mode toggle buttons
|
||||
- Semantic HTML for tab structure
|
||||
- Keyboard navigation (Tab, Arrow keys)
|
||||
|
|
@ -188,7 +166,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Download button with descriptive label
|
||||
|
||||
**Responsive Behavior:**
|
||||
|
||||
- Mobile (< 768px):
|
||||
- Single column layout
|
||||
- Horizontal scroll for images
|
||||
|
|
@ -201,7 +178,6 @@ Transformed the demo TTI page into a robust debugging workbench for developers t
|
|||
- Full layout with optimal spacing
|
||||
|
||||
**Code Examples - REST Format:**
|
||||
|
||||
```
|
||||
### Generate Image - Text to Image
|
||||
POST http://localhost:3000/api/text-to-image
|
||||
|
|
@ -217,7 +193,6 @@ X-API-Key: your-api-key
|
|||
---
|
||||
|
||||
## Main Page Refactoring
|
||||
|
||||
**Location:** `src/app/demo/tti/page.tsx`
|
||||
|
||||
### Changes Made
|
||||
|
|
@ -257,7 +232,6 @@ X-API-Key: your-api-key
|
|||
All components strictly follow the Banatie design system:
|
||||
|
||||
### Colors
|
||||
|
||||
- Backgrounds: `bg-slate-950`, `bg-slate-900/80`, `bg-slate-800`
|
||||
- Gradients: `from-amber-600 to-orange-600`
|
||||
- Text: `text-white`, `text-gray-300`, `text-gray-400`, `text-gray-500`
|
||||
|
|
@ -265,28 +239,24 @@ All components strictly follow the Banatie design system:
|
|||
- Accents: Amber for primary actions, green for success, red for errors
|
||||
|
||||
### Typography
|
||||
|
||||
- Headings: `text-3xl md:text-4xl lg:text-5xl font-bold text-white`
|
||||
- Subheadings: `text-lg font-semibold text-white`
|
||||
- Body: `text-sm text-gray-300`
|
||||
- Small: `text-xs text-gray-400`
|
||||
|
||||
### Spacing
|
||||
|
||||
- Container: `max-w-7xl mx-auto px-6`
|
||||
- Card padding: `p-5` (compact) or `p-6` (standard)
|
||||
- Section gaps: `space-y-6` or `space-y-8`
|
||||
- Button padding: `px-6 py-2.5` (compact), `px-8 py-3` (standard)
|
||||
|
||||
### Rounded Corners
|
||||
|
||||
- Cards: `rounded-2xl`
|
||||
- Buttons: `rounded-lg`
|
||||
- Inputs: `rounded-lg`
|
||||
- Badges: `rounded-full` (minimized API key), `rounded-md` (small badges)
|
||||
|
||||
### Transitions
|
||||
|
||||
- All interactive elements: `transition-all` or `transition-colors`
|
||||
- Hover states smooth and predictable
|
||||
- Animations: `animate-fade-in` (0.5s ease-out)
|
||||
|
|
@ -296,13 +266,11 @@ All components strictly follow the Banatie design system:
|
|||
## Accessibility Compliance (WCAG 2.1 AA)
|
||||
|
||||
### Semantic HTML
|
||||
|
||||
- Proper heading hierarchy: h1 → h2 (no skipped levels)
|
||||
- Landmark regions: `<header>`, `<section>`, `<main>` (implicit)
|
||||
- Form labels properly associated
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- All interactive elements keyboard accessible
|
||||
- Tab order logical and sequential
|
||||
- Focus indicators visible on all focusable elements
|
||||
|
|
@ -310,13 +278,11 @@ All components strictly follow the Banatie design system:
|
|||
- Enter key validation support
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Text on backgrounds: Minimum 4.5:1 (tested with Banatie colors)
|
||||
- Interactive elements clearly distinguishable
|
||||
- Disabled states visible but distinct
|
||||
|
||||
### ARIA Attributes
|
||||
|
||||
- `aria-label` on icon-only buttons
|
||||
- `aria-pressed` on toggle buttons
|
||||
- `aria-expanded` on collapsible sections
|
||||
|
|
@ -325,7 +291,6 @@ All components strictly follow the Banatie design system:
|
|||
- `aria-label` on sections for screen reader context
|
||||
|
||||
### Screen Reader Support
|
||||
|
||||
- Meaningful alt text on images
|
||||
- Button labels descriptive ("Close zoomed image" not just "Close")
|
||||
- State changes announced (via ARIA live regions)
|
||||
|
|
@ -355,7 +320,6 @@ All components strictly follow the Banatie design system:
|
|||
## Responsive Breakpoints
|
||||
|
||||
### Mobile (< 768px)
|
||||
|
||||
- Single column layouts
|
||||
- Stacked buttons with wrapping
|
||||
- Horizontal scroll for image comparison
|
||||
|
|
@ -363,20 +327,17 @@ All components strictly follow the Banatie design system:
|
|||
- Text sizes: base, sm, xs
|
||||
|
||||
### Tablet (>= 768px, md:)
|
||||
|
||||
- Two-column inspect mode
|
||||
- Side-by-side images maintained
|
||||
- Increased padding
|
||||
- Text sizes: md scale up
|
||||
|
||||
### Desktop (>= 1024px, lg:)
|
||||
|
||||
- Optimal spacing
|
||||
- Full feature display
|
||||
- Larger text sizes
|
||||
|
||||
### XL (>= 1280px, xl:)
|
||||
|
||||
- Max width container constrains growth
|
||||
- Centered content
|
||||
|
||||
|
|
@ -405,7 +366,6 @@ apps/landing/src/
|
|||
## Usage Examples
|
||||
|
||||
### Reusing a Prompt
|
||||
|
||||
1. Generate images
|
||||
2. Find the prompt you want to reuse
|
||||
3. Click "Reuse" button next to the prompt
|
||||
|
|
@ -413,7 +373,6 @@ apps/landing/src/
|
|||
5. Focus shifts to textarea for editing
|
||||
|
||||
### Inspecting API Data
|
||||
|
||||
1. Generate images
|
||||
2. Click "Inspect" mode toggle in result card
|
||||
3. View request/response data in two columns
|
||||
|
|
@ -421,7 +380,6 @@ apps/landing/src/
|
|||
5. Copy JSON with copy buttons
|
||||
|
||||
### Using REST Code Example
|
||||
|
||||
1. Generate images
|
||||
2. Navigate to code examples section
|
||||
3. Click "REST" tab
|
||||
|
|
@ -450,14 +408,12 @@ apps/landing/src/
|
|||
## Browser Compatibility
|
||||
|
||||
Tested and designed for:
|
||||
|
||||
- Chrome/Edge (Chromium)
|
||||
- Firefox
|
||||
- Safari
|
||||
- Mobile browsers (iOS Safari, Chrome Android)
|
||||
|
||||
Uses standard web APIs:
|
||||
|
||||
- Clipboard API (navigator.clipboard)
|
||||
- CSS Grid and Flexbox
|
||||
- CSS Custom Properties
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NextConfig } from 'next';
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
|
|
|
|||
|
|
@ -87,8 +87,7 @@ export default function ApiKeysPage() {
|
|||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Project API Keys</h1>
|
||||
<p className="text-slate-400">
|
||||
Generate API keys for your projects. Organizations and projects will be created
|
||||
automatically if they don't exist.
|
||||
Generate API keys for your projects. Organizations and projects will be created automatically if they don't exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -161,13 +160,9 @@ export default function ApiKeysPage() {
|
|||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="border-b border-slate-800">
|
||||
<td className="py-3 text-sm text-slate-300">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
key.keyType === 'master'
|
||||
? 'bg-amber-900/30 text-amber-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
key.keyType === 'master' ? 'bg-amber-900/30 text-amber-400' : 'bg-blue-900/30 text-blue-400'
|
||||
}`}>
|
||||
{key.keyType}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -185,13 +180,9 @@ export default function ApiKeysPage() {
|
|||
{key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td className="py-3 text-sm">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
key.isActive
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-red-900/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
key.isActive ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'
|
||||
}`}>
|
||||
{key.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -88,8 +88,7 @@ export default function MasterKeyPage() {
|
|||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Master Key Management</h1>
|
||||
<p className="text-slate-400">
|
||||
Bootstrap your master key or manually configure it. This key is required to generate
|
||||
project API keys.
|
||||
Bootstrap your master key or manually configure it. This key is required to generate project API keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -229,8 +229,8 @@ export default function DemoTTIPage() {
|
|||
aspectRatio,
|
||||
autoEnhance: false, // Explicitly disable enhancement for left image
|
||||
meta: {
|
||||
tags: [pairId, 'simple'], // NEW: Pair ID + "simple" tag
|
||||
},
|
||||
tags: [pairId, 'simple'] // NEW: Pair ID + "simple" tag
|
||||
}
|
||||
}),
|
||||
}),
|
||||
fetch(`${API_BASE_URL}/api/text-to-image`, {
|
||||
|
|
@ -248,8 +248,8 @@ export default function DemoTTIPage() {
|
|||
template: template || 'photorealistic', // Only template parameter
|
||||
},
|
||||
meta: {
|
||||
tags: [pairId, 'enhanced'], // NEW: Pair ID + "enhanced" tag
|
||||
},
|
||||
tags: [pairId, 'enhanced'] // NEW: Pair ID + "enhanced" tag
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
|
@ -289,8 +289,8 @@ export default function DemoTTIPage() {
|
|||
aspectRatio,
|
||||
autoEnhance: false,
|
||||
meta: {
|
||||
tags: [pairId, 'simple'],
|
||||
},
|
||||
tags: [pairId, 'simple']
|
||||
}
|
||||
},
|
||||
response: leftData,
|
||||
geminiParams: leftData.data?.geminiParams || {},
|
||||
|
|
@ -305,8 +305,8 @@ export default function DemoTTIPage() {
|
|||
template: template || 'photorealistic',
|
||||
},
|
||||
meta: {
|
||||
tags: [pairId, 'enhanced'],
|
||||
},
|
||||
tags: [pairId, 'enhanced']
|
||||
}
|
||||
},
|
||||
response: rightData,
|
||||
geminiParams: rightData.data?.geminiParams || {},
|
||||
|
|
@ -315,8 +315,8 @@ export default function DemoTTIPage() {
|
|||
enhancementOptions: {
|
||||
template,
|
||||
meta: {
|
||||
tags: [pairId, 'enhanced'],
|
||||
},
|
||||
tags: [pairId, 'enhanced']
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -334,7 +334,9 @@ export default function DemoTTIPage() {
|
|||
// Clear prompt
|
||||
setPrompt('');
|
||||
} catch (error) {
|
||||
setGenerationError(error instanceof Error ? error.message : 'Failed to generate images');
|
||||
setGenerationError(
|
||||
error instanceof Error ? error.message : 'Failed to generate images'
|
||||
);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
setGenerationStartTime(undefined);
|
||||
|
|
@ -402,10 +404,7 @@ export default function DemoTTIPage() {
|
|||
|
||||
{/* API Key Section - Only show when not validated */}
|
||||
{!apiKeyValidated && (
|
||||
<section
|
||||
className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||
aria-label="API Key Validation"
|
||||
>
|
||||
<section className="mb-6 p-5 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="API Key Validation">
|
||||
<h2 className="text-lg font-semibold text-white mb-3">API Key</h2>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
|
|
@ -431,27 +430,12 @@ export default function DemoTTIPage() {
|
|||
>
|
||||
{apiKeyVisible ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -474,10 +458,7 @@ export default function DemoTTIPage() {
|
|||
)}
|
||||
|
||||
{/* Unified Prompt & Generation Card */}
|
||||
<section
|
||||
className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl"
|
||||
aria-label="Image Generation"
|
||||
>
|
||||
<section className="mb-8 p-6 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-2xl" aria-label="Image Generation">
|
||||
{/* Prompt Textarea */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="prompt-input" className="block text-lg font-semibold text-white mb-3">
|
||||
|
|
@ -501,10 +482,7 @@ export default function DemoTTIPage() {
|
|||
<div className="mb-4 flex flex-col md:flex-row gap-3 items-start md:items-end">
|
||||
{/* Aspect Ratio */}
|
||||
<div className="flex-1 min-w-[150px]">
|
||||
<label
|
||||
htmlFor="aspect-ratio"
|
||||
className="block text-xs font-medium text-gray-400 mb-1.5"
|
||||
>
|
||||
<label htmlFor="aspect-ratio" className="block text-xs font-medium text-gray-400 mb-1.5">
|
||||
Aspect Ratio
|
||||
</label>
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0f172a;
|
||||
|
|
@ -15,18 +15,12 @@
|
|||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes gradient-shift {
|
||||
0%,
|
||||
100% {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
|
|
|
|||
|
|
@ -1,45 +1,35 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import Image from 'next/image';
|
||||
import './globals.css';
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import Image from "next/image";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Banatie - AI Image Generation API | Developer-First Platform',
|
||||
description:
|
||||
"Transform text and reference images into production-ready visuals with Banatie's developer-first AI image generation API. Powered by Google Gemini & Imagen 4.0. Join the beta.",
|
||||
keywords: [
|
||||
'AI image generation',
|
||||
'image generation API',
|
||||
'text to image',
|
||||
'Gemini API',
|
||||
'developer tools',
|
||||
'REST API',
|
||||
'image AI',
|
||||
],
|
||||
authors: [{ name: 'Banatie Team' }],
|
||||
creator: 'Banatie',
|
||||
publisher: 'Banatie',
|
||||
metadataBase: new URL('https://banatie.com'),
|
||||
title: "Banatie - AI Image Generation API | Developer-First Platform",
|
||||
description: "Transform text and reference images into production-ready visuals with Banatie's developer-first AI image generation API. Powered by Google Gemini & Imagen 4.0. Join the beta.",
|
||||
keywords: ["AI image generation", "image generation API", "text to image", "Gemini API", "developer tools", "REST API", "image AI"],
|
||||
authors: [{ name: "Banatie Team" }],
|
||||
creator: "Banatie",
|
||||
publisher: "Banatie",
|
||||
metadataBase: new URL("https://banatie.com"),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'https://banatie.com',
|
||||
title: 'Banatie - AI Image Generation API for Developers',
|
||||
description:
|
||||
'Developer-first API for AI-powered image generation. Transform text and reference images into production-ready visuals in seconds.',
|
||||
siteName: 'Banatie',
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://banatie.com",
|
||||
title: "Banatie - AI Image Generation API for Developers",
|
||||
description: "Developer-first API for AI-powered image generation. Transform text and reference images into production-ready visuals in seconds.",
|
||||
siteName: "Banatie",
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Banatie - AI Image Generation API',
|
||||
description: 'Developer-first API for AI-powered image generation. Join the beta.',
|
||||
creator: '@banatie',
|
||||
card: "summary_large_image",
|
||||
title: "Banatie - AI Image Generation API",
|
||||
description: "Developer-first API for AI-powered image generation. Join the beta.",
|
||||
creator: "@banatie",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
|
@ -47,13 +37,13 @@ export const metadata: Metadata = {
|
|||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
google: 'google-site-verification-code',
|
||||
google: "google-site-verification-code",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -115,18 +105,10 @@ export default function RootLayout({
|
|||
/>
|
||||
</div>
|
||||
<div className="flex gap-8 text-sm text-gray-400">
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Documentation
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
API Reference
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Pricing
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Contact
|
||||
</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Documentation</a>
|
||||
<a href="#" className="hover:text-white transition-colors">API Reference</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Pricing</a>
|
||||
<a href="#" className="hover:text-white transition-colors">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function Home() {
|
|||
// TODO: Replace with actual API endpoint
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
setMessage("You're on the list! Check your email for beta access details.");
|
||||
setMessage('You\'re on the list! Check your email for beta access details.');
|
||||
setEmail('');
|
||||
}, 1000);
|
||||
};
|
||||
|
|
@ -60,11 +60,7 @@ export default function Home() {
|
|||
disabled={status === 'loading' || status === 'success'}
|
||||
className="px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-cyan-600 text-white font-semibold hover:from-purple-500 hover:to-cyan-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-purple-500/25"
|
||||
>
|
||||
{status === 'loading'
|
||||
? 'Joining...'
|
||||
: status === 'success'
|
||||
? 'Joined!'
|
||||
: 'Join Beta'}
|
||||
{status === 'loading' ? 'Joining...' : status === 'success' ? 'Joined!' : 'Join Beta'}
|
||||
</button>
|
||||
</form>
|
||||
{status === 'success' && (
|
||||
|
|
@ -130,10 +126,11 @@ export default function Home() {
|
|||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||
🚀
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Instant Integration</h3>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
Instant Integration
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
Clean REST API that works with any stack. From hobbyist to enterprise, start
|
||||
generating images in minutes.
|
||||
Clean REST API that works with any stack. From hobbyist to enterprise, start generating images in minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -142,10 +139,11 @@ export default function Home() {
|
|||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||
🎯
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Reference-Guided Generation</h3>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
Reference-Guided Generation
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
Upload up to 3 reference images alongside your prompt. Perfect for brand consistency
|
||||
and style matching.
|
||||
Upload up to 3 reference images alongside your prompt. Perfect for brand consistency and style matching.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -154,10 +152,11 @@ export default function Home() {
|
|||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||
✨
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Smart Prompt Enhancement</h3>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
Smart Prompt Enhancement
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
AI-powered prompt optimization with language detection. Get better results
|
||||
automatically.
|
||||
AI-powered prompt optimization with language detection. Get better results automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -166,10 +165,11 @@ export default function Home() {
|
|||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||
🔐
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Enterprise-Ready Security</h3>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
Enterprise-Ready Security
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
API key management, rate limiting, multi-tenant architecture. Built for production
|
||||
from day one.
|
||||
API key management, rate limiting, multi-tenant architecture. Built for production from day one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -178,10 +178,11 @@ export default function Home() {
|
|||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||
⚡
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Dual AI Models</h3>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
Dual AI Models
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
Powered by Google Gemini 2.5 Flash and Imagen 4.0. Automatic fallback ensures
|
||||
reliability.
|
||||
Powered by Google Gemini 2.5 Flash and Imagen 4.0. Automatic fallback ensures reliability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -190,10 +191,11 @@ export default function Home() {
|
|||
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center mb-4 text-2xl">
|
||||
📦
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">Organized Cloud Storage</h3>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">
|
||||
Organized Cloud Storage
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
Automatic file organization by org/project/category. CDN-ready URLs for instant
|
||||
delivery.
|
||||
Automatic file organization by org/project/category. CDN-ready URLs for instant delivery.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -202,7 +204,9 @@ export default function Home() {
|
|||
{/* CTA Section */}
|
||||
<section className="relative z-10 max-w-4xl mx-auto px-6 py-16 md:py-24">
|
||||
<div className="text-center p-12 rounded-3xl bg-gradient-to-br from-purple-600/20 via-cyan-600/20 to-purple-600/20 border border-purple-500/30 backdrop-blur-sm">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">Join the Beta Program</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Join the Beta Program
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 mb-8">
|
||||
Get early access, shape the product, and lock in founder pricing.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ export default function AdminButton({
|
|||
disabled = false,
|
||||
className = '',
|
||||
}: AdminButtonProps) {
|
||||
const baseClasses =
|
||||
'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const baseClasses = 'px-6 py-3 font-semibold rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-amber-600 text-white hover:bg-amber-700 shadow-lg shadow-amber-900/30',
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ export default function CopyButton({ text, label = 'Copy', className = '' }: Cop
|
|||
<button
|
||||
onClick={handleCopy}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
copied ? 'bg-green-600 text-white' : 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||
copied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-slate-700 text-slate-200 hover:bg-slate-600'
|
||||
} ${className}`}
|
||||
>
|
||||
{copied ? '✓ Copied!' : label}
|
||||
|
|
|
|||
|
|
@ -171,10 +171,7 @@ export function AdvancedOptionsModal({
|
|||
|
||||
{/* Negative Prompts */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="negative-prompts"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
<label htmlFor="negative-prompts" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Negative Prompts
|
||||
</label>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -8,11 +8,7 @@ interface GenerationTimerProps {
|
|||
variant?: 'inline' | 'badge';
|
||||
}
|
||||
|
||||
export function GenerationTimer({
|
||||
isGenerating,
|
||||
startTime,
|
||||
variant = 'inline',
|
||||
}: GenerationTimerProps) {
|
||||
export function GenerationTimer({ isGenerating, startTime, variant = 'inline' }: GenerationTimerProps) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -39,18 +35,8 @@ export function GenerationTimer({
|
|||
if (variant === 'badge') {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-md text-xs text-gray-300">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{formatTime(elapsed)}s</span>
|
||||
</div>
|
||||
|
|
@ -60,19 +46,8 @@ export function GenerationTimer({
|
|||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<svg className="w-4 h-4 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="font-medium">{formatTime(elapsed)}s</span>
|
||||
</span>
|
||||
|
|
@ -91,12 +66,7 @@ export function CompletedTimerBadge({ durationMs }: CompletedTimerBadgeProps) {
|
|||
return (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-green-900/20 border border-green-700/50 rounded text-xs text-green-400">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{formatTime(durationMs)}s</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ export function InspectMode({ leftData, rightData, onCopy }: InspectModeProps) {
|
|||
onCopy={onCopy}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
<CollapsibleSection title="API Response" data={leftData.response} onCopy={onCopy} />
|
||||
<CollapsibleSection
|
||||
title="API Response"
|
||||
data={leftData.response}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<CollapsibleSection
|
||||
title="Gemini Parameters"
|
||||
data={leftData.geminiParams}
|
||||
|
|
@ -51,7 +55,11 @@ export function InspectMode({ leftData, rightData, onCopy }: InspectModeProps) {
|
|||
onCopy={onCopy}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
<CollapsibleSection title="API Response" data={rightData.response} onCopy={onCopy} />
|
||||
<CollapsibleSection
|
||||
title="API Response"
|
||||
data={rightData.response}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
<CollapsibleSection
|
||||
title="Gemini Parameters"
|
||||
data={rightData.geminiParams}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ export function MinimizedApiKey({
|
|||
className="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-gray-400 hover:text-white flex items-center justify-center transition-colors"
|
||||
aria-label="Minimize API key details"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -86,27 +91,12 @@ export function MinimizedApiKey({
|
|||
>
|
||||
{keyVisible ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -34,12 +34,7 @@ export function PromptReuseButton({ prompt, onReuse, label }: PromptReuseButtonP
|
|||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span className="font-medium">Reuse</span>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -75,22 +75,15 @@ export function ResultCard({
|
|||
if (!result.enhancementOptions) return '';
|
||||
|
||||
const opts: any = {};
|
||||
if (result.enhancementOptions.imageStyle)
|
||||
opts.imageStyle = result.enhancementOptions.imageStyle;
|
||||
if (result.enhancementOptions.aspectRatio)
|
||||
opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
||||
if (result.enhancementOptions.imageStyle) opts.imageStyle = result.enhancementOptions.imageStyle;
|
||||
if (result.enhancementOptions.aspectRatio) opts.aspectRatio = result.enhancementOptions.aspectRatio;
|
||||
if (result.enhancementOptions.mood) opts.mood = result.enhancementOptions.mood;
|
||||
if (result.enhancementOptions.lighting) opts.lighting = result.enhancementOptions.lighting;
|
||||
if (result.enhancementOptions.cameraAngle)
|
||||
opts.cameraAngle = result.enhancementOptions.cameraAngle;
|
||||
if (result.enhancementOptions.negativePrompts)
|
||||
opts.negativePrompts = result.enhancementOptions.negativePrompts;
|
||||
if (result.enhancementOptions.cameraAngle) opts.cameraAngle = result.enhancementOptions.cameraAngle;
|
||||
if (result.enhancementOptions.negativePrompts) opts.negativePrompts = result.enhancementOptions.negativePrompts;
|
||||
|
||||
if (Object.keys(opts).length === 0) return '';
|
||||
return JSON.stringify(opts, null, 2)
|
||||
.split('\n')
|
||||
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
||||
.join('\n');
|
||||
return JSON.stringify(opts, null, 2).split('\n').map((line, i) => i === 0 ? line : ` ${line}`).join('\n');
|
||||
};
|
||||
|
||||
const enhancementOptionsJson = buildEnhancementOptionsJson();
|
||||
|
|
@ -181,7 +174,9 @@ X-API-Key: ${apiKey}
|
|||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{result.timestamp.toLocaleString()}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{result.timestamp.toLocaleString()}
|
||||
</span>
|
||||
{result.durationMs && <CompletedTimerBadge durationMs={result.durationMs} />}
|
||||
</div>
|
||||
|
||||
|
|
@ -190,7 +185,9 @@ X-API-Key: ${apiKey}
|
|||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
viewMode === 'preview' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
viewMode === 'preview'
|
||||
? 'bg-amber-600 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
aria-pressed={viewMode === 'preview'}
|
||||
>
|
||||
|
|
@ -199,7 +196,9 @@ X-API-Key: ${apiKey}
|
|||
<button
|
||||
onClick={() => setViewMode('inspect')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
viewMode === 'inspect' ? 'bg-amber-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
viewMode === 'inspect'
|
||||
? 'bg-amber-600 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
aria-pressed={viewMode === 'inspect'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,7 @@ interface CreateKeyResponse {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export async function bootstrapMasterKey(): Promise<{
|
||||
success: boolean;
|
||||
apiKey?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
export async function bootstrapMasterKey(): Promise<{ success: boolean; apiKey?: string; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/bootstrap/initial-key`, {
|
||||
method: 'POST',
|
||||
|
|
@ -55,7 +51,7 @@ export async function bootstrapMasterKey(): Promise<{
|
|||
export async function createProjectApiKey(
|
||||
masterKey: string,
|
||||
orgSlug: string,
|
||||
projectSlug: string,
|
||||
projectSlug: string
|
||||
): Promise<{ success: boolean; apiKey?: string; error?: string }> {
|
||||
try {
|
||||
// Call API service to create the project key (API auto-creates org/project)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function getOrCreateProject(organizationId: string, name: string):
|
|||
export async function getOrCreateOrgAndProject(
|
||||
email: string,
|
||||
orgName: string,
|
||||
projectName: string,
|
||||
projectName: string
|
||||
): Promise<{ organization: Organization; project: Project }> {
|
||||
// Get or create organization
|
||||
const organization = await getOrCreateOrganization(email, orgName);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ import { drizzle } from 'drizzle-orm/postgres-js';
|
|||
import postgres from 'postgres';
|
||||
import * as schema from '@banatie/database';
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
||||
const connectionString = process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db';
|
||||
|
||||
// Create postgres client
|
||||
const client = postgres(connectionString);
|
||||
|
|
|
|||
|
|
@ -13,11 +13,17 @@ export async function getOrganizationByEmail(email: string): Promise<Organizatio
|
|||
}
|
||||
|
||||
export async function createOrganization(data: NewOrganization): Promise<Organization> {
|
||||
const [org] = await db.insert(organizations).values(data).returning();
|
||||
const [org] = await db
|
||||
.insert(organizations)
|
||||
.values(data)
|
||||
.returning();
|
||||
|
||||
return org!;
|
||||
}
|
||||
|
||||
export async function listOrganizations(): Promise<Organization[]> {
|
||||
return db.select().from(organizations).orderBy(organizations.createdAt);
|
||||
return db
|
||||
.select()
|
||||
.from(organizations)
|
||||
.orderBy(organizations.createdAt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,26 @@ import { db } from '../client';
|
|||
import { projects, type Project, type NewProject } from '@banatie/database';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export async function getProjectByName(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
): Promise<Project | null> {
|
||||
export async function getProjectByName(organizationId: string, name: string): Promise<Project | null> {
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.organizationId, organizationId), eq(projects.name, name)))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.organizationId, organizationId),
|
||||
eq(projects.name, name)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return project || null;
|
||||
}
|
||||
|
||||
export async function createProject(data: NewProject): Promise<Project> {
|
||||
const [project] = await db.insert(projects).values(data).returning();
|
||||
const [project] = await db
|
||||
.insert(projects)
|
||||
.values(data)
|
||||
.returning();
|
||||
|
||||
return project!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals", "prettier"],
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,6 @@ const nextConfig = {
|
|||
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = nextConfig
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
import type { Metadata } from 'next';
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Banatie Studio - AI Image Generation SaaS',
|
||||
description: 'Professional AI image generation platform with subscription management',
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen">{children}</div>
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
@ -3,10 +3,12 @@ export default function StudioPage() {
|
|||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-6">Banatie Studio</h1>
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-6">
|
||||
Banatie Studio
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
Professional AI image generation platform with subscription management, user
|
||||
authentication, and billing integration.
|
||||
Professional AI image generation platform with subscription management,
|
||||
user authentication, and billing integration.
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 mt-12 max-w-4xl mx-auto">
|
||||
|
|
@ -35,13 +37,13 @@ export default function StudioPage() {
|
|||
<div className="mt-12">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 max-w-2xl mx-auto">
|
||||
<p className="text-yellow-800">
|
||||
<strong>Coming Soon:</strong> This SaaS platform is under development. Based on
|
||||
Vercel's Next.js SaaS starter template.
|
||||
<strong>Coming Soon:</strong> This SaaS platform is under development.
|
||||
Based on Vercel's Next.js SaaS starter template.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
24
demo.test.ts
24
demo.test.ts
|
|
@ -1,24 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Demo Test Suite", () => {
|
||||
it("should pass a basic assertion", () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
|
||||
it("should work with strings", () => {
|
||||
const message = "Hello Vitest";
|
||||
expect(message).toContain("Vitest");
|
||||
});
|
||||
|
||||
it("should work with arrays", () => {
|
||||
const numbers = [1, 2, 3, 4, 5];
|
||||
expect(numbers).toHaveLength(5);
|
||||
expect(numbers).toContain(3);
|
||||
});
|
||||
|
||||
it("should work with objects", () => {
|
||||
const user = { name: "Test User", age: 25 };
|
||||
expect(user).toHaveProperty("name");
|
||||
expect(user.age).toBeGreaterThan(18);
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,26 @@ DELETE {{base}}/api/admin/keys/KEY_ID_HERE
|
|||
X-API-Key: {{masterKey}}
|
||||
|
||||
|
||||
### Enhance Prompt (Requires API Key)
|
||||
|
||||
POST {{base}}/api/enhance
|
||||
Content-Type: application/json
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
{
|
||||
"prompt": "Два мага сражаются в снежном лесу. У одного из них в руках посох, из которого вырывается молния, а другой маг защищается щитом из льда. Вокруг них падают снежинки, и на заднем плане видны заснеженные деревья и горы.",
|
||||
"options": {
|
||||
"imageStyle": "photorealistic",
|
||||
"aspectRatio": "landscape",
|
||||
"mood": "serene and peaceful",
|
||||
"lighting": "golden hour",
|
||||
"cameraAngle": "wide shot",
|
||||
"outputFormat": "detailed",
|
||||
"negativePrompts": ["blurry", "low quality"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
### Generate Image from Text (Requires API Key)
|
||||
|
||||
POST {{base}}/api/text-to-image
|
||||
|
|
@ -58,7 +78,37 @@ X-API-Key: {{apiKey}}
|
|||
}
|
||||
|
||||
|
||||
### Generate Image - Text to Image (alternative format)
|
||||
### Generate Image with Files (Requires API Key)
|
||||
|
||||
POST {{base}}/api/generate
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
||||
X-API-Key: {{apiKey}}
|
||||
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="prompt"
|
||||
|
||||
A majestic dragon soaring through a crystal cave filled with glowing blue crystals, sunbeams piercing through cracks in the ceiling creating dramatic lighting, highly detailed fantasy art style
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="filename"
|
||||
|
||||
dragon-crystal-cave
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="autoEnhance"
|
||||
|
||||
true
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="enhancementOptions"
|
||||
|
||||
{"imageStyle":"illustration","aspectRatio":"landscape","mood":"mystical and dramatic","lighting":"magical glow with sunbeams","cameraAngle":"wide shot","negativePrompts":["blurry","low quality","amateur"]}
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="referenceImages"; filename="reference.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
< ./reference.jpg
|
||||
------WebKitFormBoundary--
|
||||
|
||||
|
||||
### Generate Image - Text to Image
|
||||
POST http://localhost:3000/api/text-to-image
|
||||
Content-Type: application/json
|
||||
X-API-Key: bnt_61ba018f01474491cbaacec4509220d7154fffcd011f005eece4dba7889fba99
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ This document defines the complete containerization and deployment architecture
|
|||
## Architecture Summary
|
||||
|
||||
### Core Principles
|
||||
|
||||
- **Complete Service Isolation**: Banatie ecosystem is fully isolated from core VPS services
|
||||
- **Dedicated Database**: Separate PostgreSQL container exclusively for Banatie
|
||||
- **S3-Compatible Storage**: MinIO for scalable object storage with multi-tenant support
|
||||
|
|
@ -40,7 +39,7 @@ networks:
|
|||
### Network Access Matrix
|
||||
|
||||
| Service | banatie-network | proxy-network | External Access |
|
||||
| ----------- | --------------- | --------------- | --------------- |
|
||||
|---------|----------------|---------------|-----------------|
|
||||
| Banatie App | ✅ Internal | ✅ HTTP only | ❌ Direct |
|
||||
| PostgreSQL | ✅ Internal | ❌ None | ❌ None |
|
||||
| MinIO | ✅ Internal | ✅ Console only | ❌ Direct |
|
||||
|
|
@ -49,13 +48,11 @@ networks:
|
|||
### VPS Integration Points
|
||||
|
||||
**Isolation from Core Services:**
|
||||
|
||||
- **NO** shared resources with existing PostgreSQL (`/opt/services/`)
|
||||
- **NO** access to NextCloud, Gitea, or other core services
|
||||
- **ONLY** connection point: Caddy reverse proxy for HTTP routing
|
||||
|
||||
**Shared Infrastructure:**
|
||||
|
||||
- Caddy reverse proxy for SSL termination and routing
|
||||
- Host filesystem for persistent data storage
|
||||
- UFW firewall rules and security policies
|
||||
|
|
@ -63,7 +60,6 @@ networks:
|
|||
## Directory Structure
|
||||
|
||||
### Development Environment
|
||||
|
||||
```
|
||||
banatie-service/
|
||||
├── src/ # Application source code
|
||||
|
|
@ -77,7 +73,6 @@ banatie-service/
|
|||
```
|
||||
|
||||
### Production Environment (VPS)
|
||||
|
||||
```
|
||||
/opt/banatie/ # Isolated deployment directory
|
||||
├── docker-compose.yml # Production configuration
|
||||
|
|
@ -120,7 +115,6 @@ CMD ["node", "dist/server.js"]
|
|||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Hot-reload support in development
|
||||
- Production-optimized build with dependencies pruning
|
||||
- Health check endpoints for monitoring
|
||||
|
|
@ -133,7 +127,6 @@ CMD ["node", "dist/server.js"]
|
|||
**Data**: User accounts, image metadata, upload sessions, organization settings
|
||||
|
||||
**Database Schema:**
|
||||
|
||||
- `users` - User authentication and profiles
|
||||
- `organizations` - Multi-tenant organization data
|
||||
- `images` - Generated image metadata and references
|
||||
|
|
@ -147,7 +140,6 @@ CMD ["node", "dist/server.js"]
|
|||
**Ports**: 9000 (S3 API), 9001 (Web Console)
|
||||
|
||||
**Storage Strategy:**
|
||||
|
||||
- Persistent volumes for data durability
|
||||
- Bucket-per-organization architecture
|
||||
- Lifecycle policies for temporary file cleanup
|
||||
|
|
@ -177,14 +169,12 @@ banatie-{org-id}/ # Organization bucket (e.g., banatie-demo)
|
|||
### Demo Organization Setup
|
||||
|
||||
**Default Configuration:**
|
||||
|
||||
- **Organization**: `demo` (org-id: demo)
|
||||
- **User**: `guest` (user-id: guest)
|
||||
- **Bucket**: `banatie-demo`
|
||||
- **Access**: Public read for demo images
|
||||
|
||||
**Demo Bucket Structure:**
|
||||
|
||||
```
|
||||
banatie-demo/
|
||||
├── users/
|
||||
|
|
@ -278,7 +268,7 @@ services:
|
|||
target: development
|
||||
container_name: banatie-app-dev
|
||||
ports:
|
||||
- '3000:3000'
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./src:/app/src # Hot reload
|
||||
- ./logs:/app/logs
|
||||
|
|
@ -296,7 +286,7 @@ services:
|
|||
image: postgres:15-alpine
|
||||
container_name: banatie-postgres-dev
|
||||
ports:
|
||||
- '5433:5432' # Avoid conflicts with system PostgreSQL
|
||||
- "5433:5432" # Avoid conflicts with system PostgreSQL
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||
|
|
@ -307,7 +297,7 @@ services:
|
|||
POSTGRES_USER: banatie_user
|
||||
POSTGRES_PASSWORD: development_password
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U banatie_user -d banatie_db']
|
||||
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
@ -316,8 +306,8 @@ services:
|
|||
image: minio/minio:latest
|
||||
container_name: banatie-minio-dev
|
||||
ports:
|
||||
- '9000:9000' # S3 API
|
||||
- '9001:9001' # Web Console
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # Web Console
|
||||
volumes:
|
||||
- ./data/minio:/data
|
||||
networks:
|
||||
|
|
@ -327,7 +317,7 @@ services:
|
|||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
@ -365,7 +355,7 @@ services:
|
|||
volumes:
|
||||
- ./logs:/app/logs
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
@ -385,7 +375,7 @@ services:
|
|||
POSTGRES_USER: banatie_user
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U banatie_user -d banatie_db']
|
||||
test: ["CMD-SHELL", "pg_isready -U banatie_user -d banatie_db"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
@ -404,7 +394,7 @@ services:
|
|||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
@ -514,29 +504,13 @@ export interface StorageService {
|
|||
listBuckets(): Promise<string[]>;
|
||||
|
||||
// File operations
|
||||
uploadFile(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
fileName: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
): Promise<string>;
|
||||
uploadFile(orgId: string, userId: string, fileName: string, buffer: Buffer, contentType: string): Promise<string>;
|
||||
downloadFile(orgId: string, userId: string, fileName: string): Promise<Buffer>;
|
||||
deleteFile(orgId: string, userId: string, fileName: string): Promise<void>;
|
||||
|
||||
// URL generation
|
||||
getPresignedUploadUrl(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
fileName: string,
|
||||
expirySeconds: number,
|
||||
): Promise<string>;
|
||||
getPresignedDownloadUrl(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
fileName: string,
|
||||
expirySeconds: number,
|
||||
): Promise<string>;
|
||||
getPresignedUploadUrl(orgId: string, userId: string, fileName: string, expirySeconds: number): Promise<string>;
|
||||
getPresignedDownloadUrl(orgId: string, userId: string, fileName: string, expirySeconds: number): Promise<string>;
|
||||
|
||||
// File management
|
||||
listUserFiles(orgId: string, userId: string): Promise<FileMetadata[]>;
|
||||
|
|
@ -562,14 +536,14 @@ export class MinioStorageService implements StorageService {
|
|||
accessKey: string,
|
||||
secretKey: string,
|
||||
useSSL: boolean = false,
|
||||
bucketPrefix: string = 'banatie',
|
||||
bucketPrefix: string = 'banatie'
|
||||
) {
|
||||
this.client = new MinioClient({
|
||||
endPoint: endpoint.replace(/^https?:\/\//, ''),
|
||||
port: useSSL ? 443 : 9000,
|
||||
useSSL,
|
||||
accessKey,
|
||||
secretKey,
|
||||
secretKey
|
||||
});
|
||||
this.bucketPrefix = bucketPrefix;
|
||||
}
|
||||
|
|
@ -578,11 +552,7 @@ export class MinioStorageService implements StorageService {
|
|||
return `${this.bucketPrefix}-${orgId}`;
|
||||
}
|
||||
|
||||
private getFilePath(
|
||||
userId: string,
|
||||
category: 'generated' | 'references' | 'temp',
|
||||
fileName: string,
|
||||
): string {
|
||||
private getFilePath(userId: string, category: 'generated' | 'references' | 'temp', fileName: string): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
|
|
@ -669,27 +639,23 @@ INSERT INTO users (id, organization_id, username, role) VALUES
|
|||
### Local Development Setup
|
||||
|
||||
1. **Clone Repository**
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd banatie-service
|
||||
```
|
||||
|
||||
2. **Environment Configuration**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with development settings
|
||||
```
|
||||
|
||||
3. **Start Development Environment**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Verify Services**
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
docker-compose ps
|
||||
|
|
@ -704,7 +670,6 @@ docker-compose logs -f app
|
|||
```
|
||||
|
||||
5. **Development with Hot Reload**
|
||||
|
||||
```bash
|
||||
# Edit source files in src/
|
||||
# Changes automatically reload in container
|
||||
|
|
@ -728,7 +693,6 @@ docker-compose exec app pnpm lint
|
|||
### VPS Deployment Process
|
||||
|
||||
1. **Prepare VPS Directory**
|
||||
|
||||
```bash
|
||||
# SSH to VPS
|
||||
ssh usul-vps
|
||||
|
|
@ -740,7 +704,6 @@ cd /opt/banatie
|
|||
```
|
||||
|
||||
2. **Clone and Configure**
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url> .
|
||||
|
|
@ -751,7 +714,6 @@ cp .env.example .env
|
|||
```
|
||||
|
||||
3. **Generate Secure Credentials**
|
||||
|
||||
```bash
|
||||
# Generate secure passwords
|
||||
DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n\r ')
|
||||
|
|
@ -770,7 +732,6 @@ chmod 600 .env
|
|||
```
|
||||
|
||||
4. **Update Caddy Configuration**
|
||||
|
||||
```bash
|
||||
# Add Banatie routes to Caddyfile
|
||||
sudo nano /opt/services/configs/caddy/Caddyfile
|
||||
|
|
@ -782,7 +743,6 @@ cd /opt/services
|
|||
```
|
||||
|
||||
5. **Deploy Services**
|
||||
|
||||
```bash
|
||||
cd /opt/banatie
|
||||
|
||||
|
|
@ -795,7 +755,6 @@ docker-compose logs -f
|
|||
```
|
||||
|
||||
6. **Verify Deployment**
|
||||
|
||||
```bash
|
||||
# Check health endpoints
|
||||
curl https://banatie.app/health
|
||||
|
|
@ -831,7 +790,6 @@ curl https://banatie.app/health
|
|||
### Health Checks
|
||||
|
||||
**Application Health Check** (`/health`):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
|
|
@ -846,27 +804,23 @@ curl https://banatie.app/health
|
|||
```
|
||||
|
||||
**MinIO Health Check** (`/minio/health/live`):
|
||||
|
||||
- Returns 200 OK when MinIO is operational
|
||||
- Used by Docker healthcheck and monitoring
|
||||
|
||||
### Log Management
|
||||
|
||||
**Application Logs**:
|
||||
|
||||
- Location: `/opt/banatie/logs/`
|
||||
- Format: Structured JSON
|
||||
- Rotation: Daily with 30-day retention
|
||||
|
||||
**Access Logs**:
|
||||
|
||||
- Caddy logs: `/opt/services/logs/banatie_*.log`
|
||||
- Format: JSON with request/response details
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
**Database Backup**:
|
||||
|
||||
```bash
|
||||
# Create backup
|
||||
docker exec banatie-postgres pg_dump -U banatie_user banatie_db > banatie_db_backup.sql
|
||||
|
|
@ -876,7 +830,6 @@ docker exec -i banatie-postgres psql -U banatie_user banatie_db < banatie_db_bac
|
|||
```
|
||||
|
||||
**MinIO Data Backup**:
|
||||
|
||||
```bash
|
||||
# Backup MinIO data
|
||||
sudo tar -czf banatie_minio_backup.tar.gz -C /opt/banatie/data minio/
|
||||
|
|
@ -888,28 +841,24 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
|
|||
## Security Considerations
|
||||
|
||||
### Container Security
|
||||
|
||||
- Non-root users in containers
|
||||
- Read-only root filesystems where possible
|
||||
- Resource limits (memory, CPU)
|
||||
- Health checks for automatic restart
|
||||
|
||||
### Network Security
|
||||
|
||||
- Internal network isolation
|
||||
- No direct external access to database or MinIO
|
||||
- Rate limiting at proxy level
|
||||
- HTTPS-only external access
|
||||
|
||||
### Data Security
|
||||
|
||||
- Encrypted environment variables
|
||||
- Secure secret generation
|
||||
- Regular credential rotation
|
||||
- Audit logging
|
||||
|
||||
### Access Control
|
||||
|
||||
- MinIO bucket policies per organization
|
||||
- PostgreSQL row-level security
|
||||
- JWT-based API authentication
|
||||
|
|
@ -918,14 +867,12 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
|
|||
## Future Scalability
|
||||
|
||||
### Horizontal Scaling Options
|
||||
|
||||
- Multiple Banatie app containers behind load balancer
|
||||
- MinIO distributed mode for storage scaling
|
||||
- PostgreSQL read replicas for read scaling
|
||||
- Redis for session storage and caching
|
||||
|
||||
### Multi-Region Deployment
|
||||
|
||||
- Regional MinIO clusters
|
||||
- Database replication
|
||||
- CDN integration for static assets
|
||||
|
|
@ -936,7 +883,6 @@ sudo tar -xzf banatie_minio_backup.tar.gz -C /opt/banatie/data
|
|||
### Common Issues
|
||||
|
||||
**Container Won't Start**:
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs [service-name]
|
||||
|
|
@ -950,7 +896,6 @@ docker-compose up -d [service-name]
|
|||
```
|
||||
|
||||
**Database Connection Issues**:
|
||||
|
||||
```bash
|
||||
# Test database connectivity
|
||||
docker exec banatie-postgres pg_isready -U banatie_user -d banatie_db
|
||||
|
|
@ -963,7 +908,6 @@ docker-compose restart banatie-postgres
|
|||
```
|
||||
|
||||
**MinIO Access Issues**:
|
||||
|
||||
```bash
|
||||
# Check MinIO status
|
||||
docker exec banatie-minio mc admin info local
|
||||
|
|
@ -976,7 +920,6 @@ docker exec banatie-app env | grep MINIO_
|
|||
```
|
||||
|
||||
**Network Connectivity**:
|
||||
|
||||
```bash
|
||||
# Check networks
|
||||
docker network ls
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
# MinIO Setup Technical Specification
|
||||
|
||||
## Project Status
|
||||
|
||||
Starting MinIO integration from scratch. Previous implementation had compatibility issues with bucket policies in SNSD mode. New implementation uses SNMD (Single Node Multi Drive) configuration with presigned URLs for reliable file access.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Storage Strategy
|
||||
|
||||
- **Mode**: SNMD (4 virtual drives) for full S3 compatibility
|
||||
- **Access Method**: Presigned URLs only (no bucket policies)
|
||||
- **Bucket Structure**: Single bucket `banatie` with path-based organization
|
||||
- **File Organization**: `orgId/projectId/category/year-month/filename`
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- MinIO latest (`quay.io/minio/minio:latest`)
|
||||
- Docker Compose for orchestration
|
||||
- PostgreSQL for application data
|
||||
|
|
@ -23,7 +20,6 @@ Starting MinIO integration from scratch. Previous implementation had compatibili
|
|||
## Configuration Files Status
|
||||
|
||||
### Completed Files
|
||||
|
||||
- `docker-compose.yml` - SNMD configuration with 4 virtual drives
|
||||
- `.env.docker` - Environment variables for development
|
||||
- `src/services/MinioStorageService.ts` - Updated service implementation
|
||||
|
|
@ -33,7 +29,6 @@ Starting MinIO integration from scratch. Previous implementation had compatibili
|
|||
### Integration Requirements
|
||||
|
||||
#### 1. Update Application Router
|
||||
|
||||
Add images router to main application in `src/app.ts`:
|
||||
|
||||
```typescript
|
||||
|
|
@ -44,7 +39,6 @@ app.use('/api', imagesRouter);
|
|||
```
|
||||
|
||||
#### 2. Environment Variables Update
|
||||
|
||||
Update existing `.env` file with MinIO configuration:
|
||||
|
||||
```bash
|
||||
|
|
@ -66,11 +60,9 @@ PRESIGNED_URL_EXPIRY=86400
|
|||
```
|
||||
|
||||
#### 3. Database Script Update
|
||||
|
||||
Update `scripts/init-db.sql` to use new database name `banatie` instead of previous naming.
|
||||
|
||||
#### 4. Service Dependencies Update
|
||||
|
||||
Update existing image generation services to use new storage configuration:
|
||||
|
||||
```typescript
|
||||
|
|
@ -82,7 +74,7 @@ const uploadResult = await storageService.uploadFile(
|
|||
'generated',
|
||||
filename,
|
||||
buffer,
|
||||
'image/png',
|
||||
'image/png'
|
||||
);
|
||||
// Use uploadResult.url (returns API URL for presigned access)
|
||||
```
|
||||
|
|
@ -90,16 +82,13 @@ const uploadResult = await storageService.uploadFile(
|
|||
## Setup Instructions
|
||||
|
||||
### 1. Directory Structure
|
||||
|
||||
Create required directories:
|
||||
|
||||
```bash
|
||||
mkdir -p data/storage/{drive1,drive2,drive3,drive4}
|
||||
mkdir -p data/postgres
|
||||
```
|
||||
|
||||
### 2. Service Startup
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up -d
|
||||
|
|
@ -117,13 +106,11 @@ docker logs banatie-storage-init
|
|||
### 3. Verification Steps
|
||||
|
||||
#### MinIO Console Access
|
||||
|
||||
- URL: http://localhost:9001
|
||||
- Username: banatie_admin
|
||||
- Password: banatie_storage_secure_key_2024
|
||||
|
||||
#### Test Presigned URL Generation
|
||||
|
||||
```bash
|
||||
# Test image upload endpoint
|
||||
curl -X POST http://localhost:3000/api/upload \
|
||||
|
|
@ -136,7 +123,6 @@ curl -I "http://localhost:3000/api/images/default/main/generated/test-image.png"
|
|||
```
|
||||
|
||||
#### Verify SNMD Mode
|
||||
|
||||
```bash
|
||||
# Check MinIO is in erasure coding mode
|
||||
docker exec banatie-storage mc admin info local
|
||||
|
|
@ -146,7 +132,6 @@ docker exec banatie-storage mc admin info local
|
|||
## Integration Testing
|
||||
|
||||
### Required Tests
|
||||
|
||||
1. **Storage Service Initialization**
|
||||
- Verify StorageFactory creates MinioStorageService
|
||||
- Confirm bucket creation and accessibility
|
||||
|
|
@ -167,7 +152,6 @@ docker exec banatie-storage mc admin info local
|
|||
- Test MinIO connection failures
|
||||
|
||||
### Test Script Template
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# test-minio-integration.sh
|
||||
|
|
@ -197,7 +181,6 @@ fi
|
|||
## Production Considerations
|
||||
|
||||
### Environment Differences
|
||||
|
||||
Development and production use identical configuration with different values:
|
||||
|
||||
```bash
|
||||
|
|
@ -208,14 +191,12 @@ MINIO_USE_SSL=true
|
|||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- All passwords use environment variables (no hardcoded values)
|
||||
- Presigned URLs expire after 24 hours by default
|
||||
- Service user has minimal required permissions
|
||||
- MinIO admin access separate from application access
|
||||
|
||||
### Scaling Path
|
||||
|
||||
- Current SNMD setup supports development and small production loads
|
||||
- Can migrate to distributed MinIO cluster when needed
|
||||
- Presigned URL architecture remains unchanged during scaling
|
||||
|
|
@ -223,13 +204,11 @@ MINIO_USE_SSL=true
|
|||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **403 Errors**: Check presigned URL generation and MinIO service user permissions
|
||||
2. **404 Errors**: Verify file paths and bucket configuration
|
||||
3. **Connection Errors**: Confirm MinIO service health and network connectivity
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check MinIO health
|
||||
docker exec banatie-storage mc admin info local
|
||||
|
|
@ -243,7 +222,6 @@ docker logs banatie-app
|
|||
```
|
||||
|
||||
### Recovery Procedures
|
||||
|
||||
```bash
|
||||
# Reset MinIO data (development only)
|
||||
docker-compose down
|
||||
|
|
@ -257,19 +235,16 @@ docker exec banatie-storage mc mb storage/banatie
|
|||
## Implementation Priority
|
||||
|
||||
### Phase 1 (Immediate)
|
||||
|
||||
1. Update app.ts with images router
|
||||
2. Update environment configuration
|
||||
3. Test basic upload/download functionality
|
||||
|
||||
### Phase 2 (Next)
|
||||
|
||||
1. Update existing image generation services
|
||||
2. Implement comprehensive error handling
|
||||
3. Add integration tests
|
||||
|
||||
### Phase 3 (Future)
|
||||
|
||||
1. Add monitoring and logging
|
||||
2. Implement file cleanup policies
|
||||
3. Add CDN integration capability
|
||||
|
|
@ -277,7 +252,6 @@ docker exec banatie-storage mc mb storage/banatie
|
|||
## Acceptance Criteria
|
||||
|
||||
Integration is complete when:
|
||||
|
||||
- [ ] All services start successfully via docker-compose
|
||||
- [ ] MinIO operates in SNMD mode with 4 drives
|
||||
- [ ] Image upload returns API URL format
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -3,7 +3,6 @@
|
|||
"version": "1.0.0",
|
||||
"description": "Banatie AI Image Generation Service - Monorepo with API, Landing, Studio, and Admin apps",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
|
|
@ -34,13 +33,7 @@
|
|||
"typecheck:landing": "pnpm --filter @banatie/landing typecheck",
|
||||
"typecheck:studio": "pnpm --filter @banatie/studio typecheck",
|
||||
"typecheck:admin": "pnpm --filter @banatie/admin typecheck",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:api": "pnpm --filter @banatie/api-service test",
|
||||
"format": "prettier --write \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
||||
"format:check": "prettier --check \"apps/**/*.{ts,tsx,js,jsx,json,css,md}\" \"packages/**/*.{ts,tsx,js,jsx,json,css,md}\" \"*.{ts,tsx,js,jsx,json,css,md}\" --ignore-unknown",
|
||||
"test": "pnpm --filter @banatie/api-service test",
|
||||
"clean": "pnpm -r clean && rm -rf node_modules",
|
||||
"install:all": "pnpm install"
|
||||
},
|
||||
|
|
@ -57,12 +50,7 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"kill-port": "^2.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@ export default {
|
|||
out: './migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
|
||||
url: process.env.DATABASE_URL || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db',
|
||||
},
|
||||
} satisfies Config;
|
||||
|
|
@ -55,12 +55,16 @@
|
|||
"organizations_slug_unique": {
|
||||
"name": "organizations_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["slug"]
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
},
|
||||
"organizations_email_unique": {
|
||||
"name": "organizations_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -117,8 +121,12 @@
|
|||
"name": "projects_organization_id_organizations_id_fk",
|
||||
"tableFrom": "projects",
|
||||
"tableTo": "organizations",
|
||||
"columnsFrom": ["organization_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -128,7 +136,10 @@
|
|||
"projects_organization_id_slug_unique": {
|
||||
"name": "projects_organization_id_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["organization_id", "slug"]
|
||||
"columns": [
|
||||
"organization_id",
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -229,8 +240,12 @@
|
|||
"name": "api_keys_organization_id_organizations_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "organizations",
|
||||
"columnsFrom": ["organization_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
|
|
@ -238,8 +253,12 @@
|
|||
"name": "api_keys_project_id_projects_id_fk",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": ["project_id"],
|
||||
"columnsTo": ["id"],
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -249,7 +268,9 @@
|
|||
"api_keys_key_hash_unique": {
|
||||
"name": "api_keys_key_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["key_hash"]
|
||||
"columns": [
|
||||
"key_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ export const apiKeys = pgTable('api_keys', {
|
|||
keyType: text('key_type').notNull().$type<'master' | 'project'>(),
|
||||
|
||||
// Foreign key relationships
|
||||
organizationId: uuid('organization_id').references(() => organizations.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
organizationId: uuid('organization_id').references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Permissions (for future use)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ export const organizations = pgTable('organizations', {
|
|||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
});
|
||||
|
||||
export type Organization = typeof organizations.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,21 @@
|
|||
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
|
||||
import { organizations } from './organizations';
|
||||
|
||||
export const projects = pgTable(
|
||||
'projects',
|
||||
{
|
||||
export const projects = pgTable('projects', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
// Project details
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(), // URL-friendly identifier for storage paths
|
||||
organizationId: uuid('organization_id')
|
||||
.notNull()
|
||||
.references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
organizationId: uuid('organization_id').notNull().references(() => organizations.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (table) => ({
|
||||
// Unique constraint: one project slug per organization
|
||||
uniqueOrgProjectSlug: unique().on(table.organizationId, table.slug),
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
|
|
|
|||
648
pnpm-lock.yaml
648
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
16
test-api.sh
16
test-api.sh
|
|
@ -16,11 +16,17 @@ echo "2. Testing API info endpoint..."
|
|||
curl -s "$BASE_URL/api/info" | jq '.' || echo "API info endpoint failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 3: Text-to-image endpoint validation (should fail without API key)
|
||||
echo "3. Testing text-to-image endpoint (should fail without valid API key)..."
|
||||
curl -s -X POST "$BASE_URL/api/text-to-image" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prompt": "A simple test image", "filename": "test"}' | jq '.' || echo "Text-to-image endpoint test failed"
|
||||
# Test 3: Generate endpoint validation (should fail without prompt)
|
||||
echo "3. Testing generate endpoint validation..."
|
||||
curl -s -X POST "$BASE_URL/api/generate" \
|
||||
-F "filename=test" | jq '.' || echo "Generate endpoint validation test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 4: Generate endpoint validation (should fail without API key)
|
||||
echo "4. Testing generate endpoint with prompt (should fail without valid API key)..."
|
||||
curl -s -X POST "$BASE_URL/api/generate" \
|
||||
-F "prompt=A simple test image" \
|
||||
-F "filename=test" | jq '.' || echo "Generate endpoint test failed"
|
||||
echo -e "\n"
|
||||
|
||||
echo "✅ API tests completed!"
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Test script for Banatie Prompt Enhancement API endpoints
|
||||
BASE_URL="http://localhost:3000"
|
||||
|
||||
echo "🧪 Testing Banatie Prompt Enhancement API"
|
||||
echo "========================================="
|
||||
|
||||
# Test 1: Health check first
|
||||
echo "1. Testing health endpoint..."
|
||||
curl -s "$BASE_URL/health" | jq '.status' || echo "Health endpoint failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 2: API info (should now include enhance endpoint)
|
||||
echo "2. Testing API info endpoint..."
|
||||
curl -s "$BASE_URL/api/info" | jq '.endpoints' || echo "API info endpoint failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 3: Basic prompt enhancement
|
||||
echo "3. Testing basic prompt enhancement..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "cat"
|
||||
}' | jq '.' || echo "Basic enhancement test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 4: Prompt enhancement with options
|
||||
echo "4. Testing prompt enhancement with options..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "beautiful sunset",
|
||||
"options": {
|
||||
"imageStyle": "photorealistic",
|
||||
"aspectRatio": "landscape",
|
||||
"mood": "serene",
|
||||
"lighting": "golden hour"
|
||||
}
|
||||
}' | jq '.' || echo "Enhancement with options test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 5: Multilingual prompt enhancement (Spanish)
|
||||
echo "5. Testing Spanish prompt enhancement..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "un gato hermoso con ojos azules",
|
||||
"options": {
|
||||
"imageStyle": "illustration"
|
||||
}
|
||||
}' | jq '.' || echo "Spanish enhancement test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 6: Multilingual prompt enhancement (Chinese)
|
||||
echo "6. Testing Chinese prompt enhancement..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "美丽的山景",
|
||||
"options": {
|
||||
"imageStyle": "minimalist"
|
||||
}
|
||||
}' | jq '.' || echo "Chinese enhancement test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 7: Sticker style prompt
|
||||
echo "7. Testing sticker style enhancement..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "happy panda",
|
||||
"options": {
|
||||
"imageStyle": "sticker",
|
||||
"negativePrompts": ["dark", "scary"]
|
||||
}
|
||||
}' | jq '.' || echo "Sticker style test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 8: Validation error test (empty prompt)
|
||||
echo "8. Testing validation error (empty prompt)..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": ""
|
||||
}' | jq '.' || echo "Validation error test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 9: Validation error test (invalid image style)
|
||||
echo "9. Testing validation error (invalid style)..."
|
||||
curl -s -X POST "$BASE_URL/api/enhance" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "test",
|
||||
"options": {
|
||||
"imageStyle": "invalid_style"
|
||||
}
|
||||
}' | jq '.' || echo "Invalid style test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 10: Auto-enhancement in generate endpoint
|
||||
echo "10. Testing auto-enhancement in generate endpoint..."
|
||||
curl -s -X POST "$BASE_URL/api/generate" \
|
||||
-F "prompt=gato bonito" \
|
||||
-F "filename=test_autoenhance" \
|
||||
-F "autoEnhance=true" \
|
||||
-F "enhancementOptions[imageStyle]=photorealistic" | jq '.' || echo "Auto-enhancement test failed"
|
||||
echo -e "\n"
|
||||
|
||||
# Test 11: Generate without auto-enhancement (normal behavior)
|
||||
echo "11. Testing generate without auto-enhancement..."
|
||||
curl -s -X POST "$BASE_URL/api/generate" \
|
||||
-F "prompt=a simple cat" \
|
||||
-F "filename=test_normal" | jq '.' || echo "Normal generate test failed"
|
||||
echo -e "\n"
|
||||
|
||||
echo "✅ Enhancement API tests completed!"
|
||||
echo ""
|
||||
echo "📋 Test Summary:"
|
||||
echo "- Basic enhancement functionality"
|
||||
echo "- Enhancement with styling options"
|
||||
echo "- Multilingual support (Spanish, Chinese)"
|
||||
echo "- Different image styles (photorealistic, illustration, minimalist, sticker)"
|
||||
echo "- Validation error handling"
|
||||
echo "- Auto-enhancement integration with generate endpoint"
|
||||
echo ""
|
||||
echo "💡 To run with API key, ensure GEMINI_API_KEY is set in your environment"
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["**/*.test.ts", "**/*.spec.ts"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.next/**"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**",
|
||||
"**/*.config.{js,ts}",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue