Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ Topologie : API (`.venv-rocm`, STT GPU + RAG + LLM, port 8000) et service TTS
(`.venv-tts`, `backend/tts/server.py`, 127.0.0.1:8001, jamais expose). Les deux
se lancent par `deploy/start-natif.ps1` (logs dans `deploy/logs/`).

- **Front sur un serveur dedie** (Debian 13, toujours allume) : nginx + Traefik +
cloudflared y tournent ; Traefik joint l'API sur le PC du GPU
(`192.168.1.75:8000`, `API_HOST`), allume a la demande. PC eteint -> 502 sur
`/api/health` et le front annonce l'IA hors ligne. Procedure :
`docs/exploitation.md` section 10.

- **Pocket TTS ne doit jamais tourner dans `.venv-rocm`** : 27,95x temps reel
sous torch ROCm contre 0,70x sous torch CPU (meme phrase). D'ou le process
separe.
Expand Down
18 changes: 9 additions & 9 deletions deploy/docker-compose.expose.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
# PortFolio-AI - exposition publique.
#
# Internet -> Cloudflare -> cloudflared -> Traefik -> nginx (front statique)
# -> API NATIVE (host:8000)
# -> API NATIVE (PC GPU, 192.168.1.75:8000)
#
# L'API et Ollama tournent en natif sur l'hote (wheels ROCm win_amd64), pas en
# conteneur : Traefik les joint via host.docker.internal, declare dans le
# provider file. Aucun port publie sur l'hote par cette stack.
# Cette stack tourne sur le serveur front (Debian 13, toujours allume). L'API et Ollama
# tournent en natif sur le PC du GPU (wheels ROCm win_amd64), allume a la
# demande : Traefik les joint par le reseau local, declare dans le provider
# file. PC eteint -> /api repond 502 et le front annonce l'IA hors ligne.
# Aucun port publie sur l'hote par cette stack.
#
# Prerequis avant tout demarrage :
# 1. npm run build dans frontend/ AVEC NEXT_PUBLIC_API_URL=https://<domaine>/api
# 2. l'API lancee : python -m uvicorn backend.api.main:app --port 8000
# 1. npm run build dans frontend/ AVEC NEXT_PUBLIC_API_URL=https://<domaine>/api,
# puis frontend/out/ copie sur le serveur front (cf. docs/exploitation.md)
# 2. facultatif : l'API lancee sur le PC du GPU (API_HOST=192.168.1.75)
# 3. .env renseigne a la RACINE du depot (TRAEFIK_DOMAIN, CLOUDFLARE_TUNNEL_TOKEN)
# -> compose cherche deploy/.env par defaut : il FAUT lancer avec
# docker compose --env-file .env -f deploy/docker-compose.expose.yml up -d
Expand Down Expand Up @@ -71,9 +74,6 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/config:/etc/traefik/config:ro
# Necessaire pour joindre l'API native depuis le conteneur.
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
Expand Down
10 changes: 6 additions & 4 deletions deploy/start-natif.ps1
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Demarre les deux process natifs Windows du portfolio, detaches de la console :
# - TTS (Pocket TTS, .venv-tts, torch CPU) -> 127.0.0.1:8001, jamais expose
# - API (STT ROCm + RAG + LLM, .venv-rocm) -> 127.0.0.1:8000, joint par Traefik
# via host.docker.internal (Docker Desktop relaie vers la boucle locale,
# verifie) : jamais expose au reseau local, qui contournerait le rate-limit
# - API (STT ROCm + RAG + LLM, .venv-rocm) -> $env:API_HOST:8000 (defaut
# 127.0.0.1). En production, Traefik tourne sur le serveur front : API_HOST=192.168.1.75,
# et le pare-feu Windows ne doit laisser entrer que le serveur front sur le port 8000,
# sinon le reseau local contournerait le rate-limit (docs/exploitation.md).
# Le TTS vit dans son propre venv : sous le torch ROCm il mesure 27,95x temps
# reel contre 0,70x sous torch CPU.
# Usage : powershell -ExecutionPolicy Bypass -File deploy\start-natif.ps1
Expand All @@ -24,6 +25,7 @@ if (-not $env:CORS_ORIGINS) { $env:CORS_ORIGINS = "https://mathiscapart.xyz" }
$voixClonee = Join-Path $racine "backend\tts\voix\mathis.safetensors"
if (-not $env:TTS_VOIX -and (Test-Path $voixClonee)) { $env:TTS_VOIX = $voixClonee }
$env:TORCHDYNAMO_DISABLE = "1"
if (-not $env:API_HOST) { $env:API_HOST = "127.0.0.1" }

