Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94a39cf3b4 | |||
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 | |||
| addf78f01d | |||
| 5e04e84ee4 | |||
| 8d5c2e2b7f | |||
| 788d2c12f2 | |||
| b25a9746cf | |||
| 41fda9aeee | |||
| 550078268c | |||
| 0582690dca | |||
| 88278bd1dd | |||
| d24d6459a0 | |||
| 4b866e5212 | |||
| 6c6bd20f0d | |||
| 2764228abf | |||
| f95d69c915 | |||
| 70351e9d9a | |||
| ff4905126d | |||
| 0e5b5a7de4 | |||
| c8c032336b | |||
| dda27e55fc | |||
| 83ac67471e |
12
.env.example
12
.env.example
@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
|
|||||||
# 1min.ai (si LLM_PROVIDER=onemin)
|
# 1min.ai (si LLM_PROVIDER=onemin)
|
||||||
ONEMIN_API_KEY=
|
ONEMIN_API_KEY=
|
||||||
ONEMIN_MODEL=gpt-4o-mini
|
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
95
.gitea/workflows/e2e.yml
Normal 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
|
||||||
@@ -6,8 +6,10 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.igmlcreation.fr
|
GITEA_REGISTRY: git.igmlcreation.fr
|
||||||
REGISTRY_USER: ietm64
|
GITEA_REGISTRY_USER: ietm64
|
||||||
|
GHCR_REGISTRY: ghcr.io
|
||||||
|
GHCR_NAMESPACE: igmlcreation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -26,19 +28,39 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
username: ${{ env.REGISTRY_USER }}
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
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
|
- name: Extract version
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
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 }}
|
- name: Build & push ${{ matrix.component }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_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 }}:${{ 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
6
.gitignore
vendored
@@ -53,6 +53,12 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
web/test-results/
|
||||||
|
web/playwright-report/
|
||||||
|
web/blob-report/
|
||||||
|
web/playwright/.cache/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IDE / Editeurs
|
# IDE / Editeurs
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, json=payload)
|
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:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
async with client.stream("POST", url, json=payload) as response:
|
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():
|
async for line in response.aiter_lines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
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)
|
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")
|
@app.get("/models/onemin")
|
||||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.1</version>
|
<version>0.6.13</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ public class ArcService {
|
|||||||
public record DeletionImpact(int chapters, int scenes) {}
|
public record DeletionImpact(int chapters, int scenes) {}
|
||||||
|
|
||||||
public Arc createArc(String name, String description, String campaignId, int order) {
|
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()
|
Arc arc = Arc.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.campaignId(campaignId)
|
.campaignId(campaignId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return arcRepository.save(arc);
|
return arcRepository.save(arc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ public class ChapterService {
|
|||||||
public record DeletionImpact(int scenes) {}
|
public record DeletionImpact(int scenes) {}
|
||||||
|
|
||||||
public Chapter createChapter(String name, String description, String arcId, int order) {
|
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()
|
Chapter chapter = Chapter.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.arcId(arcId)
|
.arcId(arcId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return chapterRepository.save(chapter);
|
return chapterRepository.save(chapter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,16 @@ public class SceneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Scene createScene(String name, String description, String chapterId, int order) {
|
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()
|
Scene scene = Scene.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.chapterId(chapterId)
|
.chapterId(chapterId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return sceneRepository.save(scene);
|
return sceneRepository.save(scene);
|
||||||
}
|
}
|
||||||
@@ -93,7 +98,7 @@ public class SceneService {
|
|||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
for (SceneBranch b : branches) {
|
for (SceneBranch b : branches) {
|
||||||
String target = b.getTargetSceneId();
|
String target = b.targetSceneId();
|
||||||
if (target == null || target.isBlank()) {
|
if (target == null || target.isBlank()) {
|
||||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
|
|||||||
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
||||||
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
||||||
Map<String, String> filtered = filterByIntent(allSections, intent);
|
Map<String, String> filtered = filterByIntent(allSections, intent);
|
||||||
return GameSystemContext.builder()
|
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
|
||||||
.systemName(gs.getName())
|
|
||||||
.systemDescription(gs.getDescription())
|
|
||||||
.sections(filtered)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -79,12 +79,11 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.map(this::toCharacterSummary)
|
.map(this::toCharacterSummary)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return CampaignStructuralContext.builder()
|
return new CampaignStructuralContext(
|
||||||
.campaignName(campaign.getName())
|
campaign.getName(),
|
||||||
.campaignDescription(campaign.getDescription())
|
campaign.getDescription(),
|
||||||
.arcs(arcs)
|
arcs,
|
||||||
.characters(characters)
|
characters);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,10 +92,7 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* sans injecter toute sa fiche.
|
* sans injecter toute sa fiche.
|
||||||
*/
|
*/
|
||||||
private CharacterSummary toCharacterSummary(Character c) {
|
private CharacterSummary toCharacterSummary(Character c) {
|
||||||
return CharacterSummary.builder()
|
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||||
.name(c.getName())
|
|
||||||
.snippet(extractSnippet(c.getMarkdownContent()))
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractSnippet(String markdown) {
|
private static String extractSnippet(String markdown) {
|
||||||
@@ -115,12 +111,11 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||||
.map(this::toChapterSummary)
|
.map(this::toChapterSummary)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
return ArcSummary.builder()
|
return new ArcSummary(
|
||||||
.name(arc.getName())
|
arc.getName(),
|
||||||
.description(arc.getDescription())
|
arc.getDescription(),
|
||||||
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
countImages(arc.getIllustrationImageIds()),
|
||||||
.chapters(chapters)
|
chapters);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||||
@@ -137,32 +132,28 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.map(s -> toSceneSummary(s, nameById))
|
.map(s -> toSceneSummary(s, nameById))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return ChapterSummary.builder()
|
return new ChapterSummary(
|
||||||
.name(chapter.getName())
|
chapter.getName(),
|
||||||
.description(chapter.getDescription())
|
chapter.getDescription(),
|
||||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
countImages(chapter.getIllustrationImageIds()),
|
||||||
.scenes(summaries)
|
summaries);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||||
List<BranchHint> hints = scene.getBranches() == null
|
List<BranchHint> hints = scene.getBranches() == null
|
||||||
? List.of()
|
? List.of()
|
||||||
: scene.getBranches().stream()
|
: scene.getBranches().stream()
|
||||||
.map(b -> BranchHint.builder()
|
.map(b -> new BranchHint(
|
||||||
.label(b.getLabel())
|
b.label(),
|
||||||
.targetSceneName(nameById.getOrDefault(
|
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
||||||
b.getTargetSceneId(), "(scène inconnue)"))
|
b.condition()))
|
||||||
.condition(b.getCondition())
|
|
||||||
.build())
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return SceneSummary.builder()
|
return new SceneSummary(
|
||||||
.name(scene.getName())
|
scene.getName(),
|
||||||
.description(scene.getDescription())
|
scene.getDescription(),
|
||||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
countImages(scene.getIllustrationImageIds()),
|
||||||
.branches(hints)
|
hints);
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||||
|
|||||||
@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
|
|||||||
|
|
||||||
requireNonEmptyFields(template);
|
requireNonEmptyFields(template);
|
||||||
|
|
||||||
GenerationContext context = GenerationContext.builder()
|
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||||
.loreName(lore.getName())
|
// necessitent un workflow different (pas de generation LLM texte).
|
||||||
.loreDescription(lore.getDescription())
|
GenerationContext context = new GenerationContext(
|
||||||
.folderName(folder.getName())
|
lore.getName(),
|
||||||
.templateName(template.getName())
|
lore.getDescription(),
|
||||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
folder.getName(),
|
||||||
// necessitent un workflow different (pas de generation LLM texte).
|
template.getName(),
|
||||||
.templateFields(template.textFieldNames())
|
template.textFieldNames(),
|
||||||
.pageTitle(page.getTitle())
|
page.getTitle());
|
||||||
.build();
|
|
||||||
|
|
||||||
GenerationResult result = aiProvider.generatePage(context);
|
GenerationResult result = aiProvider.generatePage(context);
|
||||||
return result.values();
|
return result.values();
|
||||||
|
|||||||
@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
|
|||||||
Map<String, String> pageTitleById = pages.stream()
|
Map<String, String> pageTitleById = pages.stream()
|
||||||
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||||
|
|
||||||
return LoreStructuralContext.builder()
|
return new LoreStructuralContext(
|
||||||
.loreName(lore.getName())
|
lore.getName(),
|
||||||
.loreDescription(lore.getDescription())
|
lore.getDescription(),
|
||||||
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
|
||||||
.tags(extractUniqueTags(pages))
|
extractUniqueTags(pages));
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, List<PageSummary>> buildFoldersMap(
|
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||||
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
|
|||||||
Page page,
|
Page page,
|
||||||
Map<String, String> templateNameById,
|
Map<String, String> templateNameById,
|
||||||
Map<String, String> pageTitleById) {
|
Map<String, String> pageTitleById) {
|
||||||
return PageSummary.builder()
|
return new PageSummary(
|
||||||
.title(page.getTitle())
|
page.getTitle(),
|
||||||
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
templateNameById.getOrDefault(page.getTemplateId(), "?"),
|
||||||
.values(truncatedValues(page.getValues()))
|
truncatedValues(page.getValues()),
|
||||||
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
|
||||||
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -91,11 +91,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "rewards", a.getRewards());
|
putField(fields, "rewards", a.getRewards());
|
||||||
putField(fields, "resolution", a.getResolution());
|
putField(fields, "resolution", a.getResolution());
|
||||||
putField(fields, "gmNotes", a.getGmNotes());
|
putField(fields, "gmNotes", a.getGmNotes());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("arc", a.getName(), fields);
|
||||||
.entityType("arc")
|
|
||||||
.title(a.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromChapter(Chapter c) {
|
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||||
@@ -104,11 +100,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||||
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||||
putField(fields, "gmNotes", c.getGmNotes());
|
putField(fields, "gmNotes", c.getGmNotes());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("chapter", c.getName(), fields);
|
||||||
.entityType("chapter")
|
|
||||||
.title(c.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromScene(Scene s) {
|
private NarrativeEntityContext fromScene(Scene s) {
|
||||||
@@ -122,21 +114,13 @@ public class NarrativeEntityContextBuilder {
|
|||||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||||
putField(fields, "enemies", s.getEnemies());
|
putField(fields, "enemies", s.getEnemies());
|
||||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||||
.entityType("scene")
|
|
||||||
.title(s.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||||
return NarrativeEntityContext.builder()
|
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||||
.entityType("character")
|
|
||||||
.title(c.getName())
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||||
|
|||||||
@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
|
|||||||
? page.getValues()
|
? page.getValues()
|
||||||
: Collections.emptyMap();
|
: Collections.emptyMap();
|
||||||
|
|
||||||
return PageContext.builder()
|
return new PageContext(page.getTitle(), templateName, templateFields, values);
|
||||||
.title(page.getTitle())
|
|
||||||
.templateName(templateName)
|
|
||||||
.templateFields(templateFields)
|
|
||||||
.values(values)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Arc {
|
|||||||
private String campaignId; // Référence vers la Campaign parente
|
private String campaignId; // Référence vers la Campaign parente
|
||||||
private int order; // Ordre de l'arc dans la campagne
|
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/)
|
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||||
private String themes; // Thèmes principaux explorés dans cet arc
|
private String themes; // Thèmes principaux explorés dans cet arc
|
||||||
private String stakes; // Enjeux globaux pour les personnages
|
private String stakes; // Enjeux globaux pour les personnages
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Chapter {
|
|||||||
private String arcId; // Référence vers l'Arc parent
|
private String arcId; // Référence vers l'Arc parent
|
||||||
private int order; // Ordre du chapitre dans l'arc
|
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/)
|
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||||
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
||||||
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Scene {
|
|||||||
private String chapterId; // Référence vers le Chapter parent
|
private String chapterId; // Référence vers le Chapter parent
|
||||||
private int order; // Ordre de la scène dans le chapitre
|
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 ===
|
// === Contexte et ambiance ===
|
||||||
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
|
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)
|
private String timing; // Moment (ex: Soir, à la tombée de la nuit)
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
package com.loremind.domain.campaigncontext;
|
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.
|
* 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.
|
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||||
* <p>
|
* <p>
|
||||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
* Record Java : immuable par construction, sans aucune dépendance technique
|
||||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
|
||||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
* les records nativement via le constructeur canonique — c'est ce dont
|
||||||
|
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
|
||||||
* <p>
|
* <p>
|
||||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||||
* (validation portée par SceneService).
|
* (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
|
public record SceneBranch(String label, String targetSceneId, String condition) {
|
||||||
@Builder
|
|
||||||
@Jacksonized
|
|
||||||
public class SceneBranch {
|
|
||||||
|
|
||||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
|
||||||
String label;
|
public static SceneBranch of(String label, String targetSceneId) {
|
||||||
|
return new SceneBranch(label, targetSceneId, null);
|
||||||
/** Id de la Scene de destination, intra-chapitre uniquement. */
|
}
|
||||||
String targetSceneId;
|
|
||||||
|
|
||||||
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
|
|
||||||
String condition;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Singular;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,16 +18,16 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||||
* fait par le use case côté application layer).
|
* 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
|
public record CampaignStructuralContext(
|
||||||
@Builder
|
String campaignName,
|
||||||
public class CampaignStructuralContext {
|
String campaignDescription,
|
||||||
|
List<ArcSummary> arcs,
|
||||||
String campaignName;
|
List<CharacterSummary> characters) {
|
||||||
String campaignDescription;
|
|
||||||
@Singular List<ArcSummary> arcs;
|
|
||||||
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
|
|
||||||
@Singular List<CharacterSummary> characters;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
* 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
|
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
||||||
* (via NarrativeEntityContext, entity_type="character").
|
* (via NarrativeEntityContext, entity_type="character").
|
||||||
*/
|
*/
|
||||||
@Value
|
public record CharacterSummary(String name, String snippet) {
|
||||||
@Builder
|
|
||||||
public static class CharacterSummary {
|
|
||||||
String name;
|
|
||||||
String snippet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
/**
|
||||||
@Value
|
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||||
@Builder
|
*
|
||||||
public static class ArcSummary {
|
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
||||||
String name;
|
*/
|
||||||
String description;
|
public record ArcSummary(
|
||||||
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
String name,
|
||||||
int illustrationCount;
|
String description,
|
||||||
@Singular List<ChapterSummary> chapters;
|
int illustrationCount,
|
||||||
|
List<ChapterSummary> chapters) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
||||||
@Value
|
public record ChapterSummary(
|
||||||
@Builder
|
String name,
|
||||||
public static class ChapterSummary {
|
String description,
|
||||||
String name;
|
int illustrationCount,
|
||||||
String description;
|
List<SceneSummary> scenes) {
|
||||||
int illustrationCount;
|
|
||||||
@Singular List<SceneSummary> scenes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||||
@Value
|
public record SceneSummary(
|
||||||
@Builder
|
String name,
|
||||||
public static class SceneSummary {
|
String description,
|
||||||
String name;
|
int illustrationCount,
|
||||||
String description;
|
List<BranchHint> branches) {
|
||||||
int illustrationCount;
|
|
||||||
@Singular List<BranchHint> branches;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
/**
|
||||||
@Value
|
* Indice d'une branche narrative vers une autre scène du même chapitre.
|
||||||
@Builder
|
*
|
||||||
public static class BranchHint {
|
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
|
||||||
/** 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).
|
||||||
String label;
|
* @param condition Condition MJ privée (optionnel).
|
||||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
*/
|
||||||
String targetSceneName;
|
public record BranchHint(String label, String targetSceneName, String condition) {
|
||||||
/** Condition MJ privée (optionnel). */
|
|
||||||
String condition;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
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
|
* 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,
|
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||||
* pas l'inverse).
|
* pas l'inverse).
|
||||||
|
* <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.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record ChatRequest(
|
||||||
@Builder
|
List<ChatMessage> messages,
|
||||||
public class ChatRequest {
|
LoreStructuralContext loreContext,
|
||||||
|
PageContext pageContext,
|
||||||
|
CampaignStructuralContext campaignContext,
|
||||||
|
NarrativeEntityContext narrativeEntity,
|
||||||
|
GameSystemContext gameSystemContext) {
|
||||||
|
|
||||||
List<ChatMessage> messages;
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
|
/** Builder fluide : permet d'omettre les contextes non pertinents. */
|
||||||
LoreStructuralContext loreContext;
|
public static final class Builder {
|
||||||
|
private List<ChatMessage> messages;
|
||||||
|
private LoreStructuralContext loreContext;
|
||||||
|
private PageContext pageContext;
|
||||||
|
private CampaignStructuralContext campaignContext;
|
||||||
|
private NarrativeEntityContext narrativeEntity;
|
||||||
|
private GameSystemContext gameSystemContext;
|
||||||
|
|
||||||
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
|
private Builder() {}
|
||||||
PageContext pageContext;
|
|
||||||
|
|
||||||
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
public Builder messages(List<ChatMessage> messages) {
|
||||||
CampaignStructuralContext campaignContext;
|
this.messages = messages;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
public Builder loreContext(LoreStructuralContext loreContext) {
|
||||||
NarrativeEntityContext narrativeEntity;
|
this.loreContext = loreContext;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
public Builder pageContext(PageContext pageContext) {
|
||||||
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
this.pageContext = pageContext;
|
||||||
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
|
return this;
|
||||||
*/
|
}
|
||||||
GameSystemContext gameSystemContext;
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,20 +8,14 @@ import java.util.Map;
|
|||||||
* Contient uniquement les sections pertinentes pour l'intent de génération
|
* Contient uniquement les sections pertinentes pour l'intent de génération
|
||||||
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
||||||
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record GameSystemContext(
|
||||||
@Builder
|
String systemName,
|
||||||
public class GameSystemContext {
|
String systemDescription,
|
||||||
|
Map<String, String> sections) {
|
||||||
/** 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.
|
|
||||||
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
|
||||||
*/
|
|
||||||
Map<String, String> sections;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,19 +7,16 @@ import java.util.List;
|
|||||||
* pour remplir une Page à partir d'un Template.
|
* pour remplir une Page à partir d'un Template.
|
||||||
* <p>
|
* <p>
|
||||||
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||||
* Entité pure du domaine : aucune dépendance technique.
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
* <p>
|
*
|
||||||
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
* @param templateFields Champs à générer (clés attendues dans la réponse).
|
||||||
* C'est un DTO de domaine entrant dans le port AiProvider.
|
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
|
||||||
*/
|
*/
|
||||||
@Value
|
public record GenerationContext(
|
||||||
@Builder
|
String loreName,
|
||||||
public class GenerationContext {
|
String loreDescription,
|
||||||
|
String folderName,
|
||||||
String loreName;
|
String templateName,
|
||||||
String loreDescription;
|
List<String> templateFields,
|
||||||
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
String pageTitle) {
|
||||||
String templateName;
|
|
||||||
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
|
||||||
String pageTitle;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Singular;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -16,15 +12,14 @@ import java.util.Map;
|
|||||||
* <p>
|
* <p>
|
||||||
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
* 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).
|
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||||
|
* <p>
|
||||||
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record LoreStructuralContext(
|
||||||
@Builder
|
String loreName,
|
||||||
public class LoreStructuralContext {
|
String loreDescription,
|
||||||
|
Map<String, List<PageSummary>> folders,
|
||||||
String loreName;
|
List<String> tags) {
|
||||||
String loreDescription;
|
|
||||||
Map<String, List<PageSummary>> folders;
|
|
||||||
@Singular List<String> tags;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé projeté d'une page pour l'IA.
|
* 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
|
* uniquement ce qui est partageable en narration — les secrets MJ
|
||||||
* restent confinés à leur page d'édition).
|
* restent confinés à leur page d'édition).
|
||||||
*/
|
*/
|
||||||
@Value
|
public record PageSummary(
|
||||||
@Builder
|
String title,
|
||||||
public static class PageSummary {
|
String templateName,
|
||||||
String title;
|
Map<String, String> values,
|
||||||
String templateName;
|
List<String> tags,
|
||||||
Map<String, String> values;
|
List<String> relatedPageTitles) {
|
||||||
List<String> tags;
|
|
||||||
List<String> relatedPageTitles;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,13 +14,11 @@ import java.util.Map;
|
|||||||
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||||
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||||
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||||
|
*
|
||||||
|
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record NarrativeEntityContext(
|
||||||
@Builder
|
String entityType,
|
||||||
public class NarrativeEntityContext {
|
String title,
|
||||||
|
Map<String, String> fields) {
|
||||||
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
|
||||||
String entityType;
|
|
||||||
String title;
|
|
||||||
Map<String, String> fields;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.loremind.domain.generationcontext;
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Value;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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
|
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
||||||
* sur d'autres pages/templates.
|
* sur d'autres pages/templates.
|
||||||
* <p>
|
* <p>
|
||||||
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
* Record Java : immuable, pur domaine, aucune dépendance technique.
|
||||||
*/
|
*/
|
||||||
@Value
|
public record PageContext(
|
||||||
@Builder
|
String title,
|
||||||
public class PageContext {
|
String templateName,
|
||||||
|
List<String> templateFields,
|
||||||
String title;
|
Map<String, String> values) {
|
||||||
String templateName;
|
|
||||||
List<String> templateFields;
|
|
||||||
Map<String, String> values;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
|
|||||||
|
|
||||||
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||||
return new BrainGeneratePageRequest(
|
return new BrainGeneratePageRequest(
|
||||||
context.getLoreName(),
|
context.loreName(),
|
||||||
context.getLoreDescription(),
|
context.loreDescription(),
|
||||||
context.getFolderName(),
|
context.folderName(),
|
||||||
context.getTemplateName(),
|
context.templateName(),
|
||||||
context.getTemplateFields(),
|
context.templateFields(),
|
||||||
context.getPageTitle()
|
context.pageTitle()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
public Map<String, Object> build(ChatRequest request) {
|
public Map<String, Object> build(ChatRequest request) {
|
||||||
Map<String, Object> root = new LinkedHashMap<>();
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
root.put("messages", request.getMessages().stream()
|
root.put("messages", request.messages().stream()
|
||||||
.map(this::messageToMap)
|
.map(this::messageToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
if (request.getLoreContext() != null) {
|
if (request.loreContext() != null) {
|
||||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||||
}
|
}
|
||||||
if (request.getPageContext() != null) {
|
if (request.pageContext() != null) {
|
||||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
root.put("page_context", pageContextToMap(request.pageContext()));
|
||||||
}
|
}
|
||||||
if (request.getCampaignContext() != null) {
|
if (request.campaignContext() != null) {
|
||||||
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
|
||||||
}
|
}
|
||||||
if (request.getNarrativeEntity() != null) {
|
if (request.narrativeEntity() != null) {
|
||||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
|
||||||
}
|
}
|
||||||
if (request.getGameSystemContext() != null) {
|
if (request.gameSystemContext() != null) {
|
||||||
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
|
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||||
}
|
}
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("system_name", gs.getSystemName());
|
map.put("system_name", gs.systemName());
|
||||||
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
|
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
|
||||||
map.put("system_description", gs.getSystemDescription());
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,56 +79,56 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("lore_name", ctx.getLoreName());
|
map.put("lore_name", ctx.loreName());
|
||||||
map.put("lore_description", ctx.getLoreDescription());
|
map.put("lore_description", ctx.loreDescription());
|
||||||
|
|
||||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
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()
|
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||||
.map(this::pageSummaryToMap)
|
.map(this::pageSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
map.put("folders", foldersMap);
|
map.put("folders", foldersMap);
|
||||||
map.put("tags", ctx.getTags());
|
map.put("tags", ctx.tags());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", ps.getTitle());
|
map.put("title", ps.title());
|
||||||
map.put("template_name", ps.getTemplateName());
|
map.put("template_name", ps.templateName());
|
||||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||||
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||||
map.put("values", ps.getValues());
|
map.put("values", ps.values());
|
||||||
}
|
}
|
||||||
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
if (ps.tags() != null && !ps.tags().isEmpty()) {
|
||||||
map.put("tags", ps.getTags());
|
map.put("tags", ps.tags());
|
||||||
}
|
}
|
||||||
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
|
||||||
map.put("related_page_titles", ps.getRelatedPageTitles());
|
map.put("related_page_titles", ps.relatedPageTitles());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", pc.getTitle());
|
map.put("title", pc.title());
|
||||||
map.put("template_name", pc.getTemplateName());
|
map.put("template_name", pc.templateName());
|
||||||
map.put("template_fields", pc.getTemplateFields());
|
map.put("template_fields", pc.templateFields());
|
||||||
map.put("values", pc.getValues());
|
map.put("values", pc.values());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("campaign_name", ctx.getCampaignName());
|
map.put("campaign_name", ctx.campaignName());
|
||||||
map.put("campaign_description", ctx.getCampaignDescription());
|
map.put("campaign_description", ctx.campaignDescription());
|
||||||
map.put("arcs", ctx.getArcs().stream()
|
map.put("arcs", ctx.arcs().stream()
|
||||||
.map(this::arcSummaryToMap)
|
.map(this::arcSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||||
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
|
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||||
map.put("characters", ctx.getCharacters().stream()
|
map.put("characters", ctx.characters().stream()
|
||||||
.map(this::characterSummaryToMap)
|
.map(this::characterSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
@@ -137,9 +137,9 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("name", c.getName());
|
map.put("name", c.name());
|
||||||
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
|
if (c.snippet() != null && !c.snippet().isBlank()) {
|
||||||
map.put("snippet", c.getSnippet());
|
map.put("snippet", c.snippet());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -167,10 +167,10 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||||
return structuralSummaryToMap(
|
return structuralSummaryToMap(
|
||||||
a,
|
a,
|
||||||
ArcSummary::getName,
|
ArcSummary::name,
|
||||||
ArcSummary::getDescription,
|
ArcSummary::description,
|
||||||
ArcSummary::getIllustrationCount,
|
ArcSummary::illustrationCount,
|
||||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
||||||
.map(this::chapterSummaryToMap)
|
.map(this::chapterSummaryToMap)
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList())));
|
||||||
}
|
}
|
||||||
@@ -178,10 +178,10 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||||
return structuralSummaryToMap(
|
return structuralSummaryToMap(
|
||||||
c,
|
c,
|
||||||
ChapterSummary::getName,
|
ChapterSummary::name,
|
||||||
ChapterSummary::getDescription,
|
ChapterSummary::description,
|
||||||
ChapterSummary::getIllustrationCount,
|
ChapterSummary::illustrationCount,
|
||||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||||
.map(this::sceneSummaryToMap)
|
.map(this::sceneSummaryToMap)
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList())));
|
||||||
}
|
}
|
||||||
@@ -189,13 +189,13 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||||
return structuralSummaryToMap(
|
return structuralSummaryToMap(
|
||||||
s,
|
s,
|
||||||
SceneSummary::getName,
|
SceneSummary::name,
|
||||||
SceneSummary::getDescription,
|
SceneSummary::description,
|
||||||
SceneSummary::getIllustrationCount,
|
SceneSummary::illustrationCount,
|
||||||
(map, scene) -> {
|
(map, scene) -> {
|
||||||
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
||||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||||
map.put("branches", s.getBranches().stream()
|
map.put("branches", s.branches().stream()
|
||||||
.map(this::branchHintToMap)
|
.map(this::branchHintToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
@@ -204,19 +204,19 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("label", b.getLabel());
|
map.put("label", b.label());
|
||||||
map.put("target_scene_name", b.getTargetSceneName());
|
map.put("target_scene_name", b.targetSceneName());
|
||||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
if (b.condition() != null && !b.condition().isBlank()) {
|
||||||
map.put("condition", b.getCondition());
|
map.put("condition", b.condition());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("entity_type", ne.getEntityType());
|
map.put("entity_type", ne.entityType());
|
||||||
map.put("title", ne.getTitle());
|
map.put("title", ne.title());
|
||||||
map.put("fields", ne.getFields());
|
map.put("fields", ne.fields());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ArcJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String themes;
|
private String themes;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||||
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class SceneJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
||||||
|
|
||||||
// Contexte et ambiance
|
// Contexte et ambiance
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.campaignId(jpaEntity.getCampaignId().toString())
|
.campaignId(jpaEntity.getCampaignId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.themes(jpaEntity.getThemes())
|
.themes(jpaEntity.getThemes())
|
||||||
.stakes(jpaEntity.getStakes())
|
.stakes(jpaEntity.getStakes())
|
||||||
.gmNotes(jpaEntity.getGmNotes())
|
.gmNotes(jpaEntity.getGmNotes())
|
||||||
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.description(arc.getDescription())
|
.description(arc.getDescription())
|
||||||
.campaignId(Long.parseLong(arc.getCampaignId()))
|
.campaignId(Long.parseLong(arc.getCampaignId()))
|
||||||
.order(arc.getOrder())
|
.order(arc.getOrder())
|
||||||
|
.icon(arc.getIcon())
|
||||||
.themes(arc.getThemes())
|
.themes(arc.getThemes())
|
||||||
.stakes(arc.getStakes())
|
.stakes(arc.getStakes())
|
||||||
.gmNotes(arc.getGmNotes())
|
.gmNotes(arc.getGmNotes())
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.arcId(jpaEntity.getArcId().toString())
|
.arcId(jpaEntity.getArcId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.gmNotes(jpaEntity.getGmNotes())
|
.gmNotes(jpaEntity.getGmNotes())
|
||||||
.playerObjectives(jpaEntity.getPlayerObjectives())
|
.playerObjectives(jpaEntity.getPlayerObjectives())
|
||||||
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
||||||
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.description(chapter.getDescription())
|
.description(chapter.getDescription())
|
||||||
.arcId(Long.parseLong(chapter.getArcId()))
|
.arcId(Long.parseLong(chapter.getArcId()))
|
||||||
.order(chapter.getOrder())
|
.order(chapter.getOrder())
|
||||||
|
.icon(chapter.getIcon())
|
||||||
.gmNotes(chapter.getGmNotes())
|
.gmNotes(chapter.getGmNotes())
|
||||||
.playerObjectives(chapter.getPlayerObjectives())
|
.playerObjectives(chapter.getPlayerObjectives())
|
||||||
.narrativeStakes(chapter.getNarrativeStakes())
|
.narrativeStakes(chapter.getNarrativeStakes())
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.chapterId(jpaEntity.getChapterId().toString())
|
.chapterId(jpaEntity.getChapterId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.location(jpaEntity.getLocation())
|
.location(jpaEntity.getLocation())
|
||||||
.timing(jpaEntity.getTiming())
|
.timing(jpaEntity.getTiming())
|
||||||
.atmosphere(jpaEntity.getAtmosphere())
|
.atmosphere(jpaEntity.getAtmosphere())
|
||||||
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.description(scene.getDescription())
|
.description(scene.getDescription())
|
||||||
.chapterId(Long.parseLong(scene.getChapterId()))
|
.chapterId(Long.parseLong(scene.getChapterId()))
|
||||||
.order(scene.getOrder())
|
.order(scene.getOrder())
|
||||||
|
.icon(scene.getIcon())
|
||||||
.location(scene.getLocation())
|
.location(scene.getLocation())
|
||||||
.timing(scene.getTiming())
|
.timing(scene.getTiming())
|
||||||
.atmosphere(scene.getAtmosphere())
|
.atmosphere(scene.getAtmosphere())
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
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) {
|
||||||
|
url.append(hasQuery ? '&' : '?')
|
||||||
|
.append(key).append('=')
|
||||||
|
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ public class SecurityConfig {
|
|||||||
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.httpBasic(basic -> {});
|
.httpBasic(basic -> {});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ArcController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
||||||
Arc arc = arcMapper.toDomain(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));
|
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,11 @@ public class ArcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ArcDTO>> getAllArcs() {
|
public ResponseEntity<List<ArcDTO>> getAllArcs(
|
||||||
List<Arc> arcs = arcService.getAllArcs();
|
@RequestParam(value = "campaignId", required = false) String campaignId) {
|
||||||
List<ArcDTO> arcDTOs = arcs.stream()
|
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
|
||||||
.map(arcMapper::toDTO)
|
? arcService.getArcsByCampaignId(campaignId)
|
||||||
.collect(Collectors.toList());
|
: arcService.getAllArcs();
|
||||||
return ResponseEntity.ok(arcDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/campaign/{campaignId}")
|
|
||||||
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
|
|
||||||
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
|
|
||||||
List<ArcDTO> arcDTOs = arcs.stream()
|
List<ArcDTO> arcDTOs = arcs.stream()
|
||||||
.map(arcMapper::toDTO)
|
.map(arcMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ChapterController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
||||||
Chapter chapter = chapterMapper.toDomain(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));
|
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,11 @@ public class ChapterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
|
public ResponseEntity<List<ChapterDTO>> getAllChapters(
|
||||||
List<Chapter> chapters = chapterService.getAllChapters();
|
@RequestParam(value = "arcId", required = false) String arcId) {
|
||||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
|
||||||
.map(chapterMapper::toDTO)
|
? chapterService.getChaptersByArcId(arcId)
|
||||||
.collect(Collectors.toList());
|
: chapterService.getAllChapters();
|
||||||
return ResponseEntity.ok(chapterDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/arc/{arcId}")
|
|
||||||
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
|
|
||||||
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
|
|
||||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||||
.map(chapterMapper::toDTO)
|
.map(chapterMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public class SceneController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
|
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
|
||||||
Scene scene = sceneMapper.toDomain(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));
|
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +40,11 @@ public class SceneController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<SceneDTO>> getAllScenes() {
|
public ResponseEntity<List<SceneDTO>> getAllScenes(
|
||||||
List<Scene> scenes = sceneService.getAllScenes();
|
@RequestParam(value = "chapterId", required = false) String chapterId) {
|
||||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
|
||||||
.map(sceneMapper::toDTO)
|
? sceneService.getScenesByChapterId(chapterId)
|
||||||
.collect(Collectors.toList());
|
: sceneService.getAllScenes();
|
||||||
return ResponseEntity.ok(sceneDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/chapter/{chapterId}")
|
|
||||||
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
|
|
||||||
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
|
|
||||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
List<SceneDTO> sceneDTOs = scenes.stream()
|
||||||
.map(sceneMapper::toDTO)
|
.map(sceneMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -4,16 +4,28 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.client.RestTemplate;
|
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;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,20 +44,28 @@ public class SettingsController {
|
|||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final String brainBaseUrl;
|
private final String brainBaseUrl;
|
||||||
|
private final String brainInternalSecret;
|
||||||
|
private final boolean demoMode;
|
||||||
|
|
||||||
public SettingsController(RestTemplate restTemplate,
|
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.restTemplate = restTemplate;
|
||||||
this.brainBaseUrl = brainBaseUrl;
|
this.brainBaseUrl = brainBaseUrl;
|
||||||
|
this.brainInternalSecret = brainInternalSecret;
|
||||||
|
this.demoMode = demoMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<Map<String, Object>> getSettings() {
|
public ResponseEntity<Map<String, Object>> getSettings() {
|
||||||
|
guardDemoMode();
|
||||||
return forward(HttpMethod.GET, "/settings", null);
|
return forward(HttpMethod.GET, "/settings", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
||||||
|
guardDemoMode();
|
||||||
return forward(HttpMethod.PUT, "/settings", patch);
|
return forward(HttpMethod.PUT, "/settings", patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +79,98 @@ public class SettingsController {
|
|||||||
return forward(HttpMethod.POST, "/models/ollama/info", body);
|
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")
|
@GetMapping("/models/onemin")
|
||||||
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
||||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
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"})
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ public class ArcDTO {
|
|||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String themes;
|
private String themes;
|
||||||
private String stakes;
|
private String stakes;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class ChapterDTO {
|
|||||||
private String arcId;
|
private String arcId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
private String playerObjectives;
|
private String playerObjectives;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class SceneDTO {
|
|||||||
private String chapterId;
|
private String chapterId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String location;
|
private String location;
|
||||||
private String timing;
|
private String timing;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ArcMapper {
|
|||||||
dto.setDescription(arc.getDescription());
|
dto.setDescription(arc.getDescription());
|
||||||
dto.setCampaignId(arc.getCampaignId());
|
dto.setCampaignId(arc.getCampaignId());
|
||||||
dto.setOrder(arc.getOrder());
|
dto.setOrder(arc.getOrder());
|
||||||
|
dto.setIcon(arc.getIcon());
|
||||||
dto.setThemes(arc.getThemes());
|
dto.setThemes(arc.getThemes());
|
||||||
dto.setStakes(arc.getStakes());
|
dto.setStakes(arc.getStakes());
|
||||||
dto.setGmNotes(arc.getGmNotes());
|
dto.setGmNotes(arc.getGmNotes());
|
||||||
@@ -46,6 +47,7 @@ public class ArcMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.themes(dto.getThemes())
|
.themes(dto.getThemes())
|
||||||
.stakes(dto.getStakes())
|
.stakes(dto.getStakes())
|
||||||
.gmNotes(dto.getGmNotes())
|
.gmNotes(dto.getGmNotes())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ChapterMapper {
|
|||||||
dto.setDescription(chapter.getDescription());
|
dto.setDescription(chapter.getDescription());
|
||||||
dto.setArcId(chapter.getArcId());
|
dto.setArcId(chapter.getArcId());
|
||||||
dto.setOrder(chapter.getOrder());
|
dto.setOrder(chapter.getOrder());
|
||||||
|
dto.setIcon(chapter.getIcon());
|
||||||
dto.setGmNotes(chapter.getGmNotes());
|
dto.setGmNotes(chapter.getGmNotes());
|
||||||
dto.setPlayerObjectives(chapter.getPlayerObjectives());
|
dto.setPlayerObjectives(chapter.getPlayerObjectives());
|
||||||
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
||||||
@@ -44,6 +45,7 @@ public class ChapterMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.arcId(dto.getArcId())
|
.arcId(dto.getArcId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.gmNotes(dto.getGmNotes())
|
.gmNotes(dto.getGmNotes())
|
||||||
.playerObjectives(dto.getPlayerObjectives())
|
.playerObjectives(dto.getPlayerObjectives())
|
||||||
.narrativeStakes(dto.getNarrativeStakes())
|
.narrativeStakes(dto.getNarrativeStakes())
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class SceneMapper {
|
|||||||
dto.setDescription(scene.getDescription());
|
dto.setDescription(scene.getDescription());
|
||||||
dto.setChapterId(scene.getChapterId());
|
dto.setChapterId(scene.getChapterId());
|
||||||
dto.setOrder(scene.getOrder());
|
dto.setOrder(scene.getOrder());
|
||||||
|
dto.setIcon(scene.getIcon());
|
||||||
dto.setLocation(scene.getLocation());
|
dto.setLocation(scene.getLocation());
|
||||||
dto.setTiming(scene.getTiming());
|
dto.setTiming(scene.getTiming());
|
||||||
dto.setAtmosphere(scene.getAtmosphere());
|
dto.setAtmosphere(scene.getAtmosphere());
|
||||||
@@ -59,6 +60,7 @@ public class SceneMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.chapterId(dto.getChapterId())
|
.chapterId(dto.getChapterId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.location(dto.getLocation())
|
.location(dto.getLocation())
|
||||||
.timing(dto.getTiming())
|
.timing(dto.getTiming())
|
||||||
.atmosphere(dto.getAtmosphere())
|
.atmosphere(dto.getAtmosphere())
|
||||||
@@ -85,18 +87,14 @@ public class SceneMapper {
|
|||||||
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
||||||
if (branches == null) return new ArrayList<>();
|
if (branches == null) return new ArrayList<>();
|
||||||
return branches.stream()
|
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());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
||||||
if (dtos == null) return new ArrayList<>();
|
if (dtos == null) return new ArrayList<>();
|
||||||
return dtos.stream()
|
return dtos.stream()
|
||||||
.map(d -> SceneBranch.builder()
|
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
|
||||||
.label(d.getLabel())
|
|
||||||
.targetSceneId(d.getTargetSceneId())
|
|
||||||
.condition(d.getCondition())
|
|
||||||
.build())
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ server.port=8080
|
|||||||
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
||||||
spring.main.web-application-type=servlet
|
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
|
# Configuration de la base de donnees PostgreSQL
|
||||||
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
# 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
|
# 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
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
|
||||||
# Configuration CORS pour autoriser le Frontend Angular
|
# 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-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||||
spring.web.cors.allowed-headers=*
|
spring.web.cors.allowed-headers=*
|
||||||
spring.web.cors.allow-credentials=true
|
spring.web.cors.allow-credentials=true
|
||||||
|
|
||||||
# Configuration du Brain (service IA Python)
|
# 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
|
brain.timeout-seconds=120
|
||||||
|
|
||||||
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
|
# 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)
|
# Limites d'upload d'images (MB)
|
||||||
spring.servlet.multipart.max-file-size=10MB
|
spring.servlet.multipart.max-file-size=10MB
|
||||||
spring.servlet.multipart.max-request-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:}
|
||||||
|
|||||||
@@ -178,10 +178,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithValidBranches() {
|
void testUpdateScene_WithValidBranches() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
|
||||||
.targetSceneId("scene-2")
|
|
||||||
.label("Go to scene 2")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -203,10 +200,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchToSelf() {
|
void testUpdateScene_WithBranchToSelf() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
|
||||||
.targetSceneId("scene-1")
|
|
||||||
.label("Self-reference")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -228,10 +222,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchToDifferentChapter() {
|
void testUpdateScene_WithBranchToDifferentChapter() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
|
||||||
.targetSceneId("scene-other-chapter")
|
|
||||||
.label("Go to other chapter")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -253,10 +244,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchNullTarget() {
|
void testUpdateScene_WithBranchNullTarget() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Null target", null);
|
||||||
.targetSceneId(null)
|
|
||||||
.label("Null target")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
@@ -277,10 +265,7 @@ public class SceneServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void testUpdateScene_WithBranchBlankTarget() {
|
void testUpdateScene_WithBranchBlankTarget() {
|
||||||
// Arrange
|
// Arrange
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("Blank target", " ");
|
||||||
.targetSceneId(" ")
|
|
||||||
.label("Blank target")
|
|
||||||
.build();
|
|
||||||
Scene updatedScene = Scene.builder()
|
Scene updatedScene = Scene.builder()
|
||||||
.name("Updated Scene")
|
.name("Updated Scene")
|
||||||
.branches(List.of(branch))
|
.branches(List.of(branch))
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
assertEquals("Les Terres Brisées", ctx.getCampaignName());
|
assertEquals("Les Terres Brisées", ctx.campaignName());
|
||||||
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
|
assertEquals("Campagne dark fantasy", ctx.campaignDescription());
|
||||||
assertTrue(ctx.getArcs().isEmpty());
|
assertTrue(ctx.arcs().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -100,19 +100,19 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
assertEquals(2, ctx.getArcs().size());
|
assertEquals(2, ctx.arcs().size());
|
||||||
assertEquals("Arc A", ctx.getArcs().get(0).getName());
|
assertEquals("Arc A", ctx.arcs().get(0).name());
|
||||||
assertEquals("Arc B", ctx.getArcs().get(1).getName());
|
assertEquals("Arc B", ctx.arcs().get(1).name());
|
||||||
|
|
||||||
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
|
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
|
||||||
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
|
assertEquals(2, ctx.arcs().get(0).chapters().size());
|
||||||
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
|
assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
|
||||||
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
|
assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
|
||||||
|
|
||||||
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
|
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
|
||||||
var chADto = ctx.getArcs().get(0).getChapters().get(1);
|
var chADto = ctx.arcs().get(0).chapters().get(1);
|
||||||
assertEquals("Scene B", chADto.getScenes().get(0).getName());
|
assertEquals("Scene B", chADto.scenes().get(0).name());
|
||||||
assertEquals("Scene A", chADto.getScenes().get(1).getName());
|
assertEquals("Scene A", chADto.scenes().get(1).name());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -120,15 +120,8 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
|
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();
|
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
|
||||||
|
|
||||||
SceneBranch validBranch = SceneBranch.builder()
|
SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
|
||||||
.label("Si les joueurs fuient")
|
SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
|
||||||
.targetSceneId("s-2")
|
|
||||||
.condition("en cas de combat perdu")
|
|
||||||
.build();
|
|
||||||
SceneBranch danglingBranch = SceneBranch.builder()
|
|
||||||
.label("Vers l'inconnu")
|
|
||||||
.targetSceneId("s-inconnu")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
|
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
|
||||||
.order(1)
|
.order(1)
|
||||||
@@ -143,12 +136,12 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
|
var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
|
||||||
assertEquals(2, scene1Summary.getBranches().size());
|
assertEquals(2, scene1Summary.branches().size());
|
||||||
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
|
assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
|
||||||
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
|
assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
|
||||||
// ID inconnu → libellé de fallback
|
// ID inconnu → libellé de fallback
|
||||||
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
|
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -170,9 +163,9 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
|
|
||||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
|
assertEquals(2, ctx.arcs().get(0).illustrationCount());
|
||||||
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
|
assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
|
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
|
||||||
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
|
assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
|
|||||||
verify(aiProvider).generatePage(captor.capture());
|
verify(aiProvider).generatePage(captor.capture());
|
||||||
GenerationContext ctx = captor.getValue();
|
GenerationContext ctx = captor.getValue();
|
||||||
|
|
||||||
assertEquals("Aetheria", ctx.getLoreName());
|
assertEquals("Aetheria", ctx.loreName());
|
||||||
assertEquals("monde aérien", ctx.getLoreDescription());
|
assertEquals("monde aérien", ctx.loreDescription());
|
||||||
assertEquals("PNJ", ctx.getFolderName());
|
assertEquals("PNJ", ctx.folderName());
|
||||||
assertEquals("Personnage", ctx.getTemplateName());
|
assertEquals("Personnage", ctx.templateName());
|
||||||
assertEquals("Alice", ctx.getPageTitle());
|
assertEquals("Alice", ctx.pageTitle());
|
||||||
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
|
// 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
|
@Test
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
assertEquals("Aetheria", ctx.getLoreName());
|
assertEquals("Aetheria", ctx.loreName());
|
||||||
assertEquals("Monde aérien", ctx.getLoreDescription());
|
assertEquals("Monde aérien", ctx.loreDescription());
|
||||||
assertTrue(ctx.getFolders().isEmpty());
|
assertTrue(ctx.folders().isEmpty());
|
||||||
assertTrue(ctx.getTags().isEmpty());
|
assertTrue(ctx.tags().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
assertEquals(2, ctx.getFolders().size());
|
assertEquals(2, ctx.folders().size());
|
||||||
assertTrue(ctx.getFolders().containsKey("PNJ"));
|
assertTrue(ctx.folders().containsKey("PNJ"));
|
||||||
assertTrue(ctx.getFolders().containsKey("Lieux"));
|
assertTrue(ctx.folders().containsKey("Lieux"));
|
||||||
|
|
||||||
var pnjPages = ctx.getFolders().get("PNJ");
|
var pnjPages = ctx.folders().get("PNJ");
|
||||||
assertEquals(1, pnjPages.size());
|
assertEquals(1, pnjPages.size());
|
||||||
var aliceSummary = pnjPages.get(0);
|
var aliceSummary = pnjPages.get(0);
|
||||||
assertEquals("Alice", aliceSummary.getTitle());
|
assertEquals("Alice", aliceSummary.title());
|
||||||
assertEquals("Personnage", aliceSummary.getTemplateName());
|
assertEquals("Personnage", aliceSummary.templateName());
|
||||||
// Blank/null filtrés
|
// Blank/null filtrés
|
||||||
assertEquals(1, aliceSummary.getValues().size());
|
assertEquals(1, aliceSummary.values().size());
|
||||||
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
|
assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
|
||||||
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
|
assertEquals(List.of("hero", "magic"), aliceSummary.tags());
|
||||||
// p-2 resolved into title, p-ghost dropped silently
|
// 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 → "?"
|
// Template introuvable → "?"
|
||||||
assertEquals("?", forestSummary.getTemplateName());
|
assertEquals("?", forestSummary.templateName());
|
||||||
assertTrue(forestSummary.getValues().isEmpty());
|
assertTrue(forestSummary.values().isEmpty());
|
||||||
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
|
assertTrue(forestSummary.relatedPageTitles().isEmpty());
|
||||||
|
|
||||||
// Tags uniques entre les 2 pages
|
// Tags uniques entre les 2 pages
|
||||||
assertEquals(2, ctx.getTags().size());
|
assertEquals(2, ctx.tags().size());
|
||||||
assertTrue(ctx.getTags().contains("hero"));
|
assertTrue(ctx.tags().contains("hero"));
|
||||||
assertTrue(ctx.getTags().contains("magic"));
|
assertTrue(ctx.tags().contains("magic"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
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);
|
assertNotNull(truncated);
|
||||||
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
|
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
|
||||||
assertTrue(truncated.endsWith("…"));
|
assertTrue(truncated.endsWith("…"));
|
||||||
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
|
|||||||
|
|
||||||
LoreStructuralContext ctx = builder.build("lore-1");
|
LoreStructuralContext ctx = builder.build("lore-1");
|
||||||
|
|
||||||
var summary = ctx.getFolders().get("PNJ").get(0);
|
var summary = ctx.folders().get("PNJ").get(0);
|
||||||
assertTrue(summary.getValues().isEmpty());
|
assertTrue(summary.values().isEmpty());
|
||||||
assertTrue(summary.getTags().isEmpty());
|
assertTrue(summary.tags().isEmpty());
|
||||||
assertTrue(summary.getRelatedPageTitles().isEmpty());
|
assertTrue(summary.relatedPageTitles().isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
|
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
|
||||||
|
|
||||||
assertEquals("arc", ctx.getEntityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
assertEquals("L'arc sombre", ctx.getTitle());
|
assertEquals("L'arc sombre", ctx.title());
|
||||||
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
|
assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
|
||||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
assertEquals("trahison", ctx.fields().get("themes"));
|
||||||
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
|
assertEquals("vie ou mort", ctx.fields().get("stakes"));
|
||||||
assertEquals("pouvoir", ctx.getFields().get("rewards"));
|
assertEquals("pouvoir", ctx.fields().get("rewards"));
|
||||||
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
|
assertEquals("le roi meurt", ctx.fields().get("resolution"));
|
||||||
assertEquals("secret", ctx.getFields().get("gmNotes"));
|
assertEquals("secret", ctx.fields().get("gmNotes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -64,12 +64,12 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
|
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
|
||||||
|
|
||||||
assertEquals("chapter", ctx.getEntityType());
|
assertEquals("chapter", ctx.entityType());
|
||||||
assertEquals("Chapitre 1", ctx.getTitle());
|
assertEquals("Chapitre 1", ctx.title());
|
||||||
assertEquals("", ctx.getFields().get("description (synopsis)"));
|
assertEquals("", ctx.fields().get("description (synopsis)"));
|
||||||
assertEquals("", ctx.getFields().get("playerObjectives"));
|
assertEquals("", ctx.fields().get("playerObjectives"));
|
||||||
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
|
assertEquals("haut", ctx.fields().get("narrativeStakes"));
|
||||||
assertEquals("", ctx.getFields().get("gmNotes"));
|
assertEquals("", ctx.fields().get("gmNotes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -85,17 +85,17 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build("scene", "s-1");
|
NarrativeEntityContext ctx = builder.build("scene", "s-1");
|
||||||
|
|
||||||
assertEquals("scene", ctx.getEntityType());
|
assertEquals("scene", ctx.entityType());
|
||||||
assertEquals("L'auberge", ctx.getTitle());
|
assertEquals("L'auberge", ctx.title());
|
||||||
assertEquals("lieu calme", ctx.getFields().get("description"));
|
assertEquals("lieu calme", ctx.fields().get("description"));
|
||||||
assertEquals("Taverne", ctx.getFields().get("location"));
|
assertEquals("Taverne", ctx.fields().get("location"));
|
||||||
assertEquals("Soir", ctx.getFields().get("timing"));
|
assertEquals("Soir", ctx.fields().get("timing"));
|
||||||
assertEquals("tendue", ctx.getFields().get("atmosphere"));
|
assertEquals("tendue", ctx.fields().get("atmosphere"));
|
||||||
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
|
assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
|
||||||
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
|
assertEquals("option A...", ctx.fields().get("choicesConsequences"));
|
||||||
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
|
assertEquals("moyen", ctx.fields().get("combatDifficulty"));
|
||||||
assertEquals("3 bandits", ctx.getFields().get("enemies"));
|
assertEquals("3 bandits", ctx.fields().get("enemies"));
|
||||||
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
|
assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -104,7 +104,7 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
|
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
|
||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
|
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
|
||||||
assertEquals("arc", ctx.getEntityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
campaignCtx = CampaignStructuralContext.builder()
|
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
|
||||||
.campaignName("X").campaignDescription("d")
|
|
||||||
.build();
|
|
||||||
messages = List.of();
|
messages = List.of();
|
||||||
onUsage = mock(Consumer.class);
|
onUsage = mock(Consumer.class);
|
||||||
onToken = mock(Consumer.class);
|
onToken = mock(Consumer.class);
|
||||||
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||||
ChatRequest req = captor.getValue();
|
ChatRequest req = captor.getValue();
|
||||||
assertSame(campaignCtx, req.getCampaignContext());
|
assertSame(campaignCtx, req.campaignContext());
|
||||||
assertNull(req.getLoreContext());
|
assertNull(req.loreContext());
|
||||||
assertNull(req.getNarrativeEntity());
|
assertNull(req.narrativeEntity());
|
||||||
assertNull(req.getPageContext());
|
assertNull(req.pageContext());
|
||||||
verifyNoInteractions(loreContextBuilder);
|
verifyNoInteractions(loreContextBuilder);
|
||||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||||
}
|
}
|
||||||
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
@Test
|
@Test
|
||||||
void testExecute_LinkedCampaign_LoadsLoreContext() {
|
void testExecute_LinkedCampaign_LoadsLoreContext() {
|
||||||
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
|
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
|
||||||
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
|
LoreStructuralContext loreCtx = new LoreStructuralContext(
|
||||||
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
|
"L", "d", Collections.emptyMap(), List.of());
|
||||||
|
|
||||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
|
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
|
||||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||||
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertSame(loreCtx, captor.getValue().getLoreContext());
|
assertSame(loreCtx, captor.getValue().loreContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
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).
|
// La requete doit tout de meme partir (pas d'exception).
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
|
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
|
||||||
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
|
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
|
||||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
|
||||||
.entityType("scene").title("L'auberge").fields(Map.of()).build();
|
|
||||||
|
|
||||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
|
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
|
||||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||||
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertSame(entity, captor.getValue().getNarrativeEntity());
|
assertSame(entity, captor.getValue().narrativeEntity());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertNull(captor.getValue().getNarrativeEntity());
|
assertNull(captor.getValue().narrativeEntity());
|
||||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
loreCtx = LoreStructuralContext.builder()
|
loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
|
||||||
.loreName("Aetheria").loreDescription("d")
|
|
||||||
.folders(Collections.emptyMap())
|
|
||||||
.build();
|
|
||||||
messages = List.of();
|
messages = List.of();
|
||||||
onUsage = mock(Consumer.class);
|
onUsage = mock(Consumer.class);
|
||||||
onToken = mock(Consumer.class);
|
onToken = mock(Consumer.class);
|
||||||
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||||
ChatRequest req = captor.getValue();
|
ChatRequest req = captor.getValue();
|
||||||
assertSame(loreCtx, req.getLoreContext());
|
assertSame(loreCtx, req.loreContext());
|
||||||
assertNull(req.getPageContext());
|
assertNull(req.pageContext());
|
||||||
assertNull(req.getCampaignContext());
|
assertNull(req.campaignContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
assertNull(captor.getValue().getPageContext());
|
assertNull(captor.getValue().pageContext());
|
||||||
verifyNoInteractions(pageRepository);
|
verifyNoInteractions(pageRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
ChatRequest req = captor.getValue();
|
ChatRequest req = captor.getValue();
|
||||||
assertNotNull(req.getPageContext());
|
assertNotNull(req.pageContext());
|
||||||
assertEquals("Alice", req.getPageContext().getTitle());
|
assertEquals("Alice", req.pageContext().title());
|
||||||
assertEquals("Personnage", req.getPageContext().getTemplateName());
|
assertEquals("Personnage", req.pageContext().templateName());
|
||||||
// Seuls les champs TEXT exposes
|
// Seuls les champs TEXT exposes
|
||||||
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
|
assertEquals(List.of("Histoire"), req.pageContext().templateFields());
|
||||||
assertEquals(values, req.getPageContext().getValues());
|
assertEquals(values, req.pageContext().values());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
var pageCtx = captor.getValue().getPageContext();
|
var pageCtx = captor.getValue().pageContext();
|
||||||
assertNotNull(pageCtx);
|
assertNotNull(pageCtx);
|
||||||
assertEquals("Orphan", pageCtx.getTitle());
|
assertEquals("Orphan", pageCtx.title());
|
||||||
assertEquals("?", pageCtx.getTemplateName());
|
assertEquals("?", pageCtx.templateName());
|
||||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
assertTrue(pageCtx.templateFields().isEmpty());
|
||||||
assertTrue(pageCtx.getValues().isEmpty());
|
assertTrue(pageCtx.values().isEmpty());
|
||||||
verifyNoInteractions(templateRepository);
|
verifyNoInteractions(templateRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
|
|||||||
|
|
||||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||||
var pageCtx = captor.getValue().getPageContext();
|
var pageCtx = captor.getValue().pageContext();
|
||||||
assertEquals("?", pageCtx.getTemplateName());
|
assertEquals("?", pageCtx.templateName());
|
||||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
assertTrue(pageCtx.templateFields().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||||||
/**
|
/**
|
||||||
* Tests unitaires pour SceneBranch (Value Object).
|
* Tests unitaires pour SceneBranch (Value Object).
|
||||||
* Verifie :
|
* Verifie :
|
||||||
* - l'immuabilite (pas de setters : seul le builder permet la construction),
|
* - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
|
||||||
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
|
* - l'egalite structurelle generee par record (equals/hashCode sur tous les
|
||||||
* champs) — deux branches aux memes champs sont strictement egales,
|
* champs) — deux branches aux memes champs sont strictement egales,
|
||||||
* - le support du champ optionnel {@code condition}.
|
* - le support du champ optionnel {@code condition}.
|
||||||
*/
|
*/
|
||||||
class SceneBranchTest {
|
class SceneBranchTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_exposesAllFields() {
|
void constructor_exposesAllFields() {
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = new SceneBranch(
|
||||||
.label("Si les joueurs attaquent le garde")
|
"Si les joueurs attaquent le garde",
|
||||||
.targetSceneId("sc-combat")
|
"sc-combat",
|
||||||
.condition("initiative > 15")
|
"initiative > 15");
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
|
assertEquals("Si les joueurs attaquent le garde", branch.label());
|
||||||
assertEquals("sc-combat", branch.getTargetSceneId());
|
assertEquals("sc-combat", branch.targetSceneId());
|
||||||
assertEquals("initiative > 15", branch.getCondition());
|
assertEquals("initiative > 15", branch.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void condition_isOptional() {
|
void condition_isOptional() {
|
||||||
SceneBranch branch = SceneBranch.builder()
|
SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
|
||||||
.label("sortie par la porte")
|
|
||||||
.targetSceneId("sc-corridor")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertNull(branch.getCondition());
|
assertNull(branch.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoBranches_withSameFields_areEqual() {
|
void twoBranches_withSameFields_areEqual() {
|
||||||
SceneBranch a = SceneBranch.builder()
|
SceneBranch a = new SceneBranch("fuite", "sc-2", null);
|
||||||
.label("fuite")
|
SceneBranch b = new SceneBranch("fuite", "sc-2", null);
|
||||||
.targetSceneId("sc-2")
|
|
||||||
.condition(null)
|
|
||||||
.build();
|
|
||||||
SceneBranch b = SceneBranch.builder()
|
|
||||||
.label("fuite")
|
|
||||||
.targetSceneId("sc-2")
|
|
||||||
.condition(null)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals(a, b);
|
assertEquals(a, b);
|
||||||
assertEquals(a.hashCode(), b.hashCode());
|
assertEquals(a.hashCode(), b.hashCode());
|
||||||
@@ -58,16 +46,16 @@ class SceneBranchTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoBranches_differingOnTargetSceneId_areNotEqual() {
|
void twoBranches_differingOnTargetSceneId_areNotEqual() {
|
||||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
|
SceneBranch a = SceneBranch.of("X", "sc-1");
|
||||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
|
SceneBranch b = SceneBranch.of("X", "sc-2");
|
||||||
|
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoBranches_differingOnCondition_areNotEqual() {
|
void twoBranches_differingOnCondition_areNotEqual() {
|
||||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
|
SceneBranch a = new SceneBranch("X", "sc-1", "A");
|
||||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
|
SceneBranch b = new SceneBranch("X", "sc-1", "B");
|
||||||
|
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ class SceneTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesBranches_whenProvided() {
|
void builder_preservesBranches_whenProvided() {
|
||||||
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
|
SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
|
||||||
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
|
SceneBranch b2 = SceneBranch.of("combat", "sc-3");
|
||||||
|
|
||||||
Scene scene = Scene.builder()
|
Scene scene = Scene.builder()
|
||||||
.branches(List.of(b1, b2))
|
.branches(List.of(b1, b2))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertEquals(2, scene.getBranches().size());
|
assertEquals(2, scene.getBranches().size());
|
||||||
assertEquals("fuite", scene.getBranches().get(0).getLabel());
|
assertEquals("fuite", scene.getBranches().get(0).label());
|
||||||
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
|
assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,108 +6,97 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import org.junit.jupiter.api.Test;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
|
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
|
||||||
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
|
* Records purs : aucune dependance technique.
|
||||||
* permettent une construction incrementale du graphe narratif.
|
|
||||||
*/
|
*/
|
||||||
class CampaignStructuralContextTest {
|
class CampaignStructuralContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_constructsFullNarrativeTree() {
|
void constructor_buildsFullNarrativeTree() {
|
||||||
BranchHint branch = BranchHint.builder()
|
BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
|
||||||
.label("si les PJ fuient")
|
|
||||||
.targetSceneName("La poursuite")
|
|
||||||
.condition("PJ < moitie des HP")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
SceneSummary scene = SceneSummary.builder()
|
SceneSummary scene = new SceneSummary(
|
||||||
.name("L'auberge")
|
"L'auberge",
|
||||||
.description("Rencontre tendue avec le tavernier")
|
"Rencontre tendue avec le tavernier",
|
||||||
.illustrationCount(2)
|
2,
|
||||||
.branch(branch)
|
List.of(branch));
|
||||||
.build();
|
|
||||||
|
|
||||||
ChapterSummary chapter = ChapterSummary.builder()
|
ChapterSummary chapter = new ChapterSummary(
|
||||||
.name("L'arrivee")
|
"L'arrivee",
|
||||||
.description("Les PJ decouvrent la ville")
|
"Les PJ decouvrent la ville",
|
||||||
.scene(scene)
|
0,
|
||||||
.build();
|
List.of(scene));
|
||||||
|
|
||||||
ArcSummary arc = ArcSummary.builder()
|
ArcSummary arc = new ArcSummary(
|
||||||
.name("Acte I")
|
"Acte I",
|
||||||
.description("Mise en place")
|
"Mise en place",
|
||||||
.illustrationCount(1)
|
1,
|
||||||
.chapter(chapter)
|
List.of(chapter));
|
||||||
.build();
|
|
||||||
|
|
||||||
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
|
CampaignStructuralContext ctx = new CampaignStructuralContext(
|
||||||
.campaignName("Les Ombres")
|
"Les Ombres",
|
||||||
.campaignDescription("Une campagne dark fantasy")
|
"Une campagne dark fantasy",
|
||||||
.arc(arc)
|
List.of(arc),
|
||||||
.build();
|
List.of());
|
||||||
|
|
||||||
assertEquals("Les Ombres", ctx.getCampaignName());
|
assertEquals("Les Ombres", ctx.campaignName());
|
||||||
assertEquals(1, ctx.getArcs().size());
|
assertEquals(1, ctx.arcs().size());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
|
assertEquals(1, ctx.arcs().get(0).chapters().size());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
|
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
|
||||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
|
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BranchHint ---------------------------------------------------------
|
// --- BranchHint ---------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void branchHint_preservesAllFields() {
|
void branchHint_preservesAllFields() {
|
||||||
BranchHint b = BranchHint.builder()
|
BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
|
||||||
.label("combat")
|
|
||||||
.targetSceneName("La confrontation")
|
|
||||||
.condition("initiative > 15")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("combat", b.getLabel());
|
assertEquals("combat", b.label());
|
||||||
assertEquals("La confrontation", b.getTargetSceneName());
|
assertEquals("La confrontation", b.targetSceneName());
|
||||||
assertEquals("initiative > 15", b.getCondition());
|
assertEquals("initiative > 15", b.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void branchHint_conditionIsOptional() {
|
void branchHint_conditionIsOptional() {
|
||||||
BranchHint b = BranchHint.builder()
|
BranchHint b = new BranchHint("suite normale", "Scene 2", null);
|
||||||
.label("suite normale")
|
|
||||||
.targetSceneName("Scene 2")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertNull(b.getCondition());
|
assertNull(b.condition());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- illustrationCount --------------------------------------------------
|
// --- illustrationCount --------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
||||||
ArcSummary arc = ArcSummary.builder().name("X").build();
|
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
|
||||||
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
|
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
||||||
SceneSummary scene = SceneSummary.builder().name("X").build();
|
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
|
||||||
|
|
||||||
assertEquals(0, arc.getIllustrationCount());
|
assertEquals(0, arc.illustrationCount());
|
||||||
assertEquals(0, chapter.getIllustrationCount());
|
assertEquals(0, chapter.illustrationCount());
|
||||||
assertEquals(0, scene.getIllustrationCount());
|
assertEquals(0, scene.illustrationCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- @Singular : accumulation incrementale -----------------------------
|
// --- Construction incrementale (chapitres multiples) -------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void singular_accumulatesMultipleCalls() {
|
void multipleChapters_arePreserved() {
|
||||||
ArcSummary arc = ArcSummary.builder()
|
ArcSummary arc = new ArcSummary(
|
||||||
.name("Acte I")
|
"Acte I",
|
||||||
.chapter(ChapterSummary.builder().name("Ch1").build())
|
null,
|
||||||
.chapter(ChapterSummary.builder().name("Ch2").build())
|
0,
|
||||||
.chapter(ChapterSummary.builder().name("Ch3").build())
|
List.of(
|
||||||
.build();
|
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());
|
assertEquals(3, arc.chapters().size());
|
||||||
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
|
assertEquals("Ch2", arc.chapters().get(1).name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
@@ -26,57 +27,45 @@ class ChatRequestTest {
|
|||||||
void buildLoreOnly_leavesCampaignAndEntityNull() {
|
void buildLoreOnly_leavesCampaignAndEntityNull() {
|
||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.loreContext(LoreStructuralContext.builder()
|
.loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
|
||||||
.loreName("Ithoril")
|
|
||||||
.loreDescription("Royaume sombre")
|
|
||||||
.folders(java.util.Map.of())
|
|
||||||
.build())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertEquals(1, request.getMessages().size());
|
assertEquals(1, request.messages().size());
|
||||||
assertNotNull(request.getLoreContext());
|
assertNotNull(request.loreContext());
|
||||||
assertEquals("Ithoril", request.getLoreContext().getLoreName());
|
assertEquals("Ithoril", request.loreContext().loreName());
|
||||||
assertNull(request.getPageContext());
|
assertNull(request.pageContext());
|
||||||
assertNull(request.getCampaignContext());
|
assertNull(request.campaignContext());
|
||||||
assertNull(request.getNarrativeEntity());
|
assertNull(request.narrativeEntity());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void buildLoreWithPageFocus_hasBothContexts() {
|
void buildLoreWithPageFocus_hasBothContexts() {
|
||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
|
.loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
|
||||||
.pageContext(PageContext.builder()
|
.pageContext(new PageContext("Thorin", "PNJ", null, null))
|
||||||
.title("Thorin")
|
|
||||||
.templateName("PNJ")
|
|
||||||
.build())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertNotNull(request.getLoreContext());
|
assertNotNull(request.loreContext());
|
||||||
assertNotNull(request.getPageContext());
|
assertNotNull(request.pageContext());
|
||||||
assertEquals("Thorin", request.getPageContext().getTitle());
|
assertEquals("Thorin", request.pageContext().title());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void buildCampaignWithNarrativeEntity_hasBothContexts() {
|
void buildCampaignWithNarrativeEntity_hasBothContexts() {
|
||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.campaignContext(CampaignStructuralContext.builder()
|
.campaignContext(new CampaignStructuralContext(
|
||||||
.campaignName("Les Ombres")
|
"Les Ombres", "...", List.of(), List.of()))
|
||||||
.campaignDescription("...")
|
.narrativeEntity(new NarrativeEntityContext(
|
||||||
.build())
|
"scene", "L'auberge", Map.of("location", "Taverne")))
|
||||||
.narrativeEntity(NarrativeEntityContext.builder()
|
|
||||||
.entityType("scene")
|
|
||||||
.title("L'auberge")
|
|
||||||
.fields(java.util.Map.of("location", "Taverne"))
|
|
||||||
.build())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertNotNull(request.getCampaignContext());
|
assertNotNull(request.campaignContext());
|
||||||
assertNotNull(request.getNarrativeEntity());
|
assertNotNull(request.narrativeEntity());
|
||||||
assertEquals("scene", request.getNarrativeEntity().getEntityType());
|
assertEquals("scene", request.narrativeEntity().entityType());
|
||||||
assertNull(request.getLoreContext());
|
assertNull(request.loreContext());
|
||||||
assertNull(request.getPageContext());
|
assertNull(request.pageContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -86,10 +75,10 @@ class ChatRequestTest {
|
|||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertEquals(1, request.getMessages().size());
|
assertEquals(1, request.messages().size());
|
||||||
assertNull(request.getLoreContext());
|
assertNull(request.loreContext());
|
||||||
assertNull(request.getPageContext());
|
assertNull(request.pageContext());
|
||||||
assertNull(request.getCampaignContext());
|
assertNull(request.campaignContext());
|
||||||
assertNull(request.getNarrativeEntity());
|
assertNull(request.narrativeEntity());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
|
* 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 {
|
class GenerationContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesAllFields() {
|
void constructor_preservesAllFields() {
|
||||||
GenerationContext ctx = GenerationContext.builder()
|
GenerationContext ctx = new GenerationContext(
|
||||||
.loreName("Ithoril")
|
"Ithoril",
|
||||||
.loreDescription("Royaume sombre")
|
"Royaume sombre",
|
||||||
.folderName("PNJ")
|
"PNJ",
|
||||||
.templateName("Fiche PNJ")
|
"Fiche PNJ",
|
||||||
.templateFields(List.of("histoire", "motto", "apparence"))
|
List.of("histoire", "motto", "apparence"),
|
||||||
.pageTitle("Thorin")
|
"Thorin");
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Ithoril", ctx.getLoreName());
|
assertEquals("Ithoril", ctx.loreName());
|
||||||
assertEquals("PNJ", ctx.getFolderName());
|
assertEquals("PNJ", ctx.folderName());
|
||||||
assertEquals("Fiche PNJ", ctx.getTemplateName());
|
assertEquals("Fiche PNJ", ctx.templateName());
|
||||||
assertEquals(3, ctx.getTemplateFields().size());
|
assertEquals(3, ctx.templateFields().size());
|
||||||
assertEquals("Thorin", ctx.getPageTitle());
|
assertEquals("Thorin", ctx.pageTitle());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoContexts_withSameFields_areEqual() {
|
void twoContexts_withSameFields_areEqual() {
|
||||||
GenerationContext a = GenerationContext.builder()
|
GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||||
GenerationContext b = GenerationContext.builder()
|
|
||||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
|
||||||
assertEquals(a, b);
|
assertEquals(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoContexts_differingOnPageTitle_areNotEqual() {
|
void twoContexts_differingOnPageTitle_areNotEqual() {
|
||||||
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
|
GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
|
||||||
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
|
GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
|
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
|
||||||
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
|
* Records purs : aucune dependance technique.
|
||||||
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
|
|
||||||
*/
|
*/
|
||||||
class LoreStructuralContextTest {
|
class LoreStructuralContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesFoldersAndTags() {
|
void constructor_preservesFoldersAndTags() {
|
||||||
PageSummary pnj = PageSummary.builder()
|
PageSummary pnj = new PageSummary(
|
||||||
.title("Thorin")
|
"Thorin",
|
||||||
.templateName("PNJ")
|
"PNJ",
|
||||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||||
.tags(List.of("pnj", "allie"))
|
List.of("pnj", "allie"),
|
||||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
List.of("Taverne du Dragon d'Or"));
|
||||||
.build();
|
|
||||||
|
|
||||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||||
.loreName("Ithoril")
|
"Ithoril",
|
||||||
.loreDescription("Royaume sombre")
|
"Royaume sombre",
|
||||||
.folders(Map.of("PNJ", List.of(pnj)))
|
Map.of("PNJ", List.of(pnj)),
|
||||||
.tag("royaume")
|
List.of("royaume", "dark-fantasy"));
|
||||||
.tag("dark-fantasy")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Ithoril", ctx.getLoreName());
|
assertEquals("Ithoril", ctx.loreName());
|
||||||
assertEquals(1, ctx.getFolders().size());
|
assertEquals(1, ctx.folders().size());
|
||||||
assertEquals(1, ctx.getFolders().get("PNJ").size());
|
assertEquals(1, ctx.folders().get("PNJ").size());
|
||||||
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
|
assertEquals(2, ctx.tags().size());
|
||||||
assertTrue(ctx.getTags().contains("royaume"));
|
assertTrue(ctx.tags().contains("royaume"));
|
||||||
assertTrue(ctx.getTags().contains("dark-fantasy"));
|
assertTrue(ctx.tags().contains("dark-fantasy"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyFolders_areAllowed() {
|
void emptyFolders_areAllowed() {
|
||||||
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
|
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
|
||||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||||
.loreName("Vide")
|
"Vide",
|
||||||
.loreDescription("")
|
"",
|
||||||
.folders(Map.of("Lieux", List.of()))
|
Map.of("Lieux", List.of()),
|
||||||
.build();
|
List.of());
|
||||||
|
|
||||||
assertNotNull(ctx.getFolders().get("Lieux"));
|
assertNotNull(ctx.folders().get("Lieux"));
|
||||||
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
|
assertTrue(ctx.folders().get("Lieux").isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PageSummary --------------------------------------------------------
|
// --- PageSummary --------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void pageSummary_preservesAllFields() {
|
void pageSummary_preservesAllFields() {
|
||||||
PageSummary ps = PageSummary.builder()
|
PageSummary ps = new PageSummary(
|
||||||
.title("Le Donjon du Chaos")
|
"Le Donjon du Chaos",
|
||||||
.templateName("Lieu")
|
"Lieu",
|
||||||
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
|
Map.of("histoire", "Bati il y a 1000 ans..."),
|
||||||
.tags(List.of("donjon", "ancien"))
|
List.of("donjon", "ancien"),
|
||||||
.relatedPageTitles(List.of("Thorin", "Garde royale"))
|
List.of("Thorin", "Garde royale"));
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Le Donjon du Chaos", ps.getTitle());
|
assertEquals("Le Donjon du Chaos", ps.title());
|
||||||
assertEquals("Lieu", ps.getTemplateName());
|
assertEquals("Lieu", ps.templateName());
|
||||||
assertEquals(1, ps.getValues().size());
|
assertEquals(1, ps.values().size());
|
||||||
assertEquals(2, ps.getTags().size());
|
assertEquals(2, ps.tags().size());
|
||||||
assertEquals(2, ps.getRelatedPageTitles().size());
|
assertEquals(2, ps.relatedPageTitles().size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|||||||
class NarrativeEntityContextTest {
|
class NarrativeEntityContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesAllFields() {
|
void constructor_preservesAllFields() {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
fields.put("themes", "trahison");
|
fields.put("themes", "trahison");
|
||||||
fields.put("stakes", "la survie du royaume");
|
fields.put("stakes", "la survie du royaume");
|
||||||
|
|
||||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
|
||||||
.entityType("arc")
|
|
||||||
.title("Acte I")
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("arc", ctx.getEntityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
assertEquals("Acte I", ctx.getTitle());
|
assertEquals("Acte I", ctx.title());
|
||||||
assertEquals(2, ctx.getFields().size());
|
assertEquals(2, ctx.fields().size());
|
||||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
assertEquals("trahison", ctx.fields().get("themes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
|
|||||||
fields.put("timing", "Soir");
|
fields.put("timing", "Soir");
|
||||||
fields.put("atmosphere", "fumee");
|
fields.put("atmosphere", "fumee");
|
||||||
|
|
||||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
|
||||||
.entityType("scene")
|
|
||||||
.title("L'auberge")
|
|
||||||
.fields(fields)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
|
assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void twoContexts_differingOnEntityType_areNotEqual() {
|
void twoContexts_differingOnEntityType_areNotEqual() {
|
||||||
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
|
NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
|
||||||
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
|
NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
|
||||||
assertNotEquals(a, b);
|
assertNotEquals(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class PageContextTest {
|
class PageContextTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builder_preservesAllFields() {
|
void constructor_preservesAllFields() {
|
||||||
PageContext ctx = PageContext.builder()
|
PageContext ctx = new PageContext(
|
||||||
.title("Thorin")
|
"Thorin",
|
||||||
.templateName("PNJ")
|
"PNJ",
|
||||||
.templateFields(List.of("histoire", "apparence", "motto"))
|
List.of("histoire", "apparence", "motto"),
|
||||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
Map.of("histoire", "Nee sous une etoile rouge"));
|
||||||
.build();
|
|
||||||
|
|
||||||
assertEquals("Thorin", ctx.getTitle());
|
assertEquals("Thorin", ctx.title());
|
||||||
assertEquals("PNJ", ctx.getTemplateName());
|
assertEquals("PNJ", ctx.templateName());
|
||||||
assertEquals(3, ctx.getTemplateFields().size());
|
assertEquals(3, ctx.templateFields().size());
|
||||||
assertEquals(1, ctx.getValues().size());
|
assertEquals(1, ctx.values().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyValues_areAllowed() {
|
void emptyValues_areAllowed() {
|
||||||
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
|
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
|
||||||
PageContext ctx = PageContext.builder()
|
PageContext ctx = new PageContext(
|
||||||
.title("Nouveau PNJ")
|
"Nouveau PNJ",
|
||||||
.templateName("PNJ")
|
"PNJ",
|
||||||
.templateFields(List.of("histoire", "apparence"))
|
List.of("histoire", "apparence"),
|
||||||
.values(Map.of())
|
Map.of());
|
||||||
.build();
|
|
||||||
|
|
||||||
assertTrue(ctx.getValues().isEmpty());
|
assertTrue(ctx.values().isEmpty());
|
||||||
assertEquals(2, ctx.getTemplateFields().size());
|
assertEquals(2, ctx.templateFields().size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_loreContext_includesBasicFields() {
|
void build_loreContext_includesBasicFields() {
|
||||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
LoreStructuralContext lore = new LoreStructuralContext(
|
||||||
.loreName("Ithoril")
|
"Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
|
||||||
.loreDescription("Royaume sombre")
|
|
||||||
.folders(Map.of())
|
|
||||||
.tag("dark-fantasy")
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
|
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
|
||||||
PageSummary minimal = PageSummary.builder()
|
PageSummary minimal = new PageSummary("Thorin", "PNJ",
|
||||||
.title("Thorin")
|
Map.of(), List.of(), List.of());
|
||||||
.templateName("PNJ")
|
LoreStructuralContext lore = new LoreStructuralContext(
|
||||||
.values(Map.of())
|
"X", "", Map.of("PNJ", List.of(minimal)), List.of());
|
||||||
.tags(List.of())
|
|
||||||
.relatedPageTitles(List.of())
|
|
||||||
.build();
|
|
||||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
|
||||||
.loreName("X").loreDescription("")
|
|
||||||
.folders(Map.of("PNJ", List.of(minimal)))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
|
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
|
||||||
PageSummary full = PageSummary.builder()
|
PageSummary full = new PageSummary("Thorin", "PNJ",
|
||||||
.title("Thorin")
|
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||||
.templateName("PNJ")
|
List.of("pnj", "allie"),
|
||||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
List.of("Taverne du Dragon d'Or"));
|
||||||
.tags(List.of("pnj", "allie"))
|
LoreStructuralContext lore = new LoreStructuralContext(
|
||||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
"X", "", Map.of("PNJ", List.of(full)), List.of());
|
||||||
.build();
|
|
||||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
|
||||||
.loreName("X").loreDescription("")
|
|
||||||
.folders(Map.of("PNJ", List.of(full)))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_pageContext_includesAllFields() {
|
void build_pageContext_includesAllFields() {
|
||||||
PageContext pc = PageContext.builder()
|
PageContext pc = new PageContext("Thorin", "PNJ",
|
||||||
.title("Thorin")
|
List.of("histoire", "motto"), Map.of("histoire", "..."));
|
||||||
.templateName("PNJ")
|
|
||||||
.templateFields(List.of("histoire", "motto"))
|
|
||||||
.values(Map.of("histoire", "..."))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_campaignContext_serializesFullNarrativeTree() {
|
void build_campaignContext_serializesFullNarrativeTree() {
|
||||||
BranchHint branch = BranchHint.builder()
|
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
||||||
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
|
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
|
||||||
SceneSummary scene = SceneSummary.builder()
|
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||||
.name("L'auberge").description("Rencontre tendue")
|
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||||
.illustrationCount(3).branch(branch).build();
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
ChapterSummary chapter = ChapterSummary.builder()
|
"Les Ombres", "dark fantasy", List.of(arc), List.of());
|
||||||
.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();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||||
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
"X", "", List.of(arc), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_sceneSummary_omitsBranches_whenEmpty() {
|
void build_sceneSummary_omitsBranches_whenEmpty() {
|
||||||
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
|
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
|
||||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
"X", "", List.of(arc), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_branchHint_omitsCondition_whenBlank() {
|
void build_branchHint_omitsCondition_whenBlank() {
|
||||||
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
|
BranchHint branch = new BranchHint("X", "Y", " ");
|
||||||
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
|
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
|
||||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
"X", "", List.of(arc), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void build_narrativeEntity_includesAllFields() {
|
void build_narrativeEntity_includesAllFields() {
|
||||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
|
||||||
.entityType("scene").title("L'auberge")
|
Map.of("location", "Taverne", "timing", "Soir"));
|
||||||
.fields(Map.of("location", "Taverne", "timing", "Soir"))
|
|
||||||
.build();
|
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void build_campaignScenario_includesBothContextsAndEntity() {
|
void build_campaignScenario_includesBothContextsAndEntity() {
|
||||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
.campaignName("X").campaignDescription("").build();
|
"X", "", List.of(), List.of());
|
||||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
||||||
.entityType("arc").title("T").fields(Map.of()).build();
|
|
||||||
ChatRequest req = ChatRequest.builder()
|
ChatRequest req = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.campaignContext(camp)
|
.campaignContext(camp)
|
||||||
|
|||||||
@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void roundTrip_preservesAllBranchFields() {
|
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(
|
List<SceneBranch> source = List.of(
|
||||||
SceneBranch.builder()
|
new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
|
||||||
.label("si les joueurs attaquent")
|
SceneBranch.of("si les joueurs fuient", "sc-poursuite")
|
||||||
.targetSceneId("sc-combat")
|
|
||||||
.condition("initiative > 15")
|
|
||||||
.build(),
|
|
||||||
SceneBranch.builder()
|
|
||||||
.label("si les joueurs fuient")
|
|
||||||
.targetSceneId("sc-poursuite")
|
|
||||||
.build()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String json = converter.convertToDatabaseColumn(source);
|
String json = converter.convertToDatabaseColumn(source);
|
||||||
List<SceneBranch> back = converter.convertToEntityAttribute(json);
|
List<SceneBranch> back = converter.convertToEntityAttribute(json);
|
||||||
|
|
||||||
assertEquals(2, back.size());
|
assertEquals(2, back.size());
|
||||||
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
|
assertEquals("si les joueurs attaquent", back.get(0).label());
|
||||||
assertEquals("sc-combat", back.get(0).getTargetSceneId());
|
assertEquals("sc-combat", back.get(0).targetSceneId());
|
||||||
assertEquals("initiative > 15", back.get(0).getCondition());
|
assertEquals("initiative > 15", back.get(0).condition());
|
||||||
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
|
assertEquals("sc-poursuite", back.get(1).targetSceneId());
|
||||||
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
|
assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void save_scenePreservesBranches_viaJsonbRoundTrip() {
|
void save_scenePreservesBranches_viaJsonbRoundTrip() {
|
||||||
// Le critique : le @Jacksonized de SceneBranch doit permettre la
|
// Le critique : SceneBranch (record) doit etre reconstructible par
|
||||||
// reconstruction via builder apres serialisation Jackson.
|
// Jackson via le constructeur canonique apres serialisation JSON.
|
||||||
Scene scene = Scene.builder()
|
Scene scene = Scene.builder()
|
||||||
.chapterId(chapterId).name("Decision").order(0)
|
.chapterId(chapterId).name("Decision").order(0)
|
||||||
.branches(List.of(
|
.branches(List.of(
|
||||||
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
|
new SceneBranch("fuite", "sc-2", "HP bas"),
|
||||||
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
|
SceneBranch.of("combat", "sc-3")
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
|
|||||||
Scene r = repository.findById(saved.getId()).orElseThrow();
|
Scene r = repository.findById(saved.getId()).orElseThrow();
|
||||||
|
|
||||||
assertEquals(2, r.getBranches().size());
|
assertEquals(2, r.getBranches().size());
|
||||||
assertEquals("fuite", r.getBranches().get(0).getLabel());
|
assertEquals("fuite", r.getBranches().get(0).label());
|
||||||
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
|
assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
|
||||||
assertEquals("HP bas", r.getBranches().get(0).getCondition());
|
assertEquals("HP bas", r.getBranches().get(0).condition());
|
||||||
assertEquals("combat", r.getBranches().get(1).getLabel());
|
assertEquals("combat", r.getBranches().get(1).label());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class ArcControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByCampaign_pathVariant() throws Exception {
|
void getByCampaign_pathVariant() throws Exception {
|
||||||
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ChapterControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByArc_pathVariant() throws Exception {
|
void getByArc_pathVariant() throws Exception {
|
||||||
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class SceneControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByChapter_pathVariant() throws Exception {
|
void getByChapter_pathVariant() throws Exception {
|
||||||
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
27
demo/.env.example
Normal file
27
demo/.env.example
Normal 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
2
demo/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
46
demo/README.md
Normal file
46
demo/README.md
Normal 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
|
||||||
|
```
|
||||||
80
demo/docker-compose.infra.yml
Normal file
80
demo/docker-compose.infra.yml
Normal 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
|
||||||
32
demo/orchestrator/Dockerfile
Normal file
32
demo/orchestrator/Dockerfile
Normal 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"]
|
||||||
68
demo/orchestrator/config.go
Normal file
68
demo/orchestrator/config.go
Normal 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
339
demo/orchestrator/docker.go
Normal 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
8
demo/orchestrator/go.mod
Normal 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
231
demo/orchestrator/main.go
Normal 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 == "*/*"
|
||||||
|
}
|
||||||
127
demo/orchestrator/preparing.html
Normal file
127
demo/orchestrator/preparing.html
Normal 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>
|
||||||
79
demo/orchestrator/ratelimit.go
Normal file
79
demo/orchestrator/ratelimit.go
Normal 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
|
||||||
|
}
|
||||||
177
demo/orchestrator/sessions.go
Normal file
177
demo/orchestrator/sessions.go
Normal 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
17
docker-compose.e2e.yml
Normal 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
|
||||||
@@ -60,8 +60,15 @@ services:
|
|||||||
"
|
"
|
||||||
|
|
||||||
core:
|
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
|
container_name: loremind-core
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -79,14 +86,50 @@ services:
|
|||||||
MINIO_ENDPOINT: http://minio:9000
|
MINIO_ENDPOINT: http://minio:9000
|
||||||
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
|
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
|
||||||
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-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
|
restart: unless-stopped
|
||||||
|
|
||||||
brain:
|
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
|
container_name: loremind-brain
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
environment:
|
environment:
|
||||||
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
|
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}
|
LLM_MODEL: ${LLM_MODEL:-gemma4:26b}
|
||||||
ONEMIN_API_KEY: ${ONEMIN_API_KEY:-}
|
ONEMIN_API_KEY: ${ONEMIN_API_KEY:-}
|
||||||
ONEMIN_MODEL: ${ONEMIN_MODEL:-gpt-4o-mini}
|
ONEMIN_MODEL: ${ONEMIN_MODEL:-gpt-4o-mini}
|
||||||
@@ -100,8 +143,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
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
|
container_name: loremind-web
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
depends_on:
|
depends_on:
|
||||||
- core
|
- core
|
||||||
- brain
|
- brain
|
||||||
@@ -109,7 +154,40 @@ services:
|
|||||||
- "${WEB_PORT:-8081}:80"
|
- "${WEB_PORT:-8081}:80"
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
minio-data:
|
minio-data:
|
||||||
brain-data:
|
brain-data:
|
||||||
|
ollama-data:
|
||||||
|
|||||||
207
installers/README.md
Normal file
207
installers/README.md
Normal 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
59
installers/install.bat
Normal 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
442
installers/install.ps1
Normal 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.13
|
||||||
|
|
||||||
|
.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
306
installers/install.sh
Normal 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
|
||||||
183
installers/secure-host-ollama.ps1
Normal file
183
installers/secure-host-ollama.ps1
Normal 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 ""
|
||||||
128
installers/secure-host-ollama.sh
Normal file
128
installers/secure-host-ollama.sh
Normal 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
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "web:build"
|
"buildTarget": "web:build",
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
317
web/e2e/fixtures/api.ts
Normal file
317
web/e2e/fixtures/api.ts
Normal 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();
|
||||||
|
}
|
||||||
50
web/e2e/tests/campaign/arc-create.spec.ts
Normal file
50
web/e2e/tests/campaign/arc-create.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
web/e2e/tests/campaign/arc-delete.spec.ts
Normal file
49
web/e2e/tests/campaign/arc-delete.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
web/e2e/tests/campaign/arc-edit.spec.ts
Normal file
76
web/e2e/tests/campaign/arc-edit.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
web/e2e/tests/campaign/campaign-create.spec.ts
Normal file
96
web/e2e/tests/campaign/campaign-create.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
web/e2e/tests/campaign/campaign-delete.spec.ts
Normal file
41
web/e2e/tests/campaign/campaign-delete.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
54
web/e2e/tests/campaign/chapter-create.spec.ts
Normal file
54
web/e2e/tests/campaign/chapter-create.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
72
web/e2e/tests/campaign/chapter-edit.spec.ts
Normal file
72
web/e2e/tests/campaign/chapter-edit.spec.ts
Normal 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
Reference in New Issue
Block a user