27 Commits

Author SHA1 Message Date
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
179 changed files with 7625 additions and 1084 deletions

View File

@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini
# --- Mises a jour automatiques (Watchtower) ------------------------------
# Watchtower verifie les nouvelles versions de core/brain/web et permet
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
# MinIO sont exclus volontairement.
#
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
# COMPOSE_PROFILES=autoupdate
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
# WATCHTOWER_SCHEDULE=0 0 4 * * *
# TZ=Europe/Paris

95
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,95 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Create .env for stack
run: |
cat > .env <<'EOF'
POSTGRES_PASSWORD=ci-postgres-pass
BRAIN_INTERNAL_SECRET=ci-brain-secret
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ci-admin-pass
WEB_PORT=8081
LLM_PROVIDER=ollama
EOF
- name: Build & start stack
run: |
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
- name: Attach runner to compose network
run: |
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
if [ -z "$NET" ]; then
echo "Compose network not found" >&2
docker network ls
exit 1
fi
echo "Connecting $(hostname) to network $NET"
docker network connect "$NET" "$(hostname)"
- name: Wait for web to be ready
run: |
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
- name: Install web deps
working-directory: web
run: npm ci
- name: Work around runner clock skew for apt
run: |
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
Acquire::Check-Valid-Until "false";
Acquire::Check-Date "false";
EOF
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: web
env:
E2E_BASE_URL: http://web
CI: 'true'
run: npm run e2e
- name: Dump container logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v

View File

@@ -6,8 +6,10 @@ on:
- 'v*'
env:
REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64
GITEA_REGISTRY: git.igmlcreation.fr
GITEA_REGISTRY_USER: ietm64
GHCR_REGISTRY: ghcr.io
GHCR_NAMESPACE: igmlcreation
jobs:
build:
@@ -26,19 +28,39 @@ jobs:
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
# Login to GHCR (GitHub Container Registry) pour distribuer les images
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Extract version
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# Push vers les deux registries en un seul build (build-push-action
# accepte une liste de tags ; aucun build supplementaire necessaire).
# Naming :
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
# dans leur .env.
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
# la collision avec d'autres projets de l'org.
- name: Build & push ${{ matrix.component }}
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}

6
.gitignore vendored
View File

@@ -53,6 +53,12 @@ yarn-error.log*
.pnpm-debug.log*
coverage/
# Playwright (E2E)
web/test-results/
web/playwright-report/
web/blob-report/
web/playwright/.cache/
# ============================================================================
# IDE / Editeurs
# ============================================================================

View File

@@ -61,7 +61,16 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
if response.status_code >= 400:
body = response.text
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
if response.status_code >= 400:
# On lit le body d'erreur pour le remonter a l'utilisateur,
# sinon on ne voit que "500 Internal Server Error" sans
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
# num_ctx trop grand pour la VRAM, etc.).
body = (await response.aread()).decode("utf-8", errors="replace")
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
async for line in response.aiter_lines():
if not line.strip():
continue

View File

@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.6.1",
version="0.6.6",
)
@@ -689,6 +689,76 @@ async def get_ollama_model_info(
return OllamaModelInfoDTO(context_length=0)
@app.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@app.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.6.1</version>
<version>0.6.14</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -36,11 +36,16 @@ public class ArcService {
public record DeletionImpact(int chapters, int scenes) {}
public Arc createArc(String name, String description, String campaignId, int order) {
return createArc(name, description, campaignId, order, null);
}
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
Arc arc = Arc.builder()
.name(name)
.description(description)
.campaignId(campaignId)
.order(order)
.icon(icon)
.build();
return arcRepository.save(arc);
}

View File

@@ -30,11 +30,16 @@ public class ChapterService {
public record DeletionImpact(int scenes) {}
public Chapter createChapter(String name, String description, String arcId, int order) {
return createChapter(name, description, arcId, order, null);
}
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
Chapter chapter = Chapter.builder()
.name(name)
.description(description)
.arcId(arcId)
.order(order)
.icon(icon)
.build();
return chapterRepository.save(chapter);
}

View File

@@ -26,11 +26,16 @@ public class SceneService {
}
public Scene createScene(String name, String description, String chapterId, int order) {
return createScene(name, description, chapterId, order, null);
}
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
Scene scene = Scene.builder()
.name(name)
.description(description)
.chapterId(chapterId)
.order(order)
.icon(icon)
.build();
return sceneRepository.save(scene);
}
@@ -93,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet());
for (SceneBranch b : branches) {
String target = b.getTargetSceneId();
String target = b.targetSceneId();
if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
}

View File

@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
Map<String, String> filtered = filterByIntent(allSections, intent);
return GameSystemContext.builder()
.systemName(gs.getName())
.systemDescription(gs.getDescription())
.sections(filtered)
.build();
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
}
/**

View File

@@ -79,12 +79,11 @@ public class CampaignStructuralContextBuilder {
.map(this::toCharacterSummary)
.collect(Collectors.toList());
return CampaignStructuralContext.builder()
.campaignName(campaign.getName())
.campaignDescription(campaign.getDescription())
.arcs(arcs)
.characters(characters)
.build();
return new CampaignStructuralContext(
campaign.getName(),
campaign.getDescription(),
arcs,
characters);
}
/**
@@ -93,10 +92,7 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
return CharacterSummary.builder()
.name(c.getName())
.snippet(extractSnippet(c.getMarkdownContent()))
.build();
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
}
private static String extractSnippet(String markdown) {
@@ -115,12 +111,11 @@ public class CampaignStructuralContextBuilder {
.sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary)
.collect(Collectors.toList());
return ArcSummary.builder()
.name(arc.getName())
.description(arc.getDescription())
.illustrationCount(countImages(arc.getIllustrationImageIds()))
.chapters(chapters)
.build();
return new ArcSummary(
arc.getName(),
arc.getDescription(),
countImages(arc.getIllustrationImageIds()),
chapters);
}
private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -137,32 +132,28 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList());
return ChapterSummary.builder()
.name(chapter.getName())
.description(chapter.getDescription())
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
.scenes(summaries)
.build();
return new ChapterSummary(
chapter.getName(),
chapter.getDescription(),
countImages(chapter.getIllustrationImageIds()),
summaries);
}
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
List<BranchHint> hints = scene.getBranches() == null
? List.of()
: scene.getBranches().stream()
.map(b -> BranchHint.builder()
.label(b.getLabel())
.targetSceneName(nameById.getOrDefault(
b.getTargetSceneId(), "(scène inconnue)"))
.condition(b.getCondition())
.build())
.map(b -> new BranchHint(
b.label(),
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
b.condition()))
.collect(Collectors.toList());
return SceneSummary.builder()
.name(scene.getName())
.description(scene.getDescription())
.illustrationCount(countImages(scene.getIllustrationImageIds()))
.branches(hints)
.build();
return new SceneSummary(
scene.getName(),
scene.getDescription(),
countImages(scene.getIllustrationImageIds()),
hints);
}
/** Helper defensif : compte les illustrations attachees (null-safe). */

View File

@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template);
GenerationContext context = GenerationContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folderName(folder.getName())
.templateName(template.getName())
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
// necessitent un workflow different (pas de generation LLM texte).
.templateFields(template.textFieldNames())
.pageTitle(page.getTitle())
.build();
GenerationContext context = new GenerationContext(
lore.getName(),
lore.getDescription(),
folder.getName(),
template.getName(),
template.textFieldNames(),
page.getTitle());
GenerationResult result = aiProvider.generatePage(context);
return result.values();

View File

@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map<String, String> pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
return LoreStructuralContext.builder()
.loreName(lore.getName())
.loreDescription(lore.getDescription())
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
.tags(extractUniqueTags(pages))
.build();
return new LoreStructuralContext(
lore.getName(),
lore.getDescription(),
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
extractUniqueTags(pages));
}
private Map<String, List<PageSummary>> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page,
Map<String, String> templateNameById,
Map<String, String> pageTitleById) {
return PageSummary.builder()
.title(page.getTitle())
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
.values(truncatedValues(page.getValues()))
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
.build();
return new PageSummary(
page.getTitle(),
templateNameById.getOrDefault(page.getTemplateId(), "?"),
truncatedValues(page.getValues()),
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
}
/**

View File

@@ -91,11 +91,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes());
return NarrativeEntityContext.builder()
.entityType("arc")
.title(a.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("arc", a.getName(), fields);
}
private NarrativeEntityContext fromChapter(Chapter c) {
@@ -104,11 +100,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes());
return NarrativeEntityContext.builder()
.entityType("chapter")
.title(c.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("chapter", c.getName(), fields);
}
private NarrativeEntityContext fromScene(Scene s) {
@@ -122,21 +114,13 @@ public class NarrativeEntityContextBuilder {
putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return NarrativeEntityContext.builder()
.entityType("scene")
.title(s.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("scene", s.getName(), fields);
}
private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
return NarrativeEntityContext.builder()
.entityType("character")
.title(c.getName())
.fields(fields)
.build();
return new NarrativeEntityContext("character", c.getName(), fields);
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */

View File

@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues()
: Collections.emptyMap();
return PageContext.builder()
.title(page.getTitle())
.templateName(templateName)
.templateFields(templateFields)
.values(values)
.build();
return new PageContext(page.getTitle(), templateName, templateFields, values);
}
}

View File