Start-Process -WindowStyle Hidden -WorkingDirectory $racine `
-FilePath (Join-Path $racine ".venv-tts\Scripts\python.exe") `
Expand All @@ -32,7 +34,7 @@ Start-Process -WindowStyle Hidden -WorkingDirectory $racine `

Start-Process -WindowStyle Hidden -WorkingDirectory $racine `
-FilePath (Join-Path $racine ".venv-rocm\Scripts\python.exe") `
-ArgumentList "-m uvicorn backend.api.main:app --host 127.0.0.1 --port 8000" `
-ArgumentList "-m uvicorn backend.api.main:app --host $env:API_HOST --port 8000" `
-RedirectStandardOutput "$logs\api.out.log" -RedirectStandardError "$logs\api.err.log"

Write-Output "TTS et API lances ; logs dans $logs"
17 changes: 14 additions & 3 deletions deploy/traefik/config/portfolio.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Provider file : l'API tourne en process Windows NATIF (wheels ROCm win_amd64),
# invisible au provider Docker de Traefik. On declare donc le service a la main.
# Provider file : l'API tourne en process Windows NATIF (wheels ROCm win_amd64)
# sur le PC du GPU, alors que Traefik tourne sur le serveur front (Debian, toujours
# allume). Invisible au provider Docker, le service est declare a la main.
#
# Le ROUTEUR, lui, est en label Docker sur le service traefik : compose y
# interpole ${TRAEFIK_DOMAIN}. Aucun templating Go ici -- le provider file ne
Expand All @@ -10,8 +11,18 @@ http:
portfolio-api:
loadBalancer:
servers:
- url: "http://host.docker.internal:8000"
# IP fixe du PC du GPU sur le reseau local.
- url: "http://192.168.1.75:8000"
passHostHeader: true
serversTransport: pc-gpu

serversTransports:
# Le PC du GPU est souvent eteint : sans delai court, Traefik attendrait
# 30 s avant de repondre 502, et le front annoncerait l'IA hors ligne en
# retard (il abandonne /health au bout de 5 s).
pc-gpu:
forwardingTimeouts:
dialTimeout: 2s

middlewares:
strip-api:
Expand Down
80 changes: 64 additions & 16 deletions docs/exploitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ racine du dépôt**, sous PowerShell sauf mention contraire.
## 1. Architecture

```
┌──────── serveur front (Debian 13, toujours allumé) ────────┐
Visiteur ──HTTPS/WSS──> Cloudflare ──tunnel──> cloudflared ─┐
v
Traefik (conteneur, port 80 interne)
│ │
/ (front) v v /api (préfixe retiré)
nginx (conteneur) API FastAPI (NATIF, 127.0.0.1:8000)
frontend/out/ │ │ │ │
/ (front) v v /api (préfixe retiré), réseau local
nginx (conteneur) ─────────┼──── PC du GPU (Windows, allumé à la demande) ───
frontend/out/ API FastAPI (NATIF, 192.168.1.75:8000)
│ │ │ │
│ │ │ └─> TTS Pocket (NATIF, 127.0.0.1:8001)
│ │ └─> Ollama (NATIF, :11434) : qwen3:8b + embeddings
│ └─> Qdrant (conteneur, 127.0.0.1:6333)
Expand All @@ -22,14 +24,19 @@ Visiteur ──HTTPS/WSS──> Cloudflare ──tunnel──> cloudflared ─

| Brique | Où | Démarrage automatique au reboot |
|---|---|---|
| Qdrant | conteneur `qdrant` (projet compose `lobster`) | oui (`unless-stopped`) |
| nginx, Traefik, cloudflared | conteneurs `portfolio-*` (projet `portfolio`) | oui (`unless-stopped`) |
| nginx, Traefik, cloudflared | **serveur front**, conteneurs `portfolio-*` (projet `portfolio`) | oui (`unless-stopped`) |
| Qdrant | PC du GPU, conteneur `qdrant` (projet compose `lobster`) | oui (`unless-stopped`) |
| Ollama | application Windows | oui (dossier Démarrage de la session) |
| API (STT + RAG + LLM) | process Python natif, `.venv-rocm` | **non** |
| TTS (voix clonée) | process Python natif, `.venv-tts` | **non** |

