| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>CodeFlow Station - Anti-Stop Engine</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/feather-icons"></script> |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> |
| <link rel="stylesheet" href="style.css"> |
| </head> |
| <body class="h-screen w-screen overflow-hidden bg-[#0b1622] text-slate-200"> |
| |
| <header class="h-14 flex items-center justify-between px-4 border-b border-white/10 bg-[#09121b]"> |
| <div class="flex items-center gap-3"> |
| <div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center"> |
| <span class="text-sm font-bold">🐋</span> |
| </div> |
| <div> |
| <div class="text-sm font-semibold leading-none">CodeFlow</div> |
| <div class="text-[11px] text-slate-400">PRO • Anti-Stop Engine</div> |
| </div> |
| </div> |
|
|
| <div class="flex items-center gap-2"> |
| <button id="btnDesktop" class="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs">Desktop</button> |
| <button id="btnMobile" class="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs">Mobile</button> |
| <button id="btnRefresh" class="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs">Refresh</button> |
| <button id="btnExport" class="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs">Export</button> |
| <a href="ai-dashboard.html" class="px-3 py-1.5 rounded-lg bg-indigo-500/20 hover:bg-indigo-500/30 border border-indigo-400/20 text-xs inline-flex items-center"> |
| <i data-feather="cpu" class="w-3 h-3 mr-1"></i> IA Entreprise |
| </a> |
| </div> |
|
|
| <div class="flex items-center gap-2"> |
| <div class="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-cyan-400"></div> |
| <div class="text-sm">developer</div> |
| </div> |
| </header> |
|
|
| |
| <main class="h-[calc(100vh-56px)] grid grid-cols-12 gap-0"> |
| |
| <aside class="col-span-3 xl:col-span-2 h-full border-r border-white/10 bg-[#0c1723] p-4 relative"> |
| <div class="mb-4"> |
| <div class="flex items-center gap-2 text-sm font-semibold"> |
| <span class="w-2 h-2 rounded-full bg-emerald-400"></span> |
| Projects |
| </div> |
| </div> |
|
|
| <div class="mb-4"> |
| <label class="text-xs text-slate-400">Search...</label> |
| <input class="mt-2 w-full rounded-xl bg-white/5 border border-white/10 px-3 py-2 text-sm outline-none focus:border-indigo-400/40" |
| placeholder="Search..." /> |
| </div> |
|
|
| <div class="text-xs text-slate-400 mt-4 mb-2">PROJECTS</div> |
| <button id="btnNewProject" class="w-full rounded-xl bg-indigo-500/15 hover:bg-indigo-500/20 border border-indigo-400/20 px-3 py-2 text-sm text-left"> |
| Main Project |
| </button> |
|
|
| <div class="mt-4 rounded-2xl border border-white/10 bg-white/5 p-3"> |
| <div class="text-xs text-slate-400">Anti-Stop</div> |
| <div class="mt-1 text-xs text-slate-200 leading-5"> |
| • Active watchdog<br> |
| • Auto-resume<br> |
| • Force delivery |
| </div> |
| <div class="mt-2 flex items-center justify-between text-xs"> |
| <span class="text-slate-400">Retries</span> |
| <span id="retryPill" class="px-2 py-1 rounded-lg bg-white/5 border border-white/10 text-slate-200">0 / 3</span> |
| </div> |
| </div> |
|
|
| <div class="text-xs text-slate-400 mt-5 mb-2">SHORTCUTS</div> |
| <nav class="space-y-1"> |
| <button class="w-full text-left px-3 py-2 rounded-xl hover:bg-white/5 border border-transparent hover:border-white/10 text-sm">Documentation</button> |
| <button class="w-full text-left px-3 py-2 rounded-xl hover:bg-white/5 border border-transparent hover:border-white/10 text-sm">Library</button> |
| <button class="w-full text-left px-3 py-2 rounded-xl hover:bg-white/5 border border-transparent hover:border-white/10 text-sm">Settings</button> |
| </nav> |
|
|
| <div class="absolute bottom-3 left-4 right-4 flex items-center justify-between text-xs text-slate-400"> |
| <span class="px-2 py-1 rounded-lg bg-white/5 border border-white/10">v1.1.0</span> |
| <span>My Profile</span> |
| </div> |
| </aside> |
|
|
| |
| <section class="col-span-5 xl:col-span-6 h-full flex flex-col border-r border-white/10 bg-[#0b1622]"> |
| |
| <div class="h-12 flex items-center gap-2 px-4 border-b border-white/10 bg-[#0c1723]"> |
| <button class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs flex items-center gap-2"> |
| <i data-feather="eye"></i> Preview |
| </button> |
| <button class="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs flex items-center gap-2"> |
| <i data-feather="file-text"></i> Project |
| </button> |
| <div class="ml-auto flex items-center gap-2 text-xs text-slate-400"> |
| <span class="px-2 py-1 rounded-lg bg-white/5 border border-white/10">AI: Rosalinda</span> |
| <span id="statusPill" class="px-2 py-1 rounded-lg bg-emerald-500/10 border border-emerald-400/20 text-emerald-200">Ready</span> |
| </div> |
| </div> |
|
|
| |
| <div id="chat" class="flex-1 overflow-auto p-4 space-y-3"> |
| <div class="rounded-2xl border border-white/10 bg-white/5 p-4"> |
| <div class="text-xs text-slate-400 mb-2">Rosalinda • Agent</div> |
| <div class="text-lg font-semibold">Main Workspace</div> |
| <div class="text-sm text-slate-300 mt-1"> |
| Write a request below. I will <span class="font-semibold">plan</span>, <span class="font-semibold">generate</span>, |
| <span class="font-semibold">verify</span> and <span class="font-semibold">deliver</span> without interruption. |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="p-3 border-t border-white/10 bg-[#0c1723]"> |
| <div class="flex items-center gap-2"> |
| <button id="btnPlus" class="w-10 h-10 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 flex items-center justify-center"> |
| <i data-feather="plus"></i> |
| </button> |
| <button id="btnClip" class="w-10 h-10 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 flex items-center justify-center"> |
| <i data-feather="paperclip"></i> |
| </button> |
|
|
| <input id="prompt" |
| class="flex-1 h-10 rounded-xl bg-white/5 border border-white/10 px-4 text-sm outline-none focus:border-indigo-400/40" |
| placeholder="Search or write..." /> |
|
|
| <button id="btnMic" class="w-10 h-10 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 flex items-center justify-center"> |
| <i data-feather="mic"></i> |
| </button> |
| <button id="btnSend" class="w-10 h-10 rounded-xl bg-indigo-500/25 hover:bg-indigo-500/35 border border-indigo-400/20 flex items-center justify-center"> |
| <i data-feather="send"></i> |
| </button> |
| </div> |
| <div class="mt-2 text-[11px] text-slate-400"> |
| Mode: <span class="text-slate-200">Assistant + Generation</span> • Anti-Stop: <span class="text-slate-200">Watchdog + Resume</span> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="col-span-4 xl:col-span-4 h-full flex flex-col bg-[#0b1622]"> |
| |
| <div class="h-12 flex items-center justify-between px-4 border-b border-white/10 bg-[#0c1723]"> |
| <div class="flex items-center gap-2 text-sm font-semibold"> |
| <i data-feather="monitor"></i> Preview |
| </div> |
| <div class="flex items-center gap-2"> |
| <button id="btnBack" class="w-9 h-9 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10"> |
| <i data-feather="chevron-left"></i> |
| </button> |
| <button id="btnForward" class="w-9 h-9 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10"> |
| <i data-feather="chevron-right"></i> |
| </button> |
| <button id="btnReloadPreview" class="w-9 h-9 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10"> |
| <i data-feather="refresh-cw"></i> |
| </button> |
| <button id="btnPop" class="w-9 h-9 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10"> |
| <i data-feather="maximize"></i> |
| </button> |
| <button id="btnEdit" class="px-3 h-9 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs">Edit</button> |
| </div> |
| </div> |
|
|
| |
| <div class="px-4 pt-3"> |
| <div class="flex gap-2"> |
| <button data-tab="code" class="tabBtn px-3 py-1.5 rounded-xl bg-indigo-500/20 border border-indigo-400/20 text-xs">Code</button> |
| <button data-tab="preview" class="tabBtn px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs">Preview</button> |
| <button data-tab="media" class="tabBtn px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs">Media</button> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 p-4 overflow-hidden"> |
| |
| <div id="panel-code" class="h-full flex flex-col gap-2"> |
| <div class="flex items-center justify-between"> |
| <div class="text-xs text-slate-400">File: <span class="text-slate-200">index.html</span></div> |
| <div class="flex items-center gap-2"> |
| <button id="btnApply" class="px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/20 border border-emerald-400/20 text-xs"> |
| Apply + Preview |
| </button> |
| <button id="btnCopy" class="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs"> |
| Copy |
| </button> |
| </div> |
| </div> |
|
|
| <textarea id="code" |
| class="flex-1 w-full resize-none rounded-2xl bg-[#07111a] border border-white/10 p-3 text-xs leading-5 outline-none focus:border-indigo-400/30 font-mono" |
| spellcheck="false"></textarea> |
|
|
| <div class="text-[11px] text-slate-400"> |
| Anti-Stop: if it freezes, auto-resume + HTML repair + retry. |
| </div> |
| </div> |
|
|
| |
| <div id="panel-preview" class="hidden h-full"> |
| <div class="h-full rounded-2xl overflow-hidden border border-white/10 bg-[#07111a]"> |
| <iframe id="previewFrame" class="w-full h-full"></iframe> |
| </div> |
| <div class="mt-2 text-[11px] text-slate-400"> |
| Local preview (srcdoc) = no external iframe blocking. |
| </div> |
| </div> |
|
|
| |
| <div id="panel-media" class="hidden h-full flex flex-col gap-3"> |
| <div class="rounded-2xl border border-white/10 bg-white/5 p-3"> |
| <div class="text-xs text-slate-400 mb-2">Generation without API key</div> |
| <div class="text-sm text-slate-200"> |
| These buttons call a local backend (optional). If backend is not running, you'll see "offline". |
| </div> |
| <div class="mt-3 flex gap-2"> |
| <button id="btnGenImg" class="flex-1 px-3 py-2 rounded-xl bg-indigo-500/20 hover:bg-indigo-500/30 border border-indigo-400/20 text-xs"> |
| Generate Image |
| </button> |
| <button id="btnGenVid" class="flex-1 px-3 py-2 rounded-xl bg-indigo-500/20 hover:bg-indigo-500/30 border border-indigo-400/20 text-xs"> |
| Generate Video |
| </button> |
| </div> |
| </div> |
|
|
| <div class="flex-1 rounded-2xl border border-white/10 bg-[#07111a] p-3 overflow-auto"> |
| <div class="text-xs text-slate-400 mb-2">Results</div> |
| <div id="mediaOut" class="space-y-3 text-sm"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="h-10 px-4 flex items-center justify-between border-t border-white/10 bg-[#0c1723] text-xs text-slate-400"> |
| <div>Backend: <span id="netStatus" class="text-slate-200">Offline</span></div> |
| <div>Job: <span id="jobStatus" class="text-slate-200">—</span></div> |
| </div> |
| </section> |
| </main> |
| <script src="script.js"></script> |
| <script> |
| feather.replace(); |
| </script> |
| </body> |
| </html>const input = document.getElementById("prompt"); |
| const btnSend = document.getElementById("btnSend"); |
| const chat = document.getElementById("chat"); |
|
|
| function addMessage(author, text) { |
| const div = document.createElement("div"); |
| div.className = "rounded-xl bg-white/5 border border-white/10 p-3 text-sm"; |
| div.innerHTML = `<strong>${author}:</strong> ${text}`; |
| chat.appendChild(div); |
| chat.scrollTop = chat.scrollHeight; |
| } |
|
|
| function handleSend() { |
| const text = input.value.trim(); |
| if (!text) return; |
|
|
| console.log("MESSAGE CAPTURED:", text); |
| addMessage("You", text); |
| input.value = ""; |
|
|
| // Défense : backend absent |
| addMessage("System", "⚠️ Backend offline — aucune IA n'est connectée."); |
| } |
|
|
| btnSend.addEventListener("click", handleSend); |
|
|
| input.addEventListener("keydown", (e) => { |
| if (e.key === "Enter") { |
| e.preventDefault(); |
| handleSend(); |
| } |
| }); |
| ollama pull llama3.1 |
| { |
| "name": "rosalinda-server", |
| "type": "module", |
| "dependencies": { |
| "cors": "^2.8.5", |
| "express": "^4.19.2", |
| "helmet": "^7.1.0", |
| "express-rate-limit": "^7.4.0", |
| "node-fetch": "^3.3.2" |
| } |
| } |
| cd C:\rosalinda-server |
| npm install |
| setx ROSALINDA_INTERNAL_TOKEN "mon_super_secret" |
| # ferme / rouvre PowerShell |
| node server.js |
| zone-ai/ |
| README.md |
| .env.example |
| docker-compose.yml |
| Makefile |
| apps/ |
| api/ |
| Dockerfile |
| requirements.txt |
| alembic.ini |
| alembic/ |
| env.py |
| script.py.mako |
| versions/ |
| 0001_init.py |
| app/ |
| __init__.py |
| main.py |
| core/ |
| __init__.py |
| config.py |
| security.py |
| logging.py |
| rate_limit.py |
| errors.py |
| db/ |
| __init__.py |
| session.py |
| models.py |
| crud.py |
| api/ |
| __init__.py |
| deps.py |
| routes_auth.py |
| routes_chat.py |
| routes_jobs.py |
| routes_assets.py |
| routes_health.py |
| services/ |
| __init__.py |
| llm.py |
| images.py |
| videos.py |
| storage.py |
| safety.py |
| schemas/ |
| __init__.py |
| auth.py |
| chat.py |
| jobs.py |
| assets.py |
| workers/ |
| runner/ |
| Dockerfile |
| requirements.txt |
| worker.py |
| tasks.py |
| pipelines/ |
| __init__.py |
| image_sdxl.py |
| video_svd.py |
| util.py |
| web/ |
| Dockerfile |
| package.json |
| next.config.js |
| tailwind.config.js |
| postcss.config.js |
| tsconfig.json |
| src/ |
| app/ |
| layout.tsx |
| page.tsx |
| api/ |
| auth/ |
| login/route.ts |
| me/route.ts |
| chat/route.ts |
| jobs/route.ts |
| assets/route.ts |
| components/ |
| ChatPanel.tsx |
| StudioPanel.tsx |
| JobsPanel.tsx |
| TopBar.tsx |
| Sidebar.tsx |
| CodePanel.tsx |
| lib/ |
| api.ts |
| auth.ts |
| types.ts |
| styles/ |
| globals.css |
| md |
| Copier le code |
| # README.md |
| # Zone AI (API + Workers + Web) — Image + Video generation + Chat agent |
|
|
| ## Quickstart |
| cp .env.example .env |
| docker compose up --build |
|
|
| ## URLs |
| API: http://localhost:8080/docs |
| WEB: http://localhost:3000 |
| MinIO: http://localhost:9001 |
| env |
| Copier le code |
| # .env.example |
| APP_NAME=ZoneAI |
| ENV=dev |
| JWT_SECRET=change_me_super_secret |
| JWT_EXPIRES_MIN=1440 |
|
|
| POSTGRES_HOST=postgres |
| POSTGRES_PORT=5432 |
| POSTGRES_DB=zoneai |
| POSTGRES_USER=zoneai |
| POSTGRES_PASSWORD=zoneai_pw |
|
|
| REDIS_URL=redis://redis:6379/0 |
|
|
| S3_ENDPOINT=http://minio:9000 |
| S3_ACCESS_KEY=zoneai |
| S3_SECRET_KEY=zoneai_secret |
| S3_BUCKET=zoneai |
| S3_REGION=us-east-1 |
| S3_PUBLIC_BASE=http://localhost:9000/zoneai |
|
|
| # Optional external LLM (you can swap provider) |
| LLM_PROVIDER=stub |
| OPENAI_API_KEY= |
| OPENAI_BASE_URL=https://api.openai.com/v1 |
| OPENAI_MODEL=gpt-4o-mini |
|
|
| # Image/Video local model settings |
| HF_HOME=/models/hf |
| TORCH_HOME=/models/torch |
| IMAGE_MODEL_ID=stabilityai/stable-diffusion-xl-base-1.0 |
| VIDEO_MODEL_ID=stabilityai/stable-video-diffusion-img2vid-xt |
|
|
| # Rate limits |
| RATE_LIMIT_PER_MIN=120 |
| MAX_UPLOAD_MB=50 |
| yaml |
| Copier le code |
| # docker-compose.yml |
| services: |
| postgres: |
| image: postgres:16 |
| environment: |
| POSTGRES_DB: ${POSTGRES_DB} |
| POSTGRES_USER: ${POSTGRES_USER} |
| POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} |
| ports: ["5432:5432"] |
| volumes: ["pgdata:/var/lib/postgresql/data"] |
| healthcheck: |
| test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] |
| interval: 5s |
| timeout: 5s |
| retries: 20 |
|
|
| redis: |
| image: redis:7 |
| ports: ["6379:6379"] |
|
|
| minio: |
| image: minio/minio:RELEASE.2024-10-13T13-34-11Z |
| command: server /data --console-address ":9001" |
| environment: |
| MINIO_ROOT_USER: ${S3_ACCESS_KEY} |
| MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} |
| ports: ["9000:9000", "9001:9001"] |
| volumes: ["miniodata:/data"] |
|
|
| createbuckets: |
| image: minio/mc |
| depends_on: [minio] |
| entrypoint: > |
| /bin/sh -c " |
| mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; |
| mc mb -p local/${S3_BUCKET} || true; |
| mc anonymous set download local/${S3_BUCKET} || true; |
| exit 0; |
| " |
|
|
| api: |
| build: ./apps/api |
| env_file: .env |
| depends_on: |
| postgres: |
| condition: service_healthy |
| redis: |
| condition: service_started |
| createbuckets: |
| condition: service_completed_successfully |
| ports: ["8080:8080"] |
| volumes: |
| - ./apps/api:/app |
| command: > |
| sh -c " |
| alembic upgrade head && |
| uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload |
| " |
|
|
| worker: |
| build: ./workers/runner |
| env_file: .env |
| depends_on: |
| redis: |
| condition: service_started |
| api: |
| condition: service_started |
| volumes: |
| - ./workers/runner:/worker |
| - models:/models |
| environment: |
| NVIDIA_VISIBLE_DEVICES: all |
| command: > |
| sh -c " |
| celery -A worker.celery_app worker --loglevel=INFO --concurrency=1 |
| " |
|
|
| web: |
| build: ./web |
| env_file: .env |
| depends_on: [api] |
| ports: ["3000:3000"] |
| volumes: |
| - ./web:/web |
| - /web/node_modules |
| command: > |
| sh -c "npm install && npm run dev -- -p 3000" |
|
|
| volumes: |
| pgdata: |
| miniodata: |
| models: |
| makefile |
| Copier le code |
| # Makefile |
| up: |
| docker compose up --build |
|
|
| down: |
| docker compose down -v |
|
|
| logs: |
| docker compose logs -f --tail=200 |
| dockerfile |
| Copier le code |
| # apps/api/Dockerfile |
| FROM python:3.11-slim |
| WORKDIR /app |
| RUN apt-get update && apt-get install -y --no-install-recommends gcc curl && rm -rf /var/lib/apt/lists/* |
| COPY requirements.txt . |
| RUN pip install --no-cache-dir -r requirements.txt |
| COPY . . |
| EXPOSE 8080 |
| txt |
| Copier le code |
| # apps/api/requirements.txt |
| fastapi==0.115.5 |
| uvicorn[standard]==0.30.6 |
| pydantic==2.9.2 |
| pydantic-settings==2.5.2 |
| python-jose==3.3.0 |
| passlib[bcrypt]==1.7.4 |
| SQLAlchemy==2.0.36 |
| psycopg2-binary==2.9.10 |
| alembic==1.13.3 |
| redis==5.1.1 |
| celery==5.4.0 |
| httpx==0.27.2 |
| python-multipart==0.0.12 |
| boto3==1.35.45 |
| botocore==1.35.45 |
| tenacity==9.0.0 |
| slowapi==0.1.9 |
| orjson==3.10.10 |
| python |
| Copier le code |
| # apps/api/app/core/config.py |
| from pydantic_settings import BaseSettings |
|
|
| class Settings(BaseSettings): |
| APP_NAME: str = "ZoneAI" |
| ENV: str = "dev" |
|
|
| JWT_SECRET: str |
| JWT_EXPIRES_MIN: int = 1440 |
|
|
| POSTGRES_HOST: str |
| POSTGRES_PORT: int = 5432 |
| POSTGRES_DB: str |
| POSTGRES_USER: str |
| POSTGRES_PASSWORD: str |
|
|
| REDIS_URL: str |
|
|
| S3_ENDPOINT: str |
| S3_ACCESS_KEY: str |
| S3_SECRET_KEY: str |
| S3_BUCKET: str |
| S3_REGION: str = "us-east-1" |
| S3_PUBLIC_BASE: str |
|
|
| LLM_PROVIDER: str = "stub" |
| OPENAI_API_KEY: str | None = None |
| OPENAI_BASE_URL: str = "https://api.openai.com/v1" |
| OPENAI_MODEL: str = "gpt-4o-mini" |
|
|
| IMAGE_MODEL_ID: str = "stabilityai/stable-diffusion-xl-base-1.0" |
| VIDEO_MODEL_ID: str = "stabilityai/stable-video-diffusion-img2vid-xt" |
|
|
| RATE_LIMIT_PER_MIN: int = 120 |
| MAX_UPLOAD_MB: int = 50 |
|
|
| class Config: |
| env_file = ".env" |
| extra = "ignore" |
|
|
| settings = Settings() |
| python |
| Copier le code |
| # apps/api/app/core/security.py |
| from datetime import datetime, timedelta, timezone |
| from jose import jwt |
| from passlib.context import CryptContext |
| from .config import settings |
|
|
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") |
| ALGO = "HS256" |
|
|
| def hash_password(pw: str) -> str: |
| return pwd_context.hash(pw) |
|
|
| def verify_password(pw: str, hashed: str) -> bool: |
| return pwd_context.verify(pw, hashed) |
|
|
| def create_access_token(sub: str) -> str: |
| exp = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRES_MIN) |
| payload = {"sub": sub, "exp": exp} |
| return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGO) |
|
|
| def decode_token(token: str) -> dict: |
| return jwt.decode(token, settings.JWT_SECRET, algorithms=[ALGO]) |
| python |
| Copier le code |
| # apps/api/app/core/errors.py |
| from fastapi import HTTPException |
|
|
| def http_error(status: int, msg: str): |
| raise HTTPException(status_code=status, detail=msg) |
| python |
| Copier le code |
| # apps/api/app/core/logging.py |
| import logging |
|
|
| def setup_logging(): |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s %(levelname)s %(name)s - %(message)s", |
| ) |
| python |
| Copier le code |
| # apps/api/app/core/rate_limit.py |
| from slowapi import Limiter |
| from slowapi.util import get_remote_address |
| from .config import settings |
|
|
| limiter = Limiter(key_func=get_remote_address, default_limits=[f"{settings.RATE_LIMIT_PER_MIN}/minute"]) |
| python |
| Copier le code |
| # apps/api/app/db/session.py |
| from sqlalchemy import create_engine |
| from sqlalchemy.orm import sessionmaker, DeclarativeBase |
| from app.core.config import settings |
|
|
| DATABASE_URL = ( |
| f"postgresql+psycopg2://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}" |
| f"@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" |
| ) |
|
|
| engine = create_engine(DATABASE_URL, pool_pre_ping=True) |
| SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) |
|
|
| class Base(DeclarativeBase): |
| pass |
| python |
| Copier le code |
| # apps/api/app/db/models.py |
| from sqlalchemy import String, Text, DateTime, ForeignKey, Integer |
| from sqlalchemy.orm import Mapped, mapped_column, relationship |
| from datetime import datetime, timezone |
| from .session import Base |
|
|
| def now_utc(): |
| return datetime.now(timezone.utc) |
|
|
| class User(Base): |
| __tablename__ = "users" |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) |
| email: Mapped[str] = mapped_column(String(255), unique=True, index=True) |
| password_hash: Mapped[str] = mapped_column(String(255)) |
| created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) |
|
|
| class Asset(Base): |
| __tablename__ = "assets" |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) |
| owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) |
| kind: Mapped[str] = mapped_column(String(32)) # image|video|audio|file |
| mime: Mapped[str] = mapped_column(String(128)) |
| s3_key: Mapped[str] = mapped_column(String(512), unique=True) |
| public_url: Mapped[str] = mapped_column(Text) |
| created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) |
| owner = relationship("User") |
|
|
| class Job(Base): |
| __tablename__ = "jobs" |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) |
| owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) |
| type: Mapped[str] = mapped_column(String(32)) # chat|image|video |
| status: Mapped[str] = mapped_column(String(32), default="queued") # queued|running|done|error |
| prompt: Mapped[str] = mapped_column(Text) |
| params_json: Mapped[str] = mapped_column(Text, default="{}") |
| result_asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), nullable=True) |
| error: Mapped[str | None] = mapped_column(Text, nullable=True) |
| created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) |
| updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) |
| owner = relationship("User") |
| result_asset = relationship("Asset") |
| python |
| Copier le code |
| # apps/api/app/db/crud.py |
| from sqlalchemy.orm import Session |
| from .models import User, Job, Asset |
| from app.core.security import hash_password |
|
|
| def get_user_by_email(db: Session, email: str) -> User | None: |
| return db.query(User).filter(User.email == email).first() |
|
|
| def create_user(db: Session, email: str, password: str) -> User: |
| u = User(email=email, password_hash=hash_password(password)) |
| db.add(u) |
| db.commit() |
| db.refresh(u) |
| return u |
|
|
| def create_job(db: Session, owner_id: int, type_: str, prompt: str, params_json: str) -> Job: |
| j = Job(owner_id=owner_id, type=type_, prompt=prompt, params_json=params_json, status="queued") |
| db.add(j) |
| db.commit() |
| db.refresh(j) |
| return j |
|
|
| def set_job_status(db: Session, job_id: int, status: str, error: str | None = None, asset_id: int | None = None): |
| j = db.query(Job).filter(Job.id == job_id).first() |
| if not j: |
| return |
| j.status = status |
| j.error = error |
| j.result_asset_id = asset_id |
| db.add(j) |
| db.commit() |
| db.refresh(j) |
|
|
| def create_asset(db: Session, owner_id: int, kind: str, mime: str, s3_key: str, public_url: str) -> Asset: |
| a = Asset(owner_id=owner_id, kind=kind, mime=mime, s3_key=s3_key, public_url=public_url) |
| db.add(a) |
| db.commit() |
| db.refresh(a) |
| return a |
| python |
| Copier le code |
| # apps/api/app/api/deps.py |
| from fastapi import Depends, Header |
| from sqlalchemy.orm import Session |
| from app.db.session import SessionLocal |
| from app.core.security import decode_token |
| from app.core.errors import http_error |
| from app.db.models import User |
| from app.db.crud import get_user_by_email |
|
|
| def get_db(): |
| db = SessionLocal() |
| try: |
| yield db |
| finally: |
| db.close() |
|
|
| def get_current_user( |
| db: Session = Depends(get_db), |
| authorization: str | None = Header(default=None), |
| ) -> User: |
| if not authorization or not authorization.startswith("Bearer "): |
| http_error(401, "Missing token") |
| token = authorization.split(" ", 1)[1].strip() |
| try: |
| payload = decode_token(token) |
| email = payload.get("sub") |
| if not email: |
| http_error(401, "Invalid token") |
| u = get_user_by_email(db, email) |
| if not u: |
| http_error(401, "User not found") |
| return u |
| except Exception: |
| http_error(401, "Invalid token") |
| python |
| Copier le code |
| # apps/api/app/schemas/auth.py |
| from pydantic import BaseModel, EmailStr |
|
|
| class AuthRegisterIn(BaseModel): |
| email: EmailStr |
| password: str |
|
|
| class AuthLoginIn(BaseModel): |
| email: EmailStr |
| password: str |
|
|
| class AuthOut(BaseModel): |
| token: str |
|
|
| class MeOut(BaseModel): |
| id: int |
| email: EmailStr |
| python |
| Copier le code |
| # apps/api/app/schemas/jobs.py |
| from pydantic import BaseModel |
| from typing import Any |
|
|
| class JobCreateIn(BaseModel): |
| type: str # chat|image|video |
| prompt: str |
| params: dict[str, Any] = {} |
|
|
| class JobOut(BaseModel): |
| id: int |
| type: str |
| status: str |
| prompt: str |
| params_json: str |
| result_asset_id: int | None |
| error: str | None |
| python |
| Copier le code |
| # apps/api/app/schemas/assets.py |
| from pydantic import BaseModel |
|
|
| class AssetOut(BaseModel): |
| id: int |
| kind: str |
| mime: str |
| public_url: str |
| python |
| Copier le code |
| # apps/api/app/services/storage.py |
| import boto3 |
| from botocore.config import Config |
| from app.core.config import settings |
|
|
| s3 = boto3.client( |
| "s3", |
| endpoint_url=settings.S3_ENDPOINT, |
| aws_access_key_id=settings.S3_ACCESS_KEY, |
| aws_secret_access_key=settings.S3_SECRET_KEY, |
| region_name=settings.S3_REGION, |
| config=Config(s3={"addressing_style": "path"}), |
| ) |
|
|
| def put_bytes(key: str, data: bytes, mime: str) -> str: |
| s3.put_object(Bucket=settings.S3_BUCKET, Key=key, Body=data, ContentType=mime) |
| return f"{settings.S3_PUBLIC_BASE}/{key}" |
| python |
| Copier le code |
| # apps/api/app/services/safety.py |
| def basic_prompt_guard(prompt: str) -> None: |
| blocked = ["csam", "sexual content involving minors"] |
| low = prompt.lower() |
| for b in blocked: |
| if b in low: |
| raise ValueError("Blocked prompt") |
| python |
| Copier le code |
| # apps/api/app/services/llm.py |
| from app.core.config import settings |
| import httpx |
|
|
| class LLM: |
| async def complete(self, prompt: str) -> str: |
| if settings.LLM_PROVIDER == "openai": |
| return await self._openai(prompt) |
| return f"STUB: {prompt}" |
|
|
| async def _openai(self, prompt: str) -> str: |
| if not settings.OPENAI_API_KEY: |
| return "OPENAI_API_KEY missing" |
| headers = {"Authorization": f"Bearer {settings.OPENAI_API_KEY}"} |
| payload = { |
| "model": settings.OPENAI_MODEL, |
| "messages": [{"role": "user", "content": prompt}], |
| } |
| async with httpx.AsyncClient(base_url=settings.OPENAI_BASE_URL, timeout=60) as client: |
| r = await client.post("/chat/completions", headers=headers, json=payload) |
| r.raise_for_status() |
| data = r.json() |
| return data["choices"][0]["message"]["content"] |
|
|
| llm = LLM() |
| python |
| Copier le code |
| # apps/api/app/services/images.py |
| # Placeholder: generation is done in worker; API only enqueues jobs. |
| python |
| Copier le code |
| # apps/api/app/services/videos.py |
| # Placeholder: generation is done in worker; API only enqueues jobs. |
| python |
| Copier le code |
| # apps/api/app/api/routes_auth.py |
| from fastapi import APIRouter, Depends |
| from sqlalchemy.orm import Session |
| from app.api.deps import get_db, get_current_user |
| from app.schemas.auth import AuthRegisterIn, AuthLoginIn, AuthOut, MeOut |
| from app.db.crud import get_user_by_email, create_user |
| from app.core.security import verify_password, create_access_token |
| from app.core.errors import http_error |
|
|
| router = APIRouter(prefix="/auth", tags=["auth"]) |
|
|
| @router.post("/register", response_model=AuthOut) |
| def register(body: AuthRegisterIn, db: Session = Depends(get_db)): |
| if get_user_by_email(db, body.email): |
| http_error(409, "Email already exists") |
| create_user(db, body.email, body.password) |
| token = create_access_token(body.email) |
| return {"token": token} |
|
|
| @router.post("/login", response_model=AuthOut) |
| def login(body: AuthLoginIn, db: Session = Depends(get_db)): |
| u = get_user_by_email(db, body.email) |
| if not u or not verify_password(body.password, u.password_hash): |
| http_error(401, "Bad credentials") |
| token = create_access_token(u.email) |
| return {"token": token} |
|
|
| @router.get("/me", response_model=MeOut) |
| def me(user=Depends(get_current_user)): |
| return {"id": user.id, "email": user.email} |
| python |
| Copier le code |
| # apps/api/app/api/routes_jobs.py |
| import json |
| from fastapi import APIRouter, Depends |
| from sqlalchemy.orm import Session |
| from app.api.deps import get_db, get_current_user |
| from app.schemas.jobs import JobCreateIn, JobOut |
| from app.db.crud import create_job |
| from app.core.errors import http_error |
| from app.core.rate_limit import limiter |
| from fastapi import Request |
| from app.core.config import settings |
| from celery import Celery |
|
|
| celery_app = Celery("zoneai", broker=settings.REDIS_URL, backend=settings.REDIS_URL) |
|
|
| router = APIRouter(prefix="/jobs", tags=["jobs"]) |
|
|
| @router.post("", response_model=JobOut) |
| @limiter.limit("60/minute") |
| def create_job_route(request: Request, body: JobCreateIn, db: Session = Depends(get_db), user=Depends(get_current_user)): |
| if body.type not in ("chat", "image", "video"): |
| http_error(400, "Invalid job type") |
| params_json = json.dumps(body.params or {}, ensure_ascii=False) |
| job = create_job(db, owner_id=user.id, type_=body.type, prompt=body.prompt, params_json=params_json) |
| celery_app.send_task("tasks.run_job", args=[job.id]) |
| return JobOut( |
| id=job.id, |
| type=job.type, |
| status=job.status, |
| prompt=job.prompt, |
| params_json=job.params_json, |
| result_asset_id=job.result_asset_id, |
| error=job.error, |
| ) |
|
|
| @router.get("/{job_id}", response_model=JobOut) |
| def get_job(job_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)): |
| from app.db.models import Job |
| job = db.query(Job).filter(Job.id == job_id, Job.owner_id == user.id).first() |
| if not job: |
| http_error(404, "Not found") |
| return JobOut( |
| id=job.id, |
| type=job.type, |
| status=job.status, |
| prompt=job.prompt, |
| params_json=job.params_json, |
| result_asset_id=job.result_asset_id, |
| error=job.error, |
| ) |
| python |
| Copier le code |
| # apps/api/app/api/routes_assets.py |
| from fastapi import APIRouter, Depends |
| from sqlalchemy.orm import Session |
| from app.api.deps import get_db, get_current_user |
| from app.schemas.assets import AssetOut |
| from app.core.errors import http_error |
|
|
| router = APIRouter(prefix="/assets", tags=["assets"]) |
|
|
| @router.get("/{asset_id}", response_model=AssetOut) |
| def get_asset(asset_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)): |
| from app.db.models import Asset |
| a = db.query(Asset).filter(Asset.id == asset_id, Asset.owner_id == user.id).first() |
| if not a: |
| http_error(404, "Not found") |
| return AssetOut(id=a.id, kind=a.kind, mime=a.mime, public_url=a.public_url) |
| python |
| Copier le code |
| # apps/api/app/api/routes_chat.py |
| from fastapi import APIRouter, Depends |
| from sqlalchemy.orm import Session |
| from app.api.deps import get_db, get_current_user |
| from app.services.llm import llm |
| from app.schemas.jobs import JobCreateIn |
| from app.db.crud import create_job, set_job_status |
| import json |
|
|
| router = APIRouter(prefix="/chat", tags=["chat"]) |
|
|
| @router.post("") |
| async def chat(body: JobCreateIn, db: Session = Depends(get_db), user=Depends(get_current_user)): |
| job = create_job(db, owner_id=user.id, type_="chat", prompt=body.prompt, params_json=json.dumps(body.params or {})) |
| try: |
| set_job_status(db, job.id, "running") |
| text = await llm.complete(body.prompt) |
| set_job_status(db, job.id, "done") |
| return {"job_id": job.id, "text": text} |
| except Exception as e: |
| set_job_status(db, job.id, "error", error=str(e)) |
| return {"job_id": job.id, "error": str(e)} |
| python |
| Copier le code |
| # apps/api/app/api/routes_health.py |
| from fastapi import APIRouter |
|
|
| router = APIRouter(tags=["health"]) |
|
|
| @router.get("/health") |
| def health(): |
| return {"ok": True} |
| python |
| Copier le code |
| # apps/api/app/main.py |
| from fastapi import FastAPI |
| from fastapi.middleware.cors import CORSMiddleware |
| from slowapi.errors import RateLimitExceeded |
| from slowapi import _rate_limit_exceeded_handler |
| from app.core.logging import setup_logging |
| from app.core.rate_limit import limiter |
| from app.api.routes_health import router as health_router |
| from app.api.routes_auth import router as auth_router |
| from app.api.routes_jobs import router as jobs_router |
| from app.api.routes_assets import router as assets_router |
| from app.api.routes_chat import router as chat_router |
|
|
| setup_logging() |
|
|
| app = FastAPI(title="ZoneAI API") |
| app.state.limiter = limiter |
| app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| app.include_router(health_router) |
| app.include_router(auth_router) |
| app.include_router(chat_router) |
| app.include_router(jobs_router) |
| app.include_router(assets_router) |
| ini |
| Copier le code |
| # apps/api/alembic.ini |
| [alembic] |
| script_location = alembic |
| sqlalchemy.url = driver://user:pass@localhost/dbname |
|
|
| [loggers] |
| keys = root,sqlalchemy,alembic |
|
|
| [handlers] |
| keys = console |
|
|
| [formatters] |
| keys = generic |
|
|
| [logger_root] |
| level = INFO |
| handlers = console |
|
|
| [logger_sqlalchemy] |
| level = WARN |
| handlers = |
| qualname = sqlalchemy.engine |
|
|
| [logger_alembic] |
| level = INFO |
| handlers = |
| qualname = alembic |
|
|
| [handler_console] |
| class = StreamHandler |
| args = (sys.stderr,) |
| level = NOTSET |
| formatter = generic |
|
|
| [formatter_generic] |
| format = %(levelname)-5.5s [%(name)s] %(message)s |
| python |
| Copier le code |
| # apps/api/alembic/env.py |
| from logging.config import fileConfig |
| from sqlalchemy import engine_from_config, pool |
| from alembic import context |
| import os |
|
|
| from app.db.session import Base |
| from app.db import models # noqa |
|
|
| config = context.config |
| fileConfig(config.config_file_name) |
|
|
| def get_url(): |
| host = os.environ["POSTGRES_HOST"] |
| port = os.environ.get("POSTGRES_PORT", "5432") |
| db = os.environ["POSTGRES_DB"] |
| user = os.environ["POSTGRES_USER"] |
| pw = os.environ["POSTGRES_PASSWORD"] |
| return f"postgresql+psycopg2://{user}:{pw}@{host}:{port}/{db}" |
|
|
| target_metadata = Base.metadata |
|
|
| def run_migrations_offline(): |
| context.configure(url=get_url(), target_metadata=target_metadata, literal_binds=True) |
| with context.begin_transaction(): |
| context.run_migrations() |
|
|
| def run_migrations_online(): |
| connectable = engine_from_config( |
| {"sqlalchemy.url": get_url()}, |
| prefix="sqlalchemy.", |
| poolclass=pool.NullPool, |
| ) |
| with connectable.connect() as connection: |
| context.configure(connection=connection, target_metadata=target_metadata) |
| with context.begin_transaction(): |
| context.run_migrations() |
|
|
| if context.is_offline_mode(): |
| run_migrations_offline() |
| else: |
| run_migrations_online() |
| python |
| Copier le code |
| # apps/api/alembic/versions/0001_init.py |
| from alembic import op |
| import sqlalchemy as sa |
|
|
| revision = "0001_init" |
| down_revision = None |
| branch_labels = None |
| depends_on = None |
|
|
| def upgrade(): |
| op.create_table( |
| "users", |
| sa.Column("id", sa.Integer(), primary_key=True), |
| sa.Column("email", sa.String(length=255), nullable=False), |
| sa.Column("password_hash", sa.String(length=255), nullable=False), |
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), |
| ) |
| op.create_index("ix_users_email", "users", ["email"], unique=True) |
|
|
| op.create_table( |
| "assets", |
| sa.Column("id", sa.Integer(), primary_key=True), |
| sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), |
| sa.Column("kind", sa.String(length=32), nullable=False), |
| sa.Column("mime", sa.String(length=128), nullable=False), |
| sa.Column("s3_key", sa.String(length=512), nullable=False), |
| sa.Column("public_url", sa.Text(), nullable=False), |
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), |
| ) |
| op.create_index("ix_assets_s3_key", "assets", ["s3_key"], unique=True) |
|
|
| op.create_table( |
| "jobs", |
| sa.Column("id", sa.Integer(), primary_key=True), |
| sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), |
| sa.Column("type", sa.String(length=32), nullable=False), |
| sa.Column("status", sa.String(length=32), nullable=False), |
| sa.Column("prompt", sa.Text(), nullable=False), |
| sa.Column("params_json", sa.Text(), nullable=False), |
| sa.Column("result_asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="SET NULL")), |
| sa.Column("error", sa.Text()), |
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), |
| sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), |
| ) |
|
|
| def downgrade(): |
| op.drop_table("jobs") |
| op.drop_index("ix_assets_s3_key", table_name="assets") |
| op.drop_table("assets") |
| op.drop_index("ix_users_email", table_name="users") |
| op.drop_table("users") |
| dockerfile |
| Copier le code |
| # workers/runner/Dockerfile |
| FROM python:3.11-slim |
| WORKDIR /worker |
| RUN apt-get update && apt-get install -y --no-install-recommends git ffmpeg libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* |
| COPY requirements.txt . |
| RUN pip install --no-cache-dir -r requirements.txt |
| COPY . . |
| txt |
| Copier le code |
| # workers/runner/requirements.txt |
| celery==5.4.0 |
| redis==5.1.1 |
| SQLAlchemy==2.0.36 |
| psycopg2-binary==2.9.10 |
| boto3==1.35.45 |
| botocore==1.35.45 |
| orjson==3.10.10 |
| torch==2.4.1 |
| torchvision==0.19.1 |
| diffusers==0.31.0 |
| transformers==4.45.2 |
| accelerate==0.35.0 |
| safetensors==0.4.5 |
| opencv-python==4.10.0.84 |
| imageio==2.36.0 |
| imageio-ffmpeg==0.5.1 |
| pillow==10.4.0 |
| python |
| Copier le code |
| # workers/runner/worker.py |
| from celery import Celery |
| import os |
|
|
| REDIS_URL = os.environ["REDIS_URL"] |
| celery_app = Celery("worker", broker=REDIS_URL, backend=REDIS_URL) |
| celery_app.autodiscover_tasks(["tasks"]) |
| python |
| Copier le code |
| # workers/runner/pipelines/util.py |
| import os |
| import boto3 |
| from botocore.config import Config |
|
|
| def s3_client(): |
| endpoint = os.environ["S3_ENDPOINT"] |
| access = os.environ["S3_ACCESS_KEY"] |
| secret = os.environ["S3_SECRET_KEY"] |
| region = os.environ.get("S3_REGION", "us-east-1") |
| return boto3.client( |
| "s3", |
| endpoint_url=endpoint, |
| aws_access_key_id=access, |
| aws_secret_access_key=secret, |
| region_name=region, |
| config=Config(s3={"addressing_style": "path"}), |
| ) |
|
|
| def put_bytes(key: str, data: bytes, mime: str) -> str: |
| bucket = os.environ["S3_BUCKET"] |
| public_base = os.environ["S3_PUBLIC_BASE"] |
| s3 = s3_client() |
| s3.put_object(Bucket=bucket, Key=key, Body=data, ContentType=mime) |
| return f"{public_base}/{key}" |
| python |
| Copier le code |
| # workers/runner/pipelines/image_sdxl.py |
| import io, os, uuid |
| from PIL import Image |
| import torch |
| from diffusers import StableDiffusionXLPipeline |
| from .util import put_bytes |
|
|
| _pipe = None |
|
|
| def get_pipe(): |
| global _pipe |
| if _pipe is None: |
| model_id = os.environ.get("IMAGE_MODEL_ID", "stabilityai/stable-diffusion-xl-base-1.0") |
| _pipe = StableDiffusionXLPipeline.from_pretrained( |
| model_id, |
| torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, |
| variant="fp16" if torch.cuda.is_available() else None, |
| use_safetensors=True, |
| ) |
| if torch.cuda.is_available(): |
| _pipe = _pipe.to("cuda") |
| _pipe.enable_attention_slicing() |
| return _pipe |
|
|
| def generate_image(prompt: str, negative: str = "", width: int = 1024, height: int = 1024, steps: int = 30, guidance: float = 6.5, seed: int | None = None): |
| pipe = get_pipe() |
| gen = None |
| if seed is not None: |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| gen = torch.Generator(device=device).manual_seed(int(seed)) |
|
|
| out = pipe( |
| prompt=prompt, |
| negative_prompt=negative or None, |
| width=int(width), |
| height=int(height), |
| num_inference_steps=int(steps), |
| guidance_scale=float(guidance), |
| generator=gen, |
| ) |
| img: Image.Image = out.images[0] |
| buf = io.BytesIO() |
| img.save(buf, format="PNG") |
| data = buf.getvalue() |
| key = f"images/{uuid.uuid4().hex}.png" |
| url = put_bytes(key, data, "image/png") |
| return key, url |
| python |
| Copier le code |
| # workers/runner/pipelines/video_svd.py |
| import io, os, uuid |
| from PIL import Image |
| import numpy as np |
| import torch |
| import imageio.v3 as iio |
| from diffusers import StableVideoDiffusionPipeline |
| from .util import put_bytes |
|
|
| _pipe = None |
|
|
| def get_pipe(): |
| global _pipe |
| if _pipe is None: |
| model_id = os.environ.get("VIDEO_MODEL_ID", "stabilityai/stable-video-diffusion-img2vid-xt") |
| _pipe = StableVideoDiffusionPipeline.from_pretrained( |
| model_id, |
| torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, |
| variant="fp16" if torch.cuda.is_available() else None, |
| ) |
| if torch.cuda.is_available(): |
| _pipe = _pipe.to("cuda") |
| _pipe.enable_attention_slicing() |
| return _pipe |
|
|
| def generate_video_from_image(pil_image: Image.Image, motion_bucket_id: int = 127, fps: int = 12, frames: int = 25, seed: int | None = None): |
| pipe = get_pipe() |
| image = pil_image.convert("RGB").resize((1024, 576)) |
| gen = None |
| if seed is not None: |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| gen = torch.Generator(device=device).manual_seed(int(seed)) |
|
|
| out = pipe( |
| image=image, |
| motion_bucket_id=int(motion_bucket_id), |
| num_frames=int(frames), |
| generator=gen, |
| ) |
| video_frames = out.frames[0] |
| arr = [np.array(f) for f in video_frames] |
|
|
| buf = io.BytesIO() |
| iio.imwrite(buf, arr, extension=".mp4", fps=int(fps)) |
| data = buf.getvalue() |
| key = f"videos/{uuid.uuid4().hex}.mp4" |
| url = put_bytes(key, data, "video/mp4") |
| return key, url |
| python |
| Copier le code |
| # workers/runner/tasks.py |
| import os, json |
| from celery import shared_task |
| from sqlalchemy import create_engine |
| from sqlalchemy.orm import sessionmaker |
| from datetime import datetime, timezone |
| from pipelines.image_sdxl import generate_image |
| from pipelines.video_svd import generate_video_from_image |
| from PIL import Image |
| import requests |
|
|
| DATABASE_URL = ( |
| f"postgresql+psycopg2://{os.environ['POSTGRES_USER']}:{os.environ['POSTGRES_PASSWORD']}" |
| f"@{os.environ['POSTGRES_HOST']}:{os.environ.get('POSTGRES_PORT','5432')}/{os.environ['POSTGRES_DB']}" |
| ) |
| engine = create_engine(DATABASE_URL, pool_pre_ping=True) |
| SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) |
|
|
| def now_utc(): |
| return datetime.now(timezone.utc) |
|
|
| @shared_task(name="tasks.run_job") |
| def run_job(job_id: int): |
| db = SessionLocal() |
| try: |
| job = db.execute( |
| "SELECT id, owner_id, type, prompt, params_json FROM jobs WHERE id=:id", |
| {"id": job_id}, |
| ).mappings().first() |
| if not job: |
| return |
|
|
| db.execute("UPDATE jobs SET status='running', updated_at=:u WHERE id=:id", {"id": job_id, "u": now_utc()}) |
| db.commit() |
|
|
| params = json.loads(job["params_json"] or "{}") |
|
|
| if job["type"] == "image": |
| key, url = generate_image( |
| prompt=job["prompt"], |
| negative=params.get("negative", ""), |
| width=params.get("width", 1024), |
| height=params.get("height", 1024), |
| steps=params.get("steps", 30), |
| guidance=params.get("guidance", 6.5), |
| seed=params.get("seed"), |
| ) |
| asset_id = _insert_asset(db, job["owner_id"], "image", "image/png", key, url) |
| db.execute("UPDATE jobs SET status='done', result_asset_id=:a, updated_at=:u WHERE id=:id", {"a": asset_id, "u": now_utc(), "id": job_id}) |
| db.commit() |
| return |
|
|
| if job["type"] == "video": |
| # expects params.image_url OR params.image_asset_url |
| img_url = params.get("image_url") or params.get("image_asset_url") |
| if not img_url: |
| raise RuntimeError("video job requires params.image_url") |
| img_bytes = requests.get(img_url, timeout=60).content |
| pil = Image.open(io.BytesIO(img_bytes)).convert("RGB") |
| key, url = generate_video_from_image( |
| pil_image=pil, |
| motion_bucket_id=params.get("motion_bucket_id", 127), |
| fps=params.get("fps", 12), |
| frames=params.get("frames", 25), |
| seed=params.get("seed"), |
| ) |
| asset_id = _insert_asset(db, job["owner_id"], "video", "video/mp4", key, url) |
| db.execute("UPDATE jobs SET status='done', result_asset_id=:a, updated_at=:u WHERE id=:id", {"a": asset_id, "u": now_utc(), "id": job_id}) |
| db.commit() |
| return |
|
|
| if job["type"] == "chat": |
| db.execute("UPDATE jobs SET status='done', updated_at=:u WHERE id=:id", {"u": now_utc(), "id": job_id}) |
| db.commit() |
| return |
|
|
| raise RuntimeError("Unknown job type") |
|
|
| except Exception as e: |
| db.execute("UPDATE jobs SET status='error', error=:err, updated_at=:u WHERE id=:id", {"err": str(e), "u": now_utc(), "id": job_id}) |
| db.commit() |
| finally: |
| db.close() |
|
|
| def _insert_asset(db, owner_id: int, kind: str, mime: str, s3_key: str, public_url: str) -> int: |
| row = db.execute( |
| "INSERT INTO assets (owner_id, kind, mime, s3_key, public_url, created_at) VALUES (:o,:k,:m,:s,:p,:c) RETURNING id", |
| {"o": owner_id, "k": kind, "m": mime, "s": s3_key, "p": public_url, "c": now_utc()}, |
| ).first() |
| db.commit() |
| return int(row[0]) |
| dockerfile |
| Copier le code |
| # web/Dockerfile |
| FROM node:20-alpine |
| WORKDIR /web |
| COPY package.json package-lock.json* ./ |
| RUN npm install |
| COPY . . |
| EXPOSE 3000 |
| json |
| Copier le code |
| // web/package.json |
| { |
| "name": "zone-ai-web", |
| "private": true, |
| "version": "1.0.0", |
| "scripts": { |
| "dev": "next dev", |
| "build": "next build", |
| "start": "next start" |
| }, |
| "dependencies": { |
| "next": "15.0.3", |
| "react": "19.0.0", |
| "react-dom": "19.0.0" |
| }, |
| "devDependencies": { |
| "typescript": "5.6.3", |
| "tailwindcss": "3.4.14", |
| "postcss": "8.4.47", |
| "autoprefixer": "10.4.20" |
| } |
| } |
| js |
| Copier le code |
| // web/next.config.js |
| module.exports = { |
| reactStrictMode: true |
| }; |
| js |
| Copier le code |
| // web/tailwind.config.js |
| module.exports = { |
| content: ["./src/**/*.{ts,tsx}"], |
| theme: { extend: {} }, |
| plugins: [] |
| }; |
| js |
| Copier le code |
| // web/postcss.config.js |
| module.exports = { |
| plugins: { |
| tailwindcss: {}, |
| autoprefixer: {} |
| } |
| }; |
| json |
| Copier le code |
| // web/tsconfig.json |
| { |
| "compilerOptions": { |
| "target": "ES2022", |
| "lib": ["dom", "dom.iterable", "es2022"], |
| "allowJs": false, |
| "skipLibCheck": true, |
| "strict": true, |
| "noEmit": true, |
| "module": "esnext", |
| "moduleResolution": "bundler", |
| "resolveJsonModule": true, |
| "isolatedModules": true, |
| "jsx": "preserve", |
| "types": ["node"] |
| }, |
| "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"] |
| } |
| css |
| Copier le code |
| /* web/src/styles/globals.css */ |
| @tailwind base; |
| @tailwind components; |
| @tailwind utilities; |
|
|
| html, body { height: 100%; } |
| ts |
| Copier le code |
| // web/src/lib/types.ts |
| export type JobType = "chat" | "image" | "video"; |
| export type JobStatus = "queued" | "running" | "done" | "error"; |
|
|
| export type Job = { |
| id: number; |
| type: JobType; |
| status: JobStatus; |
| prompt: string; |
| params_json: string; |
| result_asset_id: number | null; |
| error: string | null; |
| }; |
|
|
| export type Asset = { |
| id: number; |
| kind: string; |
| mime: string; |
| public_url: string; |
| }; |
| ts |
| Copier le code |
| // web/src/lib/auth.ts |
| export const tokenKey = "zoneai_token"; |
|
|
| export function getToken(): string | null { |
| if (typeof window === "undefined") return null; |
| return localStorage.getItem(tokenKey); |
| } |
|
|
| export function setToken(token: string) { |
| localStorage.setItem(tokenKey, token); |
| } |
|
|
| export function clearToken() { |
| localStorage.removeItem(tokenKey); |
| } |
| ts |
| Copier le code |
| // web/src/lib/api.ts |
| import { getToken } from "./auth"; |
| import type { Job, Asset, JobType } from "./types"; |
|
|
| const API = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8080"; |
|
|
| async function req(path: string, opts: RequestInit = {}) { |
| const token = getToken(); |
| const headers: any = { ...(opts.headers || {}) }; |
| if (token) headers["Authorization"] = `Bearer ${token}`; |
| if (!headers["Content-Type"] && opts.body) headers["Content-Type"] = "application/json"; |
| const r = await fetch(`${API}${path}`, { ...opts, headers }); |
| const data = await r.json().catch(() => ({})); |
| if (!r.ok) throw new Error(data?.detail || "Request failed"); |
| return data; |
| } |
|
|
| export async function register(email: string, password: string) { |
| return req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }); |
| } |
| export async function login(email: string, password: string) { |
| return req("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }); |
| } |
| export async function me() { |
| return req("/auth/me", { method: "GET" }); |
| } |
|
|
| export async function createJob(type: JobType, prompt: string, params: any = {}) { |
| return req("/jobs", { method: "POST", body: JSON.stringify({ type, prompt, params }) }) as Promise<Job>; |
| } |
| export async function getJob(jobId: number) { |
| return req(`/jobs/${jobId}`, { method: "GET" }) as Promise<Job>; |
| } |
| export async function getAsset(assetId: number) { |
| return req(`/assets/${assetId}`, { method: "GET" }) as Promise<Asset>; |
| } |
| export async function chat(prompt: string) { |
| return req("/chat", { method: "POST", body: JSON.stringify({ type: "chat", prompt, params: {} }) }); |
| } |
| tsx |
| Copier le code |
| // web/src/app/layout.tsx |
| import "../styles/globals.css"; |
| import React from "react"; |
|
|
| export default function RootLayout({ children }: { children: React.ReactNode }) { |
| return ( |
| <html lang="fr"> |
| <body className="h-screen w-screen overflow-hidden bg-neutral-950 text-neutral-100"> |
| {children} |
| </body> |
| </html> |
| ); |
| } |
| tsx |
| Copier le code |
| // web/src/app/page.tsx |
| "use client"; |
|
|
| import React, { useEffect, useMemo, useState } from "react"; |
| import { clearToken, getToken, setToken } from "../lib/auth"; |
| import { chat, createJob, getAsset, getJob, login, register } from "../lib/api"; |
| import type { Asset, Job } from "../lib/types"; |
|
|
| export default function Page() { |
| const [email, setEmail] = useState("admin@zoneai.local"); |
| const [password, setPassword] = useState("admin1234"); |
| const [authReady, setAuthReady] = useState(false); |
|
|
| const [prompt, setPrompt] = useState(""); |
| const [chatLog, setChatLog] = useState<{ role: string; text: string }[]>([]); |
| const [jobs, setJobs] = useState<Job[]>([]); |
| const [asset, setAssetState] = useState<Asset | null>(null); |
|
|
| const authed = useMemo(() => !!getToken(), [authReady]); |
|
|
| useEffect(() => { setAuthReady(true); }, []); |
|
|
| async function doRegister() { |
| const r = await register(email, password); |
| setToken(r.token); |
| setAuthReady(x => !x); |
| } |
| async function doLogin() { |
| const r = await login(email, password); |
| setToken(r.token); |
| setAuthReady(x => !x); |
| } |
| function doLogout() { |
| clearToken(); |
| setAuthReady(x => !x); |
| } |
|
|
| async function sendChat() { |
| if (!prompt.trim()) return; |
| const p = prompt.trim(); |
| setPrompt(""); |
| setChatLog(l => [...l, { role: "you", text: p }]); |
| const r = await chat(p); |
| if (r?.text) setChatLog(l => [...l, { role: "rosalinda", text: r.text }]); |
| } |
|
|
| async function genImage() { |
| const p = prompt.trim(); |
| if (!p) return; |
| const j = await createJob("image", p, { width: 1024, height: 1024, steps: 30, guidance: 6.5 }); |
| setJobs(x => [j, ...x]); |
| pollJob(j.id); |
| } |
|
|
| async function genVideoFromLastImage() { |
| if (!asset?.public_url) return; |
| const j = await createJob("video", "video_from_image", { |
| image_url: asset.public_url, |
| fps: 12, |
| frames: 25, |
| motion_bucket_id: 127 |
| }); |
| setJobs(x => [j, ...x]); |
| pollJob(j.id); |
| } |
|
|
| async function pollJob(id: number) { |
| for (let i = 0; i < 120; i++) { |
| const j = await getJob(id); |
| setJobs(prev => prev.map(x => (x.id === j.id ? j : x))); |
| if (j.status === "done" && j.result_asset_id) { |
| const a = await getAsset(j.result_asset_id); |
| setAssetState(a); |
| return; |
| } |
| if (j.status === "error") return; |
| await new Promise(r => setTimeout(r, 1000)); |
| } |
| } |
|
|
| return ( |
| <div className="h-full w-full grid grid-cols-[260px_1fr_520px]"> |
| <aside className="border-r border-neutral-800 p-4 flex flex-col gap-3"> |
| <div className="text-xl font-semibold">Zone AI — Rosalinda</div> |
| <div className="text-xs text-neutral-400">Compte + sécurité + projets</div> |
|
|
| <div className="mt-2 flex flex-col gap-2"> |
| <input className="bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-sm" |
| value={email} onChange={e => setEmail(e.target.value)} placeholder="email" /> |
| <input className="bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-sm" |
| value={password} onChange={e => setPassword(e.target.value)} placeholder="password" type="password" /> |
| <div className="flex gap-2"> |
| <button className="bg-white text-black rounded px-3 py-2 text-sm w-full" onClick={doRegister}>Register</button> |
| <button className="bg-white text-black rounded px-3 py-2 text-sm w-full" onClick={doLogin}>Login</button> |
| </div> |
| <button className="bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-sm" onClick={doLogout}>Logout</button> |
| </div> |
|
|
| <div className="mt-4 text-xs text-neutral-400">Actions</div> |
| <button className="bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-sm" onClick={genImage} disabled={!authed}> |
| Générer Image |
| </button> |
| <button className="bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-sm" onClick={genVideoFromLastImage} disabled={!authed || !asset}> |
| Générer Vidéo (depuis image) |
| </button> |
|
|
| <div className="mt-auto text-xs text-neutral-500">API: http://localhost:8080</div> |
| </aside> |
|
|
| <main className="p-4 flex flex-col gap-3"> |
| <div className="text-sm text-neutral-300">Chat</div> |
| <div className="flex-1 rounded border border-neutral-800 bg-neutral-950 p-3 overflow-auto"> |
| {chatLog.map((m, idx) => ( |
| <div key={idx} className="mb-3"> |
| <div className="text-xs text-neutral-500">{m.role}</div> |
| <div className="text-sm whitespace-pre-wrap">{m.text}</div> |
| </div> |
| ))} |
| </div> |
| <div className="rounded border border-neutral-800 bg-neutral-950 p-2 flex gap-2 items-center"> |
| <input |
| className="flex-1 bg-transparent outline-none px-2 text-sm" |
| value={prompt} |
| onChange={e => setPrompt(e.target.value)} |
| placeholder="Écris une demande… (image / vidéo / question)" |
| onKeyDown={(e) => { if (e.key === "Enter") sendChat(); }} |
| /> |
| <button className="bg-white text-black rounded px-4 py-2 text-sm" onClick={sendChat} disabled={!authed}> |
| Envoyer |
| </button> |
| </div> |
| </main> |
|
|
| <section className="border-l border-neutral-800 p-4 flex flex-col gap-3"> |
| <div className="text-sm text-neutral-300">Studio</div> |
| <div className="flex-1 rounded border border-neutral-800 bg-neutral-950 p-3 overflow-auto"> |
| {asset?.kind === "image" && <img src={asset.public_url} className="w-full rounded" />} |
| {asset?.kind === "video" && ( |
| <video src={asset.public_url} className="w-full rounded" controls /> |
| )} |
| {!asset && <div className="text-sm text-neutral-500">Aucun rendu pour l’instant.</div>} |
| </div> |
| <div className="text-sm text-neutral-300">Jobs</div> |
| <div className="rounded border border-neutral-800 bg-neutral-950 p-3 max-h-[260px] overflow-auto"> |
| {jobs.map(j => ( |
| <div key={j.id} className="py-2 border-b border-neutral-900"> |
| <div className="text-xs text-neutral-500">#{j.id} {j.type}</div> |
| <div className="text-sm">{j.status}</div> |
| {j.error && <div className="text-xs text-red-400">{j.error}</div>} |
| </div> |
| ))} |
| {jobs.length === 0 && <div className="text-sm text-neutral-500">Aucun job.</div>} |
| </div> |
| </section> |
| </div> |
| ); |
| } |
| ts |
| Copier le code |
| // web/src/app/api/auth/login/route.ts |
| import { NextResponse } from "next/server"; |
| export async function POST(req: Request) { |
| const body = await req.json(); |
| const r = await fetch("http://api:8080/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); |
| const data = await r.json(); |
| return NextResponse.json(data, { status: r.status }); |
| } |
| ts |
| Copier le code |
| // web/src/app/api/auth/me/route.ts |
| import { NextResponse } from "next/server"; |
| export async function GET(req: Request) { |
| const auth = req.headers.get("authorization") || ""; |
| const r = await fetch("http://api:8080/auth/me", { headers: { authorization: auth } }); |
| const data = await r.json(); |
| return NextResponse.json(data, { status: r.status }); |
| } |
| ts |
| Copier le code |
| // web/src/app/api/chat/route.ts |
| import { NextResponse } from "next/server"; |
| export async function POST(req: Request) { |
| const auth = req.headers.get("authorization") || ""; |
| const body = await req.json(); |
| const r = await fetch("http://api:8080/chat", { method: "POST", headers: { "Content-Type": "application/json", authorization: auth }, body: JSON.stringify(body) }); |
| const data = await r.json(); |
| return NextResponse.json(data, { status: r.status }); |
| } |
| ts |
| Copier le code |
| // web/src/app/api/jobs/route.ts |
| import { NextResponse } from "next/server"; |
| export async function POST(req: Request) { |
| const auth = req.headers.get("authorization") || ""; |
| const body = await req.json(); |
| const r = await fetch("http://api:8080/jobs", { method: "POST", headers: { "Content-Type": "application/json", authorization: auth }, body: JSON.stringify(body) }); |
| const data = await r.json(); |
| return NextResponse.json(data, { status: r.status }); |
| } |
| ts |
| Copier le code |
| // web/src/app/api/assets/route.ts |
| import { NextResponse } from "next/server"; |
| export async function POST() { return NextResponse.json({ ok: true }); } |
| txt |
| Copier le code |
| # NOTE: For production, put a real reverse proxy and set NEXT_PUBLIC_API_BASE to the public API URL. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|