@@ -21,6 +21,9 @@ public class Arc {
private String campaignId; // Référence vers la Campaign parente
private int order; // Ordre de l'arc dans la campagne
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String themes; // Thèmes principaux explorés dans cet arc
private String stakes; // Enjeux globaux pour les personnages

View File

@@ -21,6 +21,9 @@ public class Chapter {
private String arcId; // Référence vers l'Arc parent
private int order; // Ordre du chapitre dans l'arc
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
private String playerObjectives; // Objectifs des joueurs dans ce chapitre

View File

@@ -21,6 +21,9 @@ public class Scene {
private String chapterId; // Référence vers le Chapter parent
private int order; // Ordre de la scène dans le chapitre
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// === Contexte et ambiance ===
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
private String timing; // Moment (ex: Soir, à la tombée de la nuit)

View File

@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
/**
* Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée.
* <p>
* Immuable (@Value) : pour "modifier" une branche on la remplace.
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
* Record Java : immuable par construction, sans aucune dépendance technique
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
* les records nativement via le constructeur canonique — c'est ce dont
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
* <p>
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService).
*
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
*/
@Value
@Builder
@Jacksonized
public class SceneBranch {
public record SceneBranch(String label, String targetSceneId, String condition) {
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
String label;
/** Id de la Scene de destination, intra-chapitre uniquement. */
String targetSceneId;
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
String condition;
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
public static SceneBranch of(String label, String targetSceneId) {
return new SceneBranch(label, targetSceneId, null);
}
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
@@ -22,16 +18,16 @@ import java.util.List;
* <p>
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
*/
@Value
@Builder
public class CampaignStructuralContext {
String campaignName;
String campaignDescription;
@Singular List<ArcSummary> arcs;
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
@Singular List<CharacterSummary> characters;
public record CampaignStructuralContext(
String campaignName,
String campaignDescription,
List<ArcSummary> arcs,
List<CharacterSummary> characters) {
/**
* Résumé d'un PJ : nom + snippet court du markdown.
@@ -40,53 +36,44 @@ public class CampaignStructuralContext {
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
@Value
@Builder
public static class CharacterSummary {
String name;
String snippet;
public record CharacterSummary(String name, String snippet) {
}
/** Résumé d'un arc : nom + description courte + ses chapitres. */
@Value
@Builder
public static class ArcSummary {
String name;
String description;
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
int illustrationCount;
@Singular List<ChapterSummary> chapters;
/**
* Résumé d'un arc : nom + description courte + ses chapitres.
*
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/
public record ArcSummary(
String name,
String description,
int illustrationCount,
List<ChapterSummary> chapters) {
}
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
@Value
@Builder
public static class ChapterSummary {
String name;
String description;
int illustrationCount;
@Singular List<SceneSummary> scenes;
public record ChapterSummary(
String name,
String description,
int illustrationCount,
List<SceneSummary> scenes) {
}
/** Résumé d'une scène : nom + description courte + branches narratives. */
@Value
@Builder
public static class SceneSummary {
String name;
String description;
int illustrationCount;
@Singular List<BranchHint> branches;
public record SceneSummary(
String name,
String description,
int illustrationCount,
List<BranchHint> branches) {
}
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
@Value
@Builder
public static class BranchHint {
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
String label;
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
String targetSceneName;
/** Condition MJ privée (optionnel). */
String condition;
/**
* Indice d'une branche narrative vers une autre scène du même chapitre.
*
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
* @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
* @param condition Condition MJ privée (optionnel).
*/
public record BranchHint(String label, String targetSceneName, String condition) {
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
@@ -21,28 +18,74 @@ import java.util.List;
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse).
*/
@Value
@Builder
public class ChatRequest {
List<ChatMessage> messages;
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
LoreStructuralContext loreContext;
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
PageContext pageContext;
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
CampaignStructuralContext campaignContext;
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
NarrativeEntityContext narrativeEntity;
/**
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* <p>
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
* qu'un constructeur à 6 paramètres souvent à null.
*
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
GameSystemContext gameSystemContext;
public record ChatRequest(
List<ChatMessage> messages,
LoreStructuralContext loreContext,
PageContext pageContext,
CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext) {
public static Builder builder() {
return new Builder();
}
/** Builder fluide : permet d'omettre les contextes non pertinents. */
public static final class Builder {
private List<ChatMessage> messages;
private LoreStructuralContext loreContext;
private PageContext pageContext;
private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext;
private Builder() {}
public Builder messages(List<ChatMessage> messages) {
this.messages = messages;
return this;
}
public Builder loreContext(LoreStructuralContext loreContext) {
this.loreContext = loreContext;
return this;
}
public Builder pageContext(PageContext pageContext) {
this.pageContext = pageContext;
return this;
}
public Builder campaignContext(CampaignStructuralContext campaignContext) {
this.campaignContext = campaignContext;
return this;
}
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
this.narrativeEntity = narrativeEntity;
return this;
}
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
this.gameSystemContext = gameSystemContext;
return this;
}
public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext);
}
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
@@ -11,20 +8,14 @@ import java.util.Map;
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
*/
@Value
@Builder
public class GameSystemContext {
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
String systemName;
/** Description courte du système (nullable). */
String systemDescription;
/**
* Sections de règles pertinentes, indexées par titre H2.
*
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
* @param systemDescription Description courte du système (nullable).
* @param sections Sections de règles pertinentes, indexées par titre H2.
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
Map<String, String> sections;
public record GameSystemContext(
String systemName,
String systemDescription,
Map<String, String> sections) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
/**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template.
* <p>
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
* Entité pure du domaine : aucune dépendance technique.
* <p>
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
* C'est un DTO de domaine entrant dans le port AiProvider.
* Record Java : pur domaine, aucune dépendance technique.
*
* @param templateFields Champs à générer (clés attendues dans la réponse).
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/
@Value
@Builder
public class GenerationContext {
String loreName;
String loreDescription;
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
String templateName;
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
String pageTitle;
public record GenerationContext(
String loreName,
String loreDescription,
String folderName,
String templateName,
List<String> templateFields,
String pageTitle) {
}

View File

@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
* <p>
* La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
* <p>
* Record Java : pur domaine, aucune dépendance technique.
*/
@Value
@Builder
public class LoreStructuralContext {
String loreName;
String loreDescription;
Map<String, List<PageSummary>> folders;
@Singular List<String> tags;
public record LoreStructuralContext(
String loreName,
String loreDescription,
Map<String, List<PageSummary>> folders,
List<String> tags) {
/**
* Résumé projeté d'une page pour l'IA.
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
* uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition).
*/
@Value
@Builder
public static class PageSummary {
String title;
String templateName;
Map<String, String> values;
List<String> tags;
List<String> relatedPageTitles;
public record PageSummary(
String title,
String templateName,
Map<String, String> values,
List<String> tags,
List<String> relatedPageTitles) {
}
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.Map;
/**
@@ -17,13 +14,11 @@ import java.util.Map;
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
*
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/
@Value
@Builder
public class NarrativeEntityContext {
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
String entityType;
String title;
Map<String, String> fields;
public record NarrativeEntityContext(
String entityType,
String title,
Map<String, String> fields) {
}

View File

@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
import lombok.Builder;
import lombok.Value;
import java.util.List;
import java.util.Map;
@@ -14,14 +11,11 @@ import java.util.Map;
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates.
* <p>
* Object de valeur immuable, pur domaine aucune dépendance technique.
* Record Java : immuable, pur domaine, aucune dépendance technique.
*/
@Value
@Builder
public class PageContext {
String title;
String templateName;
List<String> templateFields;
Map<String, String> values;
public record PageContext(
String title,
String templateName,
List<String> templateFields,
Map<String, String> values) {
}

View File

@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest(
context.getLoreName(),
context.getLoreDescription(),
context.getFolderName(),
context.getTemplateName(),
context.getTemplateFields(),
context.getPageTitle()
context.loreName(),
context.loreDescription(),
context.folderName(),
context.templateName(),
context.templateFields(),
context.pageTitle()
);
}

View File

@@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder {
public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.getMessages().stream()
root.put("messages", request.messages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
if (request.getLoreContext() != null) {
root.put("lore_context", loreContextToMap(request.getLoreContext()));
if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.loreContext()));
}
if (request.getPageContext() != null) {
root.put("page_context", pageContextToMap(request.getPageContext()));
if (request.pageContext() != null) {
root.put("page_context", pageContextToMap(request.pageContext()));
}
if (request.getCampaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
if (request.campaignContext() != null) {
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
}
if (request.getNarrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
if (request.narrativeEntity() != null) {
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
}
if (request.getGameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
}
return root;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.getSystemName());
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
map.put("system_description", gs.getSystemDescription());
map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.systemDescription());
}
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
@@ -79,56 +79,56 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("lore_name", ctx.getLoreName());
map.put("lore_description", ctx.getLoreDescription());
map.put("lore_name", ctx.loreName());
map.put("lore_description", ctx.loreDescription());
Map<String, Object> foldersMap = new LinkedHashMap<>();
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
map.put("tags", ctx.getTags());
map.put("tags", ctx.tags());
return map;
}
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.getTitle());
map.put("template_name", ps.getTemplateName());
map.put("title", ps.title());
map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
map.put("values", ps.getValues());
if (ps.values() != null && !ps.values().isEmpty()) {
map.put("values", ps.values());
}
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
map.put("tags", ps.getTags());
if (ps.tags() != null && !ps.tags().isEmpty()) {
map.put("tags", ps.tags());
}
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.getRelatedPageTitles());
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
map.put("related_page_titles", ps.relatedPageTitles());
}
return map;
}
private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.getTitle());
map.put("template_name", pc.getTemplateName());
map.put("template_fields", pc.getTemplateFields());
map.put("values", pc.getValues());
map.put("title", pc.title());
map.put("template_name", pc.templateName());
map.put("template_fields", pc.templateFields());
map.put("values", pc.values());
return map;
}
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("campaign_name", ctx.getCampaignName());
map.put("campaign_description", ctx.getCampaignDescription());
map.put("arcs", ctx.getArcs().stream()
map.put("campaign_name", ctx.campaignName());
map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
map.put("characters", ctx.getCharacters().stream()
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
@@ -137,9 +137,9 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.getName());
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
map.put("snippet", c.getSnippet());
map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.snippet());
}
return map;
}
@@ -167,10 +167,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
ArcSummary::getName,
ArcSummary::getDescription,
ArcSummary::getIllustrationCount,
(map, arc) -> map.put("chapters", arc.getChapters().stream()
ArcSummary::name,
ArcSummary::description,
ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
@@ -178,10 +178,10 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
ChapterSummary::getName,
ChapterSummary::getDescription,
ChapterSummary::getIllustrationCount,
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
ChapterSummary::name,
ChapterSummary::description,
ChapterSummary::illustrationCount,
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
@@ -189,13 +189,13 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
SceneSummary::getName,
SceneSummary::getDescription,
SceneSummary::getIllustrationCount,
SceneSummary::name,
SceneSummary::description,
SceneSummary::illustrationCount,
(map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques).
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
map.put("branches", s.getBranches().stream()
if (s.branches() != null && !s.branches().isEmpty()) {
map.put("branches", s.branches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
@@ -204,19 +204,19 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> branchHintToMap(BranchHint b) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.getLabel());
map.put("target_scene_name", b.getTargetSceneName());
if (b.getCondition() != null && !b.getCondition().isBlank()) {
map.put("condition", b.getCondition());
map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.condition());
}
return map;
}
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.getEntityType());
map.put("title", ne.getTitle());
map.put("fields", ne.getFields());
map.put("entity_type", ne.entityType());
map.put("title", ne.title());
map.put("fields", ne.fields());
return map;
}
}

View File

@@ -37,6 +37,9 @@ public class ArcJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(columnDefinition = "TEXT")
private String themes;

View File

@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
@Column(name = "gm_notes", columnDefinition = "TEXT")
private String gmNotes;

View File

@@ -39,6 +39,9 @@ public class SceneJpaEntity {
@Column(name = "\"order\"", nullable = false)
private int order;
@Column
private String icon;
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
// Contexte et ambiance

View File

@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(jpaEntity.getDescription())
.campaignId(jpaEntity.getCampaignId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.themes(jpaEntity.getThemes())
.stakes(jpaEntity.getStakes())
.gmNotes(jpaEntity.getGmNotes())
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
.description(arc.getDescription())
.campaignId(Long.parseLong(arc.getCampaignId()))
.order(arc.getOrder())
.icon(arc.getIcon())
.themes(arc.getThemes())
.stakes(arc.getStakes())
.gmNotes(arc.getGmNotes())

View File

@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(jpaEntity.getDescription())
.arcId(jpaEntity.getArcId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.gmNotes(jpaEntity.getGmNotes())
.playerObjectives(jpaEntity.getPlayerObjectives())
.narrativeStakes(jpaEntity.getNarrativeStakes())
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
.description(chapter.getDescription())
.arcId(Long.parseLong(chapter.getArcId()))
.order(chapter.getOrder())
.icon(chapter.getIcon())
.gmNotes(chapter.getGmNotes())
.playerObjectives(chapter.getPlayerObjectives())
.narrativeStakes(chapter.getNarrativeStakes())

View File

@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(jpaEntity.getDescription())
.chapterId(jpaEntity.getChapterId().toString())
.order(jpaEntity.getOrder())
.icon(jpaEntity.getIcon())
.location(jpaEntity.getLocation())
.timing(jpaEntity.getTiming())
.atmosphere(jpaEntity.getAtmosphere())
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
.description(scene.getDescription())
.chapterId(Long.parseLong(scene.getChapterId()))
.order(scene.getOrder())
.icon(scene.getIcon())
.location(scene.getLocation())
.timing(scene.getTiming())
.atmosphere(scene.getAtmosphere())

View File

@@ -0,0 +1,292 @@
package com.loremind.infrastructure.updates;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Detection des mises a jour disponibles + declenchement via Watchtower.
*
* Strategie :
* - Au demarrage, on interroge le registry pour le digest courant de chaque
* image suivie ({@code update-check.images}). On stocke ces digests comme
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
* - {@link #check()} re-interroge le registry et compare. Si un digest a
* change, une mise a jour est disponible.
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
*
* Apres un apply reussi, Watchtower redemarre core => ce service est
* re-instancie => baseline re-aligne sur le registry => check renvoie
* "pas de MAJ" (etat coherent).
*
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
* masque le badge / bouton.
*/
@Service
public class UpdateCheckService {
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
);
private final RestTemplate http;
private final String registry;
private final List<String> images;
private final String tag;
private final String watchtowerUrl;
private final String watchtowerToken;
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
public UpdateCheckService(
RestTemplateBuilder builder,
@Value("${update-check.registry:}") String registry,
@Value("${update-check.images:}") String imagesCsv,
@Value("${update-check.tag:latest}") String tag,
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
this.http = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15))
.build();
this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv);
this.tag = tag;
this.watchtowerUrl = watchtowerUrl;
this.watchtowerToken = watchtowerToken;
}
@PostConstruct
void initBaseline() {
if (!isEnabled()) {
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
return;
}
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
for (String image : images) {
try {
String digest = fetchRemoteDigest(image);
if (digest != null) {
baselineDigests.put(image, digest);
log.debug("Baseline digest for {} = {}", image, digest);
}
} catch (Exception e) {
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
}
}
}
public boolean isEnabled() {
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
}
public UpdateStatus check() {
if (!isEnabled()) {
return new UpdateStatus(false, false, List.of(), Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
for (String image : images) {
String baseline = baselineDigests.get(image);
String remote = null;
try {
remote = fetchRemoteDigest(image);
} catch (Exception e) {
log.warn("Check failed for {}: {}", image, e.getMessage());
}
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
// pour eviter un faux positif "MAJ dispo".
if (baseline == null && remote != null) {
baselineDigests.put(image, remote);
baseline = remote;
}
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
if (updateAvailable) anyUpdate = true;
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
}
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
}
public void apply() {
if (!isEnabled()) {
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(watchtowerToken);
// Watchtower /v1/update declenche un scan+update immediat de tous les
// conteneurs labellises. La reponse est synchrone et peut prendre
// plusieurs secondes; en cas de redemarrage de core, le client
// recevra une connexion coupee — c'est attendu, l'UI le gere.
http.exchange(
watchtowerUrl + "/v1/update",
HttpMethod.POST,
new HttpEntity<>(headers),
Void.class);
}
// -----------------------------------------------------------------------
// Registry HTTP API v2
// -----------------------------------------------------------------------
private String fetchRemoteDigest(String image) {
String url = registry + "/v2/" + image + "/manifests/" + tag;
HttpHeaders headers = new HttpHeaders();
headers.setAccept(MANIFEST_ACCEPT);
try {
return digestCall(url, headers);
} catch (HttpClientErrorException.Unauthorized e) {
String www = e.getResponseHeaders() == null ? null
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
String token = obtainBearerToken(www);
if (token == null) {
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
return null;
}
headers.setBearerAuth(token);
return digestCall(url, headers);
}
}
private String digestCall(String url, HttpHeaders headers) {
ResponseEntity<Void> resp = http.exchange(
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
return resp.getHeaders().getFirst("Docker-Content-Digest");
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
*/
@SuppressWarnings("rawtypes")
private String obtainBearerToken(String wwwAuth) {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
boolean hasQuery = realm.contains("?");
for (String key : new String[]{"service", "scope"}) {
String v = params.get(key);
if (v != null) {
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
// acceptent les deux, mais GHCR est strict et rejette le scope
// encode (403 DENIED). On preserve donc `:` et `/` dans la
// valeur, conformement a ce que GHCR attend
// (et que docker pull lui-meme envoie).
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
.replace("%3A", ":")
.replace("%2F", "/");
url.append(hasQuery ? '&' : '?')
.append(key).append('=')
.append(encoded);
hasQuery = true;
}
}
try {
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
Map<?, ?> body = resp.getBody();
if (body == null) return null;
Object t = body.get("token");
if (t == null) t = body.get("access_token");
return t == null ? null : t.toString();
} catch (Exception e) {
log.warn("Bearer token request failed: {}", e.getMessage());
return null;
}
}
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
private static Map<String, String> parseAuthParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();
if (!v.startsWith("http://") && !v.startsWith("https://")) {
v = "https://" + v;
}
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
return v;
}
private static List<String> parseImages(String csv) {
if (csv == null || csv.isBlank()) return List.of();
List<String> out = new ArrayList<>();
for (String part : csv.split(",")) {
String p = part.trim();
if (!p.isEmpty()) out.add(p);
}
return out;
}
// -----------------------------------------------------------------------
// Records de retour (sortis sous forme JSON par Jackson)
// -----------------------------------------------------------------------
public record UpdateStatus(
boolean enabled,
boolean updateAvailable,
List<ImageStatus> images,
Instant checkedAt) {}
public record ImageStatus(
String image,
String localDigest,
String remoteDigest,
boolean updateAvailable) {}
}

View File

@@ -66,6 +66,7 @@ public class SecurityConfig {
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/settings/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.httpBasic(basic -> {});

View File

@@ -28,7 +28,7 @@ public class ArcController {
@PostMapping
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
Arc arc = arcMapper.toDomain(arcDTO);
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder());
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
}
@@ -40,17 +40,11 @@ public class ArcController {
}
@GetMapping
public ResponseEntity<List<ArcDTO>> getAllArcs() {
List<Arc> arcs = arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(arcDTOs);
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
public ResponseEntity<List<ArcDTO>> getAllArcs(
@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
? arcService.getArcsByCampaignId(campaignId)
: arcService.getAllArcs();
List<ArcDTO> arcDTOs = arcs.stream()
.map(arcMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -28,7 +28,7 @@ public class ChapterController {
@PostMapping
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
Chapter chapter = chapterMapper.toDomain(chapterDTO);
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder());
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
}
@@ -40,17 +40,11 @@ public class ChapterController {
}
@GetMapping
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
List<Chapter> chapters = chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(chapterDTOs);
}
@GetMapping("/arc/{arcId}")
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
public ResponseEntity<List<ChapterDTO>> getAllChapters(
@RequestParam(value = "arcId", required = false) String arcId) {
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
? chapterService.getChaptersByArcId(arcId)
: chapterService.getAllChapters();
List<ChapterDTO> chapterDTOs = chapters.stream()
.map(chapterMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -0,0 +1,36 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Expose la configuration publique consommee par le frontend au demarrage.
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
* masque alors Settings / Export VTT, et les endpoints sensibles sont
* verrouilles cote serveur (cf. SettingsController).
*/
@RestController
@RequestMapping("/api/config")
public class ConfigController {
private final boolean demoMode;
private final UpdateCheckService updates;
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode,
UpdateCheckService updates) {
this.demoMode = demoMode;
this.updates = updates;
}
@GetMapping
public Map<String, Object> getPublicConfig() {
return Map.of(
"demoMode", demoMode,
"updateCheckEnabled", updates.isEnabled());
}
}

View File

@@ -28,7 +28,7 @@ public class SceneController {
@PostMapping
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
Scene scene = sceneMapper.toDomain(sceneDTO);
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder());
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
}
@@ -40,17 +40,11 @@ public class SceneController {
}
@GetMapping
public ResponseEntity<List<SceneDTO>> getAllScenes() {
List<Scene> scenes = sceneService.getAllScenes();
List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(sceneDTOs);
}
@GetMapping("/chapter/{chapterId}")
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
public ResponseEntity<List<SceneDTO>> getAllScenes(
@RequestParam(value = "chapterId", required = false) String chapterId) {
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
? sceneService.getScenesByChapterId(chapterId)
: sceneService.getAllScenes();
List<SceneDTO> sceneDTOs = scenes.stream()
.map(sceneMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -4,16 +4,28 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
@@ -32,20 +44,28 @@ public class SettingsController {
private final RestTemplate restTemplate;
private final String brainBaseUrl;
private final String brainInternalSecret;
private final boolean demoMode;
public SettingsController(RestTemplate restTemplate,
@Value("${brain.base-url}") String brainBaseUrl) {
@Value("${brain.base-url}") String brainBaseUrl,
@Value("${brain.internal-secret}") String brainInternalSecret,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.restTemplate = restTemplate;
this.brainBaseUrl = brainBaseUrl;
this.brainInternalSecret = brainInternalSecret;
this.demoMode = demoMode;
}
@GetMapping
public ResponseEntity<Map<String, Object>> getSettings() {
guardDemoMode();
return forward(HttpMethod.GET, "/settings", null);
}
@PutMapping
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
guardDemoMode();
return forward(HttpMethod.PUT, "/settings", patch);
}
@@ -59,11 +79,98 @@ public class SettingsController {
return forward(HttpMethod.POST, "/models/ollama/info", body);
}
/**
* Telecharge un modele Ollama et streame la progression au client.
* <p>
* On bypass RestTemplate (qui bufferise toute la reponse) au profit du
* client HTTP standard de Java en mode streaming. Le Brain renvoie du
* NDJSON ligne par ligne ; on relaie chaque chunk tel quel pour que le
* frontend voie la progression en temps reel.
*/
@PostMapping(value = "/models/ollama/pull", produces = "application/x-ndjson")
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
guardDemoMode();
StreamingResponseBody stream = output -> {
// Force HTTP/1.1 : le HttpClient JDK essaie HTTP/2 par defaut,
// mais uvicorn (Brain) ne supporte que HTTP/1.1 et rejette la
// tentative d'upgrade ("Unsupported upgrade request") -> la
// requete n'arrive jamais a notre endpoint Python.
HttpClient http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
// Le RestTemplate auto-injecte X-Internal-Secret via un interceptor,
// mais on bypass RestTemplate pour le streaming -> on doit ajouter
// l'entete a la main, sinon le Brain repond 401.
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
.uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
.timeout(Duration.ofMinutes(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)));
if (brainInternalSecret != null && !brainInternalSecret.isBlank()) {
reqBuilder.header("X-Internal-Secret", brainInternalSecret);
}
HttpRequest req = reqBuilder.build();
try {
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
output.write(buf, 0, n);
output.flush();
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Pull interrompu", ie);
}
};
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
}
@DeleteMapping("/models/ollama/{name}")
public ResponseEntity<Map<String, Object>> deleteOllamaModel(@PathVariable("name") String name) {
guardDemoMode();
return forward(HttpMethod.DELETE, "/models/ollama/" + name, null);
}
@GetMapping("/models/onemin")
public ResponseEntity<Map<String, Object>> listOneMinModels() {
return forward(HttpMethod.GET, "/models/onemin", null);
}
/**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
* String/Number/Boolean en valeur.
*/
private static String toJson(Map<String, Object> m) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> e : m.entrySet()) {
if (!first) sb.append(",");
sb.append("\"").append(escape(e.getKey())).append("\":");
Object v = e.getValue();
if (v == null) sb.append("null");
else if (v instanceof Number || v instanceof Boolean) sb.append(v);
else sb.append("\"").append(escape(v.toString())).append("\"");
first = false;
}
return sb.append("}").toString();
}
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
HttpHeaders headers = new HttpHeaders();

View File

@@ -0,0 +1,76 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.updates.UpdateCheckService;
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
/**
* Endpoints admin pour la verification et le declenchement des mises a jour
* des conteneurs LoreMind (core/brain/web).
*
* Protege par HTTP Basic via SecurityConfig (path /api/admin/**).
* Si la feature n'est pas configuree (WATCHTOWER_TOKEN vide), check renvoie
* {enabled:false} et apply repond 503.
*/
@RestController
@RequestMapping("/api/admin/updates")
public class UpdatesController {
private static final Logger log = LoggerFactory.getLogger(UpdatesController.class);
private final UpdateCheckService updates;
private final boolean demoMode;
public UpdatesController(UpdateCheckService updates,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.updates = updates;
this.demoMode = demoMode;
}
@GetMapping("/check")
public UpdateStatus check() {
guardDemoMode();
return updates.check();
}
@PostMapping("/apply")
public ResponseEntity<Map<String, Object>> apply() {
guardDemoMode();
if (!updates.isEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "Update apply not configured"));
}
try {
updates.apply();
return ResponseEntity.accepted()
.body(Map.of("status", "triggered",
"message", "Watchtower va telecharger et redemarrer les conteneurs."));
} catch (Exception e) {
log.error("Apply update failed", e);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "Watchtower unreachable: " + e.getMessage()));
}
}
/**
* En mode demo, les instances ne doivent pas se mettre a jour ni meme
* exposer leur statut (pas de surface d'attaque, et pas de redemarrage
* intempestif d'une demo en cours). Cohérent avec SettingsController.
*/
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Updates disabled in demo mode");
}
}
}

View File

@@ -17,6 +17,9 @@ public class ArcDTO {
private String campaignId;
private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis
private String themes;
private String stakes;

View File

@@ -17,6 +17,9 @@ public class ChapterDTO {
private String arcId;
private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis
private String gmNotes;
private String playerObjectives;

View File

@@ -17,6 +17,9 @@ public class SceneDTO {
private String chapterId;
private int order;
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
private String icon;
// Champs narratifs enrichis
private String location;
private String timing;

View File

@@ -24,6 +24,7 @@ public class ArcMapper {
dto.setDescription(arc.getDescription());
dto.setCampaignId(arc.getCampaignId());
dto.setOrder(arc.getOrder());
dto.setIcon(arc.getIcon());
dto.setThemes(arc.getThemes());
dto.setStakes(arc.getStakes());
dto.setGmNotes(arc.getGmNotes());
@@ -46,6 +47,7 @@ public class ArcMapper {
.description(dto.getDescription())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.icon(dto.getIcon())
.themes(dto.getThemes())
.stakes(dto.getStakes())
.gmNotes(dto.getGmNotes())

View File

@@ -24,6 +24,7 @@ public class ChapterMapper {
dto.setDescription(chapter.getDescription());
dto.setArcId(chapter.getArcId());
dto.setOrder(chapter.getOrder());
dto.setIcon(chapter.getIcon());
dto.setGmNotes(chapter.getGmNotes());
dto.setPlayerObjectives(chapter.getPlayerObjectives());
dto.setNarrativeStakes(chapter.getNarrativeStakes());
@@ -44,6 +45,7 @@ public class ChapterMapper {
.description(dto.getDescription())
.arcId(dto.getArcId())
.order(dto.getOrder())
.icon(dto.getIcon())
.gmNotes(dto.getGmNotes())
.playerObjectives(dto.getPlayerObjectives())
.narrativeStakes(dto.getNarrativeStakes())

View File

@@ -27,6 +27,7 @@ public class SceneMapper {
dto.setDescription(scene.getDescription());
dto.setChapterId(scene.getChapterId());
dto.setOrder(scene.getOrder());
dto.setIcon(scene.getIcon());
dto.setLocation(scene.getLocation());
dto.setTiming(scene.getTiming());
dto.setAtmosphere(scene.getAtmosphere());
@@ -59,6 +60,7 @@ public class SceneMapper {
.description(dto.getDescription())
.chapterId(dto.getChapterId())
.order(dto.getOrder())
.icon(dto.getIcon())
.location(dto.getLocation())
.timing(dto.getTiming())
.atmosphere(dto.getAtmosphere())
@@ -85,18 +87,14 @@ public class SceneMapper {
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
if (branches == null) return new ArrayList<>();
return branches.stream()
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition()))
.map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition()))
.collect(Collectors.toList());
}
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
if (dtos == null) return new ArrayList<>();
return dtos.stream()
.map(d -> SceneBranch.builder()
.label(d.getLabel())
.targetSceneId(d.getTargetSceneId())
.condition(d.getCondition())
.build())
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
.collect(Collectors.toList());
}
}

View File

@@ -5,6 +5,12 @@ server.port=8080
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
spring.main.web-application-type=servlet
# Pas de timeout sur les requetes async (StreamingResponseBody, SSE).
# Le defaut Tomcat coupe a 30s, ce qui interrompt le streaming d'un pull
# de modele Ollama (peut durer des dizaines de minutes pour un GGUF de 10+ Go).
# -1 = pas de timeout, on s'appuie sur la fermeture cote client ou cote upstream.
spring.mvc.async.request-timeout=-1
# Configuration de la base de donnees PostgreSQL
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
@@ -21,13 +27,13 @@ spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Configuration CORS pour autoriser le Frontend Angular
spring.web.cors.allowed-origins=http://localhost:4200
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.web.cors.allowed-headers=*
spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=http://localhost:8000
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
brain.timeout-seconds=120
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
@@ -50,3 +56,15 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
app.demo-mode=${DEMO_MODE:false}
# Detection des mises a jour des conteneurs Docker (registry HTTP API + Watchtower).
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
update-check.registry=${UPDATE_CHECK_REGISTRY:}
update-check.images=${UPDATE_CHECK_IMAGES:}
update-check.tag=${UPDATE_CHECK_TAG:latest}
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
update-check.watchtower-token=${WATCHTOWER_TOKEN:}

View File

@@ -178,10 +178,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithValidBranches() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId("scene-2")
.label("Go to scene 2")
.build();
SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -203,10 +200,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchToSelf() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId("scene-1")
.label("Self-reference")
.build();
SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -228,10 +222,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchToDifferentChapter() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId("scene-other-chapter")
.label("Go to other chapter")
.build();
SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -253,10 +244,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchNullTarget() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId(null)
.label("Null target")
.build();
SceneBranch branch = SceneBranch.of("Null target", null);
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))
@@ -277,10 +265,7 @@ public class SceneServiceTest {
@Test
void testUpdateScene_WithBranchBlankTarget() {
// Arrange
SceneBranch branch = SceneBranch.builder()
.targetSceneId(" ")
.label("Blank target")
.build();
SceneBranch branch = SceneBranch.of("Blank target", " ");
Scene updatedScene = Scene.builder()
.name("Updated Scene")
.branches(List.of(branch))

View File

@@ -74,9 +74,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
assertTrue(ctx.getArcs().isEmpty());
assertEquals("Les Terres Brisées", ctx.campaignName());
assertEquals("Campagne dark fantasy", ctx.campaignDescription());
assertTrue(ctx.arcs().isEmpty());
}
@Test
@@ -100,19 +100,19 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName());
assertEquals("Arc B", ctx.getArcs().get(1).getName());
assertEquals(2, ctx.arcs().size());
assertEquals("Arc A", ctx.arcs().get(0).name());
assertEquals("Arc B", ctx.arcs().get(1).name());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
assertEquals(2, ctx.arcs().get(0).chapters().size());
assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName());
assertEquals("Scene A", chADto.getScenes().get(1).getName());
var chADto = ctx.arcs().get(0).chapters().get(1);
assertEquals("Scene B", chADto.scenes().get(0).name());
assertEquals("Scene A", chADto.scenes().get(1).name());
}
@Test
@@ -120,15 +120,8 @@ public class CampaignStructuralContextBuilderTest {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder()
.label("Si les joueurs fuient")
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1)
@@ -143,12 +136,12 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
assertEquals(2, scene1Summary.getBranches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
assertEquals(2, scene1Summary.branches().size());
assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
// ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
}
@Test
@@ -170,9 +163,9 @@ public class CampaignStructuralContextBuilderTest {
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
assertEquals(2, ctx.arcs().get(0).illustrationCount());
assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
}
}

View File

@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("monde aérien", ctx.getLoreDescription());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Personnage", ctx.getTemplateName());
assertEquals("Alice", ctx.getPageTitle());
assertEquals("Aetheria", ctx.loreName());
assertEquals("monde aérien", ctx.loreDescription());
assertEquals("PNJ", ctx.folderName());
assertEquals("Personnage", ctx.templateName());
assertEquals("Alice", ctx.pageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
assertEquals(List.of("Histoire", "Apparence"), ctx.templateFields());
}
@Test

View File

@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("Monde aérien", ctx.getLoreDescription());
assertTrue(ctx.getFolders().isEmpty());
assertTrue(ctx.getTags().isEmpty());
assertEquals("Aetheria", ctx.loreName());
assertEquals("Monde aérien", ctx.loreDescription());
assertTrue(ctx.folders().isEmpty());
assertTrue(ctx.tags().isEmpty());
}
@Test
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size());
assertTrue(ctx.getFolders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux"));
assertEquals(2, ctx.folders().size());
assertTrue(ctx.folders().containsKey("PNJ"));
assertTrue(ctx.folders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ");
var pnjPages = ctx.folders().get("PNJ");
assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle());
assertEquals("Personnage", aliceSummary.getTemplateName());
assertEquals("Alice", aliceSummary.title());
assertEquals("Personnage", aliceSummary.templateName());
// Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
assertEquals(1, aliceSummary.values().size());
assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.tags());
// p-2 resolved into title, p-ghost dropped silently
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
assertEquals(List.of("La Forêt"), aliceSummary.relatedPageTitles());
var forestSummary = ctx.getFolders().get("Lieux").get(0);
var forestSummary = ctx.folders().get("Lieux").get(0);
// Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName());
assertTrue(forestSummary.getValues().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
assertEquals("?", forestSummary.templateName());
assertTrue(forestSummary.values().isEmpty());
assertTrue(forestSummary.relatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size());
assertTrue(ctx.getTags().contains("hero"));
assertTrue(ctx.getTags().contains("magic"));
assertEquals(2, ctx.tags().size());
assertTrue(ctx.tags().contains("hero"));
assertTrue(ctx.tags().contains("magic"));
}
@Test
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
String truncated = ctx.folders().get("PNJ").get(0).values().get("Histoire");
assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith(""));
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty());
assertTrue(summary.getTags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty());
var summary = ctx.folders().get("PNJ").get(0);
assertTrue(summary.values().isEmpty());
assertTrue(summary.tags().isEmpty());
assertTrue(summary.relatedPageTitles().isEmpty());
}
}

View File

@@ -44,14 +44,14 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("L'arc sombre", ctx.getTitle());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes"));
assertEquals("arc", ctx.entityType());
assertEquals("L'arc sombre", ctx.title());
assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
assertEquals("trahison", ctx.fields().get("themes"));
assertEquals("vie ou mort", ctx.fields().get("stakes"));
assertEquals("pouvoir", ctx.fields().get("rewards"));
assertEquals("le roi meurt", ctx.fields().get("resolution"));
assertEquals("secret", ctx.fields().get("gmNotes"));
}
@Test
@@ -64,12 +64,12 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType());
assertEquals("Chapitre 1", ctx.getTitle());
assertEquals("", ctx.getFields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes"));
assertEquals("chapter", ctx.entityType());
assertEquals("Chapitre 1", ctx.title());
assertEquals("", ctx.fields().get("description (synopsis)"));
assertEquals("", ctx.fields().get("playerObjectives"));
assertEquals("haut", ctx.fields().get("narrativeStakes"));
assertEquals("", ctx.fields().get("gmNotes"));
}
@Test
@@ -85,17 +85,17 @@ public class NarrativeEntityContextBuilderTest {
NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType());
assertEquals("L'auberge", ctx.getTitle());
assertEquals("lieu calme", ctx.getFields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
assertEquals("scene", ctx.entityType());
assertEquals("L'auberge", ctx.title());
assertEquals("lieu calme", ctx.fields().get("description"));
assertEquals("Taverne", ctx.fields().get("location"));
assertEquals("Soir", ctx.fields().get("timing"));
assertEquals("tendue", ctx.fields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
assertEquals("option A...", ctx.fields().get("choicesConsequences"));
assertEquals("moyen", ctx.fields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.fields().get("enemies"));
assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
}
@Test
@@ -104,7 +104,7 @@ public class NarrativeEntityContextBuilderTest {
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("arc", ctx.entityType());
}
@Test

View File

@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
campaignCtx = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("d")
.build();
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext());
assertNull(req.getLoreContext());
assertNull(req.getNarrativeEntity());
assertNull(req.getPageContext());
assertSame(campaignCtx, req.campaignContext());
assertNull(req.loreContext());
assertNull(req.narrativeEntity());
assertNull(req.pageContext());
verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder);
}
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
@Test
void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
LoreStructuralContext loreCtx = new LoreStructuralContext(
"L", "d", Collections.emptyMap(), List.of());
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext());
assertSame(loreCtx, captor.getValue().loreContext());
}
@Test
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getLoreContext());
assertNull(captor.getValue().loreContext());
// La requete doit tout de meme partir (pas d'exception).
}
@Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge").fields(Map.of()).build();
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity());
assertSame(entity, captor.getValue().narrativeEntity());
}
@Test
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity());
assertNull(captor.getValue().narrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder);
}
}

View File

@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
loreCtx = LoreStructuralContext.builder()
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext());
assertNull(req.getPageContext());
assertNull(req.getCampaignContext());
assertSame(loreCtx, req.loreContext());
assertNull(req.pageContext());
assertNull(req.campaignContext());
}
@Test
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getPageContext());
assertNull(captor.getValue().pageContext());
verifyNoInteractions(pageRepository);
}
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext());
assertEquals("Alice", req.getPageContext().getTitle());
assertEquals("Personnage", req.getPageContext().getTemplateName());
assertNotNull(req.pageContext());
assertEquals("Alice", req.pageContext().title());
assertEquals("Personnage", req.pageContext().templateName());
// Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
assertEquals(values, req.getPageContext().getValues());
assertEquals(List.of("Histoire"), req.pageContext().templateFields());
assertEquals(values, req.pageContext().values());
}
@Test
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
var pageCtx = captor.getValue().pageContext();
assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle());
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty());
assertEquals("Orphan", pageCtx.title());
assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.templateFields().isEmpty());
assertTrue(pageCtx.values().isEmpty());
verifyNoInteractions(templateRepository);
}
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
var pageCtx = captor.getValue().pageContext();
assertEquals("?", pageCtx.templateName());
assertTrue(pageCtx.templateFields().isEmpty());
}
@Test

View File

@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Tests unitaires pour SceneBranch (Value Object).
* Verifie :
* - l'immuabilite (pas de setters : seul le builder permet la construction),
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
* - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
* - l'egalite structurelle generee par record (equals/hashCode sur tous les
* champs) — deux branches aux memes champs sont strictement egales,
* - le support du champ optionnel {@code condition}.
*/
class SceneBranchTest {
@Test
void builder_exposesAllFields() {
SceneBranch branch = SceneBranch.builder()
.label("Si les joueurs attaquent le garde")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build();
void constructor_exposesAllFields() {
SceneBranch branch = new SceneBranch(
"Si les joueurs attaquent le garde",
"sc-combat",
"initiative > 15");
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
assertEquals("sc-combat", branch.getTargetSceneId());
assertEquals("initiative > 15", branch.getCondition());
assertEquals("Si les joueurs attaquent le garde", branch.label());
assertEquals("sc-combat", branch.targetSceneId());
assertEquals("initiative > 15", branch.condition());
}
@Test
void condition_isOptional() {
SceneBranch branch = SceneBranch.builder()
.label("sortie par la porte")
.targetSceneId("sc-corridor")
.build();
SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
assertNull(branch.getCondition());
assertNull(branch.condition());
}
@Test
void twoBranches_withSameFields_areEqual() {
SceneBranch a = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
SceneBranch b = SceneBranch.builder()
.label("fuite")
.targetSceneId("sc-2")
.condition(null)
.build();
SceneBranch a = new SceneBranch("fuite", "sc-2", null);
SceneBranch b = new SceneBranch("fuite", "sc-2", null);
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
@@ -58,16 +46,16 @@ class SceneBranchTest {
@Test
void twoBranches_differingOnTargetSceneId_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
SceneBranch a = SceneBranch.of("X", "sc-1");
SceneBranch b = SceneBranch.of("X", "sc-2");
assertNotEquals(a, b);
}
@Test
void twoBranches_differingOnCondition_areNotEqual() {
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
SceneBranch a = new SceneBranch("X", "sc-1", "A");
SceneBranch b = new SceneBranch("X", "sc-1", "B");
assertNotEquals(a, b);
}

View File

@@ -60,15 +60,15 @@ class SceneTest {
@Test
void builder_preservesBranches_whenProvided() {
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
SceneBranch b2 = SceneBranch.of("combat", "sc-3");
Scene scene = Scene.builder()
.branches(List.of(b1, b2))
.build();
assertEquals(2, scene.getBranches().size());
assertEquals("fuite", scene.getBranches().get(0).getLabel());
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
assertEquals("fuite", scene.getBranches().get(0).label());
assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
}
}

View File

@@ -6,108 +6,97 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
* permettent une construction incrementale du graphe narratif.
* Records purs : aucune dependance technique.
*/
class CampaignStructuralContextTest {
@Test
void builder_constructsFullNarrativeTree() {
BranchHint branch = BranchHint.builder()
.label("si les PJ fuient")
.targetSceneName("La poursuite")
.condition("PJ < moitie des HP")
.build();
void constructor_buildsFullNarrativeTree() {
BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
SceneSummary scene = SceneSummary.builder()
.name("L'auberge")
.description("Rencontre tendue avec le tavernier")
.illustrationCount(2)
.branch(branch)
.build();
SceneSummary scene = new SceneSummary(
"L'auberge",
"Rencontre tendue avec le tavernier",
2,
List.of(branch));
ChapterSummary chapter = ChapterSummary.builder()
.name("L'arrivee")
.description("Les PJ decouvrent la ville")
.scene(scene)
.build();
ChapterSummary chapter = new ChapterSummary(
"L'arrivee",
"Les PJ decouvrent la ville",
0,
List.of(scene));
ArcSummary arc = ArcSummary.builder()
.name("Acte I")
.description("Mise en place")
.illustrationCount(1)
.chapter(chapter)
.build();
ArcSummary arc = new ArcSummary(
"Acte I",
"Mise en place",
1,
List.of(chapter));
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
.campaignName("Les Ombres")
.campaignDescription("Une campagne dark fantasy")
.arc(arc)
.build();
CampaignStructuralContext ctx = new CampaignStructuralContext(
"Les Ombres",
"Une campagne dark fantasy",
List.of(arc),
List.of());
assertEquals("Les Ombres", ctx.getCampaignName());
assertEquals(1, ctx.getArcs().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
assertEquals("Les Ombres", ctx.campaignName());
assertEquals(1, ctx.arcs().size());
assertEquals(1, ctx.arcs().get(0).chapters().size());
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
}
// --- BranchHint ---------------------------------------------------------
@Test
void branchHint_preservesAllFields() {
BranchHint b = BranchHint.builder()
.label("combat")
.targetSceneName("La confrontation")
.condition("initiative > 15")
.build();
BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
assertEquals("combat", b.getLabel());
assertEquals("La confrontation", b.getTargetSceneName());
assertEquals("initiative > 15", b.getCondition());
assertEquals("combat", b.label());
assertEquals("La confrontation", b.targetSceneName());
assertEquals("initiative > 15", b.condition());
}
@Test
void branchHint_conditionIsOptional() {
BranchHint b = BranchHint.builder()
.label("suite normale")
.targetSceneName("Scene 2")
.build();
BranchHint b = new BranchHint("suite normale", "Scene 2", null);
assertNull(b.getCondition());
assertNull(b.condition());
}
// --- illustrationCount --------------------------------------------------
@Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = ArcSummary.builder().name("X").build();
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
SceneSummary scene = SceneSummary.builder().name("X").build();
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
assertEquals(0, arc.getIllustrationCount());
assertEquals(0, chapter.getIllustrationCount());
assertEquals(0, scene.getIllustrationCount());
assertEquals(0, arc.illustrationCount());
assertEquals(0, chapter.illustrationCount());
assertEquals(0, scene.illustrationCount());
}
// --- @Singular : accumulation incrementale -----------------------------
// --- Construction incrementale (chapitres multiples) -------------------
@Test
void singular_accumulatesMultipleCalls() {
ArcSummary arc = ArcSummary.builder()
.name("Acte I")
.chapter(ChapterSummary.builder().name("Ch1").build())
.chapter(ChapterSummary.builder().name("Ch2").build())
.chapter(ChapterSummary.builder().name("Ch3").build())
.build();
void multipleChapters_arePreserved() {
ArcSummary arc = new ArcSummary(
"Acte I",
null,
0,
List.of(
new ChapterSummary("Ch1", null, 0, List.of()),
new ChapterSummary("Ch2", null, 0, List.of()),
new ChapterSummary("Ch3", null, 0, List.of())));
assertEquals(3, arc.getChapters().size());
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
assertEquals(3, arc.chapters().size());
assertEquals("Ch2", arc.chapters().get(1).name());
}
}

View File

@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -26,57 +27,45 @@ class ChatRequestTest {
void buildLoreOnly_leavesCampaignAndEntityNull() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(java.util.Map.of())
.build())
.loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
.build();
assertEquals(1, request.getMessages().size());
assertNotNull(request.getLoreContext());
assertEquals("Ithoril", request.getLoreContext().getLoreName());
assertNull(request.getPageContext());
assertNull(request.getCampaignContext());
assertNull(request.getNarrativeEntity());
assertEquals(1, request.messages().size());
assertNotNull(request.loreContext());
assertEquals("Ithoril", request.loreContext().loreName());
assertNull(request.pageContext());
assertNull(request.campaignContext());
assertNull(request.narrativeEntity());
}
@Test
void buildLoreWithPageFocus_hasBothContexts() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
.pageContext(PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.build())
.loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
.pageContext(new PageContext("Thorin", "PNJ", null, null))
.build();
assertNotNull(request.getLoreContext());
assertNotNull(request.getPageContext());
assertEquals("Thorin", request.getPageContext().getTitle());
assertNotNull(request.loreContext());
assertNotNull(request.pageContext());
assertEquals("Thorin", request.pageContext().title());
}
@Test
void buildCampaignWithNarrativeEntity_hasBothContexts() {
ChatRequest request = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(CampaignStructuralContext.builder()
.campaignName("Les Ombres")
.campaignDescription("...")
.build())
.narrativeEntity(NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(java.util.Map.of("location", "Taverne"))
.build())
.campaignContext(new CampaignStructuralContext(
"Les Ombres", "...", List.of(), List.of()))
.narrativeEntity(new NarrativeEntityContext(
"scene", "L'auberge", Map.of("location", "Taverne")))
.build();
assertNotNull(request.getCampaignContext());
assertNotNull(request.getNarrativeEntity());
assertEquals("scene", request.getNarrativeEntity().getEntityType());
assertNull(request.getLoreContext());
assertNull(request.getPageContext());
assertNotNull(request.campaignContext());
assertNotNull(request.narrativeEntity());
assertEquals("scene", request.narrativeEntity().entityType());
assertNull(request.loreContext());
assertNull(request.pageContext());
}
@Test
@@ -86,10 +75,10 @@ class ChatRequestTest {
.messages(sampleMessages)
.build();
assertEquals(1, request.getMessages().size());
assertNull(request.getLoreContext());
assertNull(request.getPageContext());
assertNull(request.getCampaignContext());
assertNull(request.getNarrativeEntity());
assertEquals(1, request.messages().size());
assertNull(request.loreContext());
assertNull(request.pageContext());
assertNull(request.campaignContext());
assertNull(request.narrativeEntity());
}
}

View File

@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
/**
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
* Verifie la construction via builder et l'egalite structurelle.
* Verifie la construction et l'egalite structurelle (record).
*/
class GenerationContextTest {
@Test
void builder_preservesAllFields() {
GenerationContext ctx = GenerationContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folderName("PNJ")
.templateName("Fiche PNJ")
.templateFields(List.of("histoire", "motto", "apparence"))
.pageTitle("Thorin")
.build();
void constructor_preservesAllFields() {
GenerationContext ctx = new GenerationContext(
"Ithoril",
"Royaume sombre",
"PNJ",
"Fiche PNJ",
List.of("histoire", "motto", "apparence"),
"Thorin");
assertEquals("Ithoril", ctx.getLoreName());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Fiche PNJ", ctx.getTemplateName());
assertEquals(3, ctx.getTemplateFields().size());
assertEquals("Thorin", ctx.getPageTitle());
assertEquals("Ithoril", ctx.loreName());
assertEquals("PNJ", ctx.folderName());
assertEquals("Fiche PNJ", ctx.templateName());
assertEquals(3, ctx.templateFields().size());
assertEquals("Thorin", ctx.pageTitle());
}
@Test
void twoContexts_withSameFields_areEqual() {
GenerationContext a = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
GenerationContext b = GenerationContext.builder()
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
assertEquals(a, b);
}
@Test
void twoContexts_differingOnPageTitle_areNotEqual() {
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
assertNotEquals(a, b);
}
}

View File

@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
* Records purs : aucune dependance technique.
*/
class LoreStructuralContextTest {
@Test
void builder_preservesFoldersAndTags() {
PageSummary pnj = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.tags(List.of("pnj", "allie"))
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
.build();
void constructor_preservesFoldersAndTags() {
PageSummary pnj = new PageSummary(
"Thorin",
"PNJ",
Map.of("histoire", "Nee sous une etoile rouge"),
List.of("pnj", "allie"),
List.of("Taverne du Dragon d'Or"));
LoreStructuralContext ctx = LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(Map.of("PNJ", List.of(pnj)))
.tag("royaume")
.tag("dark-fantasy")
.build();
LoreStructuralContext ctx = new LoreStructuralContext(
"Ithoril",
"Royaume sombre",
Map.of("PNJ", List.of(pnj)),
List.of("royaume", "dark-fantasy"));
assertEquals("Ithoril", ctx.getLoreName());
assertEquals(1, ctx.getFolders().size());
assertEquals(1, ctx.getFolders().get("PNJ").size());
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
assertTrue(ctx.getTags().contains("royaume"));
assertTrue(ctx.getTags().contains("dark-fantasy"));
assertEquals("Ithoril", ctx.loreName());
assertEquals(1, ctx.folders().size());
assertEquals(1, ctx.folders().get("PNJ").size());
assertEquals(2, ctx.tags().size());
assertTrue(ctx.tags().contains("royaume"));
assertTrue(ctx.tags().contains("dark-fantasy"));
}
@Test
void emptyFolders_areAllowed() {
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
LoreStructuralContext ctx = LoreStructuralContext.builder()
.loreName("Vide")
.loreDescription("")
.folders(Map.of("Lieux", List.of()))
.build();
LoreStructuralContext ctx = new LoreStructuralContext(
"Vide",
"",
Map.of("Lieux", List.of()),
List.of());
assertNotNull(ctx.getFolders().get("Lieux"));
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
assertNotNull(ctx.folders().get("Lieux"));
assertTrue(ctx.folders().get("Lieux").isEmpty());
}
// --- PageSummary --------------------------------------------------------
@Test
void pageSummary_preservesAllFields() {
PageSummary ps = PageSummary.builder()
.title("Le Donjon du Chaos")
.templateName("Lieu")
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
.tags(List.of("donjon", "ancien"))
.relatedPageTitles(List.of("Thorin", "Garde royale"))
.build();
PageSummary ps = new PageSummary(
"Le Donjon du Chaos",
"Lieu",
Map.of("histoire", "Bati il y a 1000 ans..."),
List.of("donjon", "ancien"),
List.of("Thorin", "Garde royale"));
assertEquals("Le Donjon du Chaos", ps.getTitle());
assertEquals("Lieu", ps.getTemplateName());
assertEquals(1, ps.getValues().size());
assertEquals(2, ps.getTags().size());
assertEquals(2, ps.getRelatedPageTitles().size());
assertEquals("Le Donjon du Chaos", ps.title());
assertEquals("Lieu", ps.templateName());
assertEquals(1, ps.values().size());
assertEquals(2, ps.tags().size());
assertEquals(2, ps.relatedPageTitles().size());
}
}

View File

@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
class NarrativeEntityContextTest {
@Test
void builder_preservesAllFields() {
void constructor_preservesAllFields() {
Map<String, String> fields = new LinkedHashMap<>();
fields.put("themes", "trahison");
fields.put("stakes", "la survie du royaume");
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
.entityType("arc")
.title("Acte I")
.fields(fields)
.build();
NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
assertEquals("arc", ctx.getEntityType());
assertEquals("Acte I", ctx.getTitle());
assertEquals(2, ctx.getFields().size());
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("arc", ctx.entityType());
assertEquals("Acte I", ctx.title());
assertEquals(2, ctx.fields().size());
assertEquals("trahison", ctx.fields().get("themes"));
}
@Test
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
fields.put("timing", "Soir");
fields.put("atmosphere", "fumee");
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
.entityType("scene")
.title("L'auberge")
.fields(fields)
.build();
NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
}
@Test
void twoContexts_differingOnEntityType_areNotEqual() {
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
assertNotEquals(a, b);
}
}

View File

@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class PageContextTest {
@Test
void builder_preservesAllFields() {
PageContext ctx = PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.templateFields(List.of("histoire", "apparence", "motto"))
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.build();
void constructor_preservesAllFields() {
PageContext ctx = new PageContext(
"Thorin",
"PNJ",
List.of("histoire", "apparence", "motto"),
Map.of("histoire", "Nee sous une etoile rouge"));
assertEquals("Thorin", ctx.getTitle());
assertEquals("PNJ", ctx.getTemplateName());
assertEquals(3, ctx.getTemplateFields().size());
assertEquals(1, ctx.getValues().size());
assertEquals("Thorin", ctx.title());
assertEquals("PNJ", ctx.templateName());
assertEquals(3, ctx.templateFields().size());
assertEquals(1, ctx.values().size());
}
@Test
void emptyValues_areAllowed() {
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
PageContext ctx = PageContext.builder()
.title("Nouveau PNJ")
.templateName("PNJ")
.templateFields(List.of("histoire", "apparence"))
.values(Map.of())
.build();
PageContext ctx = new PageContext(
"Nouveau PNJ",
"PNJ",
List.of("histoire", "apparence"),
Map.of());
assertTrue(ctx.getValues().isEmpty());
assertEquals(2, ctx.getTemplateFields().size());
assertTrue(ctx.values().isEmpty());
assertEquals(2, ctx.templateFields().size());
}
}

View File

@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_loreContext_includesBasicFields() {
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("Ithoril")
.loreDescription("Royaume sombre")
.folders(Map.of())
.tag("dark-fantasy")
.build();
LoreStructuralContext lore = new LoreStructuralContext(
"Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
PageSummary minimal = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of())
.tags(List.of())
.relatedPageTitles(List.of())
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(minimal)))
.build();
PageSummary minimal = new PageSummary("Thorin", "PNJ",
Map.of(), List.of(), List.of());
LoreStructuralContext lore = new LoreStructuralContext(
"X", "", Map.of("PNJ", List.of(minimal)), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
PageSummary full = PageSummary.builder()
.title("Thorin")
.templateName("PNJ")
.values(Map.of("histoire", "Nee sous une etoile rouge"))
.tags(List.of("pnj", "allie"))
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
.build();
LoreStructuralContext lore = LoreStructuralContext.builder()
.loreName("X").loreDescription("")
.folders(Map.of("PNJ", List.of(full)))
.build();
PageSummary full = new PageSummary("Thorin", "PNJ",
Map.of("histoire", "Nee sous une etoile rouge"),
List.of("pnj", "allie"),
List.of("Taverne du Dragon d'Or"));
LoreStructuralContext lore = new LoreStructuralContext(
"X", "", Map.of("PNJ", List.of(full)), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
Map<String, Object> payload = builder.build(req);
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_pageContext_includesAllFields() {
PageContext pc = PageContext.builder()
.title("Thorin")
.templateName("PNJ")
.templateFields(List.of("histoire", "motto"))
.values(Map.of("histoire", "..."))
.build();
PageContext pc = new PageContext("Thorin", "PNJ",
List.of("histoire", "motto"), Map.of("histoire", "..."));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
Map<String, Object> payload = builder.build(req);
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_campaignContext_serializesFullNarrativeTree() {
BranchHint branch = BranchHint.builder()
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
SceneSummary scene = SceneSummary.builder()
.name("L'auberge").description("Rencontre tendue")
.illustrationCount(3).branch(branch).build();
ChapterSummary chapter = ChapterSummary.builder()
.name("L'arrivee").description("...").scene(scene).build();
ArcSummary arc = ArcSummary.builder()
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_branchHint_omitsCondition_whenBlank() {
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").arc(arc).build();
BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
Map<String, Object> payload = builder.build(req);
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_narrativeEntity_includesAllFields() {
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge")
.fields(Map.of("location", "Taverne", "timing", "Soir"))
.build();
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
Map.of("location", "Taverne", "timing", "Soir"));
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
Map<String, Object> payload = builder.build(req);
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
@Test
void build_campaignScenario_includesBothContextsAndEntity() {
CampaignStructuralContext camp = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("").build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("arc").title("T").fields(Map.of()).build();
CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(), List.of());
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
ChatRequest req = ChatRequest.builder()
.messages(sampleMessages)
.campaignContext(camp)

View File

@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
@Test
void roundTrip_preservesAllBranchFields() {
// Test critique : depend de @Jacksonized sur SceneBranch.
// Test critique : Jackson doit reconstruire SceneBranch (record) via
// son constructeur canonique sans aucune annotation.
List<SceneBranch> source = List.of(
SceneBranch.builder()
.label("si les joueurs attaquent")
.targetSceneId("sc-combat")
.condition("initiative > 15")
.build(),
SceneBranch.builder()
.label("si les joueurs fuient")
.targetSceneId("sc-poursuite")
.build()
new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
SceneBranch.of("si les joueurs fuient", "sc-poursuite")
);
String json = converter.convertToDatabaseColumn(source);
List<SceneBranch> back = converter.convertToEntityAttribute(json);
assertEquals(2, back.size());
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
assertEquals("sc-combat", back.get(0).getTargetSceneId());
assertEquals("initiative > 15", back.get(0).getCondition());
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
assertEquals("si les joueurs attaquent", back.get(0).label());
assertEquals("sc-combat", back.get(0).targetSceneId());
assertEquals("initiative > 15", back.get(0).condition());
assertEquals("sc-poursuite", back.get(1).targetSceneId());
assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
}
}

View File

@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
@Test
void save_scenePreservesBranches_viaJsonbRoundTrip() {
// Le critique : le @Jacksonized de SceneBranch doit permettre la
// reconstruction via builder apres serialisation Jackson.
// Le critique : SceneBranch (record) doit etre reconstructible par
// Jackson via le constructeur canonique apres serialisation JSON.
Scene scene = Scene.builder()
.chapterId(chapterId).name("Decision").order(0)
.branches(List.of(
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
new SceneBranch("fuite", "sc-2", "HP bas"),
SceneBranch.of("combat", "sc-3")
))
.build();
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
Scene r = repository.findById(saved.getId()).orElseThrow();
assertEquals(2, r.getBranches().size());
assertEquals("fuite", r.getBranches().get(0).getLabel());
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
assertEquals("HP bas", r.getBranches().get(0).getCondition());
assertEquals("combat", r.getBranches().get(1).getLabel());
assertEquals("fuite", r.getBranches().get(0).label());
assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
assertEquals("HP bas", r.getBranches().get(0).condition());
assertEquals("combat", r.getBranches().get(1).label());
}
@Test

View File

@@ -79,7 +79,7 @@ class ArcControllerTest {
@Test
void getByCampaign_pathVariant() throws Exception {
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -81,7 +81,7 @@ class ChapterControllerTest {
@Test
void getByArc_pathVariant() throws Exception {
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
mockMvc.perform(get("/api/chapters/arc/{id}", arcId))
mockMvc.perform(get("/api/chapters").param("arcId", arcId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

View File

@@ -85,7 +85,7 @@ class SceneControllerTest {
@Test
void getByChapter_pathVariant() throws Exception {
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId))
mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}

27
demo/.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Copie en .env sur le serveur (jamais commite).
# Registre et tag des images core / brain a spawner par session.
REGISTRY=git.igmlcreation.fr
TAG=latest
# Secret partage entre core et brain (genere aleatoirement au build de chaque
# session, mais un defaut est utile pour les checks de sante au boot).
BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server
# Capacite
MAX_SESSIONS=10
SESSION_TTL_MINUTES=20
# Rate limiting : 1 creation de session par IP par fenetre (secondes).
RATE_LIMIT_WINDOW_SECONDS=60
# Limites par conteneur de session (Docker API)
CORE_MEMORY_MB=700
BRAIN_MEMORY_MB=300
POSTGRES_MEMORY_MB=200
# Nom du reseau Docker externe Traefik (doit exister avant docker compose up)
TRAEFIK_NETWORK=traefik
# Domaine expose par Traefik
DEMO_HOST=loremind-demo.igmlcreation.fr

2
demo/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
*.log

46
demo/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Demo publique LoreMind
Deploiement d'une instance de demo ephemere pour `loremind-demo.igmlcreation.fr`.
## Principe
Chaque visiteur recoit un environnement isole spawne a la volee, detruit apres
un court delai d'inactivite. Les donnees ne sont jamais persistees.
Le mode demo (variable d'env `DEMO_MODE=true` sur le core) masque les ecrans
de configuration qui n'ont pas de sens en vitrine.
## Deploiement
Prerequis :
- Reseau Traefik existant cote host
- Images `core` et `brain` pushees au registre
```bash
cp .env.example .env
# Ajuster .env
docker compose -f docker-compose.infra.yml up -d --build
```
Premier build : 5-10 min. Suivants : incrementaux.
## Mise a jour
```bash
docker compose -f docker-compose.infra.yml pull
docker compose -f docker-compose.infra.yml up -d --build
```
Les sessions en cours sont tuees au redemarrage.
## Observations
- `docker logs loremind-demo-orchestrator -f`
- `docker ps --filter "name=demo-"`
## Desactiver
```bash
docker compose -f docker-compose.infra.yml down
docker ps -q --filter "name=demo-" | xargs -r docker rm -f
```

View File

@@ -0,0 +1,80 @@
# ==========================================================================
# LoreMind Demo - Infra permanente
# --------------------------------------------------------------------------
# - dockerproxy : expose un subset restreint de l'API Docker a l'orchestrateur
# (lecture seule sauf containers/images/networks). Remplace le mount direct
# de /var/run/docker.sock : meme avec RCE sur l'orchestrateur, un attaquant
# ne peut pas exec sur l'hote, creer des volumes, ni lire le daemon.
# - orchestrator : sert l'Angular et proxy les /api/* vers les sessions.
#
# Les conteneurs de session sont crees dynamiquement par l'orchestrateur.
# ==========================================================================
services:
dockerproxy:
image: tecnativa/docker-socket-proxy:latest
container_name: loremind-demo-dockerproxy
restart: unless-stopped
environment:
# Minimum requis par l'orchestrateur.
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
POST: 1
# Tout le reste reste a 0 (defaut) : pas d'EXEC, VOLUMES, BUILD, AUTH,
# SYSTEM, INFO, SWARM, SECRETS, CONFIGS, NODES, etc.
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- socket-proxy
# Pas de ports exposes : accessible uniquement via le reseau socket-proxy.
orchestrator:
container_name: loremind-demo-orchestrator
depends_on:
- dockerproxy
build:
context: ../
dockerfile: demo/orchestrator/Dockerfile
restart: unless-stopped
environment:
# L'orchestrateur parle a dockerproxy au lieu du socket direct.
DOCKER_HOST: tcp://dockerproxy:2375
REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
TAG: ${TAG:-latest}
MAX_SESSIONS: ${MAX_SESSIONS:-10}
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-20}
CORE_MEMORY_MB: ${CORE_MEMORY_MB:-700}
BRAIN_MEMORY_MB: ${BRAIN_MEMORY_MB:-300}
POSTGRES_MEMORY_MB: ${POSTGRES_MEMORY_MB:-200}
SESSIONS_NETWORK: loremind-demo-sessions
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
# Rate limit : 1 creation par IP par fenetre (en secondes).
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
# Domaine public : propage aux cores de session pour configurer CORS.
DEMO_HOST: ${DEMO_HOST:-loremind-demo.igmlcreation.fr}
networks:
- traefik
- sessions
- socket-proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.loremind-demo.rule=Host(`${DEMO_HOST:-loremind-demo.igmlcreation.fr}`)"
- "traefik.http.routers.loremind-demo.entrypoints=websecure"
- "traefik.http.routers.loremind-demo.tls.certresolver=letsencrypt"
- "traefik.http.services.loremind-demo.loadbalancer.server.port=80"
networks:
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik}
sessions:
# Reseau interne pour les trios de session. Pas d'acces Internet direct
# (sauf via le DNS Docker), pas expose au host.
name: loremind-demo-sessions
driver: bridge
socket-proxy:
# Reseau prive entre dockerproxy et orchestrateur. Isole du reste.
name: loremind-demo-socket-proxy
driver: bridge
internal: true

View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1.6
# Build context attendu : racine du repo LoreMind.
# Appele depuis demo/docker-compose.infra.yml avec context: ../
# --- Etage 1 : build Angular statique ---
FROM node:20-alpine AS web-build
WORKDIR /build
COPY web/package*.json ./
RUN npm ci
COPY web/ .
RUN npm run build -- --configuration production
# --- Etage 2 : build orchestrateur Go ---
# go 1.25+ requis par une dependance transitive de github.com/docker/docker
# (otelhttp v0.68+ impose cette version minimale).
FROM golang:1.25-alpine AS go-build
WORKDIR /src
COPY demo/orchestrator/ ./
# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer.
RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator .
# --- Etage final : runtime minimal ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=go-build /orchestrator /app/orchestrator
COPY --from=web-build /build/dist/web /app/static
COPY demo/orchestrator/preparing.html /app/preparing.html
EXPOSE 80
ENV STATIC_DIR=/app/static \
PREPARING_PAGE=/app/preparing.html
ENTRYPOINT ["/app/orchestrator"]

View File

@@ -0,0 +1,68 @@
package main
import (
"log"
"os"
"strconv"
"time"
)
// Config centralise les parametres lus depuis les variables d'env au boot.
type Config struct {
Registry string
Tag string
MaxSessions int
SessionTTL time.Duration
CoreMemoryBytes int64
BrainMemoryBytes int64
PostgresMemoryBytes int64
SessionsNetwork string
BrainSecretDefault string
StaticDir string
PreparingPage string
RateLimitWindow time.Duration
MaxBodyBytes int64
DemoHost string
}
func loadConfig() *Config {
return &Config{
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
Tag: envStr("TAG", "latest"),
MaxSessions: envInt("MAX_SESSIONS", 10),
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,
CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024,
BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024,
PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024,
SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"),
BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"),
StaticDir: envStr("STATIC_DIR", "/app/static"),
PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"),
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
// 10 Mo : aligne avec la limite d'upload d'image cote core.
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
// Utilise pour injecter APP_CORS_ALLOWED_ORIGINS dans les cores spawnes :
// sans ca, Spring bloque les POST avec 403 (origine rejetee).
DemoHost: envStr("DEMO_HOST", "loremind-demo.igmlcreation.fr"),
}
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
i, err := strconv.Atoi(v)
if err != nil {
log.Printf("warning: env %s=%q not a number, using default %d", key, v, def)
return def
}
return i
}

339
demo/orchestrator/docker.go Normal file
View File

@@ -0,0 +1,339 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// DockerClient parle a l'API Engine Docker en HTTP brut via le dockerproxy.
// Pas de SDK externe : evite les conflits de versions transitives qui
// rendaient github.com/docker/docker v27/v28 ininstallable proprement.
//
// L'API Engine v1.43 est exposee par Docker Engine 24+ (et le dockerproxy
// la supporte sans config supplementaire).
type DockerClient struct {
baseURL string
http *http.Client
}
func newDockerClient() (*DockerClient, error) {
base := os.Getenv("DOCKER_HOST")
if base == "" {
return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)")
}
// tcp://host:port -> http://host:port (le dockerproxy parle HTTP en clair).
base = strings.Replace(base, "tcp://", "http://", 1)
return &DockerClient{
baseURL: strings.TrimRight(base, "/") + "/v1.43",
http: &http.Client{Timeout: 60 * time.Second},
}, nil
}
// --- Types serialises vers l'API Engine ---
type containerSpec struct {
Image string `json:"Image"`
Env []string `json:"Env,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
HostConfig hostConfig `json:"HostConfig"`
NetworkingConfig networkingConfig `json:"NetworkingConfig"`
}
type hostConfig struct {
Memory int64 `json:"Memory,omitempty"`
NanoCPUs int64 `json:"NanoCPUs,omitempty"`
PidsLimit int64 `json:"PidsLimit,omitempty"`
Tmpfs map[string]string `json:"Tmpfs,omitempty"`
SecurityOpt []string `json:"SecurityOpt,omitempty"`
RestartPolicy restartPolicy `json:"RestartPolicy"`
}
type restartPolicy struct {
Name string `json:"Name"`
}
type networkingConfig struct {
EndpointsConfig map[string]endpointSettings `json:"EndpointsConfig,omitempty"`
}
type endpointSettings struct {
Aliases []string `json:"Aliases,omitempty"`
}
// runSpec : forme intermediate cote orchestrateur, mappee sur containerSpec
// au moment d'envoyer la requete.
type runSpec struct {
Name string
Image string
Env []string
Labels map[string]string
Memory int64
Tmpfs map[string]string
Net string
Alias string
}
// --- Operations de haut niveau ---
// SpawnTrio cree postgres + brain + core pour une session.
func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error {
pgName := "demo-" + sessionID + "-postgres"
brainName := "demo-" + sessionID + "-brain"
coreName := "demo-" + sessionID + "-core"
pgPassword := randomHex(16)
brainSecret := randomHex(32)
adminPassword := randomHex(16)
labels := map[string]string{"demo-session": sessionID}
if err := d.runContainer(ctx, runSpec{
Name: pgName,
Image: "postgres:16-alpine",
Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword},
Labels: copyLabels(labels, "postgres"),
Memory: cfg.PostgresMemoryBytes,
Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"},
Net: cfg.SessionsNetwork,
Alias: pgName,
}); err != nil {
return fmt.Errorf("spawn postgres: %w", err)
}
if err := d.runContainer(ctx, runSpec{
Name: brainName,
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
Env: []string{
"INTERNAL_SHARED_SECRET=" + brainSecret,
// Pas de provider LLM configure en demo : les features IA echoueront
// proprement, la demo sert principalement a explorer l'edition.
"LLM_PROVIDER=ollama",
"OLLAMA_BASE_URL=http://localhost:1",
},
Labels: copyLabels(labels, "brain"),
Memory: cfg.BrainMemoryBytes,
Net: cfg.SessionsNetwork,
Alias: brainName,
}); err != nil {
return fmt.Errorf("spawn brain: %w", err)
}
if err := d.runContainer(ctx, runSpec{
Name: coreName,
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
Env: []string{
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
"SPRING_DATASOURCE_USERNAME=loremind",
"SPRING_DATASOURCE_PASSWORD=" + pgPassword,
"BRAIN_BASE_URL=http://" + brainName + ":8000",
"BRAIN_INTERNAL_SECRET=" + brainSecret,
"ADMIN_USERNAME=admin",
"ADMIN_PASSWORD=" + adminPassword,
"DEMO_MODE=true",
// CorsConfig.java lit app.cors.allowed-origins (= APP_CORS_ALLOWED_ORIGINS
// via le relaxed binding Spring). Necessaire meme en same-origin car
// le browser envoie Origin sur les POST et le CorsFilter 403 les
// origines inconnues.
"APP_CORS_ALLOWED_ORIGINS=https://" + cfg.DemoHost,
},
Labels: copyLabels(labels, "core"),
Memory: cfg.CoreMemoryBytes,
Net: cfg.SessionsNetwork,
Alias: coreName,
}); err != nil {
return fmt.Errorf("spawn core: %w", err)
}
return nil
}
func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error {
// Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra.
_ = d.pullImage(ctx, s.Image)
spec := containerSpec{
Image: s.Image,
Env: s.Env,
Labels: s.Labels,
HostConfig: hostConfig{
Memory: s.Memory,
NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur
PidsLimit: 200, // anti fork-bomb
Tmpfs: s.Tmpfs,
SecurityOpt: []string{"no-new-privileges:true"},
RestartPolicy: restartPolicy{Name: "no"},
},
NetworkingConfig: networkingConfig{
EndpointsConfig: map[string]endpointSettings{
s.Net: {Aliases: []string{s.Alias}},
},
},
}
body, err := json.Marshal(spec)
if err != nil {
return err
}
createResp, err := d.do(ctx, "POST", "/containers/create?name="+url.QueryEscape(s.Name), body)
if err != nil {
return fmt.Errorf("create %s: %w", s.Name, err)
}
var created struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(createResp, &created); err != nil {
return fmt.Errorf("parse create %s: %w", s.Name, err)
}
if _, err := d.do(ctx, "POST", "/containers/"+created.ID+"/start", nil); err != nil {
return fmt.Errorf("start %s: %w", s.Name, err)
}
return nil
}
// pullImage drain le flux de progression. Erreur silencieuse : si le pull
// echoue (registre prive sans auth, image deja locale), runContainer aura un
// retour clair via ContainerCreate.
func (d *DockerClient) pullImage(ctx context.Context, img string) error {
req, err := http.NewRequestWithContext(ctx, "POST",
d.baseURL+"/images/create?fromImage="+url.QueryEscape(img), nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("pull %s: status %d", img, resp.StatusCode)
}
return nil
}
// WaitReady poll l'endpoint /api/config du core jusqu'a 200 ou timeout.
func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
target := "http://demo-" + sessionID + "-core:8080/api/config"
c := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
resp, err := c.Get(target)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return true
}
}
select {
case <-ctx.Done():
return false
case <-time.After(2 * time.Second):
}
}
return false
}
// KillTrio supprime tous les conteneurs labellises demo-session=<id>.
func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error {
containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID)
if err != nil {
return err
}
for _, c := range containers {
_, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil)
}
return nil
}
// ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins.
func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) {
containers, err := d.listContainersWithLabel(ctx, "demo-session")
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, c := range containers {
if v, ok := c.Labels["demo-session"]; ok && v != "" {
seen[v] = true
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out, nil
}
type containerInfo struct {
ID string `json:"Id"`
Labels map[string]string `json:"Labels"`
}
func (d *DockerClient) listContainersWithLabel(ctx context.Context, label string) ([]containerInfo, error) {
filters := map[string][]string{"label": {label}}
filtersJSON, _ := json.Marshal(filters)
q := url.Values{}
q.Set("all", "true")
q.Set("filters", string(filtersJSON))
body, err := d.do(ctx, "GET", "/containers/json?"+q.Encode(), nil)
if err != nil {
return nil, err
}
var list []containerInfo
if err := json.Unmarshal(body, &list); err != nil {
return nil, err
}
return list, nil
}
// do envoie une requete et renvoie le body. Une reponse 4xx/5xx est convertie
// en erreur avec le contenu pour faciliter le debug.
func (d *DockerClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, d.baseURL+path, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := d.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, resp.StatusCode, out)
}
return out, nil
}
// --- helpers ---
func copyLabels(base map[string]string, role string) map[string]string {
out := make(map[string]string, len(base)+1)
for k, v := range base {
out[k] = v
}
out["demo-role"] = role
return out
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

8
demo/orchestrator/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module github.com/loremind/demo-orchestrator
go 1.23
// Aucune dependance externe : on parle a Docker Engine en HTTP brut
// (cf. docker.go) plutot que d'utiliser github.com/docker/docker, dont le
// graphe transitif est instable d'une version a l'autre (sockets.DialPipe,
// errors.As/Is, otelhttp...).

231
demo/orchestrator/main.go Normal file
View File

@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const cookieName = "loremind-demo-session"
func main() {
cfg := loadConfig()
docker, err := newDockerClient()
if err != nil {
log.Fatalf("docker init: %v", err)
}
mgr := newManager(docker, cfg)
limiter := newRateLimiter(cfg.RateLimitWindow)
// Nettoyage des sessions residuelles au boot (redemarrage orchestrateur).
cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
mgr.CleanupOrphans(cleanCtx)
cancel()
go mgr.RunGC(context.Background())
mux := http.NewServeMux()
mux.HandleFunc("/_demo/ready", readyHandler(mgr))
mux.HandleFunc("/api/", apiHandler(mgr, cfg))
mux.HandleFunc("/", rootHandler(mgr, limiter, cfg))
srv := &http.Server{
Addr: ":80",
Handler: mux,
// Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le
// streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 10 * time.Minute,
IdleTimeout: 120 * time.Second,
// Headers max : 1 Mo (defaut Go), suffisant.
}
log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)",
cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("http server: %v", err)
}
}
// rootHandler gere toutes les routes non-API : sert l'Angular statique si le
// visiteur a deja une session prete, sinon cree une session (sous rate limit)
// et renvoie la page de preparation.
func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
// Visiteur connu et session prete -> sert l'app normalement.
if sess != nil && sess.Status == StatusReady {
serveStatic(w, r, cfg.StaticDir)
return
}
// On ne spawn qu'a la navigation initiale (GET d'un document HTML).
// Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher
// de nouvelle session.
if r.Method != http.MethodGet {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
if !acceptsHTML(r) {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
// Session inexistante (ou expiree) -> en creer une, sous rate limit.
if sess == nil {
ip := clientIP(r)
if !limiter.Allow(ip) {
http.Error(w, "Trop de tentatives. Merci d'attendre "+
strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.",
http.StatusTooManyRequests)
return
}
newSess, err := mgr.Create(r.Context())
if err != nil {
if errors.Is(err, ErrCapacity) {
http.Error(w, "La demo est pleine (max "+
strconv.Itoa(cfg.MaxSessions)+
" sessions simultanees). Merci de reessayer plus tard.",
http.StatusServiceUnavailable)
return
}
http.Error(w, "Impossible de creer la session : "+err.Error(),
http.StatusInternalServerError)
return
}
sess = newSess
setCookie(w, sess.ID, cfg.SessionTTL)
}
servePreparingPage(w, cfg.PreparingPage)
}
}
// apiHandler proxifie /api/* vers le core de la session.
// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire.
func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
if sess == nil {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
if sess.Status != StatusReady {
http.Error(w, "Session not ready", http.StatusServiceUnavailable)
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes)
}
proxy := sessionProxy(sess)
proxy.ServeHTTP(w, r)
}
}
// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la
// session via sync.Once : garantit une seule creation meme sous requetes
// concurrentes, sans mutex explicite.
func sessionProxy(sess *Session) *httputil.ReverseProxy {
sess.proxyOnce.Do(func() {
target, _ := url.Parse("http://" + sess.CoreHost + ":8080")
p := httputil.NewSingleHostReverseProxy(target)
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("proxy error session=%s: %v", sess.ID, err)
http.Error(w, "Upstream error", http.StatusBadGateway)
}
sess.proxy = p
})
return sess.proxy.(*httputil.ReverseProxy)
}
// readyHandler renvoie l'etat de la session en JSON pour le polling client.
// N'expose aucun ID de session ni d'information sur les autres sessions.
func readyHandler(mgr *Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
w.Header().Set("Content-Type", "application/json")
if sess == nil {
json.NewEncoder(w).Encode(map[string]any{"status": "none"})
return
}
json.NewEncoder(w).Encode(map[string]any{
"status": string(sess.Status),
"error": sess.Err,
})
}
}
// currentSession lit le cookie et retrouve la session en memoire.
// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou
// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur.
func currentSession(r *http.Request, mgr *Manager) *Session {
c, err := r.Cookie(cookieName)
if err != nil || c.Value == "" {
return nil
}
return mgr.Get(c.Value)
}
func setCookie(w http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS.
SameSite: http.SameSiteLaxMode,
MaxAge: int(ttl.Seconds()),
})
}
// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html
// pour que le routeur cote client fonctionne (SPA).
// Le check HasPrefix apres Join + Clean empeche les path traversals (..).
func serveStatic(w http.ResponseWriter, r *http.Request, dir string) {
reqPath := r.URL.Path
if reqPath == "/" || reqPath == "" {
reqPath = "/index.html"
}
fullPath := filepath.Join(dir, filepath.Clean(reqPath))
if !strings.HasPrefix(fullPath, dir) {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
http.ServeFile(w, r, fullPath)
return
}
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
}
// servePreparingPage sert la page de chargement statique. Le cookie vient
// d'etre pose, le JS de la page utilisera sessionId implicitement via le
// cookie pour poller /_demo/ready.
func servePreparingPage(w http.ResponseWriter, path string) {
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, "Preparing page not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func acceptsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*"
}

View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LoreMind — Demo en preparation</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #1a1625;
color: #e4def5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
}
.card {
max-width: 440px;
padding: 2.5rem 2rem;
}
.logo {
font-size: 2rem;
color: #b794f4;
margin-bottom: 0.5rem;
}
.subtitle {
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #9880c4;
margin-bottom: 2rem;
}
h1 {
font-size: 1.35rem;
font-weight: 500;
margin: 0 0 1rem;
}
p {
color: #aaa0c5;
line-height: 1.6;
font-size: 0.95rem;
}
.spinner {
width: 36px;
height: 36px;
margin: 1.5rem auto 0;
border: 3px solid rgba(183, 148, 244, 0.2);
border-top-color: #b794f4;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
display: none;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
border-radius: 6px;
font-size: 0.85rem;
}
.error.visible { display: block; }
</style>
</head>
<body>
<main class="card">
<div class="logo">✦ LoreMind</div>
<div class="subtitle">THE DIGITAL CODEX</div>
<h1>Preparation de votre demo…</h1>
<p>
Nous initialisons une instance isolee rien que pour vous.
Cela prend generalement 20 a 40 secondes.
</p>
<p style="font-size: 0.8rem; color: #7d6ba0; margin-top: 1rem;">
Votre session sera automatiquement reinitialisee au bout de 20 minutes.
</p>
<div class="spinner"></div>
<div id="err" class="error"></div>
</main>
<script>
(function () {
var errBox = document.getElementById('err');
var attempts = 0;
var maxAttempts = 90; // 90 * 2s = 3 min max
function poll() {
attempts++;
fetch('/_demo/ready', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'ready') {
window.location.href = '/';
return;
}
if (data.status === 'failed') {
errBox.textContent = 'Echec du demarrage : ' + (data.error || 'raison inconnue') + '. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
if (attempts >= maxAttempts) {
errBox.textContent = 'Timeout. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
setTimeout(poll, 2000);
})
.catch(function () {
if (attempts >= maxAttempts) {
errBox.textContent = 'Connexion perdue. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
setTimeout(poll, 2000);
});
}
poll();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,79 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// rateLimiter autorise au plus une action par IP dans une fenetre glissante.
// Pas de token bucket : pour un endpoint de creation de session, "1 par
// fenetre" est largement suffisant et plus simple a raisonner.
type rateLimiter struct {
mu sync.Mutex
lastSeen map[string]time.Time
window time.Duration
}
func newRateLimiter(window time.Duration) *rateLimiter {
rl := &rateLimiter{
lastSeen: make(map[string]time.Time),
window: window,
}
go rl.cleanupLoop()
return rl
}
// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre.
// Sur true, l'horloge de l'IP est reinitialisee.
func (rl *rateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window {
return false
}
rl.lastSeen[ip] = now
return true
}
// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter
// la croissance non bornee de la map sous trafic varie.
func (rl *rateLimiter) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-2 * rl.window)
rl.mu.Lock()
for ip, t := range rl.lastSeen {
if t.Before(cutoff) {
delete(rl.lastSeen, ip)
}
}
rl.mu.Unlock()
}
}
// clientIP extrait l'IP reelle du visiteur en tenant compte du setup reverse-proxy.
// Ordre de priorite :
// 1. CF-Connecting-IP : defini par Cloudflare sur la base de SA propre vue du
// peer TCP, non-forgeable par le client, ecrase toute valeur entrante.
// 2. X-Forwarded-For, derniere entree : quand seul Traefik est en front (pas
// de Cloudflare), Traefik append l'IP qu'il observe. Prendre la premiere
// serait une faille (header forgeable).
// 3. RemoteAddr : fallback si aucun header de proxy n'est present.
func clientIP(r *http.Request) string {
if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
return strings.TrimSpace(cfIP)
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View File

@@ -0,0 +1,177 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log"
"sync"
"time"
)
// SessionStatus reflete l'etat du cycle de vie d'un trio de session.
type SessionStatus string
const (
StatusStarting SessionStatus = "starting"
StatusReady SessionStatus = "ready"
StatusFailed SessionStatus = "failed"
)
// Session represente une demo isolee pour un visiteur.
// CoreHost est le hostname Docker interne du conteneur core de cette session
// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*.
type Session struct {
ID string
CreatedAt time.Time
Status SessionStatus
CoreHost string
Err string
// proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via
// sync.Once (evite la race entre deux requetes concurrentes sur la meme
// session). proxy est typee any pour ne pas contraindre sessions.go a
// importer net/http/httputil.
proxy any
proxyOnce sync.Once
}
// Manager gere le cycle de vie des sessions (creation, acces, cleanup).
// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP
// handlers + goroutine de GC).
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
docker *DockerClient
cfg *Config
}
func newManager(docker *DockerClient, cfg *Config) *Manager {
return &Manager{
sessions: make(map[string]*Session),
docker: docker,
cfg: cfg,
}
}
// ErrCapacity est retournee quand MAX_SESSIONS est atteint.
var ErrCapacity = errors.New("demo at capacity")
// Create reserve un slot et lance le spawn des conteneurs en arriere-plan.
// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand
// les conteneurs sont up et que core repond a /api/config.
func (m *Manager) Create(ctx context.Context) (*Session, error) {
m.mu.Lock()
if len(m.sessions) >= m.cfg.MaxSessions {
m.mu.Unlock()
return nil, ErrCapacity
}
id := newShortID()
sess := &Session{
ID: id,
CreatedAt: time.Now(),
Status: StatusStarting,
CoreHost: "demo-" + id + "-core",
}
m.sessions[id] = sess
m.mu.Unlock()
// Spawn asynchrone : l'utilisateur voit immediatement la page "preparation".
go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil {
log.Printf("session %s spawn failed: %v", id, err)
m.mu.Lock()
sess.Status = StatusFailed
sess.Err = err.Error()
m.mu.Unlock()
// Nettoyage best-effort des conteneurs partiellement crees.
_ = m.docker.KillTrio(context.Background(), id)
return
}
// Attente que core reponde (sinon proxy retourne 502 aux premieres requetes).
if m.docker.WaitReady(spawnCtx, id, 90*time.Second) {
m.mu.Lock()
sess.Status = StatusReady
m.mu.Unlock()
log.Printf("session %s ready", id)
} else {
log.Printf("session %s never became ready", id)
m.mu.Lock()
sess.Status = StatusFailed
sess.Err = "timeout waiting for core"
m.mu.Unlock()
_ = m.docker.KillTrio(context.Background(), id)
}
}()
return sess, nil
}
// Get renvoie la session associee a un ID, ou nil si elle n'existe plus.
func (m *Manager) Get(id string) *Session {
m.mu.Lock()
defer m.mu.Unlock()
return m.sessions[id]
}
// RunGC boucle toutes les minutes pour supprimer les sessions expirees.
// A lancer en goroutine au demarrage.
func (m *Manager) RunGC(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.gcOnce()
}
}
}
func (m *Manager) gcOnce() {
cutoff := time.Now().Add(-m.cfg.SessionTTL)
m.mu.Lock()
var expired []string
for id, s := range m.sessions {
if s.CreatedAt.Before(cutoff) {
expired = append(expired, id)
}
}
for _, id := range expired {
delete(m.sessions, id)
}
m.mu.Unlock()
for _, id := range expired {
log.Printf("session %s expired, killing containers", id)
if err := m.docker.KillTrio(context.Background(), id); err != nil {
log.Printf("kill %s: %v", id, err)
}
}
}
// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune
// session en memoire. Appele au demarrage pour gerer un redemarrage brutal.
func (m *Manager) CleanupOrphans(ctx context.Context) {
ids, err := m.docker.ListSessionIDs(ctx)
if err != nil {
log.Printf("list orphans: %v", err)
return
}
for _, id := range ids {
log.Printf("cleaning orphan session %s", id)
_ = m.docker.KillTrio(ctx, id)
}
}
// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits).
// 128 bits d'entropie rendent les collisions et le brute-force statistiquement
// impossibles, meme si un attaquant pouvait tenter des millions de cookies.
func newShortID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

17
docker-compose.e2e.yml Normal file
View File

@@ -0,0 +1,17 @@
# Override pour la CI E2E : build les images depuis les sources au lieu de les puller.
# Usage : docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
services:
core:
build:
context: ./core
image: loremind-core:e2e
brain:
build:
context: ./brain
image: loremind-brain:e2e
web:
build:
context: ./web
image: loremind-web:e2e

View File

@@ -60,8 +60,15 @@ services:
"
core:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
# Defaut : GHCR (registry public, reputation domaine elevee).
# Pour les anciennes installs qui pointaient sur Gitea, REGISTRY et
# IMAGE_NAMESPACE peuvent etre overrides dans .env :
# REGISTRY=git.igmlcreation.fr
# IMAGE_NAMESPACE=ietm64/ (le slash final est important : voir image: ci-dessous)
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}core:${TAG:-latest}
container_name: loremind-core
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
postgres:
condition: service_healthy
@@ -79,14 +86,50 @@ services:
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
# Detection des mises a jour : interroge le registry et delegue le pull/restart
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
# (l'UI masque le badge et le bouton).
UPDATE_CHECK_REGISTRY: ${REGISTRY:-ghcr.io}
UPDATE_CHECK_IMAGES: ${IMAGE_NAMESPACE:-igmlcreation/loremind-}core,${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain,${IMAGE_NAMESPACE:-igmlcreation/loremind-}web
UPDATE_CHECK_TAG: ${TAG:-latest}
WATCHTOWER_URL: http://watchtower:8080
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
restart: unless-stopped
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
# Active via COMPOSE_PROFILES=local-ollama (gere par l'installeur).
# Si l'utilisateur a deja Ollama sur l'hote, ce service reste inactif et
# OLLAMA_BASE_URL pointe vers http://host.docker.internal:11434.
ollama:
image: ollama/ollama:latest
container_name: loremind-ollama
profiles: ["local-ollama"]
volumes:
- ollama-data:/root/.ollama
# Port expose sur loopback uniquement pour debug / pull manuel de modeles.
ports:
- "127.0.0.1:11434:11434"
# GPU NVIDIA si disponible (silencieusement ignore sinon).
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart: unless-stopped
brain:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain:${TAG:-latest}
container_name: loremind-brain
labels:
- "com.centurylinklabs.watchtower.enable=true"
environment:
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
# Defaut = Ollama embarque (service ollama du compose).
# L'installeur reecrit cette valeur en http://host.docker.internal:11434
# si l'utilisateur choisit le mode "Ollama deja installe sur l'hote".
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
LLM_MODEL: ${LLM_MODEL:-gemma4:26b}
ONEMIN_API_KEY: ${ONEMIN_API_KEY:-}
ONEMIN_MODEL: ${ONEMIN_MODEL:-gpt-4o-mini}
@@ -100,8 +143,10 @@ services:
restart: unless-stopped
web:
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}web:${TAG:-latest}
container_name: loremind-web
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- core
- brain
@@ -109,7 +154,40 @@ services:
- "${WEB_PORT:-8081}:80"
restart: unless-stopped
# Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
# compatibilite de version a verifier manuellement).
watchtower:
# Fork maintenu de containrrr/watchtower (l'original est abandonne depuis
# ~2023 et son client Docker API est trop vieux pour les versions recentes
# de Docker Desktop -- erreur "client version 1.25 is too old").
# nickfedor/watchtower est un drop-in : memes variables d'environnement,
# meme API HTTP, juste l'image change.
image: nickfedor/watchtower:latest
container_name: loremind-watchtower
profiles: ["autoupdate"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_LABEL_ENABLE: "true"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_RESTARTING: "true"
# MONITOR_ONLY=true => detecte sans appliquer (l'UI declenche manuellement).
# MONITOR_ONLY=false => applique automatiquement selon WATCHTOWER_SCHEDULE.
WATCHTOWER_MONITOR_ONLY: "${WATCHTOWER_MONITOR_ONLY:-false}"
WATCHTOWER_SCHEDULE: "${WATCHTOWER_SCHEDULE:-0 0 4 * * *}"
# API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower).
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true"
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:?set WATCHTOWER_TOKEN in .env (re-run installer)}"
WATCHTOWER_TIMEOUT: 60s
WATCHTOWER_NOTIFICATIONS_LEVEL: info
TZ: ${TZ:-Europe/Paris}
restart: unless-stopped
volumes:
postgres-data:
minio-data:
brain-data:
ollama-data:

207
installers/README.md Normal file
View File

@@ -0,0 +1,207 @@
# LoreMindMJ — Installation rapide
Ces scripts installent Docker (si nécessaire), génèrent un `.env` sécurisé
et lancent la stack. Aucune configuration manuelle requise.
## Windows 10 / 11
**Procédure recommandée :**
1. Téléchargez les trois fichiers suivants dans un même dossier
(par ex. `Téléchargements\LoreMind\`) :
- [`install.bat`](install.bat) — lanceur
- [`install.ps1`](install.ps1) — script principal
- [`secure-host-ollama.ps1`](secure-host-ollama.ps1) — *uniquement si vous avez déjà Ollama sur votre PC*
2. **Clic-droit** sur `install.bat`**Exécuter en tant qu'administrateur**.
3. Acceptez le prompt UAC.
Le script :
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
2. Vérifie / installe **Docker Desktop** via `winget`.
3. Vous demande quelques choix (admin, fournisseur LLM, mode Ollama, mises à jour auto).
4. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
5. Lance la stack et ouvre `http://localhost:8081`.
Le `install.bat` sert juste à lancer `install.ps1` proprement (avec UAC + ExecutionPolicy
adaptée à la session, sans modifier les paramètres système). Il est purement
déclaratif et auditable en quelques lignes.
## Linux (Debian / Ubuntu / Fedora / Arch)
```bash
curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
```
Le script :
1. Installe **Docker** via le script officiel `get.docker.com` si absent.
2. Ajoute l'utilisateur courant au groupe `docker` (relogin nécessaire la 1ʳᵉ fois).
3. Installe dans `~/.local/share/loremind`.
4. Lance la stack et ouvre `http://localhost:8081`.
## Mode Ollama (moteur LLM local)
Pendant l'installation, l'installeur pose deux questions successives pour
déterminer comment LoreMind utilisera Ollama :
### 1. *« Avez-vous déjà Ollama installé sur cette machine ? »*
#### Réponse : **Oui** → mode **hôte sécurisé**
L'installeur appelle automatiquement le helper `secure-host-ollama.{sh,ps1}`
qui configure votre Ollama existant pour qu'il soit joignable par le conteneur
Docker LoreMind **sans être exposé sur le réseau local ni Internet**.
- **Linux** : Ollama écoute sur l'IP de la passerelle Docker (`172.17.0.1`
par défaut). Cette IP n'est jamais routée hors de la machine. Override
systemd écrit dans `/etc/systemd/system/ollama.service.d/loremind-host.conf`.
- **Windows** : Ollama écoute sur `0.0.0.0` (techniquement nécessaire avec
Docker Desktop) mais le pare-feu Windows est configuré pour ne **laisser
passer que** le loopback et les sous-réseaux Docker Desktop. Règles
ajoutées préfixées `LoreMind-Ollama-*`.
L'URL configurée dans `.env` est `OLLAMA_BASE_URL=http://host.docker.internal:11434`.
#### Réponse : **Non** → l'installeur pose la question 2.
### 2. *« Voulez-vous installer Ollama via Docker maintenant ? »*
#### Réponse : **Oui (défaut)** → mode **embarqué**
Un service `ollama` est ajouté à la stack via le profile Docker `local-ollama`.
Ollama tourne dans un conteneur dédié, sur le réseau interne Docker, **jamais
exposé au LAN ni à Internet**. Les modèles sont stockés dans le volume
Docker `ollama-data` (persistants entre redémarrages et mises à jour).
- URL : `OLLAMA_BASE_URL=http://ollama:11434` (DNS interne Docker).
- Aucune configuration réseau ou pare-feu requise.
- Support GPU NVIDIA automatique si disponible.
Pour télécharger un modèle :
```bash
docker exec -it loremind-ollama ollama pull gemma3:27b
docker exec -it loremind-ollama ollama list
```
#### Réponse : **Non** → mode **différé**
Aucune configuration Ollama n'est appliquée. L'installeur termine sans
Ollama. Vous configurez Ollama plus tard via la page **Paramètres** de LoreMind
en y indiquant l'URL de votre serveur Ollama.
### Lancer le helper de sécurisation manuellement
Si vous avez choisi le mode différé puis installé Ollama plus tard sur votre
poste, ou si vous voulez basculer du mode embarqué vers le mode hôte :
**Linux :**
```bash
bash secure-host-ollama.sh
# Puis dans .env du dossier d'installation :
# OLLAMA_BASE_URL=http://host.docker.internal:11434
# Et : docker compose up -d
```
**Windows (PowerShell admin) :**
```powershell
.\secure-host-ollama.ps1
# Puis editez .env (dans %LOCALAPPDATA%\LoreMind\) :
# OLLAMA_BASE_URL=http://host.docker.internal:11434
# Et : docker compose up -d
```
Les helpers sont **réexécutables sans risque** : ils suppriment leurs
anciennes règles avant de les recréer. Utile par exemple si vous avez
réinitialisé Docker Desktop et que les sous-réseaux ont changé.
### Annuler la configuration de sécurisation
**Linux :**
```bash
sudo rm /etc/systemd/system/ollama.service.d/loremind-host.conf
sudo systemctl daemon-reload && sudo systemctl restart ollama
```
**Windows (PowerShell admin) :**
```powershell
Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule
[Environment]::SetEnvironmentVariable("OLLAMA_HOST", $null, "User")
```
## Variables disponibles
| Variable | Défaut | Effet |
|-------------------|---------------------------------|----------------------------------------|
| `WEB_PORT` | `8081` | Port HTTP de l'UI |
| `INSTALL_DIR` | `~/.local/share/loremind` (Lin) | Dossier d'installation |
| `NON_INTERACTIVE` | `0` | `1` = aucune question, valeurs par défaut |
Exemple Linux non-interactif sur port 9000 :
```bash
WEB_PORT=9000 NON_INTERACTIVE=1 bash install.sh
```
## Mises à jour automatiques (Watchtower)
Si vous avez répondu **oui** à la question "Activer les mises à jour auto",
un container [Watchtower](https://containrrr.dev/watchtower/) est lancé en
parallèle. Il vérifie chaque nuit à 4h les nouvelles versions de
`core`, `brain` et `web` sur le registry, télécharge et redémarre les
conteneurs concernés. **Postgres et MinIO sont volontairement exclus**
(données persistantes — montée de version à valider manuellement).
### Activer / désactiver après coup
Éditer `.env` dans le dossier d'installation :
```env
COMPOSE_PROFILES=autoupdate # active
COMPOSE_PROFILES= # desactive
```
Puis :
```bash
docker compose up -d # applique le changement
docker compose stop watchtower # si on vient de le desactiver
```
### Changer l'horaire
`WATCHTOWER_SCHEDULE` dans `.env` accepte la syntaxe
[cron 6 champs](https://pkg.go.dev/github.com/robfig/cron) (sec min h jour mois j-sem).
Exemples : `0 0 4 * * *` (4h du matin, défaut), `0 30 3 * * 0` (dimanche 3h30).
### Mode "notification seulement" (sans auto-apply)
Si vous préférez être notifié *sans* que les conteneurs redémarrent
automatiquement la nuit, éditez `.env` :
```env
WATCHTOWER_MONITOR_ONLY=true
```
Puis `docker compose up -d watchtower`. Watchtower continuera à vérifier
le registry chaque nuit, le badge **MAJ** apparaîtra dans la sidebar de
l'UI, et un bouton **Mettre à jour maintenant** sera disponible dans
*Paramètres → Mises à jour*.
### Mise à jour manuelle (à tout moment)
Depuis l'interface : *Paramètres → Mises à jour → Mettre à jour maintenant*.
Ou en CLI :
```bash
docker compose pull && docker compose up -d
```
## Désinstallation
```bash
cd <dossier d'install>
docker compose down -v # -v supprime aussi les volumes (données effacées !)
```
Puis supprimer le dossier d'installation.

59
installers/install.bat Normal file
View File

@@ -0,0 +1,59 @@
@echo off
REM ============================================================================
REM LoreMindMJ - Lanceur Windows pour install.ps1
REM ----------------------------------------------------------------------------
REM Procedure :
REM 1. Clic-DROIT sur ce fichier (install.bat)
REM 2. Choisir "Executer en tant qu'administrateur"
REM 3. Accepter le prompt UAC
REM ============================================================================
setlocal
title LoreMindMJ - Installeur
echo.
echo ============================================================
echo LoreMindMJ - Installeur Windows
echo ============================================================
echo.
REM --- Verification des droits administrateur --------------------------------
net session >nul 2>&1
if %errorlevel% NEQ 0 (
echo [ERREUR] Ce script doit etre execute en tant qu'administrateur.
echo.
echo Procedure :
echo 1. Fermez cette fenetre.
echo 2. Clic-DROIT sur install.bat ^> "Executer en tant qu'administrateur".
echo 3. Acceptez le prompt UAC.
echo.
pause
exit /b 1
)
REM --- Verification de la presence d'install.ps1 -----------------------------
if not exist "%~dp0install.ps1" (
echo [ERREUR] install.ps1 introuvable dans le meme dossier que ce .bat.
echo Dossier attendu : %~dp0
echo.
pause
exit /b 1
)
REM --- Lancement du script PowerShell ----------------------------------------
REM -ExecutionPolicy Bypass : uniquement pour cette session, ne modifie pas
REM les parametres systeme.
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
set "PS_EXIT=%errorlevel%"
echo.
if %PS_EXIT% EQU 0 (
echo Installation terminee avec succes.
) else (
echo [ATTENTION] Le script PowerShell s'est termine avec le code %PS_EXIT%.
)
echo.
pause
endlocal

442
installers/install.ps1 Normal file
View File

@@ -0,0 +1,442 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Installeur officiel de LoreMindMJ pour Windows 10/11.
.DESCRIPTION
Script d'installation pas-a-pas qui :
- Verifie la presence de WSL2 et Docker Desktop ; les installe via winget si absents
- Telecharge le fichier docker-compose.yml officiel depuis le depot du projet
- Genere un fichier .env contenant des secrets aleatoires (RNG cryptographique)
- Configure le mode Ollama (embarque dans Docker ou Ollama deja installe sur l'hote)
- Demarre la stack Docker et ouvre l'application dans le navigateur
Aucune connexion sortante n'est etablie en dehors :
- du depot officiel du projet (fichier docker-compose.yml)
- du Docker Hub / registry Docker pour les images
Le code source de ce script est public et auditable a l'adresse indiquee dans .LINK.
.PARAMETER InstallDir
Dossier d'installation. Defaut : %LOCALAPPDATA%\LoreMind
.PARAMETER ComposeUrl
URL du fichier docker-compose.yml a recuperer. Defaut : version officielle du depot.
.PARAMETER WebPort
Port HTTP local sur lequel l'application sera exposee. Defaut : 8081.
.PARAMETER NonInteractive
Mode automatique pour CI / re-installation. Utilise les valeurs par defaut.
.EXAMPLE
Procedure recommandee :
1. Telechargez install.ps1 dans un dossier (clic droit -> Enregistrer la cible sous).
2. Ouvrez PowerShell en tant qu'administrateur (clic droit sur PowerShell).
3. Naviguez vers le dossier : cd C:\Chemin\Vers\Le\Dossier
4. Lancez : .\install.ps1
.NOTES
Auteur : ietm64
Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
Version : 0.6.14
.LINK
https://github.com/IGMLcreation/LoreMind
#>
[CmdletBinding()]
param(
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
[string]$ComposeUrl = "https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml",
[int]$WebPort = 8081,
[switch]$NonInteractive
)
$ErrorActionPreference = 'Stop'
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host " !! $msg" -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host " XX $msg" -ForegroundColor Red }
function Test-Admin {
# Verifie si la session courante a les droits administrateur Windows.
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$current).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function New-RandomSecret([int]$Length = 32) {
# Genere un secret aleatoire imprimable (hex) via le RNG cryptographique
# de .NET. Utilise pour les mots de passe Postgres / MinIO / tokens internes
# afin que chaque installation ait des credentials uniques.
$bytes = New-Object byte[] $Length
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
return ([BitConverter]::ToString($bytes) -replace '-','').ToLower().Substring(0, $Length)
}
function Test-Wsl2 {
try {
$out = wsl.exe --status 2>$null
return ($LASTEXITCODE -eq 0)
} catch { return $false }
}
function Test-Docker {
$cmd = Get-Command docker -ErrorAction SilentlyContinue
if (-not $cmd) { return $false }
docker info *>$null
return ($LASTEXITCODE -eq 0)
}
function Wait-Docker([int]$TimeoutSec = 600) {
# Attend que Docker reponde. Tolere les erreurs "command not found" pendant
# les premieres iterations le temps que le PATH soit rafraichi.
Write-Step "Attente du demarrage de Docker Desktop (max ${TimeoutSec}s)..."
Write-Host " Si Docker Desktop affiche un contrat de licence, acceptez-le."
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$reportedFound = $false
while ((Get-Date) -lt $deadline) {
if (Get-Command docker -ErrorAction SilentlyContinue) {
if (-not $reportedFound) {
Write-Ok "Commande 'docker' detectee, attente du daemon..."
$reportedFound = $true
}
docker info *>$null
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
}
Start-Sleep -Seconds 5
}
return $false
}
function Update-PathFromRegistry {
# winget install ne propage pas les modifs de PATH a la session courante.
# On relit la valeur PATH depuis le registre (Machine + User) et on
# l'applique a $env:PATH pour rendre 'docker.exe' immediatement utilisable.
$machinePath = [Environment]::GetEnvironmentVariable('Path','Machine')
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
$env:PATH = ($machinePath, $userPath -join ';').TrimEnd(';')
}
# ---------------------------------------------------------------------------
# 0. Verification des droits administrateur
# ---------------------------------------------------------------------------
# On NE force PAS l'elevation automatique : on demande a l'utilisateur de
# relancer le script lui-meme avec les droits admin. C'est plus transparent
# et evite les avertissements antivirus liees a l'elevation silencieuse.
if (-not (Test-Admin)) {
Write-Host ""
Write-Host "Ce script doit etre execute en tant qu'administrateur." -ForegroundColor Yellow
Write-Host ""
Write-Host "Procedure :"
Write-Host " 1. Fermez cette fenetre PowerShell."
Write-Host " 2. Cliquez-droit sur l'icone PowerShell > 'Executer en tant qu'administrateur'."
Write-Host " 3. Naviguez a nouveau vers ce dossier et relancez : .\install.ps1"
Write-Host ""
Read-Host "Appuyez sur Entree pour quitter"
exit 1
}
Write-Host ""
Write-Host "============================================================"
Write-Host " LoreMindMJ - Installeur Windows" -ForegroundColor Magenta
Write-Host "============================================================"
Write-Host ""
# ---------------------------------------------------------------------------
# 1. WSL2
# ---------------------------------------------------------------------------
Write-Step "Verification de WSL2..."
if (Test-Wsl2) {
Write-Ok "WSL2 deja installe"
} else {
Write-Warn2 "WSL2 absent - installation en cours"
wsl.exe --install --no-launch
Write-Warn2 "REDEMARRAGE REQUIS. Relancez ce script apres reboot."
Read-Host "Appuyez sur Entree pour quitter"
exit 1
}
# ---------------------------------------------------------------------------
# 2. Docker Desktop
# ---------------------------------------------------------------------------
Write-Step "Verification de Docker Desktop..."
if (Test-Docker) {
Write-Ok "Docker fonctionnel"
} else {
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Err "winget introuvable. Installez Docker Desktop manuellement : https://www.docker.com/products/docker-desktop/"
exit 1
}
Write-Warn2 "Installation de Docker Desktop via winget (gestionnaire de paquets officiel Microsoft)..."
# On invoque winget en mode interactif (l'utilisateur voit la progression).
# Les flags --accept-* sont necessaires pour ne pas bloquer sur les CGU
# (Docker Desktop a des conditions d'utilisation a accepter).
winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -ne 0) { Write-Err "Echec de l'installation Docker Desktop via winget"; exit 1 }
# winget a modifie le PATH systeme mais pas celui de la session courante.
# On le rafraichit pour que la commande 'docker' soit immediatement trouvable.
Update-PathFromRegistry
Write-Step "Lancement de Docker Desktop..."
$dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dd) { Start-Process $dd }
Write-Host ""
Write-Host " Docker Desktop demarre pour la premiere fois." -ForegroundColor Yellow
Write-Host " Au premier lancement, il affiche un contrat de licence (Subscription Service Agreement)."
Write-Host " Cliquez 'Accept' pour continuer."
Write-Host ""
Read-Host " Appuyez sur Entree une fois que Docker Desktop affiche 'Engine running' (icone baleine verte)"
if (-not (Wait-Docker 600)) {
Write-Err "Docker ne repond toujours pas apres 10 minutes."
Write-Err "Verifiez que Docker Desktop est lance et que vous avez accepte le contrat,"
Write-Err "puis relancez install.bat."
exit 1
}
}
# ---------------------------------------------------------------------------
# 3. Dossier d'installation + docker-compose.yml
# ---------------------------------------------------------------------------
Write-Step "Preparation du dossier $InstallDir"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Set-Location $InstallDir
$composePath = Join-Path $InstallDir 'docker-compose.yml'
Write-Step "Telechargement de docker-compose.yml depuis le depot officiel"
Write-Host " Source : $ComposeUrl"
# Seul telechargement reseau effectue par ce script. Aucune execution de code
# distant : le fichier est uniquement enregistre sur le disque puis passe a
# 'docker compose' pour interpretation locale.
Invoke-WebRequest -Uri $ComposeUrl -OutFile $composePath -UseBasicParsing
Write-Ok "docker-compose.yml recupere ($composePath)"
# ---------------------------------------------------------------------------
# 4. Generation du .env
# ---------------------------------------------------------------------------
$envPath = Join-Path $InstallDir '.env'
if (Test-Path $envPath) {
Write-Warn2 ".env deja present - sauvegarde en .env.bak"
Copy-Item $envPath "$envPath.bak" -Force
}
Write-Step "Configuration"
$adminUser = if ($NonInteractive) { 'admin' } else {
$r = Read-Host " Nom d'utilisateur admin [admin]"; if ([string]::IsNullOrWhiteSpace($r)) { 'admin' } else { $r }
}
$adminPass = if ($NonInteractive) { New-RandomSecret 16 } else {
$r = Read-Host " Mot de passe admin (vide = genere automatiquement)"
if ([string]::IsNullOrWhiteSpace($r)) { New-RandomSecret 16 } else { $r }
}
$llmProvider = if ($NonInteractive) { 'ollama' } else {
$r = Read-Host " Provider LLM : [ollama] / onemin"
if ($r -eq 'onemin') { 'onemin' } else { 'ollama' }
}
$onemKey = ''
if ($llmProvider -eq 'onemin' -and -not $NonInteractive) {
$onemKey = Read-Host " Cle API 1min.ai"
}
# --- Mode Ollama : 3 options possibles -------------------------------------
# 1. Hote : Ollama est deja installe sur cette machine -> on configure le
# pare-feu pour que Docker puisse l'atteindre sans exposer le port.
# 2. Embarque : Ollama tourne dans un conteneur Docker dedie (profile local-ollama).
# 3. Aucun : on n'installe rien tout de suite. L'utilisateur configurera
# Ollama plus tard via la page Parametres de LoreMind.
$ollamaMode = 'embedded' # valeurs : 'host' | 'embedded' | 'none'
$ollamaBaseUrl = 'http://ollama:11434'
if ($llmProvider -eq 'ollama') {
$hasHostOllama = if ($NonInteractive) { $false } else {
$r = Read-Host " Avez-vous deja Ollama installe sur cette machine ? [o/N]"
($r -match '^(o|O|y|Y|oui|yes)$')
}
if ($hasHostOllama) {
$ollamaMode = 'host'
} else {
# Pas d'Ollama present : proposer l'installation Docker, sinon laisser
# l'utilisateur le configurer plus tard via la page Parametres.
$installViaDocker = if ($NonInteractive) { $true } else {
$r = Read-Host " Voulez-vous installer Ollama via Docker maintenant ? [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
$ollamaMode = if ($installViaDocker) { 'embedded' } else { 'none' }
}
if ($ollamaMode -eq 'host') {
$ollamaBaseUrl = 'http://host.docker.internal:11434'
# Delegue au helper dedie : configure OLLAMA_HOST=0.0.0.0 ET ajoute des
# regles Windows Firewall qui n'autorisent l'acces qu'aux conteneurs
# Docker (loopback + sous-reseaux Docker Desktop). Resultat : Ollama
# n'est pas expose au LAN ni a Internet.
$secureHelper = Join-Path $PSScriptRoot 'secure-host-ollama.ps1'
if (Test-Path $secureHelper) {
Write-Step "Configuration securisee d'Ollama hote (helper dedie)..."
try {
& $secureHelper
} catch {
Write-Warn2 "Le helper secure-host-ollama.ps1 a echoue : $($_.Exception.Message)"
Write-Warn2 "Configurez Ollama manuellement avant de continuer."
}
Write-Host ""
Read-Host "Appuyez sur Entree une fois Ollama redemarre pour continuer l'installation"
} else {
Write-Warn2 "secure-host-ollama.ps1 introuvable a cote de install.ps1."
Write-Warn2 "Telechargez-le depuis le depot et relancez-le manuellement."
}
} elseif ($ollamaMode -eq 'embedded') {
Write-Ok "Ollama sera lance dans Docker (modeles dans un volume Docker dedie)"
} else {
# Mode 'none' : on cible host.docker.internal en supposant qu'Ollama
# sera installe plus tard sur l'hote. L'utilisateur peut aussi changer
# l'URL via la page Parametres pour pointer vers un Ollama distant.
$ollamaBaseUrl = 'http://host.docker.internal:11434'
Write-Warn2 "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de LoreMind."
}
}
$llmModel = 'gemma4:e4b'
$autoUpdate = if ($NonInteractive) { $true } else {
$r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
$profilesList = @()
if ($autoUpdate) { $profilesList += 'autoupdate' }
if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama'){ $profilesList += 'local-ollama' }
$composeProfiles = $profilesList -join ','
$envContent = @"
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
REGISTRY=ghcr.io
IMAGE_NAMESPACE=igmlcreation/loremind-
TAG=latest
WEB_PORT=$WebPort
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=$(New-RandomSecret 24)
ADMIN_USERNAME=$adminUser
ADMIN_PASSWORD=$adminPass
BRAIN_INTERNAL_SECRET=$(New-RandomSecret 32)
MINIO_USER=minioadmin
MINIO_PASSWORD=$(New-RandomSecret 24)
LLM_PROVIDER=$llmProvider
OLLAMA_BASE_URL=$ollamaBaseUrl
LLM_MODEL=$llmModel
ONEMIN_API_KEY=$onemKey
ONEMIN_MODEL=gpt-4o-mini
COMPOSE_PROFILES=$composeProfiles
WATCHTOWER_TOKEN=$(New-RandomSecret 32)
WATCHTOWER_MONITOR_ONLY=false
WATCHTOWER_SCHEDULE=0 0 4 * * *
TZ=Europe/Paris
"@
Set-Content -Path $envPath -Value $envContent -Encoding UTF8
Write-Ok ".env genere ($envPath)"
# ---------------------------------------------------------------------------
# 5. Pull + up
# ---------------------------------------------------------------------------
Write-Step "Telechargement des images Docker (peut prendre quelques minutes)"
docker compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose pull"; exit 1 }
Write-Step "Demarrage de la stack"
docker compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose up"; exit 1 }
# ---------------------------------------------------------------------------
# 5b. Telechargement du modele Ollama (mode embarque uniquement)
# ---------------------------------------------------------------------------
# En mode embarque, le conteneur Ollama est prêt mais ne contient aucun modele
# par defaut. On propose de pull le modele configure tout de suite pour que
# l'utilisateur ait quelque chose a utiliser des le premier lancement.
if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama') {
$pullNow = if ($NonInteractive) { $true } else {
$r = Read-Host " Telecharger le modele '$llmModel' maintenant ? (peut prendre quelques minutes) [O/n]"
-not ($r -match '^(n|N|no|non)$')
}
if ($pullNow) {
# Petite attente pour laisser le conteneur ollama finir son init.
Write-Step "Attente de la disponibilite du conteneur Ollama..."
$ollamaReady = $false
for ($i = 0; $i -lt 30; $i++) {
docker exec loremind-ollama ollama list *>$null
if ($LASTEXITCODE -eq 0) { $ollamaReady = $true; break }
Start-Sleep -Seconds 2
}
if (-not $ollamaReady) {
Write-Warn2 "Le conteneur Ollama ne repond pas encore. Vous pourrez pull le modele plus tard avec :"
Write-Warn2 " docker exec -it loremind-ollama ollama pull $llmModel"
} else {
Write-Step "Telechargement du modele $llmModel (peut prendre plusieurs minutes selon votre connexion)..."
docker exec loremind-ollama ollama pull $llmModel
if ($LASTEXITCODE -eq 0) {
Write-Ok "Modele $llmModel pret a l'emploi"
} else {
Write-Warn2 "Echec du pull. Reessayez manuellement : docker exec -it loremind-ollama ollama pull $llmModel"
}
}
} else {
Write-Host " Pour le telecharger plus tard : docker exec -it loremind-ollama ollama pull $llmModel"
}
}
# ---------------------------------------------------------------------------
# 6. Recap
# ---------------------------------------------------------------------------
$url = "http://localhost:$WebPort"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " LoreMindMJ est lance !" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " URL : $url"
Write-Host " Identifiant : $adminUser"
Write-Host " Mot de passe : $adminPass"
Write-Host " Dossier : $InstallDir"
if ($autoUpdate) {
Write-Host " Auto-update : active (chaque nuit a 4h via Watchtower)" -ForegroundColor Green
} else {
Write-Host " Auto-update : desactive (mise a jour manuelle uniquement)"
}
if ($llmProvider -eq 'ollama') {
switch ($ollamaMode) {
'embedded' {
Write-Host " Ollama : embarque (service Docker 'ollama')" -ForegroundColor Green
Write-Host ""
Write-Host " IMPORTANT : telechargez un modele avant utilisation :"
Write-Host " docker exec -it loremind-ollama ollama pull $llmModel"
}
'host' {
Write-Host " Ollama : hote (configure via secure-host-ollama.ps1)"
}
'none' {
Write-Host " Ollama : non configure - a faire via Parametres dans l'app" -ForegroundColor Yellow
}
}
}
Write-Host ""
Write-Host " Commandes utiles (depuis $InstallDir) :"
Write-Host " docker compose ps # etat"
Write-Host " docker compose logs -f # logs"
Write-Host " docker compose down # arret"
Write-Host " docker compose pull && docker compose up -d # mise a jour"
Write-Host ""
Start-Process $url

306
installers/install.sh Normal file
View File

@@ -0,0 +1,306 @@
#!/usr/bin/env bash
# ==========================================================================
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
# Usage :
# curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
# ==========================================================================
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
COMPOSE_URL="${COMPOSE_URL:-https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml}"
WEB_PORT="${WEB_PORT:-8081}"
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
c_cyan='\033[1;36m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'; c_off='\033[0m'
step() { echo -e "${c_cyan}==> $*${c_off}"; }
ok() { echo -e " ${c_green}OK${c_off} $*"; }
warn() { echo -e " ${c_yellow}!!${c_off} $*"; }
err() { echo -e " ${c_red}XX${c_off} $*" >&2; }
rand_hex() {
# $1 = nb de caracteres hex
local n="${1:-32}"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex $((n / 2))
else
head -c $((n * 2)) /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c "$n"
fi
}
ask() {
# ask "prompt" "default"
local prompt="$1" def="${2:-}" reply
if [ "$NON_INTERACTIVE" = "1" ]; then
echo "$def"; return
fi
if [ -n "$def" ]; then
read -r -p " $prompt [$def] " reply </dev/tty || true
else
read -r -p " $prompt " reply </dev/tty || true
fi
echo "${reply:-$def}"
}
detect_pkg() {
if command -v apt-get >/dev/null 2>&1; then echo apt
elif command -v dnf >/dev/null 2>&1; then echo dnf
elif command -v pacman >/dev/null 2>&1; then echo pacman
else echo unknown
fi
}
install_docker() {
step "Installation de Docker..."
local pm; pm="$(detect_pkg)"
case "$pm" in
apt|dnf|pacman)
# Script officiel Docker (gere apt/dnf/pacman)
curl -fsSL https://get.docker.com | sh
;;
*)
err "Gestionnaire de paquets non reconnu. Installez Docker manuellement : https://docs.docker.com/engine/install/"
exit 1
;;
esac
if ! getent group docker >/dev/null; then sudo groupadd docker || true; fi
sudo usermod -aG docker "$USER" || true
sudo systemctl enable --now docker || true
warn "Vous avez ete ajoute au groupe 'docker'. Si docker echoue ensuite, deconnectez-vous puis reconnectez-vous (ou 'newgrp docker')."
}
# ---------------------------------------------------------------------------
echo
echo "============================================================"
echo -e " ${c_cyan}LoreMindMJ - Installeur Linux${c_off}"
echo "============================================================"
echo
# 1. Docker
step "Verification de Docker..."
if ! command -v docker >/dev/null 2>&1; then
install_docker
elif ! docker info >/dev/null 2>&1; then
warn "Docker installe mais inaccessible (daemon arrete ou groupe docker manquant)"
sudo systemctl start docker || true
if ! docker info >/dev/null 2>&1; then
sudo usermod -aG docker "$USER" || true
err "Re-essayez apres 'newgrp docker' ou une nouvelle session."
exit 1
fi
fi
ok "Docker fonctionnel"
# 2. docker compose v2
step "Verification de docker compose..."
if ! docker compose version >/dev/null 2>&1; then
err "Plugin 'docker compose' manquant. Sur Debian/Ubuntu : sudo apt install docker-compose-plugin"
exit 1
fi
ok "docker compose disponible"
# 3. Dossier + compose
step "Preparation du dossier $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
step "Telechargement de docker-compose.yml"
curl -fsSL "$COMPOSE_URL" -o docker-compose.yml
ok "docker-compose.yml recupere"
# 4. .env
if [ -f .env ]; then
warn ".env existant -> sauvegarde en .env.bak"
cp .env .env.bak
fi
step "Configuration"
ADMIN_USERNAME="$(ask "Nom d'utilisateur admin" "admin")"
ADMIN_PASSWORD="$(ask "Mot de passe admin (vide = genere)" "")"
[ -z "$ADMIN_PASSWORD" ] && ADMIN_PASSWORD="$(rand_hex 16)"
LLM_PROVIDER="$(ask "Provider LLM (ollama / onemin)" "ollama")"
ONEMIN_API_KEY=""
if [ "$LLM_PROVIDER" = "onemin" ] && [ "$NON_INTERACTIVE" != "1" ]; then
ONEMIN_API_KEY="$(ask "Cle API 1min.ai" "")"
fi
# --- Mode Ollama : 3 options possibles -------------------------------------
# 1. host : Ollama deja installe sur la machine -> helper de securisation
# 2. embedded : service 'ollama' du compose (profile local-ollama)
# 3. none : aucune installation, configuration ulterieure via l'app
OLLAMA_MODE="embedded"
OLLAMA_BASE_URL_VAL="http://ollama:11434"
LLM_MODEL_VAL="gemma4:e4b"
if [ "$LLM_PROVIDER" = "ollama" ]; then
HOST_OLLAMA_REPLY="$(ask "Avez-vous deja Ollama installe sur cette machine ? [o/N]" "N")"
case "$HOST_OLLAMA_REPLY" in
o|O|y|Y|oui|yes|Oui|Yes)
OLLAMA_MODE="host"
;;
*)
# Pas d'Ollama present : proposer l'installation Docker.
INSTALL_DOCKER_REPLY="$(ask "Voulez-vous installer Ollama via Docker maintenant ? [O/n]" "O")"
case "$INSTALL_DOCKER_REPLY" in
n|N|no|non|No|Non) OLLAMA_MODE="none" ;;
*) OLLAMA_MODE="embedded" ;;
esac
;;
esac
case "$OLLAMA_MODE" in
host)
OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434"
# Delegue la configuration securisee au helper dedie : il fait
# ecouter Ollama uniquement sur l'IP du bridge Docker (jamais
# exposee au LAN ni a Internet) plutot que sur 0.0.0.0.
SECURE_HELPER="$(dirname -- "$0")/secure-host-ollama.sh"
if [ -f "$SECURE_HELPER" ]; then
step "Configuration securisee d'Ollama hote..."
bash "$SECURE_HELPER" || warn "Le helper secure-host-ollama.sh a echoue. Configurez Ollama manuellement."
else
warn "secure-host-ollama.sh introuvable a cote de install.sh."
warn "Telechargez-le depuis le depot et relancez : bash secure-host-ollama.sh"
fi
;;
embedded)
ok "Ollama sera lance dans Docker (modeles dans un volume Docker)"
;;
none)
# On cible host.docker.internal par defaut en supposant qu'Ollama
# sera installe plus tard sur l'hote. L'utilisateur peut aussi
# changer l'URL via la page Parametres pour un Ollama distant.
OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434"
warn "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de LoreMind."
;;
esac
fi
AUTO_UPDATE_REPLY="$(ask "Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]" "O")"
case "$AUTO_UPDATE_REPLY" in
n|N|no|non|No|Non) AUTO_UPDATE=0 ;;
*) AUTO_UPDATE=1 ;;
esac
# Combinaison de profiles : autoupdate et/ou local-ollama (separes par virgule).
PROFILES_ARR=()
[ "$AUTO_UPDATE" = "1" ] && PROFILES_ARR+=("autoupdate")
if [ "$LLM_PROVIDER" = "ollama" ] && [ "$OLLAMA_MODE" = "embedded" ]; then
PROFILES_ARR+=("local-ollama")
fi
COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
cat > .env <<EOF
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
REGISTRY=ghcr.io
IMAGE_NAMESPACE=igmlcreation/loremind-
TAG=latest
WEB_PORT=${WEB_PORT}
POSTGRES_DB=loremind
POSTGRES_USER=loremind
POSTGRES_PASSWORD=$(rand_hex 24)
ADMIN_USERNAME=${ADMIN_USERNAME}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
BRAIN_INTERNAL_SECRET=$(rand_hex 32)
MINIO_USER=minioadmin
MINIO_PASSWORD=$(rand_hex 24)
LLM_PROVIDER=${LLM_PROVIDER}
OLLAMA_BASE_URL=${OLLAMA_BASE_URL_VAL}
LLM_MODEL=${LLM_MODEL_VAL}
ONEMIN_API_KEY=${ONEMIN_API_KEY}
ONEMIN_MODEL=gpt-4o-mini
COMPOSE_PROFILES=${COMPOSE_PROFILES}
WATCHTOWER_TOKEN=$(rand_hex 32)
WATCHTOWER_MONITOR_ONLY=false
WATCHTOWER_SCHEDULE=0 0 4 * * *
TZ=$(timedatectl show -p Timezone --value 2>/dev/null || echo Europe/Paris)
EOF
chmod 600 .env
ok ".env genere ($INSTALL_DIR/.env)"
# 5. Pull + up
step "Telechargement des images (peut prendre quelques minutes)"
docker compose pull
step "Demarrage de la stack"
docker compose up -d
# 5b. Telechargement du modele Ollama (mode embarque uniquement)
# ----------------------------------------------------------------------------
# Le conteneur Ollama est pret mais sans modele. On propose le pull tout de
# suite pour que l'utilisateur ait quelque chose a utiliser au premier lancement.
if [ "$LLM_PROVIDER" = "ollama" ] && [ "$OLLAMA_MODE" = "embedded" ]; then
PULL_REPLY="$(ask "Telecharger le modele '${LLM_MODEL_VAL}' maintenant ? (peut prendre plusieurs minutes) [O/n]" "O")"
case "$PULL_REPLY" in
n|N|no|non|No|Non)
echo " Pour le telecharger plus tard : docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
;;
*)
step "Attente de la disponibilite du conteneur Ollama..."
OLLAMA_READY=0
for i in $(seq 1 30); do
if docker exec loremind-ollama ollama list >/dev/null 2>&1; then
OLLAMA_READY=1
break
fi
sleep 2
done
if [ "$OLLAMA_READY" = "0" ]; then
warn "Le conteneur Ollama ne repond pas encore. Vous pourrez pull plus tard :"
warn " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
else
step "Telechargement du modele ${LLM_MODEL_VAL} (peut prendre plusieurs minutes selon votre connexion)..."
if docker exec loremind-ollama ollama pull "${LLM_MODEL_VAL}"; then
ok "Modele ${LLM_MODEL_VAL} pret a l'emploi"
else
warn "Echec du pull. Reessayez : docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
fi
fi
;;
esac
fi
# 6. Recap
URL="http://localhost:${WEB_PORT}"
echo
echo -e "${c_green}============================================================${c_off}"
echo -e "${c_green} LoreMindMJ est lance !${c_off}"
echo -e "${c_green}============================================================${c_off}"
echo " URL : $URL"
echo " Identifiant : $ADMIN_USERNAME"
echo " Mot de passe : $ADMIN_PASSWORD"
echo " Dossier : $INSTALL_DIR"
if [ "$AUTO_UPDATE" = "1" ]; then
echo -e " Auto-update : ${c_green}active${c_off} (chaque nuit a 4h via Watchtower)"
else
echo " Auto-update : desactive (mise a jour manuelle uniquement)"
fi
if [ "$LLM_PROVIDER" = "ollama" ]; then
case "$OLLAMA_MODE" in
embedded)
echo -e " Ollama : ${c_green}embarque${c_off} (service Docker 'ollama')"
echo
echo " IMPORTANT : telechargez un modele avant utilisation :"
echo " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}"
;;
host)
echo " Ollama : hote (configure via secure-host-ollama.sh)"
;;
none)
echo -e " Ollama : ${c_yellow}non configure${c_off} - a faire via Parametres dans l'app"
;;
esac
fi
echo
echo " Commandes utiles (depuis $INSTALL_DIR) :"
echo " docker compose ps # etat"
echo " docker compose logs -f # logs"
echo " docker compose down # arret"
echo " docker compose pull && docker compose up -d # mise a jour"
echo
if command -v xdg-open >/dev/null 2>&1; then xdg-open "$URL" >/dev/null 2>&1 || true; fi

View File

@@ -0,0 +1,183 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Configuration securisee d'Ollama hote pour LoreMindMJ (Windows).
.DESCRIPTION
But : permettre au conteneur Docker LoreMind d'atteindre l'Ollama installe
sur l'hote, SANS exposer Ollama sur le LAN ni Internet.
Strategie (specifique a Docker Desktop / WSL2 sur Windows) :
1. Ollama doit ecouter sur 0.0.0.0 (techniquement necessaire car Docker
Desktop sur Windows utilise un reseau Hyper-V / WSL2 separe).
2. On compense en ajoutant des regles Windows Firewall qui :
- BLOQUENT le port 11434 entrant par defaut sur tout profil
- AUTORISENT 11434 uniquement depuis les sous-reseaux Docker Desktop
(detectes dynamiquement) et depuis le loopback.
Resultat : Ollama est joignable par les conteneurs Docker mais
inaccessible depuis le reseau local ou Internet.
.NOTES
Ce script doit etre execute en tant qu'administrateur.
Les regles ajoutees sont prefixees par "LoreMind-Ollama-" pour
faciliter leur identification et suppression ulterieure.
.LINK
https://github.com/IGMLcreation/LoreMind
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host " !! $msg" -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host " XX $msg" -ForegroundColor Red }
# --- 1. Verification admin -------------------------------------------------
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
$isAdmin = ([Security.Principal.WindowsPrincipal]$current).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Err "Ce script doit etre execute en tant qu'administrateur."
Write-Host ""
Write-Host "Procedure : clic-droit sur PowerShell > 'Executer en tant qu'administrateur',"
Write-Host "puis relancez ce script."
Read-Host "Appuyez sur Entree pour quitter"
exit 1
}
# --- 2. Detection des sous-reseaux Docker Desktop --------------------------
Write-Step "Detection des sous-reseaux utilises par Docker Desktop..."
$dockerSubnets = @()
# Methode 1 : interroger Docker pour les bridges actifs.
try {
$networks = docker network ls --filter driver=bridge --format "{{.Name}}" 2>$null
foreach ($net in $networks) {
if ([string]::IsNullOrWhiteSpace($net)) { continue }
$subnet = docker network inspect $net -f "{{range .IPAM.Config}}{{.Subnet}}{{end}}" 2>$null
if (-not [string]::IsNullOrWhiteSpace($subnet)) {
$dockerSubnets += $subnet.Trim()
}
}
} catch {
Write-Warn2 "Impossible d'interroger Docker pour les sous-reseaux. Utilisation des plages par defaut."
}
# Methode 2 : interfaces vEthernet (WSL/DockerNAT) detectees par Windows.
try {
$wslInterfaces = Get-NetIPConfiguration -ErrorAction SilentlyContinue |
Where-Object { $_.InterfaceAlias -match 'vEthernet \(WSL|vEthernet \(Default Switch|vEthernet \(Docker' }
foreach ($iface in $wslInterfaces) {
$ipv4 = $iface.IPv4Address
if ($ipv4 -and $ipv4.IPAddress) {
# On deduit un /24 a partir de l'adresse de l'interface (approximation safe).
$octets = $ipv4.IPAddress.Split('.')
$subnet = "{0}.{1}.{2}.0/24" -f $octets[0], $octets[1], $octets[2]
$dockerSubnets += $subnet
}
}
} catch { }
# Methode 3 : fallback sur les plages connues de Docker Desktop si rien detecte.
if ($dockerSubnets.Count -eq 0) {
Write-Warn2 "Aucun sous-reseau Docker detecte. Utilisation des plages par defaut Docker Desktop."
$dockerSubnets = @(
"172.16.0.0/12", # Plage standard des reseaux bridge Docker
"192.168.65.0/24" # Plage WSL2 / Docker Desktop frequente
)
}
# Deduplication et nettoyage.
$dockerSubnets = $dockerSubnets | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+/\d+$' } | Select-Object -Unique
Write-Ok "Sous-reseaux autorises : $($dockerSubnets -join ', ')"
# --- 3. Variable d'environnement OLLAMA_HOST -------------------------------
Write-Step "Configuration de la variable OLLAMA_HOST..."
[Environment]::SetEnvironmentVariable('OLLAMA_HOST','0.0.0.0:11434','User')
Write-Ok "OLLAMA_HOST=0.0.0.0:11434 definie au niveau utilisateur"
# --- 4. Suppression des anciennes regles LoreMind --------------------------
Write-Step "Nettoyage des anciennes regles Windows Firewall LoreMind..."
$oldRules = Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" -ErrorAction SilentlyContinue
if ($oldRules) {
$oldRules | Remove-NetFirewallRule
Write-Ok "$($oldRules.Count) ancienne(s) regle(s) supprimee(s)"
} else {
Write-Ok "Aucune ancienne regle a supprimer"
}
# --- 5. Creation des regles --------------------------------------------------
Write-Step "Creation des regles Windows Firewall..."
# 5a. Regle de blocage par defaut (priorite la plus basse en cas de conflit :
# les regles Allow ont priorite sur les Block dans Windows Firewall, donc
# ce Block sert de filet final pour tout ce qui n'est pas explicitement
# autorise par les regles ci-dessous).
New-NetFirewallRule `
-DisplayName "LoreMind-Ollama-Block-All" `
-Description "LoreMind: bloque toute connexion entrante Ollama par defaut" `
-Direction Inbound `
-Action Block `
-Protocol TCP `
-LocalPort 11434 `
-Profile Any `
-RemoteAddress Any | Out-Null
Write-Ok "Regle Block-All (port 11434) creee"
# 5b. Regle d'autorisation : loopback uniquement.
New-NetFirewallRule `
-DisplayName "LoreMind-Ollama-Allow-Loopback" `
-Description "LoreMind: autorise Ollama depuis 127.0.0.1" `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 11434 `
-Profile Any `
-RemoteAddress "127.0.0.1" | Out-Null
Write-Ok "Regle Allow-Loopback creee"
# 5c. Regles d'autorisation : sous-reseaux Docker Desktop.
foreach ($subnet in $dockerSubnets) {
$safeName = "LoreMind-Ollama-Allow-Docker-$($subnet -replace '[\./]','_')"
New-NetFirewallRule `
-DisplayName $safeName `
-Description "LoreMind: autorise Ollama depuis le sous-reseau Docker $subnet" `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 11434 `
-Profile Any `
-RemoteAddress $subnet | Out-Null
Write-Ok "Regle Allow-Docker creee pour $subnet"
}
# --- 6. Redemarrage Ollama -------------------------------------------------
Write-Step "Redemarrage d'Ollama pour appliquer OLLAMA_HOST..."
Write-Host ""
Write-Host " Pour que la variable d'environnement prenne effet, vous devez :" -ForegroundColor Yellow
Write-Host " 1. Quitter completement Ollama (icone systray > Quit Ollama)"
Write-Host " 2. Le relancer depuis le menu Demarrer"
Write-Host ""
# --- 7. Recap --------------------------------------------------------------
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Ollama hote configure de maniere securisee" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Adresse d'ecoute : 0.0.0.0:11434 (toutes interfaces)"
Write-Host " Pare-feu Windows : bloque par defaut, autorise loopback + Docker"
Write-Host " Inaccessible depuis : LAN, WiFi public, Internet"
Write-Host ""
Write-Host " Pour LoreMind, definissez dans le fichier .env :"
Write-Host " OLLAMA_BASE_URL=http://host.docker.internal:11434"
Write-Host ""
Write-Host " Pour annuler cette configuration :"
Write-Host ' Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule'
Write-Host ' [Environment]::SetEnvironmentVariable("OLLAMA_HOST",$null,"User")'
Write-Host ""

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# ============================================================================
# LoreMindMJ - Configuration securisee d'Ollama hote (Linux)
# ----------------------------------------------------------------------------
# But : permettre au conteneur Docker de LoreMind d'atteindre l'Ollama
# installe sur l'hote, SANS l'exposer sur le LAN ni Internet.
#
# Strategie : faire ecouter Ollama uniquement sur l'IP de la passerelle du
# bridge Docker (typiquement 172.17.0.1). Cette IP n'est jamais
# routee en dehors de la machine — seuls les conteneurs Docker
# peuvent l'atteindre.
#
# Ce script peut etre lance independamment de install.sh, par ex. si vous
# avez initialement choisi le mode "Ollama embarque" et changez d'avis.
#
# Usage : bash secure-host-ollama.sh
# ============================================================================
set -euo pipefail
c_cyan='\033[1;36m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'; c_off='\033[0m'
step() { echo -e "${c_cyan}==> $*${c_off}"; }
ok() { echo -e " ${c_green}OK${c_off} $*"; }
warn() { echo -e " ${c_yellow}!!${c_off} $*"; }
err() { echo -e " ${c_red}XX${c_off} $*" >&2; }
# --- 1. Verifications prealables -------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
err "Docker introuvable. Installez Docker avant de lancer ce script."
exit 1
fi
if ! command -v systemctl >/dev/null 2>&1; then
err "systemctl introuvable. Ce script suppose un systeme avec systemd."
err "Configurez OLLAMA_HOST manuellement selon votre init system."
exit 1
fi
if ! systemctl list-unit-files 2>/dev/null | grep -q '^ollama\.service'; then
err "Service systemd 'ollama' introuvable."
err "Installez Ollama via le script officiel : curl -fsSL https://ollama.com/install.sh | sh"
exit 1
fi
# --- 2. Detection de l'IP de la passerelle Docker --------------------------
step "Detection de l'IP du bridge Docker..."
BRIDGE_IP=""
# Methode 1 : docker network inspect (la plus fiable)
if BRIDGE_IP="$(docker network inspect bridge -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null)"; then
if [ -n "$BRIDGE_IP" ]; then
ok "IP du bridge Docker detectee via docker network inspect : $BRIDGE_IP"
fi
fi
# Methode 2 : interface docker0 (si docker network inspect echoue)
if [ -z "$BRIDGE_IP" ] && command -v ip >/dev/null 2>&1; then
BRIDGE_IP="$(ip -4 addr show docker0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -1)"
if [ -n "$BRIDGE_IP" ]; then
ok "IP du bridge Docker detectee via interface docker0 : $BRIDGE_IP"
fi
fi
# Methode 3 : valeur par defaut (compatible avec 99% des installations)
if [ -z "$BRIDGE_IP" ]; then
BRIDGE_IP="172.17.0.1"
warn "Detection automatique echouee, utilisation de la valeur par defaut : $BRIDGE_IP"
warn "Si Docker n'a jamais ete demarre sur cette machine, lancez 'docker info' une fois pour creer le bridge."
fi
# --- 3. Ecriture de l'override systemd -------------------------------------
step "Configuration du service systemd Ollama..."
OVERRIDE_DIR="/etc/systemd/system/ollama.service.d"
OVERRIDE_FILE="$OVERRIDE_DIR/loremind-host.conf"
sudo mkdir -p "$OVERRIDE_DIR"
sudo tee "$OVERRIDE_FILE" >/dev/null <<EOF
# Genere par LoreMind secure-host-ollama.sh
# Lie Ollama exclusivement a l'IP de la passerelle Docker.
# Consequence : Ollama est joignable depuis les conteneurs Docker
# (via host.docker.internal) mais PAS depuis le LAN ni Internet.
# Pour revenir a la configuration par defaut : sudo rm $OVERRIDE_FILE && sudo systemctl daemon-reload && sudo systemctl restart ollama
[Service]
Environment="OLLAMA_HOST=$BRIDGE_IP:11434"
EOF
ok "Override ecrit : $OVERRIDE_FILE"
# --- 4. Rechargement et redemarrage ----------------------------------------
step "Rechargement de la configuration systemd..."
sudo systemctl daemon-reload
ok "daemon-reload effectue"
step "Redemarrage du service Ollama..."
sudo systemctl restart ollama
sleep 2
if sudo systemctl is-active --quiet ollama; then
ok "Ollama redemarre et actif"
else
err "Ollama n'a pas redemarre correctement. Verifiez : sudo journalctl -u ollama -n 50"
exit 1
fi
# --- 5. Verification du binding --------------------------------------------
step "Verification : Ollama doit ecouter sur $BRIDGE_IP:11434..."
sleep 1
if command -v ss >/dev/null 2>&1; then
if ss -tln 2>/dev/null | grep -q "$BRIDGE_IP:11434"; then
ok "Ollama ecoute bien sur $BRIDGE_IP:11434"
else
warn "Verification impossible (ss n'a pas trouve le binding). Cela peut etre normal si le service vient juste de demarrer."
fi
fi
# --- 6. Recap --------------------------------------------------------------
echo
echo -e "${c_green}============================================================${c_off}"
echo -e "${c_green} Ollama hote configure de maniere securisee${c_off}"
echo -e "${c_green}============================================================${c_off}"
echo " Adresse d'ecoute : $BRIDGE_IP:11434"
echo " Accessible depuis : conteneurs Docker uniquement (via host.docker.internal)"
echo " Inaccessible depuis : LAN, WiFi public, Internet"
echo
echo " Pour LoreMind, definissez dans le fichier .env :"
echo " OLLAMA_BASE_URL=http://host.docker.internal:11434"
echo
echo " Pour annuler cette configuration :"
echo " sudo rm $OVERRIDE_FILE"
echo " sudo systemctl daemon-reload && sudo systemctl restart ollama"
echo

View File

@@ -60,7 +60,8 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "web:build"
"buildTarget": "web:build",
"proxyConfig": "proxy.conf.json"
}
}
}

317
web/e2e/fixtures/api.ts Normal file
View File

@@ -0,0 +1,317 @@
import { APIRequestContext, expect } from '@playwright/test';
export interface SeededLore {
id: string;
name: string;
rootFolderId: string;
rootFolderName: string;
}
/**
* Seed un Lore + un dossier racine via l'API backend.
* Les noms sont uniques (timestamp + random) pour éviter les collisions en parallèle.
*/
export async function seedLoreWithFolder(request: APIRequestContext): Promise<SeededLore> {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const loreName = `E2E Lore ${suffix}`;
const folderName = `E2E Folder ${suffix}`;
const loreRes = await request.post('/api/lores', {
data: { name: loreName, description: 'Seeded by Playwright' },
});
expect(loreRes.ok(), `POST /api/lores -> ${loreRes.status()}`).toBeTruthy();
const lore = await loreRes.json();
const folderRes = await request.post('/api/lore-nodes', {
data: { loreId: lore.id, name: folderName, icon: 'folder', description: '' },
});
expect(folderRes.ok(), `POST /api/lore-nodes -> ${folderRes.status()}`).toBeTruthy();
const folder = await folderRes.json();
return { id: lore.id, name: loreName, rootFolderId: folder.id, rootFolderName: folderName };
}
/** Cleanup best-effort — n'échoue pas si déjà supprimé. */
export async function deleteLore(request: APIRequestContext, loreId: string): Promise<void> {
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
}
export async function getLoreById(
request: APIRequestContext,
loreId: string,
): Promise<{ id: string; name: string; description: string }> {
const res = await request.get(`/api/lores/${loreId}`);
expect(res.ok(), `GET /api/lores/${loreId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcsForCampaign(
request: APIRequestContext,
campaignId: string,
): Promise<Array<{ id: string; name: string; campaignId: string }>> {
const res = await request.get(`/api/arcs?campaignId=${campaignId}`);
expect(res.ok(), `GET /api/arcs -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getChaptersForArc(
request: APIRequestContext,
arcId: string,
): Promise<Array<{ id: string; name: string; arcId: string }>> {
const res = await request.get(`/api/chapters?arcId=${arcId}`);
expect(res.ok(), `GET /api/chapters -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getScenesForChapter(
request: APIRequestContext,
chapterId: string,
): Promise<Array<{ id: string; name: string; chapterId: string }>> {
const res = await request.get(`/api/scenes?chapterId=${chapterId}`);
expect(res.ok(), `GET /api/scenes -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplatesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; name: string }>> {
const res = await request.get(`/api/templates?loreId=${loreId}`);
expect(res.ok(), `GET /api/templates -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededTemplate {
id: string;
name: string;
}
export async function seedTemplate(
request: APIRequestContext,
opts: { loreId: string; defaultNodeId: string; name?: string; fieldNames?: string[] },
): Promise<SeededTemplate> {
const templateName = opts.name ?? `E2E Template ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const fields = (opts.fieldNames ?? ['Nom', 'Description']).map((name) => ({ name, type: 'TEXT' }));
const res = await request.post('/api/templates', {
data: {
loreId: opts.loreId,
name: templateName,
description: 'Seeded by Playwright',
defaultNodeId: opts.defaultNodeId,
fields,
},
});
expect(res.ok(), `POST /api/templates -> ${res.status()}`).toBeTruthy();
const tpl = await res.json();
return { id: tpl.id, name: templateName };
}
export async function deleteCampaign(request: APIRequestContext, campaignId: string): Promise<void> {
await request.delete(`/api/campaigns/${campaignId}`).catch(() => undefined);
}
export interface SeededCampaign {
id: string;
name: string;
}
export async function seedCampaign(
request: APIRequestContext,
opts: { name?: string; loreId?: string | null; playerCount?: number } = {},
): Promise<SeededCampaign> {
const name = opts.name ?? `E2E Campaign ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/campaigns', {
data: {
name,
description: 'Seeded by Playwright',
playerCount: opts.playerCount ?? 4,
loreId: opts.loreId ?? null,
gameSystemId: null,
},
});
expect(res.ok(), `POST /api/campaigns -> ${res.status()}`).toBeTruthy();
const c = await res.json();
return { id: c.id, name };
}
export interface SeededArc {
id: string;
name: string;
}
export async function seedArc(
request: APIRequestContext,
opts: { campaignId: string; name?: string; order?: number },
): Promise<SeededArc> {
const name = opts.name ?? `E2E Arc ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/arcs', {
data: {
campaignId: opts.campaignId,
name,
description: '',
order: opts.order ?? 1,
},
});
expect(res.ok(), `POST /api/arcs -> ${res.status()}`).toBeTruthy();
const a = await res.json();
return { id: a.id, name };
}
export interface SeededChapter {
id: string;
name: string;
}
export async function seedChapter(
request: APIRequestContext,
opts: { arcId: string; name?: string; order?: number },
): Promise<SeededChapter> {
const name = opts.name ?? `E2E Chapter ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/chapters', {
data: { arcId: opts.arcId, name, description: '', order: opts.order ?? 1 },
});
expect(res.ok(), `POST /api/chapters -> ${res.status()}`).toBeTruthy();
const c = await res.json();
return { id: c.id, name };
}
export async function getChapterById(
request: APIRequestContext,
chapterId: string,
): Promise<{
id: string;
name: string;
description?: string;
gmNotes?: string | null;
playerObjectives?: string | null;
narrativeStakes?: string | null;
}> {
const res = await request.get(`/api/chapters/${chapterId}`);
expect(res.ok(), `GET /api/chapters/${chapterId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededScene {
id: string;
name: string;
}
export async function seedScene(
request: APIRequestContext,
opts: { chapterId: string; name?: string; order?: number },
): Promise<SeededScene> {
const name = opts.name ?? `E2E Scene ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/scenes', {
data: { chapterId: opts.chapterId, name, description: '', order: opts.order ?? 1 },
});
expect(res.ok(), `POST /api/scenes -> ${res.status()}`).toBeTruthy();
const s = await res.json();
return { id: s.id, name };
}
export async function getSceneById(
request: APIRequestContext,
sceneId: string,
): Promise<{
id: string;
name: string;
description?: string;
location?: string | null;
timing?: string | null;
atmosphere?: string | null;
playerNarration?: string | null;
gmSecretNotes?: string | null;
choicesConsequences?: string | null;
combatDifficulty?: string | null;
enemies?: string | null;
branches?: Array<{ label: string; targetSceneId: string; condition?: string }>;
}> {
const res = await request.get(`/api/scenes/${sceneId}`);
expect(res.ok(), `GET /api/scenes/${sceneId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getArcById(
request: APIRequestContext,
arcId: string,
): Promise<{
id: string;
name: string;
description?: string;
themes?: string | null;
stakes?: string | null;
gmNotes?: string | null;
rewards?: string | null;
resolution?: string | null;
}> {
const res = await request.get(`/api/arcs/${arcId}`);
expect(res.ok(), `GET /api/arcs/${arcId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getCampaigns(
request: APIRequestContext,
): Promise<Array<{ id: string; name: string; loreId: string | null }>> {
const res = await request.get('/api/campaigns');
expect(res.ok(), `GET /api/campaigns -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getPagesForLore(
request: APIRequestContext,
loreId: string,
): Promise<Array<{ id: string; title: string; nodeId: string; templateId: string }>> {
const res = await request.get(`/api/pages?loreId=${loreId}`);
expect(res.ok(), `GET /api/pages -> ${res.status()}`).toBeTruthy();
return res.json();
}
export interface SeededPage {
id: string;
title: string;
}
export async function seedPage(
request: APIRequestContext,
opts: { loreId: string; nodeId: string; templateId: string; title?: string },
): Promise<SeededPage> {
const title = opts.title ?? `E2E Page ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const res = await request.post('/api/pages', {
data: { loreId: opts.loreId, nodeId: opts.nodeId, templateId: opts.templateId, title },
});
expect(res.ok(), `POST /api/pages -> ${res.status()}`).toBeTruthy();
const page = await res.json();
return { id: page.id, title };
}
export async function getPageById(
request: APIRequestContext,
pageId: string,
): Promise<{
id: string;
title: string;
nodeId: string;
values?: Record<string, string>;
tags?: string[];
notes?: string;
}> {
const res = await request.get(`/api/pages/${pageId}`);
expect(res.ok(), `GET /api/pages/${pageId} -> ${res.status()}`).toBeTruthy();
return res.json();
}
export async function getTemplateById(
request: APIRequestContext,
templateId: string,
): Promise<{
id: string;
name: string;
description?: string;
defaultNodeId?: string | null;
fields: Array<{ name: string; type: string }>;
}> {
const res = await request.get(`/api/templates/${templateId}`);
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
return res.json();
}

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
deleteCampaign,
getArcById,
type SeededCampaign,
} from '../../fixtures/api';
test.describe('Arc creation', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates an arc and redirects to its view', async ({ page, request }) => {
const arcName = `Arc ${Date.now()}`;
const description = 'Synopsis test';
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
await expect(page.getByRole('heading', { name: /Créer un nouvel arc/i })).toBeVisible();
await page.getByLabel(/Nom de l'arc/i).fill(arcName);
await page.getByLabel(/Description/i).fill(description);
await page.getByRole('button', { name: /^Créer l'arc$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/\\d+$`));
const createdId = page.url().match(/\/arcs\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getArcById(request, createdId!);
expect(persisted.name).toBe(arcName);
expect(persisted.description).toBe(description);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
const submit = page.getByRole('button', { name: /^Créer l'arc$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom de l'arc/i).fill('Quelque chose');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Arc delete', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('deletes an arc after accepting confirm and redirects to the campaign', async ({
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
const res = await request.get(`/api/arcs/${arc.id}`);
expect(res.status()).toBe(404);
});
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
const res = await request.get(`/api/arcs/${arc.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getArcById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Arc edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('form is prefilled with the arc name', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
});
test('edits all narrative fields and persists them to API', async ({ page, request }) => {
const newName = `${arc.name} renamed`;
const values = {
description: "Un arc sombre où la trahison s'installe.",
themes: 'Trahison, rédemption, dette de sang.',
stakes: 'La survie du royaume est en jeu.',
gmNotes: 'Révéler le traître en scène 3.',
rewards: 'Relique ancienne + alliance avec le clan nordique.',
resolution: 'Le héros pardonne au traître ou le tue.',
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
await page.getByLabel(/Titre de l'arc/i).fill(newName);
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
await page.getByLabel(/Thèmes principaux/i).fill(values.themes);
await page.getByLabel(/Enjeux globaux/i).fill(values.stakes);
await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes);
await page.getByLabel(/Récompenses et progression/i).fill(values.rewards);
await page.getByLabel(/Dénouement prévu/i).fill(values.resolution);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}$`));
const persisted = await getArcById(request, arc.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.themes).toBe(values.themes);
expect(persisted.stakes).toBe(values.stakes);
expect(persisted.gmNotes).toBe(values.gmNotes);
expect(persisted.rewards).toBe(values.rewards);
expect(persisted.resolution).toBe(values.resolution);
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
const nameField = page.getByLabel(/Titre de l'arc/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('Valid');
await expect(saveBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test';
import {
seedLoreWithFolder,
deleteLore,
deleteCampaign,
getCampaigns,
type SeededLore,
} from '../../fixtures/api';
test.describe('Campaign creation', () => {
const createdCampaignIds: string[] = [];
let linkedLore: SeededLore;
test.beforeEach(async ({ request }) => {
linkedLore = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
while (createdCampaignIds.length) {
await deleteCampaign(request, createdCampaignIds.pop()!);
}
if (linkedLore?.id) await deleteLore(request, linkedLore.id);
});
test('creates a standalone campaign (no lore, no system) and shows it in the grid', async ({
page,
request,
}) => {
const campaignName = `Campagne E2E ${Date.now()}`;
const description = 'Une campagne créée par les tests automatisés.';
await page.goto('/campaigns');
await expect(page.getByRole('heading', { name: /Vos Campagnes|Campagnes/i })).toBeVisible();
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
await expect(modal).toBeVisible();
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
await modal.getByLabel(/Description/i).fill(description);
await modal.getByLabel(/Nombre de joueurs/i).fill('5');
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
await expect(modal).not.toBeVisible();
const newCard = page.locator('.campaign-card', { hasText: campaignName });
await expect(newCard).toBeVisible();
const campaigns = await getCampaigns(request);
const created = campaigns.find((c) => c.name === campaignName);
expect(created).toBeDefined();
expect(created!.loreId).toBeNull();
createdCampaignIds.push(created!.id);
});
test('creates a campaign linked to an existing lore', async ({ page, request }) => {
const campaignName = `Campagne liée ${Date.now()}`;
await page.goto('/campaigns');
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
await modal.getByLabel(/Univers associé/i).selectOption({ label: linkedLore.name });
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
await expect(modal).not.toBeVisible();
const campaigns = await getCampaigns(request);
const created = campaigns.find((c) => c.name === campaignName);
expect(created).toBeDefined();
expect(created!.loreId).toBe(linkedLore.id);
createdCampaignIds.push(created!.id);
});
test('submit is disabled without a name and when player count is invalid', async ({ page }) => {
await page.goto('/campaigns');
await page.locator('.campaign-card.card-new').click();
const modal = page.locator('.modal');
const submit = modal.getByRole('button', { name: /^Créer la campagne$/i });
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nom de la campagne/i).fill('Valid name');
await expect(submit).toBeEnabled();
await modal.getByLabel(/Nombre de joueurs/i).fill('0');
await expect(submit).toBeDisabled();
await modal.getByLabel(/Nombre de joueurs/i).fill('3');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
import { seedCampaign, deleteCampaign, type SeededCampaign } from '../../fixtures/api';
test.describe('Campaign delete', () => {
let campaign: SeededCampaign;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('deletes a campaign after accepting confirm and redirects to the list', async ({
page,
request,
}) => {
page.on('dialog', (dialog) => dialog.accept());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(/\/campaigns$/);
const res = await request.get(`/api/campaigns/${campaign.id}`);
expect(res.status()).toBe(404);
});
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
page.on('dialog', (dialog) => dialog.dismiss());
await page.goto(`/campaigns/${campaign.id}`);
await page.getByRole('button', { name: /^Supprimer$/i }).click();
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
const res = await request.get(`/api/campaigns/${campaign.id}`);
expect(res.ok()).toBeTruthy();
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
} from '../../fixtures/api';
test.describe('Chapter creation', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('creates a chapter and redirects to its view', async ({ page, request }) => {
const chapterName = `Chapitre ${Date.now()}`;
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
await expect(page.getByRole('heading', { name: /Créer un nouveau chapitre/i })).toBeVisible();
await page.getByLabel(/Nom du chapitre/i).fill(chapterName);
await page.getByLabel(/Description/i).fill('Synopsis du chapitre');
await page.getByRole('button', { name: /^Créer le chapitre$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/\\d+$`),
);
const createdId = page.url().match(/\/chapters\/(\d+)$/)?.[1];
expect(createdId).toBeTruthy();
const persisted = await getChapterById(request, createdId!);
expect(persisted.name).toBe(chapterName);
});
test('submit is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
const submit = page.getByRole('button', { name: /^Créer le chapitre$/i });
await expect(submit).toBeDisabled();
await page.getByLabel(/Nom du chapitre/i).fill('OK');
await expect(submit).toBeEnabled();
});
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from '@playwright/test';
import {
seedCampaign,
seedArc,
seedChapter,
deleteCampaign,
getChapterById,
type SeededCampaign,
type SeededArc,
type SeededChapter,
} from '../../fixtures/api';
test.describe('Chapter edit', () => {
let campaign: SeededCampaign;
let arc: SeededArc;
let chapter: SeededChapter;
test.beforeEach(async ({ request }) => {
campaign = await seedCampaign(request);
arc = await seedArc(request, { campaignId: campaign.id });
chapter = await seedChapter(request, { arcId: arc.id });
});
test.afterEach(async ({ request }) => {
if (campaign?.id) await deleteCampaign(request, campaign.id);
});
test('edits all chapter fields and persists them to API', async ({ page, request }) => {
const newName = `${chapter.name} renamed`;
const values = {
description: 'Le chapitre ouvre sur un village en proie à la peur.',
gmNotes: 'Le maire cache un pacte avec les gobelins.',
playerObjectives: "Découvrir la source des disparitions.",
narrativeStakes: "La confiance du village est en jeu.",
};
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
await expect(page.getByLabel(/Titre du chapitre/i)).toHaveValue(chapter.name);
await page.getByLabel(/Titre du chapitre/i).fill(newName);
await page.getByLabel(/Synopsis du chapitre/i).fill(values.description);
await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes);
await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives);
await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes);
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}$`),
);
const persisted = await getChapterById(request, chapter.id);
expect(persisted.name).toBe(newName);
expect(persisted.description).toBe(values.description);
expect(persisted.gmNotes).toBe(values.gmNotes);
expect(persisted.playerObjectives).toBe(values.playerObjectives);
expect(persisted.narrativeStakes).toBe(values.narrativeStakes);
});
test('save button is disabled when name is empty', async ({ page }) => {
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
const nameField = page.getByLabel(/Titre du chapitre/i);
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled();
await nameField.fill('');
await expect(saveBtn).toBeDisabled();
await nameField.fill('OK');
await expect(saveBtn).toBeEnabled();
});
});

Some files were not shown because too many files have changed in this diff Show More