Les conteneurs ne redémarrent que si Docker Desktop est lancé, et Ollama qu'à
l'ouverture de la session Windows.
Sur le PC, les conteneurs ne redémarrent que si Docker Desktop est lancé, et
Ollama qu'à l'ouverture de la session Windows.

**PC éteint** : le serveur front continue de servir le site. `/api/health` répond 502
(Traefik abandonne la connexion au PC après 2 s), le front désactive le bouton
Parler et annonce l'assistant hors ligne avec un lien LinkedIn. Qdrant reste sur
le PC : une recherche a de toute façon besoin d'Ollama pour les embeddings.

### Pourquoi l'IA n'est pas en Docker

Expand Down Expand Up @@ -91,9 +98,11 @@ Dans l'ordre :
docker compose up -d qdrant

# 2. API + TTS natifs (tue d'abord ce qui écoute sur 8000/8001)
# API_HOST : l'API écoute sur l'IP locale pour que le serveur front la joigne
$env:API_HOST = "192.168.1.75"
powershell -ExecutionPolicy Bypass -File deploy\start-natif.ps1

# 3. Exposition : nginx, Traefik, cloudflared
# 3. Exposition, SUR LE SERVEUR FRONT (bash) : nginx, Traefik, cloudflared
# --env-file obligatoire : sinon compose cherche deploy/.env
docker compose --env-file .env -f deploy/docker-compose.expose.yml up -d
```
Expand All @@ -102,6 +111,7 @@ docker compose --env-file .env -f deploy/docker-compose.expose.yml up -d

| Variable | Valeur | Rôle |
|---|---|---|
| `API_HOST` | `127.0.0.1` (production : `192.168.1.75`) | interface d'écoute de l'API |
| `CORS_ORIGINS` | `https://mathiscapart.xyz` | seule origine acceptée sur `/voice` |
| `TTS_VOIX` | `backend\tts\voix\mathis.safetensors` si présent | voix clonée, sinon `estelle` |
| `TORCHDYNAMO_DISABLE` | `1` | Triton absent des wheels ROCm Windows |
Expand Down Expand Up @@ -147,15 +157,18 @@ Pour tout redémarrer, reprendre la section 3. `stop` conserve les conteneurs ;

### Front

nginx sert directement `frontend/out/` (montage) : **le build est la mise en
ligne**, sans redémarrage.
nginx (sur le serveur front) sert directement `frontend/out/` (montage) : le build se
fait sur le PC, **la copie sur le serveur front est la mise en ligne**, sans redémarrage.

```powershell
cd frontend
$env:NEXT_PUBLIC_API_URL = "https://mathiscapart.xyz/api"
npm run build
scp -r out <utilisateur>@<serveur front>:~/PortFolio-AI/frontend/
```

`scp` écrase les fichiers mais ne supprime pas ceux qui ont disparu du build.

Sans `NEXT_PUBLIC_API_URL`, le build échoue exprès (garde-fou de `VoiceChat.tsx`).

### API et TTS
Expand Down Expand Up @@ -215,7 +228,7 @@ docker restart portfolio-traefik-1
curl.exe -s -o NUL -w "site %{http_code}`n" https://mathiscapart.xyz/
curl.exe -s https://mathiscapart.xyz/api/health # {"status":"ok"}

# Services natifs : doivent écouter sur 127.0.0.1 uniquement
# Services natifs : 8000 sur 192.168.1.75, 8001 et 6333 sur 127.0.0.1
netstat -ano | findstr LISTENING | findstr ":8000 :8001 :6333 :11434"

# Conteneurs
Expand All @@ -232,7 +245,9 @@ Contrôles de sécurité attendus :
| `https://mathiscapart.xyz/api/docs` | 404 |
| en-tête `content-security-policy` sur `/` | présent |
| WebSocket `/voice` depuis une autre origine | refusé (403) |
| 6333 et 8000 depuis une autre machine du réseau | connexion refusée |
| 6333 depuis une autre machine du réseau | connexion refusée |
| 8000 depuis une machine du réseau autre que le serveur front | connexion refusée (pare-feu) |
| site avec le PC éteint | page servie, message « hors ligne » |

