v3.0.0: Add FastAPI backend
- Add Python FastAPI backend with Pydantic validation - Port WhiskClient and MetaAIClient to Python - Create API routers for all endpoints - Add Swagger/ReDoc documentation at /docs - Update Dockerfile for multi-service container - Add lib/api.ts frontend client - Update README for V3
This commit is contained in:
parent
793d80e9cf
commit
0ef7e5475c
21 changed files with 2482 additions and 70 deletions
116
Dockerfile
116
Dockerfile
|
|
@ -1,55 +1,95 @@
|
||||||
FROM node:18-alpine AS base
|
# Stage 1: Build Next.js frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Install dependencies
|
||||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
# Rebuild the source code only when needed
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Stage 2: Build Python backend
|
||||||
FROM base AS runner
|
FROM python:3.11-slim AS backend-builder
|
||||||
|
WORKDIR /backend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY backend/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Stage 3: Production image with supervisor
|
||||||
|
FROM python:3.11-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
# Install Node.js and supervisor
|
||||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
RUN apt-get update && apt-get install -y \
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
curl \
|
||||||
|
supervisor \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
# Create non-root user
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN groupadd --system --gid 1001 appgroup \
|
||||||
|
&& useradd --system --uid 1001 --gid appgroup appuser
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
# Copy Next.js standalone build
|
||||||
|
COPY --from=frontend-builder /app/public ./public
|
||||||
|
COPY --from=frontend-builder /app/.next/standalone ./
|
||||||
|
COPY --from=frontend-builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
# Copy Python backend
|
||||||
RUN mkdir .next
|
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||||
RUN chown nextjs:nodejs .next
|
COPY --from=backend-builder /backend ./backend
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Copy data directory for prompts
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
COPY --from=frontend-builder /app/data ./data
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
# Create supervisor config
|
||||||
|
RUN mkdir -p /var/log/supervisor
|
||||||
|
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
EXPOSE 3000
|
[program:nextjs]
|
||||||
|
command=node /app/server.js
|
||||||
|
directory=/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
environment=NODE_ENV=production,PORT=3000,HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
ENV PORT 3000
|
[program:fastapi]
|
||||||
ENV HOSTNAME "0.0.0.0"
|
command=python -m uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
directory=/app/backend
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
EOF
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
# Set permissions
|
||||||
|
RUN chown -R appuser:appgroup /app /var/log/supervisor
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 3000 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000 && curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run supervisor
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|
|
||||||
92
README.md
92
README.md
|
|
@ -1,24 +1,56 @@
|
||||||
# kv-pix (V2)
|
# kv-pix (V3)
|
||||||
|
|
||||||
A modern, lightweight AI Image Generator powered by Google ImageFX (Whisk), Grok, and Meta AI. Built with Next.js 14, TypeScript, and Tailwind CSS.
|
A modern, full-stack AI Image Generator with **FastAPI backend** and **Next.js frontend**.
|
||||||
|
Powered by Google ImageFX (Whisk), Grok, and Meta AI.
|
||||||
|
|
||||||
|
## 🆕 What's New in V3
|
||||||
|
|
||||||
|
- **FastAPI Backend** - Python backend with automatic Swagger documentation
|
||||||
|
- **Better Security** - Centralized CORS, Pydantic validation, structured error handling
|
||||||
|
- **API Documentation** - Interactive docs at `/api/docs` (Swagger) and `/api/redoc`
|
||||||
|
- **Improved Architecture** - Clean separation of frontend and backend
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
|
- Python 3.11+
|
||||||
- Whisk/Grok/Meta Cookies (from respective services)
|
- Whisk/Grok/Meta Cookies (from respective services)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install frontend dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Set up backend
|
||||||
|
cd backend
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Locally
|
### Run Locally
|
||||||
|
|
||||||
|
**Terminal 1 - Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source venv/bin/activate
|
||||||
|
uvicorn main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 2 - Frontend:**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# App will be live at http://localhost:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Frontend | http://localhost:3000 |
|
||||||
|
| Backend API | http://localhost:8000 |
|
||||||
|
| Swagger UI | http://localhost:8000/docs |
|
||||||
|
| ReDoc | http://localhost:8000/redoc |
|
||||||
|
|
||||||
## 🐳 Docker Deployment (Synology NAS / linux/amd64)
|
## 🐳 Docker Deployment (Synology NAS / linux/amd64)
|
||||||
|
|
||||||
### Using Docker Compose (Recommended)
|
### Using Docker Compose (Recommended)
|
||||||
|
|
@ -28,38 +60,53 @@ docker-compose up -d
|
||||||
|
|
||||||
### Using Docker CLI
|
### Using Docker CLI
|
||||||
```bash
|
```bash
|
||||||
# Build
|
# Pull from registry
|
||||||
docker build -t kv-pix:latest .
|
docker pull git.khoavo.myds.me/vndangkhoa/apix:v3
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
docker run -d -p 3001:3000 --name kv-pix kv-pix:latest
|
docker run -d -p 3000:3000 -p 8000:8000 --name kv-pix git.khoavo.myds.me/vndangkhoa/apix:v3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Synology Container Manager
|
|
||||||
1. Pull the image or build locally.
|
|
||||||
2. Create a container from the image.
|
|
||||||
3. Map port `3000` (container) to your desired host port (e.g., `3001`).
|
|
||||||
4. Start the container.
|
|
||||||
5. Access the app at `http://<NAS_IP>:3001`.
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
- **Framework**: [Next.js 14](https://nextjs.org/) (App Router)
|
|
||||||
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) + Custom Components
|
```
|
||||||
|
┌─────────────────┐ HTTP/REST ┌──────────────────┐
|
||||||
|
│ Next.js UI │ ◄───────────────► │ FastAPI Backend │
|
||||||
|
│ (Port 3000) │ │ (Port 8000) │
|
||||||
|
└─────────────────┘ └────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
│ │
|
||||||
|
┌─────▼─────┐ ┌──────▼──────┐
|
||||||
|
│ WhiskAPI │ │ MetaAI API │
|
||||||
|
└───────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Frontend**: [Next.js 15](https://nextjs.org/) (App Router) + [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- **Backend**: [FastAPI](https://fastapi.tiangolo.com/) + [Pydantic](https://docs.pydantic.dev/)
|
||||||
- **State**: [Zustand](https://github.com/pmndrs/zustand)
|
- **State**: [Zustand](https://github.com/pmndrs/zustand)
|
||||||
- **Icons**: [Lucide React](https://lucide.dev/)
|
- **Icons**: [Lucide React](https://lucide.dev/)
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
```
|
```
|
||||||
app/ # Pages and API Routes
|
├── app/ # Next.js pages and (legacy) API routes
|
||||||
components/ # React UI Components
|
├── backend/ # FastAPI Python backend
|
||||||
lib/ # Core Logic (whisk-client, meta-client, grok-client)
|
│ ├── main.py
|
||||||
data/ # Prompt library JSON
|
│ ├── routers/ # API endpoints
|
||||||
public/ # Static assets
|
│ ├── services/ # Business logic (whisk_client, meta_client)
|
||||||
|
│ └── models/ # Pydantic request/response models
|
||||||
|
├── components/ # React UI components
|
||||||
|
├── lib/ # Frontend utilities and API client
|
||||||
|
├── data/ # Prompt library JSON
|
||||||
|
└── public/ # Static assets
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Multi-Provider**: Google Whisk (ImageFX), Grok (xAI), Meta AI (Imagine)
|
- **Multi-Provider**: Google Whisk (ImageFX), Grok (xAI), Meta AI (Imagine)
|
||||||
- **Prompt Library**: Curated prompts organized by categories and sources
|
- **FastAPI Backend**: Type-safe Python API with auto-documentation
|
||||||
|
- **Prompt Library**: Curated prompts organized by categories
|
||||||
- **Upload History**: Reuse previously uploaded reference images
|
- **Upload History**: Reuse previously uploaded reference images
|
||||||
- **Reference Chips**: Drag-and-drop references for Subject/Scene/Style
|
- **Reference Chips**: Drag-and-drop references for Subject/Scene/Style
|
||||||
- **Video Generation**: Animate images with Whisk Animate (Veo)
|
- **Video Generation**: Animate images with Whisk Animate (Veo)
|
||||||
|
|
@ -67,11 +114,12 @@ public/ # Static assets
|
||||||
- **Dark Mode**: Material 3 inspired aesthetic
|
- **Dark Mode**: Material 3 inspired aesthetic
|
||||||
|
|
||||||
## 🍪 Cookie Configuration
|
## 🍪 Cookie Configuration
|
||||||
|
|
||||||
1. Go to the respective service:
|
1. Go to the respective service:
|
||||||
- Whisk: [ImageFX](https://labs.google/fx/tools/image-fx)
|
- Whisk: [ImageFX](https://labs.google/fx/tools/image-fx)
|
||||||
- Grok: [grok.com](https://grok.com)
|
- Grok: [grok.com](https://grok.com)
|
||||||
- Meta: [meta.ai](https://www.meta.ai)
|
- Meta: [meta.ai](https://www.meta.ai)
|
||||||
2. Open DevTools (F12) -> Application -> Cookies.
|
2. Open DevTools (F12) → Application → Cookies.
|
||||||
3. Copy the cookie string (or use a "Get Cookies" extension to copy as JSON).
|
3. Copy the cookie string (or use a "Get Cookies" extension to copy as JSON).
|
||||||
4. Paste into the **Settings** menu in the app.
|
4. Paste into the **Settings** menu in the app.
|
||||||
|
|
||||||
|
|
|
||||||
114
backend/main.py
Normal file
114
backend/main.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""
|
||||||
|
KV-Pix FastAPI Backend
|
||||||
|
|
||||||
|
A secure and intuitive API backend for the KV-Pix image generation application.
|
||||||
|
Provides endpoints for:
|
||||||
|
- Whisk image and video generation
|
||||||
|
- Meta AI image generation
|
||||||
|
- Prompt library management
|
||||||
|
- Reference image uploads
|
||||||
|
- Upload history
|
||||||
|
|
||||||
|
API Documentation available at /docs (Swagger UI) and /redoc (ReDoc)
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add backend to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from routers import generate, video, references, meta, prompts, history
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Startup and shutdown events"""
|
||||||
|
print("🚀 KV-Pix FastAPI Backend starting...")
|
||||||
|
print("📚 Swagger UI available at: http://localhost:8000/docs")
|
||||||
|
print("📖 ReDoc available at: http://localhost:8000/redoc")
|
||||||
|
yield
|
||||||
|
print("👋 KV-Pix FastAPI Backend shutting down...")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="KV-Pix API",
|
||||||
|
description="""
|
||||||
|
## KV-Pix Image Generation API
|
||||||
|
|
||||||
|
A powerful API for AI image generation using multiple providers.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Whisk API**: Google's experimental image generation with reference images
|
||||||
|
- **Meta AI**: Meta's Imagine model for creative images
|
||||||
|
- **Prompt Library**: Curated prompts with categories
|
||||||
|
- **Upload History**: Track and reuse uploaded references
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
All generation endpoints require provider-specific cookies passed in the request body.
|
||||||
|
See the Settings page in the web app for cookie configuration instructions.
|
||||||
|
""",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware - allow Next.js frontend
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(generate.router)
|
||||||
|
app.include_router(video.router)
|
||||||
|
app.include_router(references.router)
|
||||||
|
app.include_router(meta.router)
|
||||||
|
app.include_router(prompts.router)
|
||||||
|
app.include_router(history.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", tags=["Health"])
|
||||||
|
async def root():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "kv-pix-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["Health"])
|
||||||
|
async def health_check():
|
||||||
|
"""Detailed health check"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"endpoints": {
|
||||||
|
"generate": "/generate",
|
||||||
|
"video": "/video/generate",
|
||||||
|
"references": "/references/upload",
|
||||||
|
"meta": "/meta/generate",
|
||||||
|
"prompts": "/prompts",
|
||||||
|
"history": "/history"
|
||||||
|
},
|
||||||
|
"documentation": {
|
||||||
|
"swagger": "/docs",
|
||||||
|
"redoc": "/redoc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
|
||||||
3
backend/models/__init__.py
Normal file
3
backend/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Models package
|
||||||
|
from .requests import *
|
||||||
|
from .responses import *
|
||||||
59
backend/models/requests.py
Normal file
59
backend/models/requests.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""
|
||||||
|
Pydantic request models for API validation
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
"""Request for Whisk image generation"""
|
||||||
|
prompt: str = Field(..., min_length=1, description="Image generation prompt")
|
||||||
|
aspectRatio: str = Field(default="1:1", description="Aspect ratio (1:1, 9:16, 16:9, 4:3, 3:4)")
|
||||||
|
refs: Optional[Dict[str, Any]] = Field(default=None, description="Reference images {subject, scene, style}")
|
||||||
|
preciseMode: bool = Field(default=False, description="Enable precise mode")
|
||||||
|
imageCount: int = Field(default=1, ge=1, le=4, description="Number of images to generate")
|
||||||
|
cookies: Optional[str] = Field(default=None, description="Whisk cookies")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoGenerateRequest(BaseModel):
|
||||||
|
"""Request for Whisk video generation"""
|
||||||
|
prompt: str = Field(..., min_length=1)
|
||||||
|
imageBase64: Optional[str] = Field(default=None, description="Base64 image data")
|
||||||
|
imageGenerationId: Optional[str] = Field(default=None, description="Existing image ID")
|
||||||
|
cookies: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceUploadRequest(BaseModel):
|
||||||
|
"""Request for uploading reference image"""
|
||||||
|
imageBase64: str = Field(..., description="Base64 encoded image")
|
||||||
|
mimeType: str = Field(..., description="Image MIME type (image/jpeg, image/png, etc.)")
|
||||||
|
category: str = Field(..., description="Reference category (subject, scene, style)")
|
||||||
|
cookies: str = Field(..., description="Whisk cookies")
|
||||||
|
|
||||||
|
|
||||||
|
class MetaGenerateRequest(BaseModel):
|
||||||
|
"""Request for Meta AI image generation"""
|
||||||
|
prompt: str = Field(..., min_length=1)
|
||||||
|
cookies: Optional[str] = Field(default=None, description="Meta AI cookies")
|
||||||
|
imageCount: int = Field(default=4, ge=1, le=4)
|
||||||
|
aspectRatio: str = Field(default="portrait", description="portrait, landscape, square")
|
||||||
|
useMetaFreeWrapper: bool = Field(default=False)
|
||||||
|
metaFreeWrapperUrl: Optional[str] = Field(default="http://localhost:8000")
|
||||||
|
|
||||||
|
|
||||||
|
class MetaVideoRequest(BaseModel):
|
||||||
|
"""Request for Meta AI video generation"""
|
||||||
|
prompt: str = Field(..., min_length=1)
|
||||||
|
cookies: str = Field(..., description="Meta AI cookies")
|
||||||
|
aspectRatio: str = Field(default="portrait")
|
||||||
|
|
||||||
|
|
||||||
|
class PromptUseRequest(BaseModel):
|
||||||
|
"""Track prompt usage"""
|
||||||
|
promptId: int = Field(..., description="Prompt ID to track")
|
||||||
|
|
||||||
|
|
||||||
|
class PromptUploadRequest(BaseModel):
|
||||||
|
"""Upload prompt thumbnail"""
|
||||||
|
promptId: int
|
||||||
|
imageBase64: str
|
||||||
113
backend/models/responses.py
Normal file
113
backend/models/responses.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""
|
||||||
|
Pydantic response models
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedImage(BaseModel):
|
||||||
|
"""Single generated image"""
|
||||||
|
data: str # base64
|
||||||
|
index: Optional[int] = None
|
||||||
|
prompt: str
|
||||||
|
aspectRatio: str
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateResponse(BaseModel):
|
||||||
|
"""Response from image generation"""
|
||||||
|
images: List[GeneratedImage]
|
||||||
|
|
||||||
|
|
||||||
|
class VideoResponse(BaseModel):
|
||||||
|
"""Response from video generation"""
|
||||||
|
success: bool
|
||||||
|
id: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceUploadResponse(BaseModel):
|
||||||
|
"""Response from reference upload"""
|
||||||
|
success: bool
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class MetaImageResult(BaseModel):
|
||||||
|
"""Meta AI generated image"""
|
||||||
|
data: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
prompt: str
|
||||||
|
model: str
|
||||||
|
aspectRatio: str = "1:1"
|
||||||
|
|
||||||
|
|
||||||
|
class MetaGenerateResponse(BaseModel):
|
||||||
|
"""Response from Meta AI generation"""
|
||||||
|
success: bool
|
||||||
|
images: List[MetaImageResult]
|
||||||
|
|
||||||
|
|
||||||
|
class MetaVideoResult(BaseModel):
|
||||||
|
"""Meta AI video result"""
|
||||||
|
url: str
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
class MetaVideoResponse(BaseModel):
|
||||||
|
"""Response from Meta AI video generation"""
|
||||||
|
success: bool
|
||||||
|
videos: List[MetaVideoResult]
|
||||||
|
conversation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Prompt(BaseModel):
|
||||||
|
"""Prompt library item"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
prompt: str
|
||||||
|
category: str
|
||||||
|
source: str
|
||||||
|
source_url: str
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
useCount: int = 0
|
||||||
|
lastUsedAt: Optional[int] = None
|
||||||
|
createdAt: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PromptCache(BaseModel):
|
||||||
|
"""Prompt library cache"""
|
||||||
|
prompts: List[Prompt]
|
||||||
|
last_updated: Optional[str] = None
|
||||||
|
lastSync: Optional[int] = None
|
||||||
|
categories: Dict[str, List[str]] = {}
|
||||||
|
total_count: int = 0
|
||||||
|
sources: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SyncResponse(BaseModel):
|
||||||
|
"""Response from prompt sync"""
|
||||||
|
success: bool
|
||||||
|
count: int
|
||||||
|
added: int
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryItem(BaseModel):
|
||||||
|
"""Upload history item"""
|
||||||
|
id: str
|
||||||
|
url: str
|
||||||
|
originalName: str
|
||||||
|
category: str
|
||||||
|
mediaId: Optional[str] = None
|
||||||
|
createdAt: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryResponse(BaseModel):
|
||||||
|
"""Response from history endpoint"""
|
||||||
|
history: List[HistoryItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Standard error response"""
|
||||||
|
error: str
|
||||||
|
details: Optional[str] = None
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
httpx>=0.26.0
|
||||||
|
pydantic>=2.5.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
aiofiles>=23.2.0
|
||||||
1
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Routers package
|
||||||
92
backend/routers/generate.py
Normal file
92
backend/routers/generate.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""
|
||||||
|
Generate Router - Whisk image generation
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from models.requests import GenerateRequest
|
||||||
|
from models.responses import GenerateResponse, GeneratedImage, ErrorResponse
|
||||||
|
from services.whisk_client import WhiskClient
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Generate"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate",
|
||||||
|
response_model=GenerateResponse,
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
401: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def generate_images(request: GenerateRequest):
|
||||||
|
"""
|
||||||
|
Generate images using Whisk API.
|
||||||
|
|
||||||
|
- **prompt**: Text description of the image to generate
|
||||||
|
- **aspectRatio**: Output aspect ratio (1:1, 9:16, 16:9, etc.)
|
||||||
|
- **refs**: Optional reference images {subject, scene, style}
|
||||||
|
- **imageCount**: Number of parallel generation requests (1-4)
|
||||||
|
- **cookies**: Whisk authentication cookies
|
||||||
|
"""
|
||||||
|
if not request.cookies:
|
||||||
|
raise HTTPException(status_code=401, detail="Whisk cookies not found. Please configure settings.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Normalize cookies if JSON format
|
||||||
|
cookie_string = request.cookies.strip()
|
||||||
|
if cookie_string.startswith('[') or cookie_string.startswith('{'):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
cookie_array = json.loads(cookie_string)
|
||||||
|
if isinstance(cookie_array, list):
|
||||||
|
cookie_string = "; ".join(
|
||||||
|
f"{c['name']}={c['value']}" for c in cookie_array
|
||||||
|
)
|
||||||
|
print(f"[Generate] Parsed {len(cookie_array)} cookies from JSON.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Generate] Failed to parse cookie JSON: {e}")
|
||||||
|
|
||||||
|
client = WhiskClient(cookie_string)
|
||||||
|
|
||||||
|
# Generate images in parallel if imageCount > 1
|
||||||
|
parallel_count = min(max(1, request.imageCount), 4)
|
||||||
|
print(f"Starting {parallel_count} parallel generation requests for prompt: \"{request.prompt[:20]}...\"")
|
||||||
|
|
||||||
|
async def single_generate():
|
||||||
|
try:
|
||||||
|
return await client.generate(
|
||||||
|
request.prompt,
|
||||||
|
request.aspectRatio,
|
||||||
|
request.refs,
|
||||||
|
request.preciseMode
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Single generation request failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[single_generate() for _ in range(parallel_count)])
|
||||||
|
all_images = [img for result in results for img in result]
|
||||||
|
|
||||||
|
if not all_images:
|
||||||
|
raise HTTPException(status_code=500, detail="All generation requests failed. Check logs or try again.")
|
||||||
|
|
||||||
|
return GenerateResponse(
|
||||||
|
images=[
|
||||||
|
GeneratedImage(
|
||||||
|
data=img.data,
|
||||||
|
index=img.index,
|
||||||
|
prompt=img.prompt,
|
||||||
|
aspectRatio=img.aspect_ratio
|
||||||
|
)
|
||||||
|
for img in all_images
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
is_auth_error = any(x in msg.lower() for x in ["401", "403", "auth", "cookies", "expired"])
|
||||||
|
status_code = 401 if is_auth_error else 500
|
||||||
|
raise HTTPException(status_code=status_code, detail=msg)
|
||||||
164
backend/routers/history.py
Normal file
164
backend/routers/history.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
"""
|
||||||
|
History Router - Upload history management
|
||||||
|
|
||||||
|
Note: This is a simplified version. The original Next.js version
|
||||||
|
stores history in memory/file. For FastAPI, we'll use a simple
|
||||||
|
JSON file approach similar to prompts.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
|
from models.responses import HistoryResponse, HistoryItem, ErrorResponse
|
||||||
|
from services.whisk_client import WhiskClient
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import base64
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/history", tags=["History"])
|
||||||
|
|
||||||
|
# History storage
|
||||||
|
HISTORY_FILE = Path(__file__).parent.parent.parent / "data" / "history.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_history() -> list:
|
||||||
|
"""Load history from JSON file"""
|
||||||
|
try:
|
||||||
|
if HISTORY_FILE.exists():
|
||||||
|
return json.loads(HISTORY_FILE.read_text())
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_history(history: list) -> None:
|
||||||
|
"""Save history to JSON file"""
|
||||||
|
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=HistoryResponse,
|
||||||
|
responses={500: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def get_history(category: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Get upload history.
|
||||||
|
|
||||||
|
- **category**: Optional filter by category (subject, scene, style)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
history = load_history()
|
||||||
|
|
||||||
|
if category:
|
||||||
|
history = [h for h in history if h.get("category") == category]
|
||||||
|
|
||||||
|
return HistoryResponse(history=[HistoryItem(**h) for h in history])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=HistoryItem,
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def upload_to_history(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
category: str = Form(default="subject"),
|
||||||
|
cookies: Optional[str] = Form(default=None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload an image to history.
|
||||||
|
|
||||||
|
- **file**: Image file to upload
|
||||||
|
- **category**: Category (subject, scene, style)
|
||||||
|
- **cookies**: Optional Whisk cookies to also upload to Whisk
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Read file
|
||||||
|
content = await file.read()
|
||||||
|
base64_data = base64.b64encode(content).decode('utf-8')
|
||||||
|
mime_type = file.content_type or 'image/png'
|
||||||
|
|
||||||
|
# Upload to Whisk if cookies provided
|
||||||
|
media_id = None
|
||||||
|
if cookies:
|
||||||
|
try:
|
||||||
|
client = WhiskClient(cookies)
|
||||||
|
media_id = await client.upload_reference_image(base64_data, mime_type, category)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[History] Whisk upload failed: {e}")
|
||||||
|
|
||||||
|
# Create history item
|
||||||
|
new_item = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"url": f"data:{mime_type};base64,{base64_data}",
|
||||||
|
"originalName": file.filename or "upload.png",
|
||||||
|
"category": category,
|
||||||
|
"mediaId": media_id,
|
||||||
|
"createdAt": int(datetime.now().timestamp() * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to history
|
||||||
|
history = load_history()
|
||||||
|
history.insert(0, new_item) # Add to beginning
|
||||||
|
save_history(history)
|
||||||
|
|
||||||
|
return HistoryItem(**new_item)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Upload error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{item_id}",
|
||||||
|
responses={
|
||||||
|
404: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def delete_history_item(item_id: str):
|
||||||
|
"""
|
||||||
|
Delete an item from history.
|
||||||
|
|
||||||
|
- **item_id**: ID of the history item to delete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
history = load_history()
|
||||||
|
original_len = len(history)
|
||||||
|
|
||||||
|
history = [h for h in history if h.get("id") != item_id]
|
||||||
|
|
||||||
|
if len(history) == original_len:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
save_history(history)
|
||||||
|
|
||||||
|
return {"success": True, "deleted": item_id}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"",
|
||||||
|
responses={500: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def clear_history():
|
||||||
|
"""
|
||||||
|
Clear all history items.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
save_history([])
|
||||||
|
return {"success": True, "message": "History cleared"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
122
backend/routers/meta.py
Normal file
122
backend/routers/meta.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""
|
||||||
|
Meta AI Router - Meta AI image and video generation
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from models.requests import MetaGenerateRequest, MetaVideoRequest
|
||||||
|
from models.responses import MetaGenerateResponse, MetaImageResult, MetaVideoResponse, MetaVideoResult, ErrorResponse
|
||||||
|
from services.meta_client import MetaAIClient
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/meta", tags=["Meta AI"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate",
|
||||||
|
response_model=MetaGenerateResponse,
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
401: {"model": ErrorResponse},
|
||||||
|
422: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def meta_generate(request: MetaGenerateRequest):
|
||||||
|
"""
|
||||||
|
Generate images using Meta AI's Imagine model.
|
||||||
|
|
||||||
|
- **prompt**: Text description of the image to generate
|
||||||
|
- **cookies**: Meta AI cookies (optional if using free wrapper)
|
||||||
|
- **imageCount**: Number of images to generate (1-4)
|
||||||
|
- **aspectRatio**: portrait, landscape, or square
|
||||||
|
- **useMetaFreeWrapper**: Use free API wrapper instead of direct Meta AI
|
||||||
|
- **metaFreeWrapperUrl**: URL of the free wrapper service
|
||||||
|
"""
|
||||||
|
# Only check for cookies if NOT using free wrapper
|
||||||
|
if not request.useMetaFreeWrapper and not request.cookies:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Meta AI cookies required. Configure in Settings or use Free Wrapper."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[Meta AI Route] Generating images for: \"{request.prompt[:30]}...\" ({request.aspectRatio})")
|
||||||
|
|
||||||
|
# Diagnostic: Check cookie count
|
||||||
|
if request.cookies:
|
||||||
|
try:
|
||||||
|
cookies = request.cookies.strip()
|
||||||
|
if cookies.startswith('['):
|
||||||
|
parsed = json.loads(cookies)
|
||||||
|
count = len(parsed) if isinstance(parsed, list) else 0
|
||||||
|
else:
|
||||||
|
count = len(cookies.split(';'))
|
||||||
|
print(f"[Meta AI Route] Received {count} cookies (Free Wrapper: {request.useMetaFreeWrapper})")
|
||||||
|
except:
|
||||||
|
print(f"[Meta AI Route] Cookie format: {type(request.cookies)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = MetaAIClient(
|
||||||
|
cookies=request.cookies or "",
|
||||||
|
use_free_wrapper=request.useMetaFreeWrapper,
|
||||||
|
free_wrapper_url=request.metaFreeWrapperUrl or "http://localhost:8000"
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await client.generate(
|
||||||
|
request.prompt,
|
||||||
|
request.imageCount,
|
||||||
|
request.aspectRatio
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download images as base64 for storage
|
||||||
|
images = []
|
||||||
|
for img in results:
|
||||||
|
base64_data = img.data
|
||||||
|
if not base64_data and img.url:
|
||||||
|
try:
|
||||||
|
base64_data = await client.download_as_base64(img.url)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Meta AI Route] Failed to download image: {e}")
|
||||||
|
|
||||||
|
images.append(MetaImageResult(
|
||||||
|
data=base64_data or "",
|
||||||
|
url=img.url,
|
||||||
|
prompt=img.prompt,
|
||||||
|
model=img.model,
|
||||||
|
aspectRatio="1:1"
|
||||||
|
))
|
||||||
|
|
||||||
|
valid_images = [img for img in images if img.data or img.url]
|
||||||
|
|
||||||
|
if not valid_images:
|
||||||
|
raise HTTPException(status_code=422, detail="No valid images generated")
|
||||||
|
|
||||||
|
return MetaGenerateResponse(success=True, images=valid_images)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Meta AI Route] Error: {e}")
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/video",
|
||||||
|
response_model=MetaVideoResponse,
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
401: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def meta_video(request: MetaVideoRequest):
|
||||||
|
"""
|
||||||
|
Generate video from text prompt using Meta AI's Kadabra engine.
|
||||||
|
|
||||||
|
- **prompt**: Text description for video generation
|
||||||
|
- **cookies**: Meta AI cookies
|
||||||
|
- **aspectRatio**: portrait, landscape, or square
|
||||||
|
"""
|
||||||
|
# Note: Meta AI video generation via GraphQL is complex
|
||||||
|
# This is a placeholder - the full implementation would require
|
||||||
|
# porting the entire meta/video/route.ts logic
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=501,
|
||||||
|
detail="Meta AI video generation not yet implemented in FastAPI backend"
|
||||||
|
)
|
||||||
177
backend/routers/prompts.py
Normal file
177
backend/routers/prompts.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""
|
||||||
|
Prompts Router - Prompt library management
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from models.requests import PromptUseRequest, PromptUploadRequest
|
||||||
|
from models.responses import PromptCache, SyncResponse, ErrorResponse
|
||||||
|
from services.prompts_service import (
|
||||||
|
get_prompts,
|
||||||
|
sync_prompts,
|
||||||
|
track_prompt_use,
|
||||||
|
upload_prompt_image
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/prompts", tags=["Prompts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PromptCache,
|
||||||
|
responses={500: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def list_prompts():
|
||||||
|
"""
|
||||||
|
Get all prompts from the library.
|
||||||
|
|
||||||
|
Returns cached prompts with metadata including:
|
||||||
|
- All prompts with titles, descriptions, and content
|
||||||
|
- Categories and sources
|
||||||
|
- Last sync timestamp
|
||||||
|
|
||||||
|
Triggers background sync if last sync was more than 1 hour ago.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache = await get_prompts()
|
||||||
|
|
||||||
|
# Lazy Auto-Crawl: Check if sync is needed (every 1 hour)
|
||||||
|
ONE_HOUR = 60 * 60 * 1000
|
||||||
|
last_sync = cache.last_sync or 0
|
||||||
|
|
||||||
|
import time
|
||||||
|
if int(time.time() * 1000) - last_sync > ONE_HOUR:
|
||||||
|
print("[Auto-Crawl] Triggering background sync...")
|
||||||
|
# Fire and forget - don't await
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(sync_prompts())
|
||||||
|
|
||||||
|
return cache.to_dict()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to load prompts")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sync",
|
||||||
|
response_model=SyncResponse,
|
||||||
|
responses={500: {"model": ErrorResponse}}
|
||||||
|
)
|
||||||
|
async def sync_prompts_endpoint():
|
||||||
|
"""
|
||||||
|
Manually trigger a sync of prompts from all sources.
|
||||||
|
|
||||||
|
Crawls prompt sources and merges with existing prompts.
|
||||||
|
Returns count of total and newly added prompts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await sync_prompts()
|
||||||
|
return SyncResponse(**result)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Sync failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Sync failed")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/use",
|
||||||
|
responses={
|
||||||
|
404: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def use_prompt(request: PromptUseRequest):
|
||||||
|
"""
|
||||||
|
Track usage of a prompt.
|
||||||
|
|
||||||
|
Increments the use count and updates lastUsedAt timestamp.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prompt = await track_prompt_use(request.promptId)
|
||||||
|
if not prompt:
|
||||||
|
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"promptId": prompt.id,
|
||||||
|
"useCount": prompt.use_count,
|
||||||
|
"lastUsedAt": prompt.last_used_at
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/upload",
|
||||||
|
responses={
|
||||||
|
404: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def upload_image(request: PromptUploadRequest):
|
||||||
|
"""
|
||||||
|
Upload a thumbnail image for a prompt.
|
||||||
|
|
||||||
|
Stores the base64 image data with the prompt.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prompt = await upload_prompt_image(request.promptId, request.imageBase64)
|
||||||
|
if not prompt:
|
||||||
|
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"promptId": prompt.id,
|
||||||
|
"imageCount": len(prompt.images) if prompt.images else 0
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate",
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
404: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def generate_from_prompt(
|
||||||
|
promptId: int,
|
||||||
|
aspectRatio: str = "1:1",
|
||||||
|
cookies: str = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate images using a prompt from the library.
|
||||||
|
|
||||||
|
This is a convenience endpoint that:
|
||||||
|
1. Fetches the prompt by ID
|
||||||
|
2. Calls the generate endpoint with the prompt content
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache = await get_prompts()
|
||||||
|
|
||||||
|
# Find the prompt
|
||||||
|
prompt = None
|
||||||
|
for p in cache.prompts:
|
||||||
|
if p.id == promptId:
|
||||||
|
prompt = p
|
||||||
|
break
|
||||||
|
|
||||||
|
if not prompt:
|
||||||
|
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||||
|
|
||||||
|
# Track usage
|
||||||
|
await track_prompt_use(promptId)
|
||||||
|
|
||||||
|
# Return prompt info for frontend to generate
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"prompt": prompt.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
92
backend/routers/references.py
Normal file
92
backend/routers/references.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""
|
||||||
|
References Router - Reference image upload
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from models.requests import ReferenceUploadRequest
|
||||||
|
from models.responses import ReferenceUploadResponse, ErrorResponse
|
||||||
|
from services.whisk_client import WhiskClient
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter(tags=["References"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/references/upload",
|
||||||
|
response_model=ReferenceUploadResponse,
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
401: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def upload_reference(request: ReferenceUploadRequest):
|
||||||
|
"""
|
||||||
|
Upload a reference image for Whisk generation.
|
||||||
|
|
||||||
|
- **imageBase64**: Base64 encoded image data
|
||||||
|
- **mimeType**: Image MIME type (image/jpeg, image/png, image/webp, image/gif)
|
||||||
|
- **category**: Reference category (subject, scene, style)
|
||||||
|
- **cookies**: Whisk authentication cookies
|
||||||
|
"""
|
||||||
|
# Validate MIME type
|
||||||
|
allowed_types = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
if request.mimeType not in allowed_types:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unsupported file type: {request.mimeType}. Please use JPG, PNG, or WEBP."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize cookies
|
||||||
|
valid_cookies = request.cookies.strip()
|
||||||
|
is_json = False
|
||||||
|
|
||||||
|
trimmed = valid_cookies.strip()
|
||||||
|
if trimmed.startswith('[') or trimmed.startswith('{'):
|
||||||
|
is_json = True
|
||||||
|
try:
|
||||||
|
cookie_array = json.loads(trimmed)
|
||||||
|
if isinstance(cookie_array, list):
|
||||||
|
valid_cookies = "; ".join(
|
||||||
|
f"{c['name']}={c['value']}" for c in cookie_array
|
||||||
|
)
|
||||||
|
print(f"[API] Successfully parsed {len(cookie_array)} cookies from JSON.")
|
||||||
|
elif isinstance(cookie_array, dict) and 'name' in cookie_array and 'value' in cookie_array:
|
||||||
|
valid_cookies = f"{cookie_array['name']}={cookie_array['value']}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[API] Failed to parse cookie JSON, falling back to raw value: {e}")
|
||||||
|
|
||||||
|
# Validate cookie format
|
||||||
|
if '=' not in valid_cookies:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail='Invalid Cookie Format. Cookies must be in "name=value" format or a JSON list.'
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[API] Uploading reference image ({request.category}, {request.mimeType})...")
|
||||||
|
print(f"[API] Using cookies (first 100 chars): {valid_cookies[:100]}...")
|
||||||
|
print(f"[API] Cookie was JSON: {is_json}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = WhiskClient(valid_cookies)
|
||||||
|
|
||||||
|
# Remove data URI header if present
|
||||||
|
raw_base64 = request.imageBase64
|
||||||
|
if raw_base64.startswith('data:'):
|
||||||
|
raw_base64 = raw_base64.split(',', 1)[1] if ',' in raw_base64 else raw_base64
|
||||||
|
|
||||||
|
media_id = await client.upload_reference_image(
|
||||||
|
raw_base64,
|
||||||
|
request.mimeType,
|
||||||
|
request.category
|
||||||
|
)
|
||||||
|
|
||||||
|
if not media_id:
|
||||||
|
raise HTTPException(status_code=500, detail="Upload returned no ID")
|
||||||
|
|
||||||
|
return ReferenceUploadResponse(success=True, id=media_id)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Reference Upload API failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
55
backend/routers/video.py
Normal file
55
backend/routers/video.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""
|
||||||
|
Video Router - Whisk video generation
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from models.requests import VideoGenerateRequest
|
||||||
|
from models.responses import VideoResponse, ErrorResponse
|
||||||
|
from services.whisk_client import WhiskClient
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Video"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/video/generate",
|
||||||
|
response_model=VideoResponse,
|
||||||
|
responses={
|
||||||
|
400: {"model": ErrorResponse},
|
||||||
|
401: {"model": ErrorResponse},
|
||||||
|
500: {"model": ErrorResponse}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def generate_video(request: VideoGenerateRequest):
|
||||||
|
"""
|
||||||
|
Generate video from an image using Whisk Animate (Veo).
|
||||||
|
|
||||||
|
- **prompt**: Motion description for the video
|
||||||
|
- **imageBase64**: Base64 encoded source image (optional if imageGenerationId provided)
|
||||||
|
- **imageGenerationId**: Existing Whisk image ID (optional if imageBase64 provided)
|
||||||
|
- **cookies**: Whisk authentication cookies
|
||||||
|
"""
|
||||||
|
if not request.cookies:
|
||||||
|
raise HTTPException(status_code=401, detail="Whisk cookies not found. Please configure settings.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = WhiskClient(request.cookies)
|
||||||
|
|
||||||
|
print(f"[Video API] Generating video for prompt: \"{request.prompt[:50]}...\"")
|
||||||
|
|
||||||
|
result = await client.generate_video(
|
||||||
|
request.imageGenerationId or "",
|
||||||
|
request.prompt,
|
||||||
|
request.imageBase64
|
||||||
|
)
|
||||||
|
|
||||||
|
return VideoResponse(
|
||||||
|
success=True,
|
||||||
|
id=result.id,
|
||||||
|
url=result.url,
|
||||||
|
status=result.status
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Video Generation API failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Services package
|
||||||
525
backend/services/meta_client.py
Normal file
525
backend/services/meta_client.py
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
"""
|
||||||
|
Meta AI Client for Python/FastAPI
|
||||||
|
Port of lib/providers/meta-client.ts
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Session initialization from meta.ai
|
||||||
|
- GraphQL mutation for image generation (Abra)
|
||||||
|
- Streaming response parsing
|
||||||
|
- Polling for async results
|
||||||
|
- Free wrapper fallback support
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
|
META_AI_BASE = "https://www.meta.ai"
|
||||||
|
GRAPHQL_ENDPOINT = f"{META_AI_BASE}/api/graphql/"
|
||||||
|
|
||||||
|
# Orientation mapping
|
||||||
|
ORIENTATION_MAP = {
|
||||||
|
"portrait": "VERTICAL",
|
||||||
|
"landscape": "HORIZONTAL",
|
||||||
|
"square": "SQUARE"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MetaImageResult:
|
||||||
|
def __init__(self, url: str, data: Optional[str], prompt: str, model: str):
|
||||||
|
self.url = url
|
||||||
|
self.data = data
|
||||||
|
self.prompt = prompt
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"url": self.url,
|
||||||
|
"data": self.data,
|
||||||
|
"prompt": self.prompt,
|
||||||
|
"model": self.model,
|
||||||
|
"aspectRatio": "1:1"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MetaSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.lsd: Optional[str] = None
|
||||||
|
self.fb_dtsg: Optional[str] = None
|
||||||
|
self.access_token: Optional[str] = None
|
||||||
|
self.external_conversation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MetaAIClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cookies: str,
|
||||||
|
use_free_wrapper: bool = True,
|
||||||
|
free_wrapper_url: str = "http://localhost:8000"
|
||||||
|
):
|
||||||
|
self.cookies = self._normalize_cookies(cookies)
|
||||||
|
self.session = MetaSession()
|
||||||
|
self.use_free_wrapper = use_free_wrapper
|
||||||
|
self.free_wrapper_url = free_wrapper_url
|
||||||
|
|
||||||
|
if self.cookies:
|
||||||
|
self._parse_session_from_cookies()
|
||||||
|
|
||||||
|
def _normalize_cookies(self, cookies: str) -> str:
|
||||||
|
"""Normalize cookies from JSON array to string format"""
|
||||||
|
if not cookies:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
trimmed = cookies.strip()
|
||||||
|
if trimmed.startswith('['):
|
||||||
|
parsed = json.loads(trimmed)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return "; ".join(
|
||||||
|
f"{c['name']}={c['value']}" for c in parsed
|
||||||
|
if isinstance(c, dict) and 'name' in c and 'value' in c
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
def _parse_session_from_cookies(self) -> None:
|
||||||
|
"""Extract session tokens from cookies"""
|
||||||
|
lsd_match = re.search(r'lsd=([^;]+)', self.cookies)
|
||||||
|
if lsd_match:
|
||||||
|
self.session.lsd = lsd_match.group(1)
|
||||||
|
|
||||||
|
dtsg_match = re.search(r'fb_dtsg=([^;]+)', self.cookies)
|
||||||
|
if dtsg_match:
|
||||||
|
self.session.fb_dtsg = dtsg_match.group(1)
|
||||||
|
|
||||||
|
def _parse_cookies_to_dict(self, cookie_str: str) -> Dict[str, str]:
|
||||||
|
"""Parse cookie string to dictionary"""
|
||||||
|
result = {}
|
||||||
|
if not cookie_str:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for pair in cookie_str.split(';'):
|
||||||
|
pair = pair.strip()
|
||||||
|
if '=' in pair:
|
||||||
|
key, _, value = pair.partition('=')
|
||||||
|
result[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_session(self) -> MetaSession:
|
||||||
|
"""Get initialized session tokens"""
|
||||||
|
if not self.use_free_wrapper and not self.session.lsd and not self.session.fb_dtsg:
|
||||||
|
await self._init_session()
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
def get_cookies(self) -> str:
|
||||||
|
return self.cookies
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 4,
|
||||||
|
aspect_ratio: str = "portrait"
|
||||||
|
) -> List[MetaImageResult]:
|
||||||
|
"""Generate images using Meta AI's Imagine model"""
|
||||||
|
print(f"[Meta AI] Generating images for: \"{prompt[:50]}...\" ({aspect_ratio})")
|
||||||
|
|
||||||
|
if self.use_free_wrapper:
|
||||||
|
return await self._generate_with_free_wrapper(prompt, num_images)
|
||||||
|
|
||||||
|
# Initialize session if needed
|
||||||
|
if not self.session.access_token:
|
||||||
|
await self._init_session()
|
||||||
|
|
||||||
|
# Use "Imagine" prefix for image generation
|
||||||
|
image_prompt = prompt if prompt.lower().startswith('imagine') else f"Imagine {prompt}"
|
||||||
|
|
||||||
|
# Send the prompt via GraphQL
|
||||||
|
response = await self._send_prompt(image_prompt, aspect_ratio)
|
||||||
|
|
||||||
|
# Extract images
|
||||||
|
images = self._extract_images(response, prompt)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
print("[Meta AI] No images in initial response, polling...")
|
||||||
|
images = await self._poll_for_images(response, prompt)
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
async def _generate_with_free_wrapper(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
num_images: int
|
||||||
|
) -> List[MetaImageResult]:
|
||||||
|
"""Generate using free API wrapper"""
|
||||||
|
print(f"[Meta Wrapper] Generating for: \"{prompt[:50]}...\" via {self.free_wrapper_url}")
|
||||||
|
|
||||||
|
cookie_dict = self._parse_cookies_to_dict(self.cookies)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.free_wrapper_url}/chat",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"message": f"Imagine {prompt}",
|
||||||
|
"stream": False,
|
||||||
|
"cookies": cookie_dict
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_text = response.text[:200]
|
||||||
|
raise Exception(f"Meta Wrapper Error: {response.status_code} - {error_text}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
images: List[MetaImageResult] = []
|
||||||
|
|
||||||
|
# Check for media in response
|
||||||
|
if data.get("media") and isinstance(data["media"], list):
|
||||||
|
for m in data["media"]:
|
||||||
|
if m.get("url"):
|
||||||
|
images.append(MetaImageResult(
|
||||||
|
url=m["url"],
|
||||||
|
data=None,
|
||||||
|
prompt=prompt,
|
||||||
|
model="meta-wrapper"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Fallback checks
|
||||||
|
if not images and data.get("images") and isinstance(data["images"], list):
|
||||||
|
for url in data["images"]:
|
||||||
|
images.append(MetaImageResult(
|
||||||
|
url=url,
|
||||||
|
data=None,
|
||||||
|
prompt=prompt,
|
||||||
|
model="meta-wrapper"
|
||||||
|
))
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
raise Exception("Meta Wrapper returned no images")
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
async def _init_session(self) -> None:
|
||||||
|
"""Initialize session - get access token from meta.ai page"""
|
||||||
|
print("[Meta AI] Initializing session...")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
META_AI_BASE,
|
||||||
|
headers={
|
||||||
|
"Cookie": self.cookies,
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
html = response.text
|
||||||
|
|
||||||
|
# Extract access token
|
||||||
|
token_match = re.search(r'"accessToken":"([^"]+)"', html)
|
||||||
|
if not token_match:
|
||||||
|
token_match = re.search(r'accessToken["\']\\s*:\\s*["\']([^"\']+)["\']', html)
|
||||||
|
|
||||||
|
# Extract LSD token
|
||||||
|
lsd_match = (
|
||||||
|
re.search(r'"LSD",\[\],\{"token":"([^"]+)"', html) or
|
||||||
|
re.search(r'"lsd":"([^"]+)"', html) or
|
||||||
|
re.search(r'name="lsd" value="([^"]+)"', html)
|
||||||
|
)
|
||||||
|
if lsd_match:
|
||||||
|
self.session.lsd = lsd_match.group(1)
|
||||||
|
|
||||||
|
# Extract DTSG token
|
||||||
|
dtsg_match = (
|
||||||
|
re.search(r'"DTSGInitialData".*?"token":"([^"]+)"', html) or
|
||||||
|
re.search(r'"token":"([^"]+)"', html)
|
||||||
|
)
|
||||||
|
if dtsg_match:
|
||||||
|
self.session.fb_dtsg = dtsg_match.group(1)
|
||||||
|
|
||||||
|
if token_match:
|
||||||
|
self.session.access_token = token_match.group(1)
|
||||||
|
print("[Meta AI] Got access token")
|
||||||
|
elif 'login_form' in html or 'login_page' in html:
|
||||||
|
raise Exception("Meta AI: Cookies expired or invalid")
|
||||||
|
else:
|
||||||
|
print("[Meta AI] Warning: Failed to extract access token")
|
||||||
|
|
||||||
|
async def _send_prompt(self, prompt: str, aspect_ratio: str = "portrait") -> Any:
|
||||||
|
"""Send prompt via GraphQL mutation"""
|
||||||
|
external_conversation_id = str(uuid.uuid4())
|
||||||
|
timestamp = int(asyncio.get_event_loop().time() * 1000)
|
||||||
|
random_part = int(str(uuid.uuid4().int)[:7])
|
||||||
|
offline_threading_id = str((timestamp << 22) | random_part)
|
||||||
|
|
||||||
|
self.session.external_conversation_id = external_conversation_id
|
||||||
|
orientation = ORIENTATION_MAP.get(aspect_ratio, "VERTICAL")
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"message": {
|
||||||
|
"sensitive_string_value": prompt
|
||||||
|
},
|
||||||
|
"externalConversationId": external_conversation_id,
|
||||||
|
"offlineThreadingId": offline_threading_id,
|
||||||
|
"suggestedPromptIndex": None,
|
||||||
|
"flashVideoRecapInput": {"images": []},
|
||||||
|
"flashPreviewInput": None,
|
||||||
|
"promptPrefix": None,
|
||||||
|
"entrypoint": "ABRA__CHAT__TEXT",
|
||||||
|
"icebreaker_type": "TEXT",
|
||||||
|
"imagineClientOptions": {"orientation": orientation},
|
||||||
|
"__relay_internal__pv__AbraDebugDevOnlyrelayprovider": False,
|
||||||
|
"__relay_internal__pv__WebPixelRatiorelayprovider": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"fb_api_caller_class": "RelayModern",
|
||||||
|
"fb_api_req_friendly_name": "useAbraSendMessageMutation",
|
||||||
|
"variables": json.dumps(variables),
|
||||||
|
"server_timestamps": "true",
|
||||||
|
"doc_id": "7783822248314888"
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.session.lsd:
|
||||||
|
body["lsd"] = self.session.lsd
|
||||||
|
if self.session.fb_dtsg:
|
||||||
|
body["fb_dtsg"] = self.session.fb_dtsg
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Cookie": self.cookies,
|
||||||
|
"Origin": META_AI_BASE,
|
||||||
|
"Referer": f"{META_AI_BASE}/",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.session.access_token:
|
||||||
|
headers["Authorization"] = f"OAuth {self.session.access_token}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
GRAPHQL_ENDPOINT,
|
||||||
|
headers=headers,
|
||||||
|
data=body
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_text = response.text
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Meta AI Error: {response.status_code} - {raw_text[:500]}")
|
||||||
|
|
||||||
|
# Parse streaming response
|
||||||
|
last_valid_response = None
|
||||||
|
for line in raw_text.split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(line)
|
||||||
|
|
||||||
|
streaming_state = (
|
||||||
|
parsed.get("data", {})
|
||||||
|
.get("xfb_abra_send_message", {})
|
||||||
|
.get("bot_response_message", {})
|
||||||
|
.get("streaming_state")
|
||||||
|
)
|
||||||
|
|
||||||
|
if streaming_state == "OVERALL_DONE":
|
||||||
|
last_valid_response = parsed
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for imagine_card
|
||||||
|
imagine_card = (
|
||||||
|
parsed.get("data", {})
|
||||||
|
.get("xfb_abra_send_message", {})
|
||||||
|
.get("bot_response_message", {})
|
||||||
|
.get("imagine_card")
|
||||||
|
)
|
||||||
|
if imagine_card and imagine_card.get("session", {}).get("media_sets"):
|
||||||
|
last_valid_response = parsed
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not last_valid_response:
|
||||||
|
if "login_form" in raw_text or "facebook.com/login" in raw_text:
|
||||||
|
raise Exception("Meta AI: Session expired. Please refresh cookies.")
|
||||||
|
raise Exception("Meta AI: No valid response found")
|
||||||
|
|
||||||
|
return last_valid_response
|
||||||
|
|
||||||
|
def _extract_images(self, response: Any, original_prompt: str) -> List[MetaImageResult]:
|
||||||
|
"""Extract image URLs from Meta AI response"""
|
||||||
|
images: List[MetaImageResult] = []
|
||||||
|
|
||||||
|
message_data = (
|
||||||
|
response.get("data", {})
|
||||||
|
.get("xfb_abra_send_message", {})
|
||||||
|
.get("bot_response_message")
|
||||||
|
)
|
||||||
|
|
||||||
|
if message_data:
|
||||||
|
images.extend(self._extract_images_from_message(message_data, original_prompt))
|
||||||
|
|
||||||
|
# Recursive search fallback
|
||||||
|
if not images and response.get("data"):
|
||||||
|
print("[Meta AI] Structured extraction failed, doing recursive search...")
|
||||||
|
found_urls = self._recursive_search_for_images(response["data"])
|
||||||
|
for url in found_urls:
|
||||||
|
images.append(MetaImageResult(
|
||||||
|
url=url,
|
||||||
|
data=None,
|
||||||
|
prompt=original_prompt,
|
||||||
|
model="meta"
|
||||||
|
))
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
def _extract_images_from_message(
|
||||||
|
self,
|
||||||
|
message_data: Dict,
|
||||||
|
original_prompt: str
|
||||||
|
) -> List[MetaImageResult]:
|
||||||
|
"""Helper to extract images from a single message node"""
|
||||||
|
images: List[MetaImageResult] = []
|
||||||
|
|
||||||
|
imagine_card = message_data.get("imagine_card")
|
||||||
|
if imagine_card and imagine_card.get("session", {}).get("media_sets"):
|
||||||
|
for media_set in imagine_card["session"]["media_sets"]:
|
||||||
|
imagine_media = media_set.get("imagine_media", [])
|
||||||
|
for media in imagine_media:
|
||||||
|
url = media.get("uri") or media.get("image_uri")
|
||||||
|
if url:
|
||||||
|
images.append(MetaImageResult(
|
||||||
|
url=url,
|
||||||
|
data=None,
|
||||||
|
prompt=original_prompt,
|
||||||
|
model="meta"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check attachments
|
||||||
|
attachments = message_data.get("attachments", [])
|
||||||
|
for attachment in attachments:
|
||||||
|
media = attachment.get("media", {})
|
||||||
|
url = media.get("image_uri") or media.get("uri")
|
||||||
|
if url:
|
||||||
|
images.append(MetaImageResult(
|
||||||
|
url=url,
|
||||||
|
data=None,
|
||||||
|
prompt=original_prompt,
|
||||||
|
model="meta"
|
||||||
|
))
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
def _recursive_search_for_images(
|
||||||
|
self,
|
||||||
|
obj: Any,
|
||||||
|
found: Optional[set] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Recursive search for image-like URLs"""
|
||||||
|
if found is None:
|
||||||
|
found = set()
|
||||||
|
|
||||||
|
if not obj or not isinstance(obj, (dict, list)):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key, val in obj.items():
|
||||||
|
if isinstance(val, str):
|
||||||
|
if ('fbcdn.net' in val or 'meta.ai' in val) and \
|
||||||
|
any(ext in val for ext in ['.jpg', '.png', '.webp', 'image_uri=', '/imagine/']):
|
||||||
|
found.add(val)
|
||||||
|
elif isinstance(val, (dict, list)):
|
||||||
|
self._recursive_search_for_images(val, found)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
self._recursive_search_for_images(item, found)
|
||||||
|
|
||||||
|
return list(found)
|
||||||
|
|
||||||
|
async def _poll_for_images(
|
||||||
|
self,
|
||||||
|
initial_response: Any,
|
||||||
|
prompt: str
|
||||||
|
) -> List[MetaImageResult]:
|
||||||
|
"""Poll for image generation completion"""
|
||||||
|
conversation_id = (
|
||||||
|
initial_response.get("data", {})
|
||||||
|
.get("node", {})
|
||||||
|
.get("external_conversation_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not conversation_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_attempts = 30
|
||||||
|
poll_interval = 2
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
print(f"[Meta AI] Polling attempt {attempt + 1}/{max_attempts}...")
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
|
||||||
|
variables = {"external_conversation_id": conversation_id}
|
||||||
|
body = {
|
||||||
|
"fb_api_caller_class": "RelayModern",
|
||||||
|
"fb_api_req_friendly_name": "KadabraPromptRootQuery",
|
||||||
|
"variables": json.dumps(variables),
|
||||||
|
"doc_id": "25290569913909283"
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.session.lsd:
|
||||||
|
body["lsd"] = self.session.lsd
|
||||||
|
if self.session.fb_dtsg:
|
||||||
|
body["fb_dtsg"] = self.session.fb_dtsg
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Cookie": self.cookies,
|
||||||
|
"Origin": META_AI_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.session.access_token:
|
||||||
|
headers["Authorization"] = f"OAuth {self.session.access_token}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
GRAPHQL_ENDPOINT,
|
||||||
|
headers=headers,
|
||||||
|
data=body
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
images = self._extract_images(data, prompt)
|
||||||
|
if images:
|
||||||
|
print(f"[Meta AI] Got {len(images)} image(s) after polling!")
|
||||||
|
return images
|
||||||
|
|
||||||
|
status = data.get("data", {}).get("kadabra_prompt", {}).get("status")
|
||||||
|
if status in ["FAILED", "ERROR"]:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Meta AI] Poll error: {e}")
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def download_as_base64(self, url: str) -> str:
|
||||||
|
"""Download image from URL and convert to base64"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Cookie": self.cookies,
|
||||||
|
"Referer": META_AI_BASE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return base64.b64encode(response.content).decode('utf-8')
|
||||||
151
backend/services/prompts_service.py
Normal file
151
backend/services/prompts_service.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""
|
||||||
|
Prompts Service for Python/FastAPI
|
||||||
|
Port of lib/prompts-service.ts
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Read/write prompts.json
|
||||||
|
- Sync prompts from crawlers (placeholder - crawlers complex to port)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Path to prompts data file (relative to project root)
|
||||||
|
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||||
|
DATA_FILE = DATA_DIR / "prompts.json"
|
||||||
|
|
||||||
|
|
||||||
|
class Prompt:
|
||||||
|
def __init__(self, data: Dict[str, Any]):
|
||||||
|
self.id = data.get("id", 0)
|
||||||
|
self.title = data.get("title", "")
|
||||||
|
self.description = data.get("description", "")
|
||||||
|
self.prompt = data.get("prompt", "")
|
||||||
|
self.category = data.get("category", "")
|
||||||
|
self.source = data.get("source", "")
|
||||||
|
self.source_url = data.get("source_url", "")
|
||||||
|
self.images = data.get("images", [])
|
||||||
|
self.use_count = data.get("useCount", 0)
|
||||||
|
self.last_used_at = data.get("lastUsedAt")
|
||||||
|
self.created_at = data.get("createdAt")
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"prompt": self.prompt,
|
||||||
|
"category": self.category,
|
||||||
|
"source": self.source,
|
||||||
|
"source_url": self.source_url,
|
||||||
|
"images": self.images,
|
||||||
|
"useCount": self.use_count,
|
||||||
|
"lastUsedAt": self.last_used_at,
|
||||||
|
"createdAt": self.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PromptCache:
|
||||||
|
def __init__(self, data: Dict[str, Any]):
|
||||||
|
self.prompts = [Prompt(p) for p in data.get("prompts", [])]
|
||||||
|
self.last_updated = data.get("last_updated")
|
||||||
|
self.last_sync = data.get("lastSync")
|
||||||
|
self.categories = data.get("categories", {})
|
||||||
|
self.total_count = data.get("total_count", 0)
|
||||||
|
self.sources = data.get("sources", [])
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"prompts": [p.to_dict() for p in self.prompts],
|
||||||
|
"last_updated": self.last_updated,
|
||||||
|
"lastSync": self.last_sync,
|
||||||
|
"categories": self.categories,
|
||||||
|
"total_count": self.total_count,
|
||||||
|
"sources": self.sources
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_prompts() -> PromptCache:
|
||||||
|
"""Read prompts from JSON file"""
|
||||||
|
try:
|
||||||
|
if DATA_FILE.exists():
|
||||||
|
content = DATA_FILE.read_text(encoding='utf-8')
|
||||||
|
data = json.loads(content)
|
||||||
|
return PromptCache(data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PromptsService] Error reading prompts: {e}")
|
||||||
|
|
||||||
|
return PromptCache({
|
||||||
|
"prompts": [],
|
||||||
|
"last_updated": None,
|
||||||
|
"categories": {},
|
||||||
|
"total_count": 0,
|
||||||
|
"sources": []
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def save_prompts(cache: PromptCache) -> None:
|
||||||
|
"""Save prompts to JSON file"""
|
||||||
|
try:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = json.dumps(cache.to_dict(), indent=2, ensure_ascii=False)
|
||||||
|
DATA_FILE.write_text(content, encoding='utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PromptsService] Error saving prompts: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_prompts() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Sync prompts from sources.
|
||||||
|
Note: The crawler implementation is complex and would require porting
|
||||||
|
the JavaScript crawlers. For now, this just refreshes the timestamp.
|
||||||
|
"""
|
||||||
|
print("[PromptsService] Starting sync...")
|
||||||
|
|
||||||
|
cache = await get_prompts()
|
||||||
|
now = int(datetime.now().timestamp() * 1000)
|
||||||
|
|
||||||
|
# Update sync timestamp
|
||||||
|
cache.last_sync = now
|
||||||
|
cache.last_updated = datetime.now().isoformat()
|
||||||
|
|
||||||
|
await save_prompts(cache)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"count": len(cache.prompts),
|
||||||
|
"added": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def track_prompt_use(prompt_id: int) -> Optional[Prompt]:
|
||||||
|
"""Track usage of a prompt"""
|
||||||
|
cache = await get_prompts()
|
||||||
|
|
||||||
|
for prompt in cache.prompts:
|
||||||
|
if prompt.id == prompt_id:
|
||||||
|
prompt.use_count += 1
|
||||||
|
prompt.last_used_at = int(datetime.now().timestamp() * 1000)
|
||||||
|
await save_prompts(cache)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_prompt_image(prompt_id: int, image_base64: str) -> Optional[Prompt]:
|
||||||
|
"""Upload an image for a prompt"""
|
||||||
|
cache = await get_prompts()
|
||||||
|
|
||||||
|
for prompt in cache.prompts:
|
||||||
|
if prompt.id == prompt_id:
|
||||||
|
if prompt.images is None:
|
||||||
|
prompt.images = []
|
||||||
|
prompt.images.append(f"data:image/png;base64,{image_base64}")
|
||||||
|
await save_prompts(cache)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
return None
|
||||||
410
backend/services/whisk_client.py
Normal file
410
backend/services/whisk_client.py
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
"""
|
||||||
|
Whisk Client for Python/FastAPI
|
||||||
|
Port of lib/whisk-client.ts
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Cookie parsing (JSON array or string format)
|
||||||
|
- Access token retrieval from Whisk API
|
||||||
|
- Image generation with aspect ratio support
|
||||||
|
- Reference image upload
|
||||||
|
- Video generation with polling
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import base64
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
|
# Whisk API endpoints
|
||||||
|
AUTH_URL = "https://aisandbox-pa.googleapis.com/v1:signInWithIdp"
|
||||||
|
GENERATE_URL = "https://aisandbox-pa.googleapis.com/v1:runImagine"
|
||||||
|
RECIPE_URL = "https://aisandbox-pa.googleapis.com/v1:runRecipe"
|
||||||
|
UPLOAD_URL = "https://aisandbox-pa.googleapis.com/v1:uploadMedia"
|
||||||
|
VIDEO_URL = "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClips"
|
||||||
|
VIDEO_STATUS_URL = "https://aisandbox-pa.googleapis.com/v1:runVideoFxSingleClipsStatusCheck"
|
||||||
|
|
||||||
|
# Aspect ratio mapping
|
||||||
|
ASPECT_RATIOS = {
|
||||||
|
"1:1": "IMAGE_ASPECT_RATIO_SQUARE",
|
||||||
|
"9:16": "IMAGE_ASPECT_RATIO_PORTRAIT",
|
||||||
|
"16:9": "IMAGE_ASPECT_RATIO_LANDSCAPE",
|
||||||
|
"4:3": "IMAGE_ASPECT_RATIO_LANDSCAPE_FOUR_THREE",
|
||||||
|
"3:4": "IMAGE_ASPECT_RATIO_PORTRAIT",
|
||||||
|
"Auto": "IMAGE_ASPECT_RATIO_SQUARE"
|
||||||
|
}
|
||||||
|
|
||||||
|
MEDIA_CATEGORIES = {
|
||||||
|
"subject": "MEDIA_CATEGORY_SUBJECT",
|
||||||
|
"scene": "MEDIA_CATEGORY_SCENE",
|
||||||
|
"style": "MEDIA_CATEGORY_STYLE"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedImage:
|
||||||
|
def __init__(self, data: str, index: int, prompt: str, aspect_ratio: str):
|
||||||
|
self.data = data
|
||||||
|
self.index = index
|
||||||
|
self.prompt = prompt
|
||||||
|
self.aspect_ratio = aspect_ratio
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"data": self.data,
|
||||||
|
"index": self.index,
|
||||||
|
"prompt": self.prompt,
|
||||||
|
"aspectRatio": self.aspect_ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WhiskVideoResult:
|
||||||
|
def __init__(self, id: str, url: Optional[str], status: str):
|
||||||
|
self.id = id
|
||||||
|
self.url = url
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"url": self.url,
|
||||||
|
"status": self.status
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WhiskClient:
|
||||||
|
def __init__(self, cookie_input: str):
|
||||||
|
self.cookies = self._parse_cookies(cookie_input)
|
||||||
|
self.access_token: Optional[str] = None
|
||||||
|
self.token_expires: int = 0
|
||||||
|
self.cookie_string = ""
|
||||||
|
|
||||||
|
if not self.cookies:
|
||||||
|
raise ValueError("No valid cookies provided")
|
||||||
|
|
||||||
|
# Build cookie string for requests
|
||||||
|
self.cookie_string = "; ".join(
|
||||||
|
f"{name}={value}" for name, value in self.cookies.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_cookies(self, input_str: str) -> Dict[str, str]:
|
||||||
|
"""Parse cookies from string or JSON format"""
|
||||||
|
if not input_str or not input_str.strip():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
trimmed = input_str.strip()
|
||||||
|
cookies: Dict[str, str] = {}
|
||||||
|
|
||||||
|
# Handle JSON array format (e.g., from Cookie-Editor)
|
||||||
|
if trimmed.startswith('[') or trimmed.startswith('{'):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(trimmed)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
for c in parsed:
|
||||||
|
if isinstance(c, dict) and 'name' in c and 'value' in c:
|
||||||
|
cookies[c['name']] = c['value']
|
||||||
|
return cookies
|
||||||
|
elif isinstance(parsed, dict) and 'name' in parsed and 'value' in parsed:
|
||||||
|
return {parsed['name']: parsed['value']}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Handle string format (key=value; key2=value2)
|
||||||
|
for pair in trimmed.split(';'):
|
||||||
|
pair = pair.strip()
|
||||||
|
if '=' in pair:
|
||||||
|
key, _, value = pair.partition('=')
|
||||||
|
cookies[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
"""Get or refresh access token from Whisk API"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Return cached token if still valid
|
||||||
|
if self.access_token and self.token_expires > int(time.time() * 1000):
|
||||||
|
return self.access_token
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
AUTH_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cookie": self.cookie_string
|
||||||
|
},
|
||||||
|
json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Auth failed: {response.status_code} - {response.text[:200]}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
self.access_token = data.get("authToken")
|
||||||
|
expires_in = int(data.get("expiresIn", 3600))
|
||||||
|
self.token_expires = int(time.time() * 1000) + (expires_in * 1000) - 60000
|
||||||
|
|
||||||
|
if not self.access_token:
|
||||||
|
raise Exception("No auth token in response")
|
||||||
|
|
||||||
|
return self.access_token
|
||||||
|
|
||||||
|
async def upload_reference_image(
|
||||||
|
self,
|
||||||
|
file_base64: str,
|
||||||
|
mime_type: str,
|
||||||
|
category: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Upload a reference image and return media ID"""
|
||||||
|
token = await self.get_access_token()
|
||||||
|
|
||||||
|
data_uri = f"data:{mime_type};base64,{file_base64}"
|
||||||
|
media_category = MEDIA_CATEGORIES.get(category.lower(), MEDIA_CATEGORIES["subject"])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"mediaData": data_uri,
|
||||||
|
"imageOptions": {
|
||||||
|
"imageCategory": media_category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
UPLOAD_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Cookie": self.cookie_string
|
||||||
|
},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"[WhiskClient] Upload failed: {response.status_code}")
|
||||||
|
raise Exception(f"Upload failed: {response.text[:200]}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
media_id = data.get("generationId") or data.get("imageMediaId")
|
||||||
|
|
||||||
|
if not media_id:
|
||||||
|
print(f"[WhiskClient] No media ID in response: {data}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"[WhiskClient] Upload successful, mediaId: {media_id}")
|
||||||
|
return media_id
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
aspect_ratio: str = "1:1",
|
||||||
|
refs: Optional[Dict[str, Any]] = None,
|
||||||
|
precise_mode: bool = False
|
||||||
|
) -> List[GeneratedImage]:
|
||||||
|
"""Generate images using Whisk API"""
|
||||||
|
token = await self.get_access_token()
|
||||||
|
refs = refs or {}
|
||||||
|
|
||||||
|
# Build media inputs
|
||||||
|
media_inputs = []
|
||||||
|
|
||||||
|
def add_refs(category: str, ids):
|
||||||
|
"""Helper to add refs (handles both single string and array)"""
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
id_list = [ids] if isinstance(ids, str) else ids
|
||||||
|
cat_enum = MEDIA_CATEGORIES.get(category.lower())
|
||||||
|
for ref_id in id_list:
|
||||||
|
if ref_id:
|
||||||
|
media_inputs.append({
|
||||||
|
"mediaId": ref_id,
|
||||||
|
"mediaCategory": cat_enum
|
||||||
|
})
|
||||||
|
|
||||||
|
add_refs("subject", refs.get("subject"))
|
||||||
|
add_refs("scene", refs.get("scene"))
|
||||||
|
add_refs("style", refs.get("style"))
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
aspect_enum = ASPECT_RATIOS.get(aspect_ratio, ASPECT_RATIOS["1:1"])
|
||||||
|
|
||||||
|
# Determine endpoint based on refs
|
||||||
|
has_refs = len(media_inputs) > 0
|
||||||
|
endpoint = RECIPE_URL if has_refs else GENERATE_URL
|
||||||
|
|
||||||
|
if has_refs:
|
||||||
|
# Recipe format (with refs)
|
||||||
|
recipe_inputs = []
|
||||||
|
|
||||||
|
def add_recipe_refs(category: str, ids):
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
id_list = [ids] if isinstance(ids, str) else ids
|
||||||
|
cat_enum = MEDIA_CATEGORIES.get(category.lower())
|
||||||
|
for ref_id in id_list:
|
||||||
|
if ref_id:
|
||||||
|
recipe_inputs.append({
|
||||||
|
"inputType": cat_enum,
|
||||||
|
"mediaId": ref_id
|
||||||
|
})
|
||||||
|
|
||||||
|
add_recipe_refs("subject", refs.get("subject"))
|
||||||
|
add_recipe_refs("scene", refs.get("scene"))
|
||||||
|
add_recipe_refs("style", refs.get("style"))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"recipeInputs": recipe_inputs,
|
||||||
|
"generationConfig": {
|
||||||
|
"aspectRatio": aspect_enum,
|
||||||
|
"numberOfImages": 4,
|
||||||
|
"personalizationConfig": {}
|
||||||
|
},
|
||||||
|
"textPromptInput": {
|
||||||
|
"text": prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Direct imagine format (no refs)
|
||||||
|
payload = {
|
||||||
|
"imagineConfig": {
|
||||||
|
"aspectRatio": aspect_enum,
|
||||||
|
"imaginePrompt": prompt,
|
||||||
|
"numberOfImages": 4,
|
||||||
|
"imageSafetyMode": "BLOCK_SOME"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"[WhiskClient] Generating with prompt: \"{prompt[:50]}...\"")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
endpoint,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Cookie": self.cookie_string
|
||||||
|
},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_text = response.text[:500]
|
||||||
|
if "401" in error_text or "403" in error_text:
|
||||||
|
raise Exception("Whisk auth failed - cookies may be expired")
|
||||||
|
raise Exception(f"Generation failed: {response.status_code} - {error_text}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract images
|
||||||
|
images: List[GeneratedImage] = []
|
||||||
|
image_list = data.get("generatedImages", [])
|
||||||
|
|
||||||
|
for i, img in enumerate(image_list):
|
||||||
|
image_data = img.get("encodedImage", "")
|
||||||
|
if image_data:
|
||||||
|
images.append(GeneratedImage(
|
||||||
|
data=image_data,
|
||||||
|
index=i,
|
||||||
|
prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio
|
||||||
|
))
|
||||||
|
|
||||||
|
print(f"[WhiskClient] Generated {len(images)} images")
|
||||||
|
return images
|
||||||
|
|
||||||
|
async def generate_video(
|
||||||
|
self,
|
||||||
|
image_generation_id: str,
|
||||||
|
prompt: str,
|
||||||
|
image_base64: Optional[str] = None,
|
||||||
|
aspect_ratio: str = "16:9"
|
||||||
|
) -> WhiskVideoResult:
|
||||||
|
"""Generate a video from an image using Whisk Animate (Veo)"""
|
||||||
|
token = await self.get_access_token()
|
||||||
|
|
||||||
|
# If we have base64 but no generation ID, upload first
|
||||||
|
actual_gen_id = image_generation_id
|
||||||
|
if not actual_gen_id and image_base64:
|
||||||
|
actual_gen_id = await self.upload_reference_image(
|
||||||
|
image_base64, "image/png", "subject"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not actual_gen_id:
|
||||||
|
raise Exception("No image generation ID available for video")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"generationId": actual_gen_id,
|
||||||
|
"videoFxConfig": {
|
||||||
|
"aspectRatio": aspect_ratio.replace(":", "_"),
|
||||||
|
"prompt": prompt,
|
||||||
|
"duration": "5s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
VIDEO_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Cookie": self.cookie_string
|
||||||
|
},
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Video init failed: {response.text[:200]}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
video_gen_id = data.get("videoGenId")
|
||||||
|
if not video_gen_id:
|
||||||
|
raise Exception("No video generation ID in response")
|
||||||
|
|
||||||
|
print(f"[WhiskClient] Video generation started: {video_gen_id}")
|
||||||
|
|
||||||
|
# Poll for completion
|
||||||
|
return await self.poll_video_status(video_gen_id, token)
|
||||||
|
|
||||||
|
async def poll_video_status(
|
||||||
|
self,
|
||||||
|
video_gen_id: str,
|
||||||
|
token: str
|
||||||
|
) -> WhiskVideoResult:
|
||||||
|
"""Poll for video generation status until complete or failed"""
|
||||||
|
max_attempts = 60
|
||||||
|
poll_interval = 3
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
print(f"[WhiskClient] Polling video status {attempt + 1}/{max_attempts}...")
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
VIDEO_STATUS_URL,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Cookie": self.cookie_string
|
||||||
|
},
|
||||||
|
json={"videoGenId": video_gen_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
status = data.get("status", "")
|
||||||
|
video_url = data.get("videoUri")
|
||||||
|
|
||||||
|
if status == "COMPLETE" and video_url:
|
||||||
|
print(f"[WhiskClient] Video complete: {video_url[:50]}...")
|
||||||
|
return WhiskVideoResult(
|
||||||
|
id=video_gen_id,
|
||||||
|
url=video_url,
|
||||||
|
status="complete"
|
||||||
|
)
|
||||||
|
elif status in ["FAILED", "ERROR"]:
|
||||||
|
raise Exception(f"Video generation failed: {status}")
|
||||||
|
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
|
||||||
|
raise Exception("Video generation timed out")
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"last_updated": "2026-01-07T15:48:03.652Z",
|
"last_updated": "2026-01-13T00:23:04.935Z",
|
||||||
"lastSync": 1767800883652,
|
"lastSync": 1768263784935,
|
||||||
"categories": {
|
"categories": {
|
||||||
"style": [
|
"style": [
|
||||||
"Illustration",
|
"Illustration",
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ services:
|
||||||
container_name: kv-pix
|
container_name: kv-pix
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8558:3000"
|
- "3000:3000" # Next.js frontend
|
||||||
|
- "8000:8000" # FastAPI backend
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
metaai-free-api:
|
- ./data:/app/data # Persist prompt library
|
||||||
build: ./services/metaai-api
|
# Optional: Meta AI Free Wrapper (if needed)
|
||||||
container_name: metaai-free-api
|
# metaai-free-api:
|
||||||
restart: unless-stopped
|
# build: ./services/metaai-api
|
||||||
ports:
|
# container_name: metaai-free-api
|
||||||
- "8000:8000"
|
# restart: unless-stopped
|
||||||
|
# ports:
|
||||||
|
# - "8001:8000"
|
||||||
|
|
|
||||||
236
lib/api.ts
Normal file
236
lib/api.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
/**
|
||||||
|
* API Client for FastAPI Backend
|
||||||
|
*
|
||||||
|
* Centralized API calls to the FastAPI backend.
|
||||||
|
* Used by frontend components to call the new Python backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface GenerateParams {
|
||||||
|
prompt: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
refs?: { subject?: string | string[]; scene?: string | string[]; style?: string | string[] };
|
||||||
|
preciseMode?: boolean;
|
||||||
|
imageCount?: number;
|
||||||
|
cookies: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedImage {
|
||||||
|
data: string;
|
||||||
|
index?: number;
|
||||||
|
prompt: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoParams {
|
||||||
|
prompt: string;
|
||||||
|
imageBase64?: string;
|
||||||
|
imageGenerationId?: string;
|
||||||
|
cookies: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferenceUploadParams {
|
||||||
|
imageBase64: string;
|
||||||
|
mimeType: string;
|
||||||
|
category: string;
|
||||||
|
cookies: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaGenerateParams {
|
||||||
|
prompt: string;
|
||||||
|
cookies?: string;
|
||||||
|
imageCount?: number;
|
||||||
|
aspectRatio?: string;
|
||||||
|
useMetaFreeWrapper?: boolean;
|
||||||
|
metaFreeWrapperUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to handle API responses
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
|
throw new Error(error.error || error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate images using Whisk API
|
||||||
|
*/
|
||||||
|
export async function generateImages(params: GenerateParams): Promise<{ images: GeneratedImage[] }> {
|
||||||
|
const response = await fetch(`${API_BASE}/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate video using Whisk API
|
||||||
|
*/
|
||||||
|
export async function generateVideo(params: VideoParams): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
status?: string;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE}/video/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload reference image
|
||||||
|
*/
|
||||||
|
export async function uploadReference(params: ReferenceUploadParams): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
id: string;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE}/references/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate images using Meta AI
|
||||||
|
*/
|
||||||
|
export async function generateMetaImages(params: MetaGenerateParams): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
images: Array<{
|
||||||
|
data?: string;
|
||||||
|
url?: string;
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE}/meta/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prompts from library
|
||||||
|
*/
|
||||||
|
export async function getPrompts(): Promise<{
|
||||||
|
prompts: any[];
|
||||||
|
last_updated: string | null;
|
||||||
|
lastSync: number | null;
|
||||||
|
categories: Record<string, string[]>;
|
||||||
|
total_count: number;
|
||||||
|
sources: string[];
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE}/prompts`);
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync prompts from sources
|
||||||
|
*/
|
||||||
|
export async function syncPrompts(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
count: number;
|
||||||
|
added: number;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE}/prompts/sync`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track prompt usage
|
||||||
|
*/
|
||||||
|
export async function trackPromptUse(promptId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
promptId: number;
|
||||||
|
useCount: number;
|
||||||
|
lastUsedAt: number;
|
||||||
|
}> {
|
||||||
|
const response = await fetch(`${API_BASE}/prompts/use`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ promptId })
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upload history
|
||||||
|
*/
|
||||||
|
export async function getHistory(category?: string): Promise<{
|
||||||
|
history: Array<{
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
originalName: string;
|
||||||
|
category: string;
|
||||||
|
mediaId?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const url = category ? `${API_BASE}/history?category=${category}` : `${API_BASE}/history`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload to history
|
||||||
|
*/
|
||||||
|
export async function uploadToHistory(file: File, category: string, cookies?: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
originalName: string;
|
||||||
|
category: string;
|
||||||
|
mediaId?: string;
|
||||||
|
}> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('category', category);
|
||||||
|
if (cookies) formData.append('cookies', cookies);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/history`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete history item
|
||||||
|
*/
|
||||||
|
export async function deleteHistoryItem(itemId: string): Promise<{ success: boolean }> {
|
||||||
|
const response = await fetch(`${API_BASE}/history/${itemId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all history
|
||||||
|
*/
|
||||||
|
export async function clearHistory(): Promise<{ success: boolean }> {
|
||||||
|
const response = await fetch(`${API_BASE}/history`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
export async function healthCheck(): Promise<{ status: string; service: string; version: string }> {
|
||||||
|
const response = await fetch(`${API_BASE}/health`);
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue