|
|
<!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. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|