## 7. Journaux

Expand Down Expand Up @@ -265,10 +280,15 @@ Les journaux natifs sont écrasés à chaque lancement de `start-natif.ps1`.

## 9. Sécurité : l'essentiel

- **Tout ce qui est natif écoute sur `127.0.0.1`** : API (8000), TTS (8001),
Qdrant (6333). Traefik joint l'API via `host.docker.internal`, qui relaie vers
la boucle locale. Ne jamais revenir à `0.0.0.0` : Qdrant n'a pas de clé API
et l'API contournerait le rate-limit.
- **TTS (8001) et Qdrant (6333) écoutent sur `127.0.0.1`.** L'API (8000) écoute
sur `192.168.1.75` pour que Traefik la joigne depuis le serveur front : le pare-feu
Windows doit n'y laisser entrer **que le serveur front**, sinon une machine du réseau
local contournerait le rate-limit. Ne jamais passer Qdrant à `0.0.0.0` : il
n'a pas de clé API.
- **Attention aux règles « Python » du pare-feu** : Windows en a créé qui
autorisent `python.exe` en entrée sur tous les ports depuis n'importe où. Tant
qu'elles existent, une règle limitée au serveur front ne sert à rien (une règle
d'autorisation suffit à laisser passer). Cf. section 10.
- **Ollama écoute encore sur `0.0.0.0`** (`OLLAMA_HOST` au niveau machine) :
point ouvert de l'audit. Le passer à `127.0.0.1` le fermerait au réseau local
sans gêner les conteneurs.
Expand All @@ -282,3 +302,31 @@ Les journaux natifs sont écrasés à chaque lancement de `start-natif.ps1`.
l'historique en CI.
- **Dépendances** : Dependabot chaque semaine ; `pip-audit` et `npm audit` en CI.
Pour auditer les venvs de prod : `pip-audit --path .venv-rocm/Lib/site-packages`.

## 10. Mise en place du serveur front

Ordre à respecter : **le tunnel ne doit jamais tourner sur les deux machines à
la fois** avec le même jeton. Cloudflare répartirait les visiteurs entre les
deux connecteurs.

1. **Pare-feu du PC** (PowerShell administrateur). Désactiver les règles
génériques créées par Windows pour Python, puis n'autoriser que le serveur front :
```powershell
Get-NetFirewallApplicationFilter | Where-Object Program -match 'python' | Get-NetFirewallRule | Disable-NetFirewallRule
New-NetFirewallRule -DisplayName "PortFolio-AI API depuis le serveur front" -Direction Inbound `
-Protocol TCP -LocalPort 8000 -LocalAddress 192.168.1.75 -RemoteAddress <IP du serveur front> -Action Allow
```
2. **API sur l'IP locale** : `$env:API_HOST = "192.168.1.75"` puis `start-natif.ps1`.
Vérifier depuis le serveur front : `curl -s http://192.168.1.75:8000/health`.
3. **Sur le serveur front** : cloner le dépôt, créer `.env` à la racine (`TRAEFIK_DOMAIN`,
`CLOUDFLARE_TUNNEL_TOKEN`, repris du PC), copier `frontend/out/` (section 5).
4. **Couper l'ancienne stack sur le PC** :
`docker compose --env-file .env -f deploy/docker-compose.expose.yml down`.
5. **Démarrer la stack sur le serveur front** :
`docker compose --env-file .env -f deploy/docker-compose.expose.yml up -d`.
6. **Vérifier** (section 6), puis éteindre l'API sur le PC et vérifier que le
site reste en ligne avec le message « hors ligne ».

Sur Debian, le `watch` du provider file de Traefik fonctionne probablement (la
limite constatée venait du montage Windows) : non vérifié, redémarrer Traefik
reste la méthode sûre.
5 changes: 5 additions & 0 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ Contrat WebSocket `/voice` : le navigateur envoie des trames PCM16 mono 24 kHz
de 80 ms puis `{"type":"end"}` ; le serveur renvoie `transcript`, `token`, des
trames audio PCM16 24 kHz, puis `sources` (bloc terminal) ou `error`.

