2523 lines
68 KiB
Markdown
2523 lines
68 KiB
Markdown
# ImageCreator Implementation Plan
|
||||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** Build a full-stack image generation website with user authentication, prompt optimization, and T2I/I2I generation capabilities.
|
|||
|
|
|
|||
|
|
**Architecture:** FastAPI backend with PostgreSQL, Vue 3 frontend with Naive UI, Docker Compose deployment. Backend handles all image generation logic via adapter pattern (referencing prompt-optimizer architecture).
|
|||
|
|
|
|||
|
|
**Tech Stack:** Python FastAPI, SQLAlchemy, PostgreSQL, JWT, Vue 3, TypeScript, Naive UI, TailwindCSS, Vite, Docker
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## File Structure
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
/opt/imagecreator/imagecreator/
|
|||
|
|
├── backend/
|
|||
|
|
│ ├── app/
|
|||
|
|
│ │ ├── __init__.py
|
|||
|
|
│ │ ├── main.py # FastAPI app entry
|
|||
|
|
│ │ ├── config.py # Settings from env
|
|||
|
|
│ │ ├── database.py # SQLAlchemy engine/session
|
|||
|
|
│ │ ├── api/
|
|||
|
|
│ │ │ ├── __init__.py
|
|||
|
|
│ │ │ ├── deps.py # Dependency injection (get_db, get_current_user)
|
|||
|
|
│ │ │ ├── auth.py # /api/auth routes
|
|||
|
|
│ │ │ ├── users.py # /api/users routes
|
|||
|
|
│ │ │ ├── images.py # /api/images routes
|
|||
|
|
│ │ │ └── history.py # /api/history routes
|
|||
|
|
│ │ ├── core/
|
|||
|
|
│ │ │ ├── __init__.py
|
|||
|
|
│ │ │ └── security.py # JWT, password hashing
|
|||
|
|
│ │ ├── models/
|
|||
|
|
│ │ │ ├── __init__.py
|
|||
|
|
│ │ │ ├── user.py # User SQLAlchemy model
|
|||
|
|
│ │ │ └── history.py # GenerationHistory model
|
|||
|
|
│ │ ├── schemas/
|
|||
|
|
│ │ │ ├── __init__.py
|
|||
|
|
│ │ │ ├── auth.py # Login/Register schemas
|
|||
|
|
│ │ │ ├── user.py # User schemas
|
|||
|
|
│ │ │ ├── image.py # Image generate/optimize schemas
|
|||
|
|
│ │ │ └── history.py # History schemas
|
|||
|
|
│ │ └── services/
|
|||
|
|
│ │ ├── __init__.py
|
|||
|
|
│ │ ├── auth.py # AuthService (register, login)
|
|||
|
|
│ │ ├── image.py # ImageService (generate, optimize)
|
|||
|
|
│ │ └── llm.py # LLM client for prompt optimization
|
|||
|
|
│ ├── tests/
|
|||
|
|
│ │ ├── __init__.py
|
|||
|
|
│ │ ├── test_auth.py
|
|||
|
|
│ │ ├── test_images.py
|
|||
|
|
│ │ └── test_history.py
|
|||
|
|
│ ├── requirements.txt
|
|||
|
|
│ └── Dockerfile
|
|||
|
|
├── frontend/
|
|||
|
|
│ ├── src/
|
|||
|
|
│ │ ├── api/
|
|||
|
|
│ │ │ ├── index.ts # Axios instance
|
|||
|
|
│ │ │ ├── auth.ts # Auth API calls
|
|||
|
|
│ │ │ ├── images.ts # Image generation API calls
|
|||
|
|
│ │ │ └── history.ts # History API calls
|
|||
|
|
│ │ ├── components/
|
|||
|
|
│ │ │ ├── ImageGenerator.vue
|
|||
|
|
│ │ │ ├── PromptOptimizer.vue
|
|||
|
|
│ │ │ ├── ImageHistory.vue
|
|||
|
|
│ │ │ ├── ImagePreview.vue
|
|||
|
|
│ │ │ └── ...
|
|||
|
|
│ │ ├── views/
|
|||
|
|
│ │ │ ├── Login.vue
|
|||
|
|
│ │ │ ├── Register.vue
|
|||
|
|
│ │ │ ├── Home.vue
|
|||
|
|
│ │ │ └── History.vue
|
|||
|
|
│ │ ├── stores/
|
|||
|
|
│ │ │ └── auth.ts # Pinia auth store
|
|||
|
|
│ │ ├── router/
|
|||
|
|
│ │ │ └── index.ts
|
|||
|
|
│ │ ├── App.vue
|
|||
|
|
│ │ └── main.ts
|
|||
|
|
│ ├── package.json
|
|||
|
|
│ ├── vite.config.ts
|
|||
|
|
│ ├── tailwind.config.js
|
|||
|
|
│ ├── index.html
|
|||
|
|
│ └── Dockerfile
|
|||
|
|
├── docker-compose.yml
|
|||
|
|
└── README.md
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 1: Project Structure & Backend Foundation
|
|||
|
|
|
|||
|
|
### Task 1: Create project directory structure
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/__init__.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/main.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/config.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/database.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/api/__init__.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/api/deps.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/core/__init__.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/core/security.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/models/__init__.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/models/user.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/models/history.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/schemas/__init__.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/schemas/auth.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/schemas/user.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/schemas/image.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/schemas/history.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/services/__init__.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/services/auth.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/services/image.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/services/llm.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/requirements.txt`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/Dockerfile`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/` (Vue project structure)
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create backend directory structure**
|
|||
|
|
|
|||
|
|
Run: `mkdir -p /opt/imagecreator/imagecreator/backend/app/{api,core,models,schemas,services} /opt/imagecreator/imagecreator/backend/tests`
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create backend requirements.txt**
|
|||
|
|
|
|||
|
|
```txt
|
|||
|
|
fastapi==0.109.0
|
|||
|
|
uvicorn[standard]==0.27.0
|
|||
|
|
sqlalchemy==2.0.25
|
|||
|
|
psycopg2-binary==2.9.9
|
|||
|
|
python-jose[cryptography]==3.3.0
|
|||
|
|
passlib[bcrypt]==1.7.4
|
|||
|
|
python-multipart==0.0.6
|
|||
|
|
pydantic==2.5.3
|
|||
|
|
pydantic-settings==2.1.0
|
|||
|
|
google-genai==0.8.0
|
|||
|
|
openai==1.12.0
|
|||
|
|
aiofiles==23.2.1
|
|||
|
|
httpx==0.26.0
|
|||
|
|
pytest==8.0.0
|
|||
|
|
pytest-asyncio==0.23.4
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create backend/app/__init__.py and main.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/__init__.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/main.py
|
|||
|
|
from fastapi import FastAPI
|
|||
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|||
|
|
|
|||
|
|
from app.api import auth, users, images, history
|
|||
|
|
from app.config import settings
|
|||
|
|
from app.database import engine
|
|||
|
|
from app.models import user, history as history_model
|
|||
|
|
|
|||
|
|
app = FastAPI(title="ImageCreator API", version="1.0.0")
|
|||
|
|
|
|||
|
|
app.add_middleware(
|
|||
|
|
CORSMiddleware,
|
|||
|
|
allow_origins=settings.CORS_ORIGINS,
|
|||
|
|
allow_credentials=True,
|
|||
|
|
allow_methods=["*"],
|
|||
|
|
allow_headers=["*"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|||
|
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
|||
|
|
app.include_router(images.router, prefix="/api/images", tags=["images"])
|
|||
|
|
app.include_router(history.router, prefix="/api/history", tags=["history"])
|
|||
|
|
|
|||
|
|
@app.get("/health")
|
|||
|
|
def health_check():
|
|||
|
|
return {"status": "ok"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create app/config.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/config.py
|
|||
|
|
from pydantic_settings import BaseSettings
|
|||
|
|
from functools import lru_cache
|
|||
|
|
|
|||
|
|
class Settings(BaseSettings):
|
|||
|
|
# Database
|
|||
|
|
DATABASE_URL: str = "postgresql://user:pass@localhost:5432/imagecreator"
|
|||
|
|
|
|||
|
|
# JWT
|
|||
|
|
SECRET_KEY: str = "your-secret-key-change-in-production"
|
|||
|
|
ALGORITHM: str = "HS256"
|
|||
|
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|||
|
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
|||
|
|
|
|||
|
|
# CORS
|
|||
|
|
CORS_ORIGINS: list[str] = ["http://localhost:3000"]
|
|||
|
|
|
|||
|
|
# LLM API Keys
|
|||
|
|
GEMINI_API_KEY: str = ""
|
|||
|
|
OPENAI_API_KEY: str = ""
|
|||
|
|
|
|||
|
|
# Image storage
|
|||
|
|
IMAGE_STORAGE_PATH: str = "/app/uploads"
|
|||
|
|
|
|||
|
|
class Config:
|
|||
|
|
env_file = ".env"
|
|||
|
|
|
|||
|
|
@lru_cache()
|
|||
|
|
def get_settings():
|
|||
|
|
return Settings()
|
|||
|
|
|
|||
|
|
settings = get_settings()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Create app/database.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/database.py
|
|||
|
|
from sqlalchemy import create_engine
|
|||
|
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
|||
|
|
|
|||
|
|
from app.config import settings
|
|||
|
|
|
|||
|
|
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
|||
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|||
|
|
Base = declarative_base()
|
|||
|
|
|
|||
|
|
def get_db():
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
yield db
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: create backend project structure"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 2: Implement database models
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/models/user.py`
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/models/history.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_models.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create User model**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/models/user.py
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime
|
|||
|
|
from sqlalchemy import Column, String, DateTime
|
|||
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|||
|
|
|
|||
|
|
from app.database import Base
|
|||
|
|
|
|||
|
|
class User(Base):
|
|||
|
|
__tablename__ = "users"
|
|||
|
|
|
|||
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|||
|
|
username = Column(String(32), unique=True, nullable=False, index=True)
|
|||
|
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
|||
|
|
hashed_password = Column(String(255), nullable=False)
|
|||
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|||
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/models/history.py
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime
|
|||
|
|
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Enum as SQLEnum
|
|||
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|||
|
|
from sqlalchemy.orm import relationship
|
|||
|
|
import enum
|
|||
|
|
|
|||
|
|
from app.database import Base
|
|||
|
|
|
|||
|
|
class GenerationMode(str, enum.Enum):
|
|||
|
|
TEXT2IMAGE = "text2image"
|
|||
|
|
IMAGE2IMAGE = "image2image"
|
|||
|
|
|
|||
|
|
class GenerationHistory(Base):
|
|||
|
|
__tablename__ = "generation_history"
|
|||
|
|
|
|||
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|||
|
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|||
|
|
original_prompt = Column(Text, nullable=False)
|
|||
|
|
optimized_prompt = Column(Text, nullable=False)
|
|||
|
|
mode = Column(SQLEnum(GenerationMode), nullable=False)
|
|||
|
|
model = Column(String(32), nullable=False)
|
|||
|
|
image_url = Column(String(512), nullable=False)
|
|||
|
|
input_image_url = Column(String(512), nullable=True) # For I2I
|
|||
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|||
|
|
|
|||
|
|
user = relationship("User", backref="generation_history")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create test for models**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_models.py
|
|||
|
|
import pytest
|
|||
|
|
from sqlalchemy import create_engine
|
|||
|
|
from sqlalchemy.orm import sessionmaker
|
|||
|
|
from app.database import Base
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.models.history import GenerationHistory, GenerationMode
|
|||
|
|
|
|||
|
|
engine = create_engine("sqlite:///:memory:")
|
|||
|
|
TestSessionLocal = sessionmaker(bind=engine)
|
|||
|
|
|
|||
|
|
def test_user_model():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
|
|||
|
|
user = User(username="testuser", email="test@example.com", hashed_password="hashed")
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
|
|||
|
|
assert user.id is not None
|
|||
|
|
assert user.username == "testuser"
|
|||
|
|
assert user.email == "test@example.com"
|
|||
|
|
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
def test_generation_history_model():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
|
|||
|
|
user = User(username="testuser2", email="test2@example.com", hashed_password="hashed")
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
|
|||
|
|
history = GenerationHistory(
|
|||
|
|
user_id=user.id,
|
|||
|
|
original_prompt="a cat",
|
|||
|
|
optimized_prompt="a fluffy orange cat sitting on a windowsill",
|
|||
|
|
mode=GenerationMode.TEXT2IMAGE,
|
|||
|
|
model="gemini-2.5-flash-image",
|
|||
|
|
image_url="/images/cat.png"
|
|||
|
|
)
|
|||
|
|
db.add(history)
|
|||
|
|
db.commit()
|
|||
|
|
|
|||
|
|
assert history.id is not None
|
|||
|
|
assert history.user_id == user.id
|
|||
|
|
assert history.mode == GenerationMode.TEXT2IMAGE
|
|||
|
|
|
|||
|
|
db.close()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Run tests**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_models.py -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: add database models (User, GenerationHistory)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3: Implement security module (JWT & password)
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/core/security.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_security.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_security.py
|
|||
|
|
import pytest
|
|||
|
|
from app.core.security import verify_password, hash_password, create_access_token, decode_access_token
|
|||
|
|
|
|||
|
|
def test_password_hash_and_verify():
|
|||
|
|
password = "testpassword123"
|
|||
|
|
hashed = hash_password(password)
|
|||
|
|
|
|||
|
|
assert hashed != password
|
|||
|
|
assert verify_password(password, hashed) is True
|
|||
|
|
assert verify_password("wrongpassword", hashed) is False
|
|||
|
|
|
|||
|
|
def test_access_token_creation_and_decode():
|
|||
|
|
token = create_access_token({"sub": "testuser"})
|
|||
|
|
|
|||
|
|
assert token is not None
|
|||
|
|
payload = decode_access_token(token)
|
|||
|
|
|
|||
|
|
assert payload is not None
|
|||
|
|
assert payload.sub == "testuser"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_security.py -v`
|
|||
|
|
Expected: FAIL - verify_password not defined
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Write minimal implementation**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/core/security.py
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from typing import Optional
|
|||
|
|
from jose import JWTError, jwt
|
|||
|
|
from passlib.context import CryptContext
|
|||
|
|
|
|||
|
|
from app.config import settings
|
|||
|
|
|
|||
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||
|
|
|
|||
|
|
def hash_password(password: str) -> str:
|
|||
|
|
return pwd_context.hash(password)
|
|||
|
|
|
|||
|
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||
|
|
return pwd_context.verify(plain_password, hashed_password)
|
|||
|
|
|
|||
|
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|||
|
|
to_encode = data.copy()
|
|||
|
|
if expires_delta:
|
|||
|
|
expire = datetime.utcnow() + expires_delta
|
|||
|
|
else:
|
|||
|
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|||
|
|
to_encode.update({"exp": expire})
|
|||
|
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
|||
|
|
return encoded_jwt
|
|||
|
|
|
|||
|
|
def decode_access_token(token: str) -> Optional[dict]:
|
|||
|
|
try:
|
|||
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
|||
|
|
return payload
|
|||
|
|
except JWTError:
|
|||
|
|
return None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run tests**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_security.py -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: add JWT and password security module"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 4: Implement auth API & service
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/schemas/auth.py`
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/services/auth.py`
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/api/auth.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_auth_api.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_auth_api.py
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import create_engine
|
|||
|
|
from sqlalchemy.orm import sessionmaker
|
|||
|
|
from app.database import Base, get_db
|
|||
|
|
from app.main import app
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.core.security import hash_password
|
|||
|
|
|
|||
|
|
engine = create_engine("sqlite:///:memory:")
|
|||
|
|
TestSessionLocal = sessionmaker(bind=engine)
|
|||
|
|
|
|||
|
|
def override_get_db():
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
try:
|
|||
|
|
yield db
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
app.dependency_overrides[get_db] = override_get_db
|
|||
|
|
client = TestClient(app)
|
|||
|
|
|
|||
|
|
def test_register_success():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
response = client.post("/api/auth/register", json={
|
|||
|
|
"username": "newuser",
|
|||
|
|
"email": "new@example.com",
|
|||
|
|
"password": "password123"
|
|||
|
|
})
|
|||
|
|
assert response.status_code == 201
|
|||
|
|
data = response.json()
|
|||
|
|
assert data["username"] == "newuser"
|
|||
|
|
assert "id" in data
|
|||
|
|
|
|||
|
|
def test_register_duplicate_username():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
user = User(username="existing", email="existing@example.com", hashed_password=hash_password("pass"))
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
response = client.post("/api/auth/register", json={
|
|||
|
|
"username": "existing",
|
|||
|
|
"email": "new@example.com",
|
|||
|
|
"password": "password123"
|
|||
|
|
})
|
|||
|
|
assert response.status_code == 400
|
|||
|
|
|
|||
|
|
def test_login_success():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
user = User(username="loginuser", email="login@example.com", hashed_password=hash_password("correctpassword"))
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
response = client.post("/api/auth/login", json={
|
|||
|
|
"username": "loginuser",
|
|||
|
|
"password": "correctpassword"
|
|||
|
|
})
|
|||
|
|
assert response.status_code == 200
|
|||
|
|
data = response.json()
|
|||
|
|
assert "access_token" in data
|
|||
|
|
assert "token_type" in data
|
|||
|
|
|
|||
|
|
def test_login_wrong_password():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
user = User(username="wrongpass", email="wrongpass@example.com", hashed_password=hash_password("correctpassword"))
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
response = client.post("/api/auth/login", json={
|
|||
|
|
"username": "wrongpass",
|
|||
|
|
"password": "wrongpassword"
|
|||
|
|
})
|
|||
|
|
assert response.status_code == 401
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_auth_api.py -v`
|
|||
|
|
Expected: FAIL - modules not found
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Write schemas**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/schemas/auth.py
|
|||
|
|
from pydantic import BaseModel, EmailStr
|
|||
|
|
|
|||
|
|
class UserRegister(BaseModel):
|
|||
|
|
username: str
|
|||
|
|
email: EmailStr
|
|||
|
|
password: str
|
|||
|
|
|
|||
|
|
class UserLogin(BaseModel):
|
|||
|
|
username: str
|
|||
|
|
password: str
|
|||
|
|
|
|||
|
|
class Token(BaseModel):
|
|||
|
|
access_token: str
|
|||
|
|
refresh_token: str
|
|||
|
|
token_type: str = "bearer"
|
|||
|
|
|
|||
|
|
class TokenRefresh(BaseModel):
|
|||
|
|
refresh_token: str
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/schemas/user.py
|
|||
|
|
from pydantic import BaseModel, EmailStr
|
|||
|
|
from typing import Optional
|
|||
|
|
from datetime import datetime
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
class UserBase(BaseModel):
|
|||
|
|
username: str
|
|||
|
|
email: EmailStr
|
|||
|
|
|
|||
|
|
class UserCreate(UserBase):
|
|||
|
|
password: str
|
|||
|
|
|
|||
|
|
class UserResponse(UserBase):
|
|||
|
|
id: UUID
|
|||
|
|
created_at: datetime
|
|||
|
|
|
|||
|
|
class Config:
|
|||
|
|
from_attributes = True
|
|||
|
|
|
|||
|
|
class UserUpdate(BaseModel):
|
|||
|
|
email: Optional[EmailStr] = None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Write auth service**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/services/auth.py
|
|||
|
|
from typing import Optional
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.core.security import hash_password, verify_password
|
|||
|
|
|
|||
|
|
class AuthService:
|
|||
|
|
def __init__(self, db: Session):
|
|||
|
|
self.db = db
|
|||
|
|
|
|||
|
|
def register(self, username: str, email: str, password: str) -> User:
|
|||
|
|
# Check if user exists
|
|||
|
|
existing = self.db.query(User).filter(
|
|||
|
|
(User.username == username) | (User.email == email)
|
|||
|
|
).first()
|
|||
|
|
if existing:
|
|||
|
|
raise ValueError("Username or email already registered")
|
|||
|
|
|
|||
|
|
hashed_password = hash_password(password)
|
|||
|
|
user = User(username=username, email=email, hashed_password=hashed_password)
|
|||
|
|
self.db.add(user)
|
|||
|
|
self.db.commit()
|
|||
|
|
self.db.refresh(user)
|
|||
|
|
return user
|
|||
|
|
|
|||
|
|
def authenticate(self, username: str, password: str) -> Optional[User]:
|
|||
|
|
user = self.db.query(User).filter(User.username == username).first()
|
|||
|
|
if not user:
|
|||
|
|
return None
|
|||
|
|
if not verify_password(password, user.hashed_password):
|
|||
|
|
return None
|
|||
|
|
return user
|
|||
|
|
|
|||
|
|
def get_user_by_id(self, user_id: UUID) -> Optional[User]:
|
|||
|
|
return self.db.query(User).filter(User.id == user_id).first()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Write auth API routes**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/api/auth.py
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.database import get_db
|
|||
|
|
from app.schemas.auth import UserRegister, UserLogin, Token, TokenRefresh
|
|||
|
|
from app.schemas.user import UserResponse
|
|||
|
|
from app.services.auth import AuthService
|
|||
|
|
from app.core.security import create_access_token
|
|||
|
|
|
|||
|
|
router = APIRouter()
|
|||
|
|
|
|||
|
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|||
|
|
def register(data: UserRegister, db: Session = Depends(get_db)):
|
|||
|
|
service = AuthService(db)
|
|||
|
|
try:
|
|||
|
|
user = service.register(data.username, data.email, data.password)
|
|||
|
|
return user
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|||
|
|
|
|||
|
|
@router.post("/login", response_model=Token)
|
|||
|
|
def login(data: UserLogin, db: Session = Depends(get_db)):
|
|||
|
|
service = AuthService(db)
|
|||
|
|
user = service.authenticate(data.username, data.password)
|
|||
|
|
if not user:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|||
|
|
detail="Incorrect username or password"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
access_token = create_access_token(data={"sub": str(user.id)})
|
|||
|
|
# For simplicity, using same token as refresh - in production, use separate refresh token
|
|||
|
|
return Token(access_token=access_token, refresh_token=access_token)
|
|||
|
|
|
|||
|
|
@router.post("/refresh", response_model=Token)
|
|||
|
|
def refresh(data: TokenRefresh, db: Session = Depends(get_db)):
|
|||
|
|
# Simplified: in production, validate refresh token properly
|
|||
|
|
from app.core.security import decode_access_token
|
|||
|
|
payload = decode_access_token(data.refresh_token)
|
|||
|
|
if not payload:
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|||
|
|
|
|||
|
|
access_token = create_access_token(data={"sub": payload.get("sub")})
|
|||
|
|
return Token(access_token=access_token, refresh_token=data.refresh_token)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Write api/deps.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/api/deps.py
|
|||
|
|
from typing import Generator
|
|||
|
|
from fastapi import Depends, HTTPException, status
|
|||
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.database import get_db
|
|||
|
|
from app.core.security import decode_access_token
|
|||
|
|
from app.services.auth import AuthService
|
|||
|
|
|
|||
|
|
security = HTTPBearer()
|
|||
|
|
|
|||
|
|
def get_current_user(
|
|||
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
token = credentials.credentials
|
|||
|
|
payload = decode_access_token(token)
|
|||
|
|
if not payload:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|||
|
|
detail="Invalid or expired token"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
user_id_str = payload.get("sub")
|
|||
|
|
if not user_id_str:
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|||
|
|
|
|||
|
|
from uuid import UUID
|
|||
|
|
try:
|
|||
|
|
user_id = UUID(user_id_str)
|
|||
|
|
except ValueError:
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user ID")
|
|||
|
|
|
|||
|
|
service = AuthService(db)
|
|||
|
|
user = service.get_user_by_id(user_id)
|
|||
|
|
if not user:
|
|||
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
|||
|
|
|
|||
|
|
return user
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Run tests**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_auth_api.py -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement auth API (register, login, JWT)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 5: Implement users API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/api/users.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_users_api.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_users_api.py
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import create_engine
|
|||
|
|
from sqlalchemy.orm import sessionmaker
|
|||
|
|
from app.database import Base, get_db
|
|||
|
|
from app.main import app
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.core.security import hash_password, create_access_token
|
|||
|
|
|
|||
|
|
engine = create_engine("sqlite:///:memory:")
|
|||
|
|
TestSessionLocal = sessionmaker(bind=engine)
|
|||
|
|
|
|||
|
|
def override_get_db():
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
try:
|
|||
|
|
yield db
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
app.dependency_overrides[get_db] = override_get_db
|
|||
|
|
client = TestClient(app)
|
|||
|
|
|
|||
|
|
def get_auth_header(user_id):
|
|||
|
|
token = create_access_token({"sub": str(user_id)})
|
|||
|
|
return {"Authorization": f"Bearer {token}"}
|
|||
|
|
|
|||
|
|
def test_get_current_user():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
user = User(username="testuser", email="test@example.com", hashed_password=hash_password("pass"))
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
db.refresh(user)
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
response = client.get("/api/users/me", headers=get_auth_header(user.id))
|
|||
|
|
assert response.status_code == 200
|
|||
|
|
data = response.json()
|
|||
|
|
assert data["username"] == "testuser"
|
|||
|
|
assert data["email"] == "test@example.com"
|
|||
|
|
assert "password" not in data
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_users_api.py -v`
|
|||
|
|
Expected: FAIL
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Write users API**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/api/users.py
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.database import get_db
|
|||
|
|
from app.api.deps import get_current_user
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.schemas.user import UserResponse, UserUpdate
|
|||
|
|
from app.services.auth import AuthService
|
|||
|
|
|
|||
|
|
router = APIRouter()
|
|||
|
|
|
|||
|
|
@router.get("/me", response_model=UserResponse)
|
|||
|
|
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
|||
|
|
return current_user
|
|||
|
|
|
|||
|
|
@router.put("/me", response_model=UserResponse)
|
|||
|
|
def update_current_user(
|
|||
|
|
data: UserUpdate,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
service = AuthService(db)
|
|||
|
|
if data.email and data.email != current_user.email:
|
|||
|
|
# Check if email is taken
|
|||
|
|
existing = db.query(User).filter(User.email == data.email, User.id != current_user.id).first()
|
|||
|
|
if existing:
|
|||
|
|
raise HTTPException(status_code=400, detail="Email already registered")
|
|||
|
|
current_user.email = data.email
|
|||
|
|
|
|||
|
|
db.commit()
|
|||
|
|
db.refresh(current_user)
|
|||
|
|
return current_user
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run tests**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_users_api.py -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement users API"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 6: Implement LLM and prompt optimization service
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/services/llm.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/app/services/prompt.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_prompt.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_prompt.py
|
|||
|
|
import pytest
|
|||
|
|
from app.services.prompt import PromptOptimizationService
|
|||
|
|
|
|||
|
|
def test_optimize_text2image_prompt():
|
|||
|
|
service = PromptOptimizationService()
|
|||
|
|
result = service.optimize_text2image("a cat")
|
|||
|
|
|
|||
|
|
assert result is not None
|
|||
|
|
assert len(result) > len("a cat")
|
|||
|
|
assert "cat" in result.lower()
|
|||
|
|
|
|||
|
|
def test_optimize_image2image_prompt():
|
|||
|
|
service = PromptOptimizationService()
|
|||
|
|
result = service.optimize_image2image("make it colorful", "a cat image")
|
|||
|
|
|
|||
|
|
assert result is not None
|
|||
|
|
assert len(result) > 0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_prompt.py -v`
|
|||
|
|
Expected: FAIL - module not found
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Write LLM service**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/services/llm.py
|
|||
|
|
from typing import Optional
|
|||
|
|
import google.genai as genai
|
|||
|
|
from app.config import settings
|
|||
|
|
|
|||
|
|
class LLMService:
|
|||
|
|
def __init__(self):
|
|||
|
|
genai.configure(api_key=settings.GEMINI_API_KEY)
|
|||
|
|
|
|||
|
|
def generate_text(self, prompt: str, system_instruction: Optional[str] = None) -> str:
|
|||
|
|
client = genai.GenerativeModel("gemini-2.5-flash")
|
|||
|
|
response = client.generate_content(prompt)
|
|||
|
|
return response.text
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/services/prompt.py
|
|||
|
|
from app.services.llm import LLMService
|
|||
|
|
|
|||
|
|
TEXT2IMAGE_SYSTEM_PROMPT = """You are an expert at optimizing text-to-image prompts.
|
|||
|
|
Your task is to take user's simple or vague prompt and enhance it with detailed, vivid descriptions.
|
|||
|
|
Include details about: subject, setting, lighting, mood, style, composition, and quality modifiers.
|
|||
|
|
Output ONLY the optimized prompt in English, no explanations."""
|
|||
|
|
|
|||
|
|
IMAGE2IMAGE_SYSTEM_PROMPT = """You are an expert at optimizing image-to-image prompts.
|
|||
|
|
The user wants to modify an existing image. Your task is to clearly describe what should be changed.
|
|||
|
|
|
|||
|
|
Important rules:
|
|||
|
|
1. Identify if user wants to ADD, REMOVE, REPLACE, or ENHANCE something
|
|||
|
|
2. Be specific about what to keep vs what to change
|
|||
|
|
3. Describe the desired style, mood, and quality
|
|||
|
|
4. Output ONLY the optimized prompt in English, no explanations."""
|
|||
|
|
|
|||
|
|
class PromptOptimizationService:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.llm = LLMService()
|
|||
|
|
|
|||
|
|
def optimize_text2image(self, user_prompt: str) -> str:
|
|||
|
|
full_prompt = f"{TEXT2IMAGE_SYSTEM_PROMPT}\n\nUser's original prompt: {user_prompt}"
|
|||
|
|
return self.llm.generate_text(full_prompt)
|
|||
|
|
|
|||
|
|
def optimize_image2image(self, user_prompt: str, original_description: str = "") -> str:
|
|||
|
|
context = f"Original image description: {original_description}\n\n" if original_description else ""
|
|||
|
|
full_prompt = f"{IMAGE2IMAGE_SYSTEM_PROMPT}\n\n{context}User's modification request: {user_prompt}"
|
|||
|
|
return self.llm.generate_text(full_prompt)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run tests (skipped if no API key)**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_prompt.py -v`
|
|||
|
|
Expected: SKIP or FAIL (if no API key)
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement LLM and prompt optimization service"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 7: Implement image generation service
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/services/image.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_image_service.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_image_service.py
|
|||
|
|
import pytest
|
|||
|
|
from app.services.image import ImageGenerationService
|
|||
|
|
|
|||
|
|
def test_generate_text2image_request_structure():
|
|||
|
|
# Test that the service creates proper request structure
|
|||
|
|
# This is a unit test - actual generation would require API key
|
|||
|
|
service = ImageGenerationService()
|
|||
|
|
# Just verify service initializes
|
|||
|
|
assert service is not None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write image service**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/services/image.py
|
|||
|
|
import base64
|
|||
|
|
import os
|
|||
|
|
import uuid
|
|||
|
|
from typing import Optional
|
|||
|
|
import google.genai as genai
|
|||
|
|
from app.config import settings
|
|||
|
|
|
|||
|
|
class ImageGenerationService:
|
|||
|
|
def __init__(self):
|
|||
|
|
genai.configure(api_key=settings.GEMINI_API_KEY)
|
|||
|
|
|
|||
|
|
async def generate_text2image(
|
|||
|
|
self,
|
|||
|
|
prompt: str,
|
|||
|
|
model: str = "gemini-2.5-flash-image"
|
|||
|
|
) -> dict:
|
|||
|
|
client = genai.GenerativeModel(model)
|
|||
|
|
|
|||
|
|
response = client.generate_content(
|
|||
|
|
prompt,
|
|||
|
|
generation_config={"response_modalities": ["TEXT", "IMAGE"]}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
image_data = None
|
|||
|
|
for part in response.candidates[0].content.parts:
|
|||
|
|
if part.inline_data:
|
|||
|
|
image_data = base64.b64encode(part.inline_data.data).decode('utf-8')
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"image_b64": image_data,
|
|||
|
|
"model": model,
|
|||
|
|
"finish_reason": response.candidates[0].finish_reason
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def generate_image2image(
|
|||
|
|
self,
|
|||
|
|
prompt: str,
|
|||
|
|
input_image_b64: str,
|
|||
|
|
input_mime_type: str = "image/png",
|
|||
|
|
model: str = "gemini-2.5-flash-image"
|
|||
|
|
) -> dict:
|
|||
|
|
client = genai.GenerativeModel(model)
|
|||
|
|
|
|||
|
|
response = client.generate_content(
|
|||
|
|
[
|
|||
|
|
{"text": prompt},
|
|||
|
|
{"inline_data": {"mime_type": input_mime_type, "data": input_image_b64}}
|
|||
|
|
],
|
|||
|
|
generation_config={"response_modalities": ["TEXT", "IMAGE"]}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
image_data = None
|
|||
|
|
for part in response.candidates[0].content.parts:
|
|||
|
|
if part.inline_data:
|
|||
|
|
image_data = base64.b64encode(part.inline_data.data).decode('utf-8')
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"image_b64": image_data,
|
|||
|
|
"model": model,
|
|||
|
|
"finish_reason": response.candidates[0].finish_reason
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def save_image(self, image_b64: str, user_id: str) -> str:
|
|||
|
|
os.makedirs(settings.IMAGE_STORAGE_PATH, exist_ok=True)
|
|||
|
|
filename = f"{user_id}/{uuid.uuid4()}.png"
|
|||
|
|
filepath = os.path.join(settings.IMAGE_STORAGE_PATH, filename)
|
|||
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|||
|
|
|
|||
|
|
image_data = base64.b64decode(image_b64)
|
|||
|
|
with open(filepath, "wb") as f:
|
|||
|
|
f.write(image_data)
|
|||
|
|
|
|||
|
|
return f"/uploads/{filename}"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement image generation service"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 8: Implement images API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/schemas/image.py`
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/api/images.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_images_api.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_images_api.py
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import create_engine
|
|||
|
|
from sqlalchemy.orm import sessionmaker
|
|||
|
|
from app.database import Base, get_db
|
|||
|
|
from app.main import app
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.core.security import hash_password, create_access_token
|
|||
|
|
|
|||
|
|
engine = create_engine("sqlite:///:memory:")
|
|||
|
|
TestSessionLocal = sessionmaker(bind=engine)
|
|||
|
|
|
|||
|
|
def override_get_db():
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
try:
|
|||
|
|
yield db
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
app.dependency_overrides[get_db] = override_get_db
|
|||
|
|
client = TestClient(app)
|
|||
|
|
|
|||
|
|
def get_auth_header(user_id):
|
|||
|
|
token = create_access_token({"sub": str(user_id)})
|
|||
|
|
return {"Authorization": f"Bearer {token}"}
|
|||
|
|
|
|||
|
|
def test_optimize_prompt_requires_auth():
|
|||
|
|
response = client.post("/api/images/optimize", json={"prompt": "a cat"})
|
|||
|
|
assert response.status_code == 403 # No auth header
|
|||
|
|
|
|||
|
|
def test_generate_requires_auth():
|
|||
|
|
response = client.post("/api/images/generate", json={
|
|||
|
|
"prompt": "a cat",
|
|||
|
|
"mode": "text2image",
|
|||
|
|
"model": "gemini-2.5-flash-image"
|
|||
|
|
})
|
|||
|
|
assert response.status_code == 403
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write schemas**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/schemas/image.py
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from typing import Optional
|
|||
|
|
from enum import Enum
|
|||
|
|
|
|||
|
|
class GenerationMode(str, Enum):
|
|||
|
|
TEXT2IMAGE = "text2image"
|
|||
|
|
IMAGE2IMAGE = "image2image"
|
|||
|
|
|
|||
|
|
class PromptOptimizeRequest(BaseModel):
|
|||
|
|
prompt: str
|
|||
|
|
mode: GenerationMode = GenerationMode.TEXT2IMAGE
|
|||
|
|
original_description: Optional[str] = None
|
|||
|
|
|
|||
|
|
class PromptOptimizeResponse(BaseModel):
|
|||
|
|
optimized_prompt: str
|
|||
|
|
|
|||
|
|
class ImageGenerateRequest(BaseModel):
|
|||
|
|
prompt: str
|
|||
|
|
optimized_prompt: str
|
|||
|
|
mode: GenerationMode
|
|||
|
|
model: str = "gemini-2.5-flash-image"
|
|||
|
|
input_image_b64: Optional[str] = None # For I2I
|
|||
|
|
|
|||
|
|
class ImageGenerateResponse(BaseModel):
|
|||
|
|
image_url: str
|
|||
|
|
model: str
|
|||
|
|
optimized_prompt: str
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Write images API**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/api/images.py
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.database import get_db
|
|||
|
|
from app.api.deps import get_current_user
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.schemas.image import (
|
|||
|
|
PromptOptimizeRequest,
|
|||
|
|
PromptOptimizeResponse,
|
|||
|
|
ImageGenerateRequest,
|
|||
|
|
ImageGenerateResponse,
|
|||
|
|
)
|
|||
|
|
from app.services.prompt import PromptOptimizationService
|
|||
|
|
from app.services.image import ImageGenerationService
|
|||
|
|
from app.services.auth import AuthService
|
|||
|
|
|
|||
|
|
router = APIRouter()
|
|||
|
|
|
|||
|
|
@router.post("/optimize", response_model=PromptOptimizeResponse)
|
|||
|
|
async def optimize_prompt(
|
|||
|
|
data: PromptOptimizeRequest,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
service = PromptOptimizationService()
|
|||
|
|
try:
|
|||
|
|
if data.mode == "text2image":
|
|||
|
|
optimized = service.optimize_text2image(data.prompt)
|
|||
|
|
else:
|
|||
|
|
optimized = service.optimize_image2image(data.prompt, data.original_description or "")
|
|||
|
|
|
|||
|
|
return PromptOptimizeResponse(optimized_prompt=optimized)
|
|||
|
|
except Exception as e:
|
|||
|
|
raise HTTPException(status_code=500, detail=f"Optimization failed: {str(e)}")
|
|||
|
|
|
|||
|
|
@router.post("/generate", response_model=ImageGenerateResponse)
|
|||
|
|
async def generate_image(
|
|||
|
|
data: ImageGenerateRequest,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
service = ImageGenerationService()
|
|||
|
|
try:
|
|||
|
|
if data.mode == "text2image":
|
|||
|
|
result = await service.generate_text2image(data.optimized_prompt, data.model)
|
|||
|
|
else:
|
|||
|
|
if not data.input_image_b64:
|
|||
|
|
raise HTTPException(status_code=400, detail="input_image_b64 required for image2image")
|
|||
|
|
result = await service.generate_image2image(
|
|||
|
|
data.optimized_prompt,
|
|||
|
|
data.input_image_b64,
|
|||
|
|
"image/png",
|
|||
|
|
data.model
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Save image
|
|||
|
|
image_url = service.save_image(result["image_b64"], str(current_user.id))
|
|||
|
|
|
|||
|
|
return ImageGenerateResponse(
|
|||
|
|
image_url=image_url,
|
|||
|
|
model=result["model"],
|
|||
|
|
optimized_prompt=data.optimized_prompt
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run tests**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_images_api.py -v`
|
|||
|
|
Expected: PASS (auth tests)
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement images API (optimize, generate)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 9: Implement history API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `/opt/imagecreator/imagecreator/backend/app/api/history.py`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/tests/test_history_api.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write failing test**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# tests/test_history_api.py
|
|||
|
|
import pytest
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import create_engine
|
|||
|
|
from sqlalchemy.orm import sessionmaker
|
|||
|
|
from app.database import Base, get_db
|
|||
|
|
from app.main import app
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.models.history import GenerationHistory, GenerationMode
|
|||
|
|
from app.core.security import hash_password, create_access_token
|
|||
|
|
|
|||
|
|
engine = create_engine("sqlite:///:memory:")
|
|||
|
|
TestSessionLocal = sessionmaker(bind=engine)
|
|||
|
|
|
|||
|
|
def override_get_db():
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
try:
|
|||
|
|
yield db
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
app.dependency_overrides[get_db] = override_get_db
|
|||
|
|
client = TestClient(app)
|
|||
|
|
|
|||
|
|
def get_auth_header(user_id):
|
|||
|
|
token = create_access_token({"sub": str(user_id)})
|
|||
|
|
return {"Authorization": f"Bearer {token}"}
|
|||
|
|
|
|||
|
|
def test_get_history_empty():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
user = User(username="histtest", email="hist@example.com", hashed_password=hash_password("pass"))
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
db.refresh(user)
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
response = client.get("/api/history", headers=get_auth_header(user.id))
|
|||
|
|
assert response.status_code == 200
|
|||
|
|
assert response.json() == []
|
|||
|
|
|
|||
|
|
def test_get_history_with_items():
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
db = TestSessionLocal()
|
|||
|
|
user = User(username="histtest2", email="hist2@example.com", hashed_password=hash_password("pass"))
|
|||
|
|
db.add(user)
|
|||
|
|
db.commit()
|
|||
|
|
db.refresh(user)
|
|||
|
|
|
|||
|
|
history = GenerationHistory(
|
|||
|
|
user_id=user.id,
|
|||
|
|
original_prompt="a cat",
|
|||
|
|
optimized_prompt="a fluffy orange cat",
|
|||
|
|
mode=GenerationMode.TEXT2IMAGE,
|
|||
|
|
model="gemini",
|
|||
|
|
image_url="/images/cat.png"
|
|||
|
|
)
|
|||
|
|
db.add(history)
|
|||
|
|
db.commit()
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
response = client.get("/api/history", headers=get_auth_header(user.id))
|
|||
|
|
assert response.status_code == 200
|
|||
|
|
data = response.json()
|
|||
|
|
assert len(data) == 1
|
|||
|
|
assert data[0]["original_prompt"] == "a cat"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write history API**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/api/history.py
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
from typing import List
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
from app.database import get_db
|
|||
|
|
from app.api.deps import get_current_user
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.models.history import GenerationHistory
|
|||
|
|
from app.schemas.history import HistoryResponse
|
|||
|
|
|
|||
|
|
router = APIRouter()
|
|||
|
|
|
|||
|
|
@router.get("", response_model=List[HistoryResponse])
|
|||
|
|
def get_history(
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
items = db.query(GenerationHistory).filter(
|
|||
|
|
GenerationHistory.user_id == current_user.id
|
|||
|
|
).order_by(GenerationHistory.created_at.desc()).all()
|
|||
|
|
return items
|
|||
|
|
|
|||
|
|
@router.get("/{history_id}", response_model=HistoryResponse)
|
|||
|
|
def get_history_item(
|
|||
|
|
history_id: UUID,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
item = db.query(GenerationHistory).filter(
|
|||
|
|
GenerationHistory.id == history_id,
|
|||
|
|
GenerationHistory.user_id == current_user.id
|
|||
|
|
).first()
|
|||
|
|
|
|||
|
|
if not item:
|
|||
|
|
raise HTTPException(status_code=404, detail="History item not found")
|
|||
|
|
return item
|
|||
|
|
|
|||
|
|
@router.delete("/{history_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|||
|
|
def delete_history_item(
|
|||
|
|
history_id: UUID,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
item = db.query(GenerationHistory).filter(
|
|||
|
|
GenerationHistory.id == history_id,
|
|||
|
|
GenerationHistory.user_id == current_user.id
|
|||
|
|
).first()
|
|||
|
|
|
|||
|
|
if not item:
|
|||
|
|
raise HTTPException(status_code=404, detail="History item not found")
|
|||
|
|
|
|||
|
|
db.delete(item)
|
|||
|
|
db.commit()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Write history schema**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# app/schemas/history.py
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from typing import Optional
|
|||
|
|
from datetime import datetime
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
class HistoryResponse(BaseModel):
|
|||
|
|
id: UUID
|
|||
|
|
original_prompt: str
|
|||
|
|
optimized_prompt: str
|
|||
|
|
mode: str
|
|||
|
|
model: str
|
|||
|
|
image_url: str
|
|||
|
|
input_image_url: Optional[str] = None
|
|||
|
|
created_at: datetime
|
|||
|
|
|
|||
|
|
class Config:
|
|||
|
|
from_attributes = True
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run tests**
|
|||
|
|
|
|||
|
|
Run: `cd /opt/imagecreator/imagecreator/backend && pytest tests/test_history_api.py -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement history API"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 10: Backend Docker setup
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/backend/Dockerfile`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/docker-compose.yml`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create backend Dockerfile**
|
|||
|
|
|
|||
|
|
```dockerfile
|
|||
|
|
# backend/Dockerfile
|
|||
|
|
FROM python:3.11-slim
|
|||
|
|
|
|||
|
|
WORKDIR /app
|
|||
|
|
|
|||
|
|
COPY requirements.txt .
|
|||
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|||
|
|
|
|||
|
|
COPY app/ ./app/
|
|||
|
|
|
|||
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
|
|
&& rm -rf /var/lib/apt/lists/*
|
|||
|
|
|
|||
|
|
# Create uploads directory
|
|||
|
|
RUN mkdir -p /app/uploads
|
|||
|
|
|
|||
|
|
EXPOSE 8000
|
|||
|
|
|
|||
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create docker-compose.yml**
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
# docker-compose.yml
|
|||
|
|
version: '3.8'
|
|||
|
|
|
|||
|
|
services:
|
|||
|
|
backend:
|
|||
|
|
build: ./backend
|
|||
|
|
ports:
|
|||
|
|
- "8000:8000"
|
|||
|
|
environment:
|
|||
|
|
- DATABASE_URL=postgresql://user:pass@db:5432/imagecreator
|
|||
|
|
- SECRET_KEY=change-this-secret-key-in-production
|
|||
|
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
|||
|
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
|||
|
|
volumes:
|
|||
|
|
- uploads:/app/uploads
|
|||
|
|
depends_on:
|
|||
|
|
- db
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
frontend:
|
|||
|
|
build: ./frontend
|
|||
|
|
ports:
|
|||
|
|
- "3000:80"
|
|||
|
|
depends_on:
|
|||
|
|
- backend
|
|||
|
|
|
|||
|
|
db:
|
|||
|
|
image: postgres:15-alpine
|
|||
|
|
environment:
|
|||
|
|
- POSTGRES_USER=user
|
|||
|
|
- POSTGRES_PASSWORD=pass
|
|||
|
|
- POSTGRES_DB=imagecreator
|
|||
|
|
volumes:
|
|||
|
|
- postgres_data:/var/lib/postgresql/data
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
volumes:
|
|||
|
|
postgres_data:
|
|||
|
|
uploads:
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create .env.example**
|
|||
|
|
|
|||
|
|
```env
|
|||
|
|
# .env.example
|
|||
|
|
SECRET_KEY=your-secret-key-here
|
|||
|
|
GEMINI_API_KEY=your-gemini-api-key
|
|||
|
|
OPENAI_API_KEY=your-openai-api-key
|
|||
|
|
DATABASE_URL=postgresql://user:pass@localhost:5432/imagecreator
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: add backend Dockerfile and docker-compose"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 2: Frontend Development
|
|||
|
|
|
|||
|
|
### Task 11: Initialize Vue 3 frontend project
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/package.json`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/vite.config.ts`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/index.html`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/tailwind.config.js`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/main.ts`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/App.vue`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/router/index.ts`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/api/index.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create package.json**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"name": "imagecreator-frontend",
|
|||
|
|
"version": "1.0.0",
|
|||
|
|
"private": true,
|
|||
|
|
"type": "module",
|
|||
|
|
"scripts": {
|
|||
|
|
"dev": "vite",
|
|||
|
|
"build": "vue-tsc && vite build",
|
|||
|
|
"preview": "vite preview"
|
|||
|
|
},
|
|||
|
|
"dependencies": {
|
|||
|
|
"vue": "^3.4.0",
|
|||
|
|
"vue-router": "^4.2.0",
|
|||
|
|
"pinia": "^2.1.0",
|
|||
|
|
"axios": "^1.6.0",
|
|||
|
|
"naive-ui": "^2.38.0"
|
|||
|
|
},
|
|||
|
|
"devDependencies": {
|
|||
|
|
"@vitejs/plugin-vue": "^5.0.0",
|
|||
|
|
"typescript": "^5.3.0",
|
|||
|
|
"vite": "^5.0.0",
|
|||
|
|
"vue-tsc": "^1.8.0",
|
|||
|
|
"tailwindcss": "^3.4.0",
|
|||
|
|
"autoprefixer": "^10.4.0",
|
|||
|
|
"postcss": "^8.4.0"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create vite.config.ts**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// vite.config.ts
|
|||
|
|
import { defineConfig } from 'vite'
|
|||
|
|
import vue from '@vitejs/plugin-vue'
|
|||
|
|
|
|||
|
|
export default defineConfig({
|
|||
|
|
plugins: [vue()],
|
|||
|
|
server: {
|
|||
|
|
port: 5173,
|
|||
|
|
proxy: {
|
|||
|
|
'/api': {
|
|||
|
|
target: 'http://localhost:8000',
|
|||
|
|
changeOrigin: true
|
|||
|
|
},
|
|||
|
|
'/uploads': {
|
|||
|
|
target: 'http://localhost:8000',
|
|||
|
|
changeOrigin: true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create tailwind.config.js**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// tailwind.config.js
|
|||
|
|
export default {
|
|||
|
|
content: [
|
|||
|
|
"./index.html",
|
|||
|
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
|||
|
|
],
|
|||
|
|
theme: {
|
|||
|
|
extend: {},
|
|||
|
|
},
|
|||
|
|
plugins: [],
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create main.ts**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/main.ts
|
|||
|
|
import { createApp } from 'vue'
|
|||
|
|
import { createPinia } from 'pinia'
|
|||
|
|
import naive from 'naive-ui'
|
|||
|
|
import App from './App.vue'
|
|||
|
|
import router from './router'
|
|||
|
|
import './style.css'
|
|||
|
|
|
|||
|
|
const app = createApp(App)
|
|||
|
|
app.use(createPinia())
|
|||
|
|
app.use(router)
|
|||
|
|
app.use(naive)
|
|||
|
|
app.mount('#app')
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Create api/index.ts**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/api/index.ts
|
|||
|
|
import axios from 'axios'
|
|||
|
|
|
|||
|
|
const api = axios.create({
|
|||
|
|
baseURL: '/api',
|
|||
|
|
timeout: 30000,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
api.interceptors.request.use((config) => {
|
|||
|
|
const token = localStorage.getItem('access_token')
|
|||
|
|
if (token) {
|
|||
|
|
config.headers.Authorization = `Bearer ${token}`
|
|||
|
|
}
|
|||
|
|
return config
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
api.interceptors.response.use(
|
|||
|
|
(response) => response,
|
|||
|
|
async (error) => {
|
|||
|
|
if (error.response?.status === 401) {
|
|||
|
|
localStorage.removeItem('access_token')
|
|||
|
|
window.location.href = '/login'
|
|||
|
|
}
|
|||
|
|
return Promise.reject(error)
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
export default api
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Create App.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- src/App.vue -->
|
|||
|
|
<template>
|
|||
|
|
<n-config-provider :theme-overlord="themeOverrides">
|
|||
|
|
<n-message-provider>
|
|||
|
|
<router-view />
|
|||
|
|
</n-message-provider>
|
|||
|
|
</n-config-provider>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { NConfigProvider, NMessageProvider } from 'naive-ui'
|
|||
|
|
|
|||
|
|
const themeOverrides = {}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Create router**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/router/index.ts
|
|||
|
|
import { createRouter, createWebHistory } from 'vue-router'
|
|||
|
|
|
|||
|
|
const routes = [
|
|||
|
|
{ path: '/login', name: 'Login', component: () => import('../views/Login.vue') },
|
|||
|
|
{ path: '/register', name: 'Register', component: () => import('../views/Register.vue') },
|
|||
|
|
{ path: '/', name: 'Home', component: () => import('../views/Home.vue'), meta: { requiresAuth: true } },
|
|||
|
|
{ path: '/history', name: 'History', component: () => import('../views/History.vue'), meta: { requiresAuth: true } },
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const router = createRouter({
|
|||
|
|
history: createWebHistory(),
|
|||
|
|
routes,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
router.beforeEach((to, from, next) => {
|
|||
|
|
const token = localStorage.getItem('access_token')
|
|||
|
|
if (to.meta.requiresAuth && !token) {
|
|||
|
|
next('/login')
|
|||
|
|
} else if ((to.path === '/login' || to.path === '/register') && token) {
|
|||
|
|
next('/')
|
|||
|
|
} else {
|
|||
|
|
next()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
export default router
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Create style.css**
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* src/style.css */
|
|||
|
|
@tailwind base;
|
|||
|
|
@tailwind components;
|
|||
|
|
@tailwind utilities;
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 9: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: initialize Vue 3 frontend project"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 12: Implement auth stores and API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/stores/auth.ts`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/api/auth.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write auth API**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/api/auth.ts
|
|||
|
|
import api from './index'
|
|||
|
|
|
|||
|
|
export interface LoginRequest {
|
|||
|
|
username: string
|
|||
|
|
password: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface RegisterRequest {
|
|||
|
|
username: string
|
|||
|
|
email: string
|
|||
|
|
password: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface AuthResponse {
|
|||
|
|
access_token: string
|
|||
|
|
refresh_token: string
|
|||
|
|
token_type: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const authApi = {
|
|||
|
|
login(data: LoginRequest) {
|
|||
|
|
return api.post<AuthResponse>('/auth/login', data)
|
|||
|
|
},
|
|||
|
|
register(data: RegisterRequest) {
|
|||
|
|
return api.post<any>('/auth/register', data)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write auth store**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/stores/auth.ts
|
|||
|
|
import { defineStore } from 'pinia'
|
|||
|
|
import { ref } from 'vue'
|
|||
|
|
import { authApi } from '../api/auth'
|
|||
|
|
|
|||
|
|
export const useAuthStore = defineStore('auth', () => {
|
|||
|
|
const token = ref<string | null>(localStorage.getItem('access_token'))
|
|||
|
|
const user = ref<{ id: string; username: string; email: string } | null>(null)
|
|||
|
|
|
|||
|
|
const login = async (username: string, password: string) => {
|
|||
|
|
const response = await authApi.login({ username, password })
|
|||
|
|
token.value = response.data.access_token
|
|||
|
|
localStorage.setItem('access_token', response.data.access_token)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const register = async (username: string, email: string, password: string) => {
|
|||
|
|
await authApi.register({ username, email, password })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const logout = () => {
|
|||
|
|
token.value = null
|
|||
|
|
user.value = null
|
|||
|
|
localStorage.removeItem('access_token')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { token, user, login, register, logout }
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement frontend auth store and API"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 13: Implement Login and Register views
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/views/Login.vue`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/views/Register.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write Login.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- src/views/Login.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div class="min-h-screen flex items-center justify-center bg-gray-100">
|
|||
|
|
<n-card class="w-96" title="登录">
|
|||
|
|
<n-form ref="formRef" :model="form" :rules="rules">
|
|||
|
|
<n-form-item path="username" label="用户名">
|
|||
|
|
<n-input v-model:value="form.username" placeholder="输入用户名" />
|
|||
|
|
</n-form-item>
|
|||
|
|
<n-form-item path="password" label="密码">
|
|||
|
|
<n-input v-model:value="form.password" type="password" placeholder="输入密码" />
|
|||
|
|
</n-form-item>
|
|||
|
|
<n-button type="primary" block :loading="loading" @click="handleLogin">
|
|||
|
|
登录
|
|||
|
|
</n-button>
|
|||
|
|
</n-form>
|
|||
|
|
<div class="mt-4 text-center">
|
|||
|
|
还没有账号?<router-link to="/register" class="text-blue-500">注册</router-link>
|
|||
|
|
</div>
|
|||
|
|
</n-card>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref } from 'vue'
|
|||
|
|
import { useRouter } from 'vue-router'
|
|||
|
|
import { NCard, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
|
|||
|
|
import { useAuthStore } from '../stores/auth'
|
|||
|
|
|
|||
|
|
const router = useRouter()
|
|||
|
|
const message = useMessage()
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
const form = ref({ username: '', password: '' })
|
|||
|
|
const loading = ref(false)
|
|||
|
|
|
|||
|
|
const rules = {
|
|||
|
|
username: [{ required: true, message: '请输入用户名' }],
|
|||
|
|
password: [{ required: true, message: '请输入密码' }],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleLogin = async () => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
await authStore.login(form.value.username, form.value.password)
|
|||
|
|
message.success('登录成功')
|
|||
|
|
router.push('/')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
message.error(error.response?.data?.detail || '登录失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write Register.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- src/views/Register.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div class="min-h-screen flex items-center justify-center bg-gray-100">
|
|||
|
|
<n-card class="w-96" title="注册">
|
|||
|
|
<n-form ref="formRef" :model="form" :rules="rules">
|
|||
|
|
<n-form-item path="username" label="用户名">
|
|||
|
|
<n-input v-model:value="form.username" placeholder="输入用户名" />
|
|||
|
|
</n-form-item>
|
|||
|
|
<n-form-item path="email" label="邮箱">
|
|||
|
|
<n-input v-model:value="form.email" placeholder="输入邮箱" />
|
|||
|
|
</n-form-item>
|
|||
|
|
<n-form-item path="password" label="密码">
|
|||
|
|
<n-input v-model:value="form.password" type="password" placeholder="输入密码" />
|
|||
|
|
</n-form-item>
|
|||
|
|
<n-button type="primary" block :loading="loading" @click="handleRegister">
|
|||
|
|
注册
|
|||
|
|
</n-button>
|
|||
|
|
</n-form>
|
|||
|
|
<div class="mt-4 text-center">
|
|||
|
|
已有账号?<router-link to="/login" class="text-blue-500">登录</router-link>
|
|||
|
|
</div>
|
|||
|
|
</n-card>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref } from 'vue'
|
|||
|
|
import { useRouter } from 'vue-router'
|
|||
|
|
import { NCard, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
|
|||
|
|
import { useAuthStore } from '../stores/auth'
|
|||
|
|
|
|||
|
|
const router = useRouter()
|
|||
|
|
const message = useMessage()
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
const form = ref({ username: '', email: '', password: '' })
|
|||
|
|
const loading = ref(false)
|
|||
|
|
|
|||
|
|
const rules = {
|
|||
|
|
username: [{ required: true, message: '请输入用户名' }],
|
|||
|
|
email: [
|
|||
|
|
{ required: true, message: '请输入邮箱' },
|
|||
|
|
{ type: 'email', message: '请输入有效邮箱' }
|
|||
|
|
],
|
|||
|
|
password: [
|
|||
|
|
{ required: true, message: '请输入密码' },
|
|||
|
|
{ min: 6, message: '密码至少6位' }
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleRegister = async () => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
await authStore.register(form.value.username, form.value.email, form.value.password)
|
|||
|
|
message.success('注册成功,请登录')
|
|||
|
|
router.push('/login')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
message.error(error.response?.data?.detail || '注册失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement Login and Register views"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 14: Implement Home view with image generation
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/views/Home.vue`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/api/images.ts`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/components/ImageGenerator.vue`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/components/PromptOptimizer.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write images API**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/api/images.ts
|
|||
|
|
import api from './index'
|
|||
|
|
|
|||
|
|
export interface PromptOptimizeRequest {
|
|||
|
|
prompt: string
|
|||
|
|
mode: 'text2image' | 'image2image'
|
|||
|
|
original_description?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ImageGenerateRequest {
|
|||
|
|
prompt: string
|
|||
|
|
optimized_prompt: string
|
|||
|
|
mode: 'text2image' | 'image2image'
|
|||
|
|
model: string
|
|||
|
|
input_image_b64?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const imagesApi = {
|
|||
|
|
optimizePrompt(data: PromptOptimizeRequest) {
|
|||
|
|
return api.post<{ optimized_prompt: string }>('/images/optimize', data)
|
|||
|
|
},
|
|||
|
|
generateImage(data: ImageGenerateRequest) {
|
|||
|
|
return api.post<{ image_url: string; model: string }>('/images/generate', data)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write Home.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- src/views/Home.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div class="min-h-screen bg-gray-50">
|
|||
|
|
<nav class="bg-white shadow">
|
|||
|
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
|||
|
|
<h1 class="text-xl font-bold">ImageCreator</h1>
|
|||
|
|
<div class="flex gap-4">
|
|||
|
|
<router-link to="/" class="text-blue-500">生成</router-link>
|
|||
|
|
<router-link to="/history" class="text-blue-500">历史</router-link>
|
|||
|
|
<n-button size="small" @click="handleLogout">退出</n-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</nav>
|
|||
|
|
|
|||
|
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
|||
|
|
<n-tabs v-model:value="activeMode" type="segment">
|
|||
|
|
<n-tab-pane name="text2image" tab="文生图" />
|
|||
|
|
<n-tab-pane name="image2image" tab="图生图" />
|
|||
|
|
</n-tabs>
|
|||
|
|
|
|||
|
|
<div class="mt-6 grid grid-cols-2 gap-6">
|
|||
|
|
<div>
|
|||
|
|
<n-card title="提示词输入">
|
|||
|
|
<n-input
|
|||
|
|
v-model:value="originalPrompt"
|
|||
|
|
type="textarea"
|
|||
|
|
placeholder="描述你想要生成的图片..."
|
|||
|
|
:rows="4"
|
|||
|
|
/>
|
|||
|
|
<div class="mt-4">
|
|||
|
|
<n-button type="primary" block @click="handleOptimize" :loading="optimizing">
|
|||
|
|
优化提示词
|
|||
|
|
</n-button>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="optimizedPrompt" class="mt-4">
|
|||
|
|
<n-divider>优化后</n-divider>
|
|||
|
|
<p class="text-gray-700 whitespace-pre-wrap">{{ optimizedPrompt }}</p>
|
|||
|
|
</div>
|
|||
|
|
</n-card>
|
|||
|
|
|
|||
|
|
<n-card v-if="activeMode === 'image2image'" title="上传参考图片" class="mt-4">
|
|||
|
|
<n-upload
|
|||
|
|
accept="image/*"
|
|||
|
|
:max-size="10 * 1024 * 1024"
|
|||
|
|
@change="handleImageUpload"
|
|||
|
|
@error="handleUploadError"
|
|||
|
|
>
|
|||
|
|
<n-button>选择图片</n-button>
|
|||
|
|
</n-upload>
|
|||
|
|
</n-card>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<n-card title="生成结果">
|
|||
|
|
<div v-if="generating" class="flex justify-center py-8">
|
|||
|
|
<n-spin size="large" />
|
|||
|
|
</div>
|
|||
|
|
<div v-else-if="generatedImageUrl" class="text-center">
|
|||
|
|
<img :src="generatedImageUrl" class="max-w-full rounded" alt="Generated" />
|
|||
|
|
<n-button type="primary" class="mt-4" tag="a" :href="generatedImageUrl" download>
|
|||
|
|
下载图片
|
|||
|
|
</n-button>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="text-center text-gray-400 py-8">
|
|||
|
|
生成的图片将显示在这里
|
|||
|
|
</div>
|
|||
|
|
</n-card>
|
|||
|
|
|
|||
|
|
<div class="mt-4">
|
|||
|
|
<n-button
|
|||
|
|
type="primary"
|
|||
|
|
block
|
|||
|
|
size="large"
|
|||
|
|
@click="handleGenerate"
|
|||
|
|
:disabled="!optimizedPrompt"
|
|||
|
|
:loading="generating"
|
|||
|
|
>
|
|||
|
|
生成图片
|
|||
|
|
</n-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref } from 'vue'
|
|||
|
|
import { useRouter } from 'vue-router'
|
|||
|
|
import { NCard, NInput, NButton, NTabs, NTabPane, NDivider, NSpin, NUpload, useMessage, useDialog } from 'naive-ui'
|
|||
|
|
import { useAuthStore } from '../stores/auth'
|
|||
|
|
import { imagesApi } from '../api/images'
|
|||
|
|
|
|||
|
|
const router = useRouter()
|
|||
|
|
const message = useMessage()
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
const activeMode = ref<'text2image' | 'image2image'>('text2image')
|
|||
|
|
const originalPrompt = ref('')
|
|||
|
|
const optimizedPrompt = ref('')
|
|||
|
|
const generatedImageUrl = ref('')
|
|||
|
|
const inputImageB64 = ref('')
|
|||
|
|
const optimizing = ref(false)
|
|||
|
|
const generating = ref(false)
|
|||
|
|
|
|||
|
|
const handleLogout = () => {
|
|||
|
|
authStore.logout()
|
|||
|
|
router.push('/login')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleOptimize = async () => {
|
|||
|
|
if (!originalPrompt.value.trim()) {
|
|||
|
|
message.warning('请输入提示词')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
optimizing.value = true
|
|||
|
|
try {
|
|||
|
|
const response = await imagesApi.optimizePrompt({
|
|||
|
|
prompt: originalPrompt.value,
|
|||
|
|
mode: activeMode.value,
|
|||
|
|
})
|
|||
|
|
optimizedPrompt.value = response.data.optimized_prompt
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('优化失败')
|
|||
|
|
} finally {
|
|||
|
|
optimizing.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleImageUpload = (options: any) => {
|
|||
|
|
const file = options.file.file
|
|||
|
|
if (!file) return
|
|||
|
|
|
|||
|
|
const reader = new FileReader()
|
|||
|
|
reader.onload = (e) => {
|
|||
|
|
const result = e.target?.result as string
|
|||
|
|
inputImageB64.value = result.split(',')[1] // Remove data URL prefix
|
|||
|
|
message.success('图片上传成功')
|
|||
|
|
}
|
|||
|
|
reader.readAsDataURL(file)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleUploadError = () => {
|
|||
|
|
message.error('图片上传失败')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleGenerate = async () => {
|
|||
|
|
if (!optimizedPrompt.value) {
|
|||
|
|
message.warning('请先优化提示词')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generating.value = true
|
|||
|
|
generatedImageUrl.value = ''
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await imagesApi.generateImage({
|
|||
|
|
prompt: originalPrompt.value,
|
|||
|
|
optimized_prompt: optimizedPrompt.value,
|
|||
|
|
mode: activeMode.value,
|
|||
|
|
model: 'gemini-2.5-flash-image',
|
|||
|
|
input_image_b64: activeMode.value === 'image2image' ? inputImageB64.value : undefined,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Construct full URL for image
|
|||
|
|
generatedImageUrl.value = `/uploads/${response.data.image_url.split('/uploads/')[1]}`
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('生成失败')
|
|||
|
|
} finally {
|
|||
|
|
generating.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement Home view with image generation"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 15: Implement History view
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/views/History.vue`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/src/api/history.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write history API**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/api/history.ts
|
|||
|
|
import api from './index'
|
|||
|
|
|
|||
|
|
export interface HistoryItem {
|
|||
|
|
id: string
|
|||
|
|
original_prompt: string
|
|||
|
|
optimized_prompt: string
|
|||
|
|
mode: string
|
|||
|
|
model: string
|
|||
|
|
image_url: string
|
|||
|
|
input_image_url: string | null
|
|||
|
|
created_at: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const historyApi = {
|
|||
|
|
getHistory() {
|
|||
|
|
return api.get<HistoryItem[]>('/history')
|
|||
|
|
},
|
|||
|
|
deleteHistory(id: string) {
|
|||
|
|
return api.delete(`/history/${id}`)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write History.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- src/views/History.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div class="min-h-screen bg-gray-50">
|
|||
|
|
<nav class="bg-white shadow">
|
|||
|
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
|||
|
|
<h1 class="text-xl font-bold">ImageCreator</h1>
|
|||
|
|
<div class="flex gap-4">
|
|||
|
|
<router-link to="/" class="text-blue-500">生成</router-link>
|
|||
|
|
<router-link to="/history" class="text-blue-500">历史</router-link>
|
|||
|
|
<n-button size="small" @click="handleLogout">退出</n-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</nav>
|
|||
|
|
|
|||
|
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
|||
|
|
<h2 class="text-2xl font-bold mb-6">生成历史</h2>
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="flex justify-center py-8">
|
|||
|
|
<n-spin size="large" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="history.length === 0" class="text-center text-gray-400 py-8">
|
|||
|
|
暂无历史记录
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16">
|
|||
|
|
<n-gi v-for="item in history" :key="item.id">
|
|||
|
|
<n-card class="h-full">
|
|||
|
|
<template #header>
|
|||
|
|
<div class="flex justify-between items-center">
|
|||
|
|
<span class="text-sm text-gray-500">{{ item.mode === 'text2image' ? '文生图' : '图生图' }}</span>
|
|||
|
|
<n-button text type="error" size="small" @click="handleDelete(item.id)">
|
|||
|
|
删除
|
|||
|
|
</n-button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<img
|
|||
|
|
:src="item.image_url"
|
|||
|
|
class="w-full h-48 object-cover rounded"
|
|||
|
|
alt="Generated"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div class="mt-4">
|
|||
|
|
<p class="text-sm text-gray-500 mb-1">原始提示词:</p>
|
|||
|
|
<p class="text-sm">{{ item.original_prompt }}</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mt-2">
|
|||
|
|
<p class="text-sm text-gray-500 mb-1">优化后:</p>
|
|||
|
|
<p class="text-sm">{{ item.optimized_prompt }}</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<div class="text-xs text-gray-400">
|
|||
|
|
{{ formatDate(item.created_at) }} | {{ item.model }}
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</n-card>
|
|||
|
|
</n-gi>
|
|||
|
|
</n-grid>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted } from 'vue'
|
|||
|
|
import { useRouter } from 'vue-router'
|
|||
|
|
import { NCard, NGrid, NGi, NButton, NSpin, useMessage, useDialog } from 'naive-ui'
|
|||
|
|
import { useAuthStore } from '../stores/auth'
|
|||
|
|
import { historyApi, HistoryItem } from '../api/history'
|
|||
|
|
|
|||
|
|
const router = useRouter()
|
|||
|
|
const message = useMessage()
|
|||
|
|
const dialog = useDialog()
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
const history = ref<HistoryItem[]>([])
|
|||
|
|
const loading = ref(false)
|
|||
|
|
|
|||
|
|
const fetchHistory = async () => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
const response = await historyApi.getHistory()
|
|||
|
|
history.value = response.data
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('获取历史失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDelete = (id: string) => {
|
|||
|
|
dialog.warning({
|
|||
|
|
title: '确认删除',
|
|||
|
|
content: '确定要删除这条记录吗?',
|
|||
|
|
positiveText: '确定',
|
|||
|
|
negativeText: '取消',
|
|||
|
|
onPositiveClick: async () => {
|
|||
|
|
try {
|
|||
|
|
await historyApi.deleteHistory(id)
|
|||
|
|
message.success('删除成功')
|
|||
|
|
fetchHistory()
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('删除失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const formatDate = (dateStr: string) => {
|
|||
|
|
return new Date(dateStr).toLocaleString('zh-CN')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleLogout = () => {
|
|||
|
|
authStore.logout()
|
|||
|
|
router.push('/login')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(fetchHistory)
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: implement History view"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 16: Frontend Dockerfile and nginx config
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/Dockerfile`
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/frontend/nginx.conf`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create Dockerfile**
|
|||
|
|
|
|||
|
|
```dockerfile
|
|||
|
|
# frontend/Dockerfile
|
|||
|
|
FROM node:20-alpine as builder
|
|||
|
|
|
|||
|
|
WORKDIR /app
|
|||
|
|
COPY package.json ./
|
|||
|
|
RUN npm install
|
|||
|
|
COPY . .
|
|||
|
|
RUN npm run build
|
|||
|
|
|
|||
|
|
FROM nginx:alpine
|
|||
|
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
|||
|
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||
|
|
|
|||
|
|
EXPOSE 80
|
|||
|
|
CMD ["nginx", "-g", "daemon off;"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create nginx.conf**
|
|||
|
|
|
|||
|
|
```nginx
|
|||
|
|
# frontend/nginx.conf
|
|||
|
|
server {
|
|||
|
|
listen 80;
|
|||
|
|
server_name localhost;
|
|||
|
|
root /usr/share/nginx/html;
|
|||
|
|
index index.html;
|
|||
|
|
|
|||
|
|
location / {
|
|||
|
|
try_files $uri $uri/ /index.html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
location /api/ {
|
|||
|
|
proxy_pass http://backend:8000/api/;
|
|||
|
|
proxy_set_header Host $host;
|
|||
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
location /uploads/ {
|
|||
|
|
proxy_pass http://backend:8000/uploads/;
|
|||
|
|
proxy_set_header Host $host;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "feat: add frontend Dockerfile and nginx config"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 3: Final Integration
|
|||
|
|
|
|||
|
|
### Task 17: Create README
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `/opt/imagecreator/imagecreator/README.md`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write README**
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
# ImageCreator
|
|||
|
|
|
|||
|
|
A full-stack image generation website with user authentication, prompt optimization, and T2I/I2I generation.
|
|||
|
|
|
|||
|
|
## Features
|
|||
|
|
|
|||
|
|
- User authentication (register/login)
|
|||
|
|
- Prompt optimization using LLM
|
|||
|
|
- Text-to-Image generation
|
|||
|
|
- Image-to-Image generation
|
|||
|
|
- Generation history
|
|||
|
|
|
|||
|
|
## Tech Stack
|
|||
|
|
|
|||
|
|
- **Backend**: FastAPI, SQLAlchemy, PostgreSQL
|
|||
|
|
- **Frontend**: Vue 3, Naive UI, TailwindCSS
|
|||
|
|
- **Deployment**: Docker Compose
|
|||
|
|
|
|||
|
|
## Quick Start
|
|||
|
|
|
|||
|
|
### Prerequisites
|
|||
|
|
|
|||
|
|
- Docker & Docker Compose
|
|||
|
|
- Gemini API key (for image generation)
|
|||
|
|
|
|||
|
|
### Environment Setup
|
|||
|
|
|
|||
|
|
1. Copy `.env.example` to `.env` and configure:
|
|||
|
|
```bash
|
|||
|
|
cp .env.example .env
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
2. Edit `.env` and add your API keys:
|
|||
|
|
```
|
|||
|
|
GEMINI_API_KEY=your_gemini_api_key
|
|||
|
|
SECRET_KEY=your-secret-key
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Running with Docker Compose
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
docker-compose up -d
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Frontend: http://localhost:3000
|
|||
|
|
- Backend API: http://localhost:8000
|
|||
|
|
- API Docs: http://localhost:8000/docs
|
|||
|
|
|
|||
|
|
## Development
|
|||
|
|
|
|||
|
|
### Backend
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd backend
|
|||
|
|
pip install -r requirements.txt
|
|||
|
|
uvicorn app.main:app --reload --port 8000
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Frontend
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend
|
|||
|
|
npm install
|
|||
|
|
npm run dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Project Structure
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
imagecreator/
|
|||
|
|
├── backend/ # FastAPI backend
|
|||
|
|
│ ├── app/
|
|||
|
|
│ │ ├── api/ # API routes
|
|||
|
|
│ │ ├── core/ # Security, config
|
|||
|
|
│ │ ├── models/ # Database models
|
|||
|
|
│ │ ├── schemas/ # Pydantic schemas
|
|||
|
|
│ │ └── services/ # Business logic
|
|||
|
|
│ └── tests/
|
|||
|
|
├── frontend/ # Vue 3 frontend
|
|||
|
|
│ └── src/
|
|||
|
|
│ ├── api/ # API clients
|
|||
|
|
│ ├── views/ # Page components
|
|||
|
|
│ ├── stores/ # Pinia stores
|
|||
|
|
│ └── router/ # Vue Router
|
|||
|
|
└── docker-compose.yml
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## License
|
|||
|
|
|
|||
|
|
AGPL-3.0
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A && git commit -m "docs: add README"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
**Total Tasks: 17**
|
|||
|
|
|
|||
|
|
### Phase 1: Backend (Tasks 1-10)
|
|||
|
|
- Project structure, config, database
|
|||
|
|
- User & History models
|
|||
|
|
- Security (JWT, password)
|
|||
|
|
- Auth API & service
|
|||
|
|
- Users API
|
|||
|
|
- LLM & Prompt optimization service
|
|||
|
|
- Image generation service
|
|||
|
|
- Images API
|
|||
|
|
- History API
|
|||
|
|
- Backend Docker
|
|||
|
|
|
|||
|
|
### Phase 2: Frontend (Tasks 11-16)
|
|||
|
|
- Vue 3 project init
|
|||
|
|
- Auth stores and API
|
|||
|
|
- Login/Register views
|
|||
|
|
- Home view with generation
|
|||
|
|
- History view
|
|||
|
|
- Frontend Docker
|
|||
|
|
|
|||
|
|
### Phase 3: Final (Task 17)
|
|||
|
|
- README documentation
|