Au chargement, la page interroge `GET /health` (délai 5 s). Sans réponse `ok`
(PC du GPU éteint, proxy en 502/504, Qdrant ou Ollama absent), le bouton Parler
est désactivé et un message annonce l'assistant hors ligne, avec un renvoi vers
le parcours écrit et LinkedIn. Le front, lui, reste toujours en ligne.

Points propres aux navigateurs mobiles, tous couverts par des tests :

- Les AudioContext sont créés et repris **dans le geste** de l'utilisateur,
Expand Down
39 changes: 38 additions & 1 deletion frontend/components/VoiceChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
analyserMessageVoix,
type SourceCitee,
} from "../lib/voix";
import { LIENS } from "../lib/site";

// Contrat WebSocket de /voice (imposé, ne pas modifier — cf. CLAUDE.md) :
// client -> serveur : trames BINAIRES PCM16 mono 24000 Hz, tranches de 80 ms
Expand All @@ -31,6 +32,10 @@ const API_URL =

const WS_URL = API_URL.replace(/^http/, "ws") + "/voice";

// Le GPU est sur un PC allumé à la demande, le front reste en ligne : sans
// réponse de /health dans ce délai, l'assistant est annoncé hors ligne.
const DELAI_SANTE_MS = 5000;

async function chargerWorklet(contexte: AudioContext) {
const url = URL.createObjectURL(new Blob([CODE_WORKLET_CAPTURE], { type: "application/javascript" }));
try {
Expand Down Expand Up @@ -76,6 +81,7 @@ export default function VoiceChat() {
const [tours, setTours] = useState<Tour[]>([]);
const [erreur, setErreur] = useState<string | null>(null);
const [analyseur, setAnalyseur] = useState<AnalyserNode | null>(null);
const [horsLigne, setHorsLigne] = useState(false);

const wsRef = useRef<WebSocket | null>(null);
const ctxCaptureRef = useRef<AudioContext | null>(null);
Expand Down Expand Up @@ -128,6 +134,22 @@ export default function VoiceChat() {
[]
);

useEffect(() => {
let monte = true;
const controleur = new AbortController();
const minuterie = setTimeout(() => controleur.abort(), DELAI_SANTE_MS);
fetch(`${API_URL}/health`, { signal: controleur.signal, cache: "no-store" })
.then((r) => r.ok)
.catch(() => false)
.then((ok) => monte && setHorsLigne(!ok))
.finally(() => clearTimeout(minuterie));
return () => {
monte = false;
clearTimeout(minuterie);
controleur.abort();
};
}, []);

function majTourCourant(maj: (t: Tour) => Tour) {
setTours((liste) => (liste.length ? [maj(liste[0]), ...liste.slice(1)] : liste));
}
Expand Down Expand Up @@ -335,6 +357,7 @@ export default function VoiceChat() {
else if (etat === "connexion") bouton = { libelle: "Connexion…", action: () => {}, variante: "visiteur", desactive: true };
else if (etat === "reflexion" || etat === "reponse") bouton = { libelle: "Arrêter", action: arreter, variante: "assistant" };
else bouton = { libelle: etat === "repos" ? "Parler" : "Réessayer de parler", action: demarrer, variante: "repos" };
if (horsLigne && etat === "repos") bouton.desactive = true;
actionRef.current = bouton.desactive ? () => {} : bouton.action;

const voix = etat === "reponse" || etat === "reflexion" ? "assistant" : "visiteur";
Expand All @@ -354,10 +377,24 @@ export default function VoiceChat() {
{bouton.libelle}
</button>
<p className="statut" aria-live="polite">
{STATUTS[etat]}
{horsLigne && etat === "repos" ? "" : STATUTS[etat]}
</p>
</div>

{horsLigne && etat === "repos" && (
<p className="alerte" role="status">
L&apos;assistant vocal est hors ligne. Il tourne sur une carte graphique
personnelle, allumée seulement par moments : le garder disponible en
permanence coûterait trop cher en ressources et en électricité. Le{" "}
<a href="/parcours/">parcours écrit</a> reste consultable, et vous pouvez
me contacter sur{" "}
<a href={LIENS.linkedin} target="_blank" rel="noopener noreferrer">
LinkedIn
</a>
.
</p>
)}

{etat === "refus-micro" && (
<p className="alerte" role="alert">
Le micro est bloqué. Autorisez-le depuis l&apos;icône à gauche de la barre
Expand Down
Loading