Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a708c74425 | |||
| 9ad7651c44 | |||
| 389392fd1d | |||
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 | |||
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 | |||
| addf78f01d | |||
| 5e04e84ee4 | |||
| 8d5c2e2b7f | |||
| 788d2c12f2 | |||
| b25a9746cf | |||
| 41fda9aeee |
12
.env.example
12
.env.example
@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
|
||||
# 1min.ai (si LLM_PROVIDER=onemin)
|
||||
ONEMIN_API_KEY=
|
||||
ONEMIN_MODEL=gpt-4o-mini
|
||||
|
||||
# --- Mises a jour automatiques (Watchtower) ------------------------------
|
||||
# Watchtower verifie les nouvelles versions de core/brain/web et permet
|
||||
# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
|
||||
# MinIO sont exclus volontairement.
|
||||
#
|
||||
# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
|
||||
# COMPOSE_PROFILES=autoupdate
|
||||
# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
|
||||
# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
|
||||
# WATCHTOWER_SCHEDULE=0 0 4 * * *
|
||||
# TZ=Europe/Paris
|
||||
|
||||
@@ -6,8 +6,10 @@ on:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
REGISTRY: git.igmlcreation.fr
|
||||
REGISTRY_USER: ietm64
|
||||
GITEA_REGISTRY: git.igmlcreation.fr
|
||||
GITEA_REGISTRY_USER: ietm64
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
GHCR_NAMESPACE: igmlcreation
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -26,19 +28,39 @@ jobs:
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.REGISTRY_USER }}
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
|
||||
# Login to GHCR (GitHub Container Registry) pour distribuer les images
|
||||
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
|
||||
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Push vers les deux registries en un seul build (build-push-action
|
||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||
# Naming :
|
||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||
# dans leur .env.
|
||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||
# la collision avec d'autres projets de l'org.
|
||||
- name: Build & push ${{ matrix.component }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,6 +91,7 @@ Thumbs.db
|
||||
# Documentation hors-code (conservee hors du repo)
|
||||
# ============================================================================
|
||||
docs/
|
||||
loremind-docs/
|
||||
|
||||
# ============================================================================
|
||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
||||
|
||||
311
INSTALL.md
311
INSTALL.md
@@ -1,311 +0,0 @@
|
||||
# Installation de LoreMindMJ
|
||||
|
||||
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
|
||||
5 a 10 minutes selon la qualite de la connexion reseau.
|
||||
|
||||
## 1. Prerequis
|
||||
|
||||
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) /
|
||||
[Mac](https://www.docker.com/products/docker-desktop/)) ou
|
||||
**Docker Engine + Compose v2** (Linux). Verification :
|
||||
```
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
|
||||
|
||||
- **Un fournisseur LLM**, au choix :
|
||||
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
|
||||
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
|
||||
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
|
||||
a l'usage, aucune installation supplementaire requise).
|
||||
|
||||
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
|
||||
la taille des modeles Ollama si l'option locale est retenue.
|
||||
|
||||
## 2. Recuperation des fichiers
|
||||
|
||||
Telecharger les deux fichiers suivants depuis la
|
||||
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
|
||||
les placer dans un dossier dedie (par exemple `~/loremind/` ou
|
||||
`C:\Programs\loremind\`) :
|
||||
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
|
||||
Le code source n'est pas necessaire : les images sont pre-construites et
|
||||
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
|
||||
premier `docker compose pull` les telechargera automatiquement.
|
||||
|
||||
## 3. Configuration du fichier `.env`
|
||||
|
||||
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
|
||||
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
|
||||
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
|
||||
non-securise par defaut.
|
||||
|
||||
### `POSTGRES_PASSWORD`
|
||||
|
||||
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
|
||||
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
|
||||
memoriser au-dela du fichier `.env`.
|
||||
|
||||
### `ADMIN_PASSWORD`
|
||||
|
||||
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
|
||||
sera demandee par le navigateur lors de toute modification de la configuration
|
||||
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
|
||||
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
|
||||
|
||||
### `BRAIN_INTERNAL_SECRET`
|
||||
|
||||
Secret partage entre le service Java (`core`) et le service Python (`brain`).
|
||||
Empeche toute requete externe d'atteindre directement le service Brain.
|
||||
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
|
||||
|
||||
```
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Sous Windows sans `openssl`, utiliser PowerShell :
|
||||
|
||||
```powershell
|
||||
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
|
||||
```
|
||||
|
||||
### Variables optionnelles
|
||||
|
||||
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
|
||||
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
|
||||
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
|
||||
section 5).
|
||||
|
||||
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
|
||||
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
|
||||
personnel et peuvent etre conservees en l'etat.
|
||||
|
||||
## 4. Lancement de la stack
|
||||
|
||||
Depuis le dossier contenant `docker-compose.yml` et `.env` :
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
|
||||
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
|
||||
La progression peut etre suivie via :
|
||||
|
||||
```
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
|
||||
en arriere-plan.)
|
||||
|
||||
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
|
||||
dans un navigateur.
|
||||
|
||||
### Verification du fonctionnement
|
||||
|
||||
```
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
|
||||
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
|
||||
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
|
||||
apres creation du bucket d'images : ce comportement est normal.
|
||||
|
||||
## 5. Configuration du fournisseur LLM
|
||||
|
||||
### Ollama (local, gratuit)
|
||||
|
||||
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
|
||||
modele :
|
||||
|
||||
```
|
||||
ollama pull gemma4:26b
|
||||
```
|
||||
|
||||
Dans `.env` :
|
||||
|
||||
```
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=gemma4:26b
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
```
|
||||
|
||||
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
|
||||
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
|
||||
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
|
||||
`extra_hosts` equivalent.
|
||||
|
||||
### 1min.ai (cloud, paye)
|
||||
|
||||
Dans `.env` :
|
||||
|
||||
```
|
||||
LLM_PROVIDER=onemin
|
||||
ONEMIN_API_KEY=sk-...
|
||||
ONEMIN_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
### Modification a chaud
|
||||
|
||||
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
|
||||
l'ecran **Parametres** de l'application. Les modifications sont persistees
|
||||
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
|
||||
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
|
||||
demarrage.
|
||||
|
||||
## 6. Mise a jour
|
||||
|
||||
```
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
|
||||
stockees dans des volumes Docker et survivent aux mises a jour.
|
||||
|
||||
## 7. Sauvegarde
|
||||
|
||||
Les donnees sont reparties dans trois volumes Docker :
|
||||
|
||||
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
|
||||
campagnes, pages, templates, branches narratives, etc.).
|
||||
- `loremindmj_minio-data` — images uploadees.
|
||||
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
|
||||
1min.ai).
|
||||
|
||||
### Export SQL de la base
|
||||
|
||||
```
|
||||
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
|
||||
```
|
||||
|
||||
### Sauvegarde complete des volumes
|
||||
|
||||
Arreter la stack au prealable afin de garantir la coherence des donnees :
|
||||
|
||||
```
|
||||
docker compose stop
|
||||
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
|
||||
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
|
||||
docker compose start
|
||||
```
|
||||
|
||||
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
|
||||
|
||||
## 8. Resolution des problemes
|
||||
|
||||
### Port 8081 deja utilise
|
||||
|
||||
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
|
||||
relancer :
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
|
||||
|
||||
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
|
||||
contenu du fichier `.env`.
|
||||
|
||||
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
|
||||
|
||||
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
|
||||
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
|
||||
`ADMIN_PASSWORD`.
|
||||
|
||||
### Erreurs `password authentication failed` en boucle dans les logs Postgres
|
||||
|
||||
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
|
||||
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
|
||||
Deux options :
|
||||
|
||||
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
|
||||
```
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
```
|
||||
- **Modifier le mot de passe en base** sans toucher au volume :
|
||||
```
|
||||
docker compose exec postgres psql -U postgres
|
||||
```
|
||||
Puis dans le prompt `psql` :
|
||||
```sql
|
||||
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
|
||||
\q
|
||||
```
|
||||
Redemarrer ensuite le Core : `docker compose restart core`.
|
||||
|
||||
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
|
||||
|
||||
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
|
||||
|
||||
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
|
||||
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
|
||||
nom d'un modele liste ?
|
||||
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
|
||||
- Consulter les logs du Brain :
|
||||
```
|
||||
docker compose logs brain
|
||||
```
|
||||
|
||||
### Un service ne demarre pas ou reste en etat `unhealthy`
|
||||
|
||||
Consulter les logs du service concerne :
|
||||
|
||||
```
|
||||
docker compose logs <service>
|
||||
```
|
||||
|
||||
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
|
||||
|
||||
### Redemarrage d'un service apres modification du `.env`
|
||||
|
||||
```
|
||||
docker compose up -d <service>
|
||||
```
|
||||
|
||||
Redemarrage complet : `docker compose restart`.
|
||||
|
||||
### Remise a zero complete (PERTE DES DONNEES)
|
||||
|
||||
```
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
|
||||
et parametres est perdu de maniere definitive.
|
||||
|
||||
### "No such image" ou "pull access denied" au premier lancement
|
||||
|
||||
Le registry Gitea peut necessiter une authentification selon la visibilite
|
||||
configuree pour les images. Contacter l'editeur du projet.
|
||||
|
||||
## 9. Exposition reseau des services
|
||||
|
||||
- **Interface web** : http://localhost:8081 (port configurable via
|
||||
`WEB_PORT`).
|
||||
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
|
||||
expose vers l'hote.
|
||||
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
|
||||
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
|
||||
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
|
||||
n'est actif qu'en developpement.
|
||||
- **Brain Python** : accessible uniquement via le reseau Docker interne.
|
||||
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
|
||||
automatiquement par le Core Java et jamais exposee au navigateur.
|
||||
|
||||
## 10. Desinstallation
|
||||
|
||||
```
|
||||
docker compose down -v
|
||||
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
|
||||
```
|
||||
|
||||
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.
|
||||
70
README.md
70
README.md
@@ -1,69 +1,31 @@
|
||||
# LoreMind
|
||||
|
||||
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré.
|
||||

|
||||
|
||||
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
||||
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
||||
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
||||
|
||||
## Documentation
|
||||
|
||||
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
||||
|
||||
Pour l'installation, consultez le guide dans cette dernière .
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
||||
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
|
||||
|
||||
## Captures d'écran
|
||||
## Démo
|
||||
|
||||
### Page d'accueil
|
||||

|
||||
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
||||
|
||||
### Recherche
|
||||

|
||||
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
||||
|
||||
## Stack Technologique
|
||||
|
||||
LoreMind utilise une architecture distribuée pour séparer les responsabilités :
|
||||
|
||||
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates)
|
||||
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
|
||||
- **Backend IA** : Python - Traitement des LLM et génération de contenu
|
||||
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Java (Domain-Driven Design & Hexagonal)
|
||||
|
||||
Le Backend Core respecte strictement :
|
||||
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
|
||||
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
|
||||
|
||||
#### Bounded Contexts
|
||||
- **LoreContext** : Gestion de l'encyclopédie de l'univers
|
||||
- **CampaignContext** : Suivi des sessions et chronologie
|
||||
- **GenerationContext** : Gestion des requêtes IA et templates
|
||||
|
||||
#### Couches
|
||||
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
|
||||
- **Application** : Orchestration des flux (Use Cases)
|
||||
- **Infrastructure** : Implémentation technique (Adapters)
|
||||
|
||||
## Installation
|
||||
|
||||
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono :
|
||||
|
||||
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases)
|
||||
2. Renommer `.env.example` → `.env` et changer `POSTGRES_PASSWORD`
|
||||
3. `docker compose up -d` → ouvrir http://localhost:8081
|
||||
|
||||
Mise à jour : `docker compose pull && docker compose up -d`.
|
||||
|
||||
## Développement (contributeurs)
|
||||
|
||||
Pour builder les images localement depuis les sources :
|
||||
|
||||
```bash
|
||||
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git
|
||||
cd LoreMindMJ
|
||||
# Créer un docker-compose.override.yml local (voir docs de contrib)
|
||||
docker compose up -d --build
|
||||
```
|
||||
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
||||
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.domain.models import (
|
||||
ChatMessage,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
@@ -198,10 +199,12 @@ class ChatUseCase:
|
||||
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
||||
)
|
||||
characters_block = ChatUseCase._format_characters(ctx.characters)
|
||||
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
|
||||
return (
|
||||
"--- CAMPAGNE COURANTE ---\n"
|
||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
||||
f"{characters_block}\n"
|
||||
f"{characters_block}"
|
||||
f"{npcs_block}\n"
|
||||
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||
"déclenchées par un choix des joueurs) :\n"
|
||||
f"{arcs_block}"
|
||||
@@ -231,6 +234,33 @@ class ChatUseCase:
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_npcs(npcs: list[NpcSummary]) -> str:
|
||||
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
|
||||
|
||||
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
|
||||
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
|
||||
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
|
||||
"""
|
||||
if not npcs:
|
||||
return (
|
||||
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
|
||||
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
|
||||
"fais pas comme s'ils existaient déjà dans la campagne.\n"
|
||||
)
|
||||
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
|
||||
for n in npcs:
|
||||
if n.snippet:
|
||||
lines.append(f"- **{n.name}** — {n.snippet}")
|
||||
else:
|
||||
lines.append(f"- **{n.name}** (fiche vide)")
|
||||
lines.append(
|
||||
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
|
||||
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
|
||||
"en revanche proposer librement de NOUVEAUX PNJ."
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||
if not arcs:
|
||||
@@ -319,7 +349,8 @@ class ChatUseCase:
|
||||
"arc": "ARC",
|
||||
"chapter": "CHAPITRE",
|
||||
"scene": "SCÈNE",
|
||||
"character": "FICHE DE PERSONNAGE",
|
||||
"character": "FICHE DE PERSONNAGE (PJ)",
|
||||
"npc": "FICHE DE PNJ",
|
||||
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
||||
if ne.fields:
|
||||
fields_block = "\n".join(
|
||||
|
||||
@@ -170,6 +170,7 @@ class CampaignStructuralContext:
|
||||
campaign_description: str | None
|
||||
arcs: list[ArcSummary]
|
||||
characters: list["CharacterSummary"] = field(default_factory=list)
|
||||
npcs: list["NpcSummary"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -185,6 +186,19 @@ class CharacterSummary:
|
||||
snippet: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NpcSummary:
|
||||
"""Résumé d'un PNJ : symétrique à CharacterSummary.
|
||||
|
||||
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
|
||||
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
|
||||
pour focus sur la fiche complète.
|
||||
"""
|
||||
|
||||
name: str
|
||||
snippet: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NarrativeEntityContext:
|
||||
"""Contexte d'une entité narrative précise en cours d'édition.
|
||||
|
||||
@@ -61,7 +61,16 @@ class OllamaLLMProvider:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
if response.status_code >= 400:
|
||||
body = response.text
|
||||
try:
|
||||
err_obj = json.loads(body)
|
||||
err_msg = err_obj.get("error") or body
|
||||
except json.JSONDecodeError:
|
||||
err_msg = body
|
||||
raise LLMProviderError(
|
||||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream("POST", url, json=payload) as response:
|
||||
response.raise_for_status()
|
||||
if response.status_code >= 400:
|
||||
# On lit le body d'erreur pour le remonter a l'utilisateur,
|
||||
# sinon on ne voit que "500 Internal Server Error" sans
|
||||
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
|
||||
# num_ctx trop grand pour la VRAM, etc.).
|
||||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||
try:
|
||||
err_obj = json.loads(body)
|
||||
err_msg = err_obj.get("error") or body
|
||||
except json.JSONDecodeError:
|
||||
err_msg = body
|
||||
raise LLMProviderError(
|
||||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.domain.models import (
|
||||
CampaignStructuralContext,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
ChatMessage,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
@@ -40,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.6.5",
|
||||
version="0.6.6",
|
||||
)
|
||||
|
||||
|
||||
@@ -205,6 +206,13 @@ class CharacterSummaryDTO(BaseModel):
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class NpcSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class CampaignContextDTO(BaseModel):
|
||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||
|
||||
@@ -212,12 +220,13 @@ class CampaignContextDTO(BaseModel):
|
||||
campaign_description: str | None = None
|
||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NarrativeEntityDTO(BaseModel):
|
||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
||||
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
|
||||
title: str
|
||||
fields: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@@ -553,11 +562,16 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||
for c in dto.characters
|
||||
]
|
||||
npcs = [
|
||||
NpcSummary(name=n.name, snippet=n.snippet)
|
||||
for n in dto.npcs
|
||||
]
|
||||
return CampaignStructuralContext(
|
||||
campaign_name=dto.campaign_name,
|
||||
campaign_description=dto.campaign_description,
|
||||
arcs=arcs,
|
||||
characters=characters,
|
||||
npcs=npcs,
|
||||
)
|
||||
|
||||
|
||||
@@ -689,6 +703,76 @@ async def get_ollama_model_info(
|
||||
return OllamaModelInfoDTO(context_length=0)
|
||||
|
||||
|
||||
@app.post("/models/ollama/pull")
|
||||
async def pull_ollama_model(
|
||||
body: dict[str, str],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> StreamingResponse:
|
||||
"""Telecharge un modele depuis Ollama et streame la progression.
|
||||
|
||||
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
|
||||
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
|
||||
digest, success. On reemet ce flux tel quel au client (le front
|
||||
parsera les lignes et affichera une barre de progression).
|
||||
|
||||
Le timeout est intentionnellement tres long (60 min) car certains
|
||||
modeles font 30+ Go.
|
||||
"""
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/pull"
|
||||
|
||||
async def stream() -> AsyncIterator[bytes]:
|
||||
# On utilise un timeout long pour la lecture (60 min) mais court pour
|
||||
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
|
||||
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
|
||||
if r.status_code != 200:
|
||||
# Ollama renvoie un message JSON d'erreur. On le passe
|
||||
# tel quel au client en preservant le code HTTP.
|
||||
body_text = await r.aread()
|
||||
yield body_text
|
||||
return
|
||||
async for chunk in r.aiter_bytes():
|
||||
yield chunk
|
||||
except httpx.HTTPError as e:
|
||||
# Erreur reseau : on emet une ligne JSON d'erreur compatible
|
||||
# avec le format NDJSON d'Ollama.
|
||||
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
|
||||
yield err.encode("utf-8")
|
||||
|
||||
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
|
||||
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
|
||||
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@app.delete("/models/ollama/{name:path}")
|
||||
async def delete_ollama_model(
|
||||
name: str,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, str]:
|
||||
"""Supprime un modele du serveur Ollama.
|
||||
|
||||
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
|
||||
sans avoir besoin de URL-encoder cote client.
|
||||
"""
|
||||
if not name.strip():
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/delete"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.request("DELETE", url, json={"model": name})
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
|
||||
return {"status": "deleted", "name": name}
|
||||
|
||||
|
||||
@app.get("/models/onemin")
|
||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.6.5</version>
|
||||
<version>0.7.1</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches de PNJ (campagne).
|
||||
*/
|
||||
@Service
|
||||
public class NpcService {
|
||||
|
||||
private final NpcRepository npcRepository;
|
||||
|
||||
public NpcService(NpcRepository npcRepository) {
|
||||
this.npcRepository = npcRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Npc.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
*/
|
||||
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
|
||||
public Npc createNpc(NpcData data) {
|
||||
int order = data.order() != null
|
||||
? data.order()
|
||||
: nextOrderFor(data.campaignId());
|
||||
Npc npc = Npc.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return npcRepository.save(npc);
|
||||
}
|
||||
|
||||
public Optional<Npc> getNpcById(String id) {
|
||||
return npcRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Npc> getNpcsByCampaignId(String campaignId) {
|
||||
return npcRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public Npc updateNpc(String id, NpcData data) {
|
||||
Npc existing = npcRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
return npcRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteNpc(String id) {
|
||||
npcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return npcRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Npc::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public class SceneService {
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
for (SceneBranch b : branches) {
|
||||
String target = b.getTargetSceneId();
|
||||
String target = b.targetSceneId();
|
||||
if (target == null || target.isBlank()) {
|
||||
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
|
||||
}
|
||||
|
||||
@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
|
||||
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
|
||||
Map<String, String> allSections = parseH2Sections(gs.getRulesMarkdown());
|
||||
Map<String, String> filtered = filterByIntent(allSections, intent);
|
||||
return GameSystemContext.builder()
|
||||
.systemName(gs.getName())
|
||||
.systemDescription(gs.getDescription())
|
||||
.sections(filtered)
|
||||
.build();
|
||||
return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder {
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
|
||||
public CampaignStructuralContextBuilder(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
}
|
||||
|
||||
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||
|
||||
/**
|
||||
@@ -79,12 +85,17 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return CampaignStructuralContext.builder()
|
||||
.campaignName(campaign.getName())
|
||||
.campaignDescription(campaign.getDescription())
|
||||
.arcs(arcs)
|
||||
.characters(characters)
|
||||
.build();
|
||||
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||
.map(this::toNpcSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new CampaignStructuralContext(
|
||||
campaign.getName(),
|
||||
campaign.getDescription(),
|
||||
arcs,
|
||||
characters,
|
||||
npcs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,10 +104,12 @@ public class CampaignStructuralContextBuilder {
|
||||
* sans injecter toute sa fiche.
|
||||
*/
|
||||
private CharacterSummary toCharacterSummary(Character c) {
|
||||
return CharacterSummary.builder()
|
||||
.name(c.getName())
|
||||
.snippet(extractSnippet(c.getMarkdownContent()))
|
||||
.build();
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||
}
|
||||
|
||||
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||
private NpcSummary toNpcSummary(Npc n) {
|
||||
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
|
||||
}
|
||||
|
||||
private static String extractSnippet(String markdown) {
|
||||
@@ -115,12 +128,11 @@ public class CampaignStructuralContextBuilder {
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
.map(this::toChapterSummary)
|
||||
.collect(Collectors.toList());
|
||||
return ArcSummary.builder()
|
||||
.name(arc.getName())
|
||||
.description(arc.getDescription())
|
||||
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
||||
.chapters(chapters)
|
||||
.build();
|
||||
return new ArcSummary(
|
||||
arc.getName(),
|
||||
arc.getDescription(),
|
||||
countImages(arc.getIllustrationImageIds()),
|
||||
chapters);
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
@@ -137,32 +149,28 @@ public class CampaignStructuralContextBuilder {
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ChapterSummary.builder()
|
||||
.name(chapter.getName())
|
||||
.description(chapter.getDescription())
|
||||
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
||||
.scenes(summaries)
|
||||
.build();
|
||||
return new ChapterSummary(
|
||||
chapter.getName(),
|
||||
chapter.getDescription(),
|
||||
countImages(chapter.getIllustrationImageIds()),
|
||||
summaries);
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
.map(b -> BranchHint.builder()
|
||||
.label(b.getLabel())
|
||||
.targetSceneName(nameById.getOrDefault(
|
||||
b.getTargetSceneId(), "(scène inconnue)"))
|
||||
.condition(b.getCondition())
|
||||
.build())
|
||||
.map(b -> new BranchHint(
|
||||
b.label(),
|
||||
nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return SceneSummary.builder()
|
||||
.name(scene.getName())
|
||||
.description(scene.getDescription())
|
||||
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
||||
.branches(hints)
|
||||
.build();
|
||||
return new SceneSummary(
|
||||
scene.getName(),
|
||||
scene.getDescription(),
|
||||
countImages(scene.getIllustrationImageIds()),
|
||||
hints);
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
|
||||
@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
|
||||
|
||||
requireNonEmptyFields(template);
|
||||
|
||||
GenerationContext context = GenerationContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folderName(folder.getName())
|
||||
.templateName(template.getName())
|
||||
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||
// necessitent un workflow different (pas de generation LLM texte).
|
||||
.templateFields(template.textFieldNames())
|
||||
.pageTitle(page.getTitle())
|
||||
.build();
|
||||
GenerationContext context = new GenerationContext(
|
||||
lore.getName(),
|
||||
lore.getDescription(),
|
||||
folder.getName(),
|
||||
template.getName(),
|
||||
template.textFieldNames(),
|
||||
page.getTitle());
|
||||
|
||||
GenerationResult result = aiProvider.generatePage(context);
|
||||
return result.values();
|
||||
|
||||
@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
|
||||
Map<String, String> pageTitleById = pages.stream()
|
||||
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||
|
||||
return LoreStructuralContext.builder()
|
||||
.loreName(lore.getName())
|
||||
.loreDescription(lore.getDescription())
|
||||
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
||||
.tags(extractUniqueTags(pages))
|
||||
.build();
|
||||
return new LoreStructuralContext(
|
||||
lore.getName(),
|
||||
lore.getDescription(),
|
||||
buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
|
||||
extractUniqueTags(pages));
|
||||
}
|
||||
|
||||
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
|
||||
Page page,
|
||||
Map<String, String> templateNameById,
|
||||
Map<String, String> pageTitleById) {
|
||||
return PageSummary.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
||||
.values(truncatedValues(page.getValues()))
|
||||
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
||||
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
||||
.build();
|
||||
return new PageSummary(
|
||||
page.getTitle(),
|
||||
templateNameById.getOrDefault(page.getTemplateId(), "?"),
|
||||
truncatedValues(page.getValues()),
|
||||
page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
|
||||
resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
|
||||
public NarrativeEntityContextBuilder(
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||
*
|
||||
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
|
||||
* @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
|
||||
* @param entityId l'ID de l'entité
|
||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||
*/
|
||||
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
|
||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||
case "scene" -> fromScene(loadScene(entityId));
|
||||
case "character" -> fromCharacter(loadCharacter(entityId));
|
||||
case "npc" -> fromNpc(loadNpc(entityId));
|
||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||
};
|
||||
}
|
||||
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
||||
}
|
||||
|
||||
private Npc loadNpc(String id) {
|
||||
return npcRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
|
||||
}
|
||||
|
||||
// --- Mapping entité → VO ------------------------------------------------
|
||||
|
||||
private NarrativeEntityContext fromArc(Arc a) {
|
||||
@@ -91,11 +102,7 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "rewards", a.getRewards());
|
||||
putField(fields, "resolution", a.getResolution());
|
||||
putField(fields, "gmNotes", a.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("arc")
|
||||
.title(a.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("arc", a.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||
@@ -104,11 +111,7 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||
putField(fields, "gmNotes", c.getGmNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("chapter")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("chapter", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromScene(Scene s) {
|
||||
@@ -122,21 +125,19 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||
putField(fields, "enemies", s.getEnemies());
|
||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title(s.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||
return NarrativeEntityContext.builder()
|
||||
.entityType("character")
|
||||
.title(c.getName())
|
||||
.fields(fields)
|
||||
.build();
|
||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromNpc(Npc n) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
|
||||
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||
}
|
||||
|
||||
/** 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()
|
||||
: Collections.emptyMap();
|
||||
|
||||
return PageContext.builder()
|
||||
.title(page.getTitle())
|
||||
.templateName(templateName)
|
||||
.templateFields(templateFields)
|
||||
.values(values)
|
||||
.build();
|
||||
return new PageContext(page.getTitle(), templateName, templateFields, values);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import java.time.LocalDateTime;
|
||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
|
||||
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
|
||||
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
|
||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
||||
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
||||
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
|
||||
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||
* <p>
|
||||
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
|
||||
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
|
||||
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
|
||||
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
|
||||
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
|
||||
* <p>
|
||||
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
|
||||
* PJ/PNJ piloté par GameSystem.
|
||||
* <p>
|
||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
|
||||
* gérés via le système Page/Template du LoreContext.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Npc {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
|
||||
private String markdownContent;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -1,31 +1,25 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
/**
|
||||
* Value Object représentant une "sortie" narrative depuis une Scene.
|
||||
* Décrit un choix offert aux joueurs et la scène de destination associée.
|
||||
* <p>
|
||||
* Immuable (@Value) : pour "modifier" une branche on la remplace.
|
||||
* @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
|
||||
* de reconstruire l'objet en passant par le builder malgré l'absence de setters.
|
||||
* Record Java : immuable par construction, sans aucune dépendance technique
|
||||
* (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
|
||||
* les records nativement via le constructeur canonique — c'est ce dont
|
||||
* dépend le SceneBranchListJsonConverter pour le stockage JSONB.
|
||||
* <p>
|
||||
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
|
||||
* (validation portée par SceneService).
|
||||
*
|
||||
* @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
|
||||
* @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
|
||||
* @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
@Jacksonized
|
||||
public class SceneBranch {
|
||||
public record SceneBranch(String label, String targetSceneId, String condition) {
|
||||
|
||||
/** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
|
||||
/** Id de la Scene de destination, intra-chapitre uniquement. */
|
||||
String targetSceneId;
|
||||
|
||||
/** Notes MJ privées sur la condition de déclenchement (optionnel). */
|
||||
String condition;
|
||||
/** Raccourci pour construire une branche sans condition (cas le plus courant). */
|
||||
public static SceneBranch of(String label, String targetSceneId) {
|
||||
return new SceneBranch(label, targetSceneId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.campaigncontext.ports;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Port de sortie pour la persistance des fiches de PNJ (campagne).
|
||||
*/
|
||||
public interface NpcRepository {
|
||||
|
||||
Npc save(Npc npc);
|
||||
|
||||
Optional<Npc> findById(String id);
|
||||
|
||||
List<Npc> findByCampaignId(String campaignId);
|
||||
|
||||
void deleteById(String id);
|
||||
|
||||
boolean existsById(String id);
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -22,16 +18,18 @@ import java.util.List;
|
||||
* <p>
|
||||
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||
* fait par le use case côté application layer).
|
||||
* <p>
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*
|
||||
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
||||
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class CampaignStructuralContext {
|
||||
|
||||
String campaignName;
|
||||
String campaignDescription;
|
||||
@Singular List<ArcSummary> arcs;
|
||||
/** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
|
||||
@Singular List<CharacterSummary> characters;
|
||||
public record CampaignStructuralContext(
|
||||
String campaignName,
|
||||
String campaignDescription,
|
||||
List<ArcSummary> arcs,
|
||||
List<CharacterSummary> characters,
|
||||
List<NpcSummary> npcs) {
|
||||
|
||||
/**
|
||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||
@@ -40,53 +38,52 @@ public class CampaignStructuralContext {
|
||||
* La fiche complète n'est injectée que si le PJ est l'entité focus
|
||||
* (via NarrativeEntityContext, entity_type="character").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class CharacterSummary {
|
||||
String name;
|
||||
String snippet;
|
||||
public record CharacterSummary(String name, String snippet) {
|
||||
}
|
||||
|
||||
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ArcSummary {
|
||||
String name;
|
||||
String description;
|
||||
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
||||
int illustrationCount;
|
||||
@Singular List<ChapterSummary> chapters;
|
||||
/**
|
||||
* Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
|
||||
* Snippet court extrait du markdown — la fiche complète est réservée
|
||||
* à un usage focus (à venir, entity_type="npc").
|
||||
*/
|
||||
public record NpcSummary(String name, String snippet) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||
*
|
||||
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
|
||||
*/
|
||||
public record ArcSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<ChapterSummary> chapters) {
|
||||
}
|
||||
|
||||
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class ChapterSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<SceneSummary> scenes;
|
||||
public record ChapterSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<SceneSummary> scenes) {
|
||||
}
|
||||
|
||||
/** Résumé d'une scène : nom + description courte + branches narratives. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class SceneSummary {
|
||||
String name;
|
||||
String description;
|
||||
int illustrationCount;
|
||||
@Singular List<BranchHint> branches;
|
||||
public record SceneSummary(
|
||||
String name,
|
||||
String description,
|
||||
int illustrationCount,
|
||||
List<BranchHint> branches) {
|
||||
}
|
||||
|
||||
/** Indice d'une branche narrative vers une autre scène du même chapitre. */
|
||||
@Value
|
||||
@Builder
|
||||
public static class BranchHint {
|
||||
/** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
|
||||
String label;
|
||||
/** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
|
||||
String targetSceneName;
|
||||
/** Condition MJ privée (optionnel). */
|
||||
String condition;
|
||||
/**
|
||||
* Indice d'une branche narrative vers une autre scène du même chapitre.
|
||||
*
|
||||
* @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
|
||||
* @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
|
||||
* @param condition Condition MJ privée (optionnel).
|
||||
*/
|
||||
public record BranchHint(String label, String targetSceneName, String condition) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -21,28 +18,74 @@ import java.util.List;
|
||||
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
|
||||
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||
* pas l'inverse).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class ChatRequest {
|
||||
|
||||
List<ChatMessage> messages;
|
||||
|
||||
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
|
||||
LoreStructuralContext loreContext;
|
||||
|
||||
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
|
||||
PageContext pageContext;
|
||||
|
||||
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
||||
CampaignStructuralContext campaignContext;
|
||||
|
||||
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
||||
NarrativeEntityContext narrativeEntity;
|
||||
|
||||
/**
|
||||
* Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
||||
* <p>
|
||||
* Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
|
||||
* dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
|
||||
* qu'un constructeur à 6 paramètres souvent à null.
|
||||
*
|
||||
* @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
|
||||
* @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
|
||||
* @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
|
||||
* @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
|
||||
* @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
|
||||
* Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
|
||||
*/
|
||||
GameSystemContext gameSystemContext;
|
||||
public record ChatRequest(
|
||||
List<ChatMessage> messages,
|
||||
LoreStructuralContext loreContext,
|
||||
PageContext pageContext,
|
||||
CampaignStructuralContext campaignContext,
|
||||
NarrativeEntityContext narrativeEntity,
|
||||
GameSystemContext gameSystemContext) {
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/** Builder fluide : permet d'omettre les contextes non pertinents. */
|
||||
public static final class Builder {
|
||||
private List<ChatMessage> messages;
|
||||
private LoreStructuralContext loreContext;
|
||||
private PageContext pageContext;
|
||||
private CampaignStructuralContext campaignContext;
|
||||
private NarrativeEntityContext narrativeEntity;
|
||||
private GameSystemContext gameSystemContext;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
public Builder messages(List<ChatMessage> messages) {
|
||||
this.messages = messages;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder loreContext(LoreStructuralContext loreContext) {
|
||||
this.loreContext = loreContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder pageContext(PageContext pageContext) {
|
||||
this.pageContext = pageContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder campaignContext(CampaignStructuralContext campaignContext) {
|
||||
this.campaignContext = campaignContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
|
||||
this.narrativeEntity = narrativeEntity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder gameSystemContext(GameSystemContext gameSystemContext) {
|
||||
this.gameSystemContext = gameSystemContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChatRequest build() {
|
||||
return new ChatRequest(messages, loreContext, pageContext,
|
||||
campaignContext, narrativeEntity, gameSystemContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -11,20 +8,14 @@ import java.util.Map;
|
||||
* Contient uniquement les sections pertinentes pour l'intent de génération
|
||||
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
|
||||
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GameSystemContext {
|
||||
|
||||
/** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
|
||||
String systemName;
|
||||
|
||||
/** Description courte du système (nullable). */
|
||||
String systemDescription;
|
||||
|
||||
/**
|
||||
* Sections de règles pertinentes, indexées par titre H2.
|
||||
*
|
||||
* @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
|
||||
* @param systemDescription Description courte du système (nullable).
|
||||
* @param sections Sections de règles pertinentes, indexées par titre H2.
|
||||
* Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
|
||||
*/
|
||||
Map<String, String> sections;
|
||||
public record GameSystemContext(
|
||||
String systemName,
|
||||
String systemDescription,
|
||||
Map<String, String> sections) {
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -10,19 +7,16 @@ import java.util.List;
|
||||
* pour remplir une Page à partir d'un Template.
|
||||
* <p>
|
||||
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
|
||||
* Entité pure du domaine : aucune dépendance technique.
|
||||
* <p>
|
||||
* Immuable via @Value (Lombok) : pas de setters, tous les champs final.
|
||||
* C'est un DTO de domaine entrant dans le port AiProvider.
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*
|
||||
* @param templateFields Champs à générer (clés attendues dans la réponse).
|
||||
* @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class GenerationContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
|
||||
String templateName;
|
||||
List<String> templateFields; // Champs à générer (clés attendues dans la réponse)
|
||||
String pageTitle;
|
||||
public record GenerationContext(
|
||||
String loreName,
|
||||
String loreDescription,
|
||||
String folderName,
|
||||
String templateName,
|
||||
List<String> templateFields,
|
||||
String pageTitle) {
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -16,15 +12,14 @@ import java.util.Map;
|
||||
* <p>
|
||||
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
||||
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||
* <p>
|
||||
* Record Java : pur domaine, aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class LoreStructuralContext {
|
||||
|
||||
String loreName;
|
||||
String loreDescription;
|
||||
Map<String, List<PageSummary>> folders;
|
||||
@Singular List<String> tags;
|
||||
public record LoreStructuralContext(
|
||||
String loreName,
|
||||
String loreDescription,
|
||||
Map<String, List<PageSummary>> folders,
|
||||
List<String> tags) {
|
||||
|
||||
/**
|
||||
* Résumé projeté d'une page pour l'IA.
|
||||
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
|
||||
* uniquement ce qui est partageable en narration — les secrets MJ
|
||||
* restent confinés à leur page d'édition).
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public static class PageSummary {
|
||||
String title;
|
||||
String templateName;
|
||||
Map<String, String> values;
|
||||
List<String> tags;
|
||||
List<String> relatedPageTitles;
|
||||
public record PageSummary(
|
||||
String title,
|
||||
String templateName,
|
||||
Map<String, String> values,
|
||||
List<String> tags,
|
||||
List<String> relatedPageTitles) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -17,13 +14,11 @@ import java.util.Map;
|
||||
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||
*
|
||||
* @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class NarrativeEntityContext {
|
||||
|
||||
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
||||
String entityType;
|
||||
String title;
|
||||
Map<String, String> fields;
|
||||
public record NarrativeEntityContext(
|
||||
String entityType,
|
||||
String title,
|
||||
Map<String, String> fields) {
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package com.loremind.domain.generationcontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -14,14 +11,11 @@ import java.util.Map;
|
||||
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
|
||||
* sur d'autres pages/templates.
|
||||
* <p>
|
||||
* Object de valeur immuable, pur domaine — aucune dépendance technique.
|
||||
* Record Java : immuable, pur domaine, aucune dépendance technique.
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class PageContext {
|
||||
|
||||
String title;
|
||||
String templateName;
|
||||
List<String> templateFields;
|
||||
Map<String, String> values;
|
||||
public record PageContext(
|
||||
String title,
|
||||
String templateName,
|
||||
List<String> templateFields,
|
||||
Map<String, String> values) {
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
|
||||
|
||||
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
|
||||
return new BrainGeneratePageRequest(
|
||||
context.getLoreName(),
|
||||
context.getLoreDescription(),
|
||||
context.getFolderName(),
|
||||
context.getTemplateName(),
|
||||
context.getTemplateFields(),
|
||||
context.getPageTitle()
|
||||
context.loreName(),
|
||||
context.loreDescription(),
|
||||
context.folderName(),
|
||||
context.templateName(),
|
||||
context.templateFields(),
|
||||
context.pageTitle()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
@@ -38,35 +39,35 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
public Map<String, Object> build(ChatRequest request) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("messages", request.getMessages().stream()
|
||||
root.put("messages", request.messages().stream()
|
||||
.map(this::messageToMap)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
if (request.getLoreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
||||
if (request.loreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||
}
|
||||
if (request.getPageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
||||
if (request.pageContext() != null) {
|
||||
root.put("page_context", pageContextToMap(request.pageContext()));
|
||||
}
|
||||
if (request.getCampaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
||||
if (request.campaignContext() != null) {
|
||||
root.put("campaign_context", campaignContextToMap(request.campaignContext()));
|
||||
}
|
||||
if (request.getNarrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
||||
if (request.narrativeEntity() != null) {
|
||||
root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
|
||||
}
|
||||
if (request.getGameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
|
||||
if (request.gameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.getSystemName());
|
||||
if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
|
||||
map.put("system_description", gs.getSystemDescription());
|
||||
map.put("system_name", gs.systemName());
|
||||
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
|
||||
map.put("system_description", gs.systemDescription());
|
||||
}
|
||||
map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
|
||||
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -79,67 +80,82 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.getLoreName());
|
||||
map.put("lore_description", ctx.getLoreDescription());
|
||||
map.put("lore_name", ctx.loreName());
|
||||
map.put("lore_description", ctx.loreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||
.map(this::pageSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
map.put("folders", foldersMap);
|
||||
map.put("tags", ctx.getTags());
|
||||
map.put("tags", ctx.tags());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.getTitle());
|
||||
map.put("template_name", ps.getTemplateName());
|
||||
map.put("title", ps.title());
|
||||
map.put("template_name", ps.templateName());
|
||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
||||
map.put("values", ps.getValues());
|
||||
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||
map.put("values", ps.values());
|
||||
}
|
||||
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
||||
map.put("tags", ps.getTags());
|
||||
if (ps.tags() != null && !ps.tags().isEmpty()) {
|
||||
map.put("tags", ps.tags());
|
||||
}
|
||||
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.getRelatedPageTitles());
|
||||
if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
|
||||
map.put("related_page_titles", ps.relatedPageTitles());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", pc.getTitle());
|
||||
map.put("template_name", pc.getTemplateName());
|
||||
map.put("template_fields", pc.getTemplateFields());
|
||||
map.put("values", pc.getValues());
|
||||
map.put("title", pc.title());
|
||||
map.put("template_name", pc.templateName());
|
||||
map.put("template_fields", pc.templateFields());
|
||||
map.put("values", pc.values());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("campaign_name", ctx.getCampaignName());
|
||||
map.put("campaign_description", ctx.getCampaignDescription());
|
||||
map.put("arcs", ctx.getArcs().stream()
|
||||
map.put("campaign_name", ctx.campaignName());
|
||||
map.put("campaign_description", ctx.campaignDescription());
|
||||
map.put("arcs", ctx.arcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||
if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
|
||||
map.put("characters", ctx.getCharacters().stream()
|
||||
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||
map.put("characters", ctx.characters().stream()
|
||||
.map(this::characterSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
|
||||
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
|
||||
map.put("npcs", ctx.npcs().stream()
|
||||
.map(this::npcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", c.getName());
|
||||
if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
|
||||
map.put("snippet", c.getSnippet());
|
||||
map.put("name", c.name());
|
||||
if (c.snippet() != null && !c.snippet().isBlank()) {
|
||||
map.put("snippet", c.snippet());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", n.name());
|
||||
if (n.snippet() != null && !n.snippet().isBlank()) {
|
||||
map.put("snippet", n.snippet());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -167,10 +183,10 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::getName,
|
||||
ArcSummary::getDescription,
|
||||
ArcSummary::getIllustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
||||
ArcSummary::name,
|
||||
ArcSummary::description,
|
||||
ArcSummary::illustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.chapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
@@ -178,10 +194,10 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::getName,
|
||||
ChapterSummary::getDescription,
|
||||
ChapterSummary::getIllustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
||||
ChapterSummary::name,
|
||||
ChapterSummary::description,
|
||||
ChapterSummary::illustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
@@ -189,13 +205,13 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::getName,
|
||||
SceneSummary::getDescription,
|
||||
SceneSummary::getIllustrationCount,
|
||||
SceneSummary::name,
|
||||
SceneSummary::description,
|
||||
SceneSummary::illustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||
map.put("branches", s.branches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
@@ -204,19 +220,19 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
map.put("label", b.label());
|
||||
map.put("target_scene_name", b.targetSceneName());
|
||||
if (b.condition() != null && !b.condition().isBlank()) {
|
||||
map.put("condition", b.condition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.getEntityType());
|
||||
map.put("title", ne.getTitle());
|
||||
map.put("fields", ne.getFields());
|
||||
map.put("entity_type", ne.entityType());
|
||||
map.put("title", ne.title());
|
||||
map.put("fields", ne.fields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité JPA pour les fiches de PNJ d'une campagne.
|
||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "npcs")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class NpcJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
||||
private String markdownContent;
|
||||
|
||||
@Column(name = "campaign_id", nullable = false)
|
||||
private Long campaignId;
|
||||
|
||||
@Column(name = "\"order\"", nullable = false)
|
||||
private int order;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.loremind.infrastructure.persistence.jpa;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
|
||||
|
||||
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.loremind.infrastructure.persistence.postgres;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresNpcRepository implements NpcRepository {
|
||||
|
||||
private final NpcJpaRepository jpaRepository;
|
||||
|
||||
public PostgresNpcRepository(NpcJpaRepository jpaRepository) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Npc save(Npc npc) {
|
||||
NpcJpaEntity entity = toJpaEntity(npc);
|
||||
NpcJpaEntity saved = jpaRepository.save(entity);
|
||||
return toDomainEntity(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Npc> findById(String id) {
|
||||
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Npc> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(String id) {
|
||||
jpaRepository.deleteById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String id) {
|
||||
return jpaRepository.existsById(Long.parseLong(id));
|
||||
}
|
||||
|
||||
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||
return Npc.builder()
|
||||
.id(e.getId().toString())
|
||||
.name(e.getName())
|
||||
.markdownContent(e.getMarkdownContent())
|
||||
.campaignId(e.getCampaignId().toString())
|
||||
.order(e.getOrder())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private NpcJpaEntity toJpaEntity(Npc n) {
|
||||
Long id = n.getId() != null ? Long.parseLong(n.getId()) : null;
|
||||
return NpcJpaEntity.builder()
|
||||
.id(id)
|
||||
.name(n.getName())
|
||||
.markdownContent(n.getMarkdownContent())
|
||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||
.order(n.getOrder())
|
||||
.createdAt(n.getCreatedAt())
|
||||
.updatedAt(n.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
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`).
|
||||
* - Si l'init echoue (reseau Docker pas encore pret, registry transitoirement
|
||||
* indisponible), un thread daemon de retry avec backoff complete les
|
||||
* baselines manquantes en arriere-plan.
|
||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
||||
* change, une mise a jour est disponible. Si la baseline manque (echec
|
||||
* de tous les retries), retourne {@link ImageStatusKind#UNKNOWN} pour
|
||||
* cette image — JAMAIS d'alignement silencieux (eviterait des MAJ ratees).
|
||||
* - {@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;
|
||||
}
|
||||
|
||||
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */
|
||||
private static final long[] BASELINE_RETRY_BACKOFFS_MS = {2_000, 5_000, 15_000, 30_000, 60_000};
|
||||
|
||||
@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);
|
||||
boolean complete = tryBaselineMissing();
|
||||
if (!complete) {
|
||||
startBaselineRetryThread();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente de poser la baseline pour les images qui ne l'ont pas encore.
|
||||
* @return true si TOUTES les images ont leur baseline apres cet essai.
|
||||
*/
|
||||
private boolean tryBaselineMissing() {
|
||||
for (String image : images) {
|
||||
if (baselineDigests.containsKey(image)) continue;
|
||||
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());
|
||||
}
|
||||
}
|
||||
return baselineDigests.size() == images.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance un thread daemon qui retente de poser les baselines manquantes
|
||||
* avec backoff. Le thread s'arrete des que toutes les baselines sont
|
||||
* posees, ou apres epuisement des backoffs (et alors {@link #check()}
|
||||
* retournera UNKNOWN pour ces images jusqu'au prochain redemarrage).
|
||||
*/
|
||||
private void startBaselineRetryThread() {
|
||||
Thread t = new Thread(() -> {
|
||||
for (long backoff : BASELINE_RETRY_BACKOFFS_MS) {
|
||||
try {
|
||||
Thread.sleep(backoff);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
if (tryBaselineMissing()) {
|
||||
log.info("Baseline complete after retry");
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.warn("Baseline incomplete after all retries; check() will return UNKNOWN for missing images");
|
||||
}, "update-baseline-retry");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
||||
}
|
||||
|
||||
public UpdateStatus check() {
|
||||
if (!isEnabled()) {
|
||||
return new UpdateStatus(false, false, false, List.of(), Instant.now());
|
||||
}
|
||||
List<ImageStatus> statuses = new ArrayList<>();
|
||||
boolean anyUpdate = false;
|
||||
boolean anyUnknown = 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());
|
||||
}
|
||||
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
|
||||
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
|
||||
ImageStatusKind kind;
|
||||
if (baseline == null || remote == null) {
|
||||
kind = ImageStatusKind.UNKNOWN;
|
||||
anyUnknown = true;
|
||||
} else if (baseline.equals(remote)) {
|
||||
kind = ImageStatusKind.UP_TO_DATE;
|
||||
} else {
|
||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||
anyUpdate = true;
|
||||
}
|
||||
statuses.add(new ImageStatus(image, baseline, remote, kind));
|
||||
}
|
||||
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
|
||||
}
|
||||
|
||||
public void apply() {
|
||||
if (!isEnabled()) {
|
||||
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(watchtowerToken);
|
||||
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
||||
// conteneurs labellises. La reponse est synchrone et peut prendre
|
||||
// plusieurs secondes; en cas de redemarrage de core, le client
|
||||
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
||||
http.exchange(
|
||||
watchtowerUrl + "/v1/update",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(headers),
|
||||
Void.class);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry HTTP API v2
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private String fetchRemoteDigest(String image) {
|
||||
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setAccept(MANIFEST_ACCEPT);
|
||||
try {
|
||||
return digestCall(url, headers);
|
||||
} catch (HttpClientErrorException.Unauthorized e) {
|
||||
String www = e.getResponseHeaders() == null ? null
|
||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||
String token = obtainBearerToken(www);
|
||||
if (token == null) {
|
||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||
return null;
|
||||
}
|
||||
headers.setBearerAuth(token);
|
||||
return digestCall(url, headers);
|
||||
}
|
||||
}
|
||||
|
||||
private String digestCall(String url, HttpHeaders headers) {
|
||||
ResponseEntity<Void> resp = http.exchange(
|
||||
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
||||
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
||||
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
private String obtainBearerToken(String wwwAuth) {
|
||||
if (wwwAuth == null) return null;
|
||||
String prefix = "Bearer ";
|
||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
|
||||
String realm = params.get("realm");
|
||||
if (realm == null) return null;
|
||||
StringBuilder url = new StringBuilder(realm);
|
||||
boolean hasQuery = realm.contains("?");
|
||||
for (String key : new String[]{"service", "scope"}) {
|
||||
String v = params.get(key);
|
||||
if (v != null) {
|
||||
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
|
||||
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
|
||||
// acceptent les deux, mais GHCR est strict et rejette le scope
|
||||
// encode (403 DENIED). On preserve donc `:` et `/` dans la
|
||||
// valeur, conformement a ce que GHCR attend
|
||||
// (et que docker pull lui-meme envoie).
|
||||
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/");
|
||||
url.append(hasQuery ? '&' : '?')
|
||||
.append(key).append('=')
|
||||
.append(encoded);
|
||||
hasQuery = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
||||
Map<?, ?> body = resp.getBody();
|
||||
if (body == null) return null;
|
||||
Object t = body.get("token");
|
||||
if (t == null) t = body.get("access_token");
|
||||
return t == null ? null : t.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Bearer token request failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
|
||||
private static Map<String, String> parseAuthParams(String s) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
int i = 0;
|
||||
int n = s.length();
|
||||
while (i < n) {
|
||||
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
|
||||
int eq = s.indexOf('=', i);
|
||||
if (eq < 0) break;
|
||||
String key = s.substring(i, eq).trim();
|
||||
int valStart = eq + 1;
|
||||
String val;
|
||||
if (valStart < n && s.charAt(valStart) == '"') {
|
||||
int valEnd = s.indexOf('"', valStart + 1);
|
||||
if (valEnd < 0) break;
|
||||
val = s.substring(valStart + 1, valEnd);
|
||||
i = valEnd + 1;
|
||||
} else {
|
||||
int valEnd = s.indexOf(',', valStart);
|
||||
if (valEnd < 0) valEnd = n;
|
||||
val = s.substring(valStart, valEnd).trim();
|
||||
i = valEnd;
|
||||
}
|
||||
out.put(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String normalizeRegistry(String value) {
|
||||
if (value == null || value.isBlank()) return "";
|
||||
String v = value.trim();
|
||||
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
||||
v = "https://" + v;
|
||||
}
|
||||
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<String> parseImages(String csv) {
|
||||
if (csv == null || csv.isBlank()) return List.of();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String part : csv.split(",")) {
|
||||
String p = part.trim();
|
||||
if (!p.isEmpty()) out.add(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Records de retour (sortis sous forme JSON par Jackson)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Etat tri-state d'une image vis-a-vis du registry.
|
||||
* <ul>
|
||||
* <li>{@link #UP_TO_DATE} : digest local == digest remote.</li>
|
||||
* <li>{@link #UPDATE_AVAILABLE} : digests differents, MAJ disponible.</li>
|
||||
* <li>{@link #UNKNOWN} : impossible de comparer (baseline ou remote manquant).
|
||||
* L'UI doit afficher un avertissement plutot que de declarer "a jour".</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
||||
|
||||
public record UpdateStatus(
|
||||
boolean enabled,
|
||||
boolean updateAvailable,
|
||||
boolean anyUnknown,
|
||||
List<ImageStatus> images,
|
||||
Instant checkedAt) {}
|
||||
|
||||
/**
|
||||
* Le champ {@code updateAvailable} est conserve pour la compatibilite
|
||||
* avec les anciens clients ; il est strictement derive de {@code status}
|
||||
* dans le constructeur compact.
|
||||
*/
|
||||
public record ImageStatus(
|
||||
String image,
|
||||
String localDigest,
|
||||
String remoteDigest,
|
||||
ImageStatusKind status,
|
||||
boolean updateAvailable) {
|
||||
|
||||
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) {
|
||||
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ public class SecurityConfig {
|
||||
// Preflight CORS toujours libre (le browser n'envoie pas Authorization sur OPTIONS)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.httpBasic(basic -> {});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -18,13 +19,18 @@ import java.util.Map;
|
||||
public class ConfigController {
|
||||
|
||||
private final boolean demoMode;
|
||||
private final UpdateCheckService updates;
|
||||
|
||||
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
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);
|
||||
return Map.of(
|
||||
"demoMode", demoMode,
|
||||
"updateCheckEnabled", updates.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.campaigncontext.NpcService;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||
import com.loremind.infrastructure.web.mapper.NpcMapper;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/npcs")
|
||||
public class NpcController {
|
||||
|
||||
private final NpcService npcService;
|
||||
private final NpcMapper npcMapper;
|
||||
|
||||
public NpcController(NpcService npcService, NpcMapper npcMapper) {
|
||||
this.npcService = npcService;
|
||||
this.npcMapper = npcMapper;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
||||
Npc created = npcService.createNpc(
|
||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
||||
);
|
||||
return ResponseEntity.ok(npcMapper.toDTO(created));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<NpcDTO> getNpcById(@PathVariable String id) {
|
||||
return npcService.getNpcById(id)
|
||||
.map(n -> ResponseEntity.ok(npcMapper.toDTO(n)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/campaign/{campaignId}")
|
||||
public ResponseEntity<List<NpcDTO>> getNpcsByCampaign(@PathVariable String campaignId) {
|
||||
List<NpcDTO> dtos = npcService.getNpcsByCampaignId(campaignId).stream()
|
||||
.map(npcMapper::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||
Npc updated = npcService.updateNpc(
|
||||
id,
|
||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
||||
);
|
||||
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteNpc(@PathVariable String id) {
|
||||
npcService.deleteNpc(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -15,7 +17,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -34,13 +44,16 @@ public class SettingsController {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String brainBaseUrl;
|
||||
private final String brainInternalSecret;
|
||||
private final boolean demoMode;
|
||||
|
||||
public SettingsController(RestTemplate restTemplate,
|
||||
@Value("${brain.base-url}") String brainBaseUrl,
|
||||
@Value("${brain.internal-secret}") String brainInternalSecret,
|
||||
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.brainBaseUrl = brainBaseUrl;
|
||||
this.brainInternalSecret = brainInternalSecret;
|
||||
this.demoMode = demoMode;
|
||||
}
|
||||
|
||||
@@ -66,11 +79,92 @@ public class SettingsController {
|
||||
return forward(HttpMethod.POST, "/models/ollama/info", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Telecharge un modele Ollama et streame la progression au client.
|
||||
* <p>
|
||||
* On bypass RestTemplate (qui bufferise toute la reponse) au profit du
|
||||
* client HTTP standard de Java en mode streaming. Le Brain renvoie du
|
||||
* NDJSON ligne par ligne ; on relaie chaque chunk tel quel pour que le
|
||||
* frontend voie la progression en temps reel.
|
||||
*/
|
||||
@PostMapping(value = "/models/ollama/pull", produces = "application/x-ndjson")
|
||||
public ResponseEntity<StreamingResponseBody> pullOllamaModel(@RequestBody Map<String, Object> body) {
|
||||
guardDemoMode();
|
||||
StreamingResponseBody stream = output -> {
|
||||
// Force HTTP/1.1 : le HttpClient JDK essaie HTTP/2 par defaut,
|
||||
// mais uvicorn (Brain) ne supporte que HTTP/1.1 et rejette la
|
||||
// tentative d'upgrade ("Unsupported upgrade request") -> la
|
||||
// requete n'arrive jamais a notre endpoint Python.
|
||||
HttpClient http = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
// Le RestTemplate auto-injecte X-Internal-Secret via un interceptor,
|
||||
// mais on bypass RestTemplate pour le streaming -> on doit ajouter
|
||||
// l'entete a la main, sinon le Brain repond 401.
|
||||
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
|
||||
.timeout(Duration.ofMinutes(60))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)));
|
||||
if (brainInternalSecret != null && !brainInternalSecret.isBlank()) {
|
||||
reqBuilder.header("X-Internal-Secret", brainInternalSecret);
|
||||
}
|
||||
HttpRequest req = reqBuilder.build();
|
||||
try {
|
||||
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||
try (InputStream in = resp.body()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
output.write(buf, 0, n);
|
||||
output.flush();
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Pull interrompu", ie);
|
||||
}
|
||||
};
|
||||
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
|
||||
}
|
||||
|
||||
@DeleteMapping("/models/ollama/{name}")
|
||||
public ResponseEntity<Map<String, Object>> deleteOllamaModel(@PathVariable("name") String name) {
|
||||
guardDemoMode();
|
||||
return forward(HttpMethod.DELETE, "/models/ollama/" + name, null);
|
||||
}
|
||||
|
||||
@GetMapping("/models/onemin")
|
||||
public ResponseEntity<Map<String, Object>> listOneMinModels() {
|
||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
|
||||
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
|
||||
* String/Number/Boolean en valeur.
|
||||
*/
|
||||
private static String toJson(Map<String, Object> m) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> e : m.entrySet()) {
|
||||
if (!first) sb.append(",");
|
||||
sb.append("\"").append(escape(e.getKey())).append("\":");
|
||||
Object v = e.getValue();
|
||||
if (v == null) sb.append("null");
|
||||
else if (v instanceof Number || v instanceof Boolean) sb.append(v);
|
||||
else sb.append("\"").append(escape(v.toString())).append("\"");
|
||||
first = false;
|
||||
}
|
||||
return sb.append("}").toString();
|
||||
}
|
||||
|
||||
private static String escape(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
|
||||
}
|
||||
|
||||
private void guardDemoMode() {
|
||||
if (demoMode) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.loremind.infrastructure.web.dto.campaigncontext;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO pour les fiches de PNJ d'une campagne.
|
||||
*/
|
||||
@Data
|
||||
public class NpcDTO {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private String markdownContent;
|
||||
private String campaignId;
|
||||
private int order;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class NpcMapper {
|
||||
|
||||
public NpcDTO toDTO(Npc n) {
|
||||
if (n == null) return null;
|
||||
NpcDTO dto = new NpcDTO();
|
||||
dto.setId(n.getId());
|
||||
dto.setName(n.getName());
|
||||
dto.setMarkdownContent(n.getMarkdownContent());
|
||||
dto.setCampaignId(n.getCampaignId());
|
||||
dto.setOrder(n.getOrder());
|
||||
return dto;
|
||||
}
|
||||
|
||||
public Npc toDomain(NpcDTO dto) {
|
||||
if (dto == null) return null;
|
||||
return Npc.builder()
|
||||
.id(dto.getId())
|
||||
.name(dto.getName())
|
||||
.markdownContent(dto.getMarkdownContent())
|
||||
.campaignId(dto.getCampaignId())
|
||||
.order(dto.getOrder())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -87,18 +87,14 @@ public class SceneMapper {
|
||||
private List<SceneBranchDTO> toBranchDTOs(List<SceneBranch> branches) {
|
||||
if (branches == null) return new ArrayList<>();
|
||||
return branches.stream()
|
||||
.map(b -> new SceneBranchDTO(b.getLabel(), b.getTargetSceneId(), b.getCondition()))
|
||||
.map(b -> new SceneBranchDTO(b.label(), b.targetSceneId(), b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<SceneBranch> toBranchDomain(List<SceneBranchDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
return dtos.stream()
|
||||
.map(d -> SceneBranch.builder()
|
||||
.label(d.getLabel())
|
||||
.targetSceneId(d.getTargetSceneId())
|
||||
.condition(d.getCondition())
|
||||
.build())
|
||||
.map(d -> new SceneBranch(d.getLabel(), d.getTargetSceneId(), d.getCondition()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ server.port=8080
|
||||
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
||||
spring.main.web-application-type=servlet
|
||||
|
||||
# Pas de timeout sur les requetes async (StreamingResponseBody, SSE).
|
||||
# Le defaut Tomcat coupe a 30s, ce qui interrompt le streaming d'un pull
|
||||
# de modele Ollama (peut durer des dizaines de minutes pour un GGUF de 10+ Go).
|
||||
# -1 = pas de timeout, on s'appuie sur la fermeture cote client ou cote upstream.
|
||||
spring.mvc.async.request-timeout=-1
|
||||
|
||||
# Configuration de la base de donnees PostgreSQL
|
||||
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
||||
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
|
||||
@@ -54,3 +60,11 @@ 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:}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour NpcService.
|
||||
* Couvre la création (avec auto-calcul de l'order), la lecture, la mise à jour
|
||||
* (incl. cas non trouvé), la suppression, et le calcul d'order.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class NpcServiceTest {
|
||||
|
||||
@Mock
|
||||
private NpcRepository npcRepository;
|
||||
|
||||
@InjectMocks
|
||||
private NpcService npcService;
|
||||
|
||||
private Npc testNpc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testNpc = Npc.builder()
|
||||
.id("npc-1")
|
||||
.name("Borin le forgeron")
|
||||
.markdownContent("# Borin\nForgeron nain")
|
||||
.campaignId("camp-1")
|
||||
.order(1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateNpc_WithExplicitOrder() {
|
||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||
|
||||
Npc result = npcService.createNpc(
|
||||
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
|
||||
|
||||
assertNotNull(result);
|
||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||
verify(npcRepository).save(captor.capture());
|
||||
assertEquals(5, captor.getValue().getOrder());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateNpc_AutoComputesNextOrder_WhenNullProvided() {
|
||||
// Existant : 2 PNJ avec orders 0 et 3 → next = 4
|
||||
Npc a = Npc.builder().id("a").campaignId("camp-1").order(0).build();
|
||||
Npc b = Npc.builder().id("b").campaignId("camp-1").order(3).build();
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||
|
||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
|
||||
|
||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||
verify(npcRepository).save(captor.capture());
|
||||
assertEquals(4, captor.getValue().getOrder());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateNpc_FirstNpcGetsOrderZero() {
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||
|
||||
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
|
||||
|
||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||
verify(npcRepository).save(captor.capture());
|
||||
assertEquals(0, captor.getValue().getOrder());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNpcById_Found() {
|
||||
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||
|
||||
Optional<Npc> result = npcService.getNpcById("npc-1");
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("Borin le forgeron", result.get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNpcById_NotFound() {
|
||||
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||
|
||||
Optional<Npc> result = npcService.getNpcById("missing");
|
||||
|
||||
assertFalse(result.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNpcsByCampaignId_DelegatesToRepository() {
|
||||
Npc a = Npc.builder().id("a").campaignId("camp-1").order(1).build();
|
||||
Npc b = Npc.builder().id("b").campaignId("camp-1").order(2).build();
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||
|
||||
List<Npc> result = npcService.getNpcsByCampaignId("camp-1");
|
||||
|
||||
assertEquals(2, result.size());
|
||||
verify(npcRepository).findByCampaignId("camp-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateNpc_Success() {
|
||||
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Npc result = npcService.updateNpc("npc-1",
|
||||
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
|
||||
|
||||
assertEquals("Borin renommé", result.getName());
|
||||
assertEquals("# v2", result.getMarkdownContent());
|
||||
assertEquals(7, result.getOrder());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateNpc_OrderNullPreservesExistingOrder() {
|
||||
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Npc result = npcService.updateNpc("npc-1",
|
||||
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
|
||||
|
||||
// testNpc avait order=1 → préservé
|
||||
assertEquals(1, result.getOrder());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateNpc_NotFoundThrows() {
|
||||
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> npcService.updateNpc("missing",
|
||||
new NpcService.NpcData("x", null, "camp-1", null)));
|
||||
assertTrue(ex.getMessage().contains("missing"));
|
||||
verify(npcRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteNpc_DelegatesToRepository() {
|
||||
npcService.deleteNpc("npc-1");
|
||||
verify(npcRepository).deleteById("npc-1");
|
||||
}
|
||||
}
|
||||
@@ -178,10 +178,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithValidBranches() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-2")
|
||||
.label("Go to scene 2")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Go to scene 2", "scene-2");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -203,10 +200,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchToSelf() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-1")
|
||||
.label("Self-reference")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Self-reference", "scene-1");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -228,10 +222,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchToDifferentChapter() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId("scene-other-chapter")
|
||||
.label("Go to other chapter")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Go to other chapter", "scene-other-chapter");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -253,10 +244,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchNullTarget() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId(null)
|
||||
.label("Null target")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Null target", null);
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
@@ -277,10 +265,7 @@ public class SceneServiceTest {
|
||||
@Test
|
||||
void testUpdateScene_WithBranchBlankTarget() {
|
||||
// Arrange
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.targetSceneId(" ")
|
||||
.label("Blank target")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("Blank target", " ");
|
||||
Scene updatedScene = Scene.builder()
|
||||
.name("Updated Scene")
|
||||
.branches(List.of(branch))
|
||||
|
||||
@@ -3,12 +3,15 @@ package com.loremind.application.generationcontext;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -43,6 +46,8 @@ public class CampaignStructuralContextBuilderTest {
|
||||
private SceneRepository sceneRepository;
|
||||
@Mock
|
||||
private CharacterRepository characterRepository;
|
||||
@Mock
|
||||
private NpcRepository npcRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CampaignStructuralContextBuilder builder;
|
||||
@@ -74,9 +79,9 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
assertEquals("Les Terres Brisées", ctx.getCampaignName());
|
||||
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
|
||||
assertTrue(ctx.getArcs().isEmpty());
|
||||
assertEquals("Les Terres Brisées", ctx.campaignName());
|
||||
assertEquals("Campagne dark fantasy", ctx.campaignDescription());
|
||||
assertTrue(ctx.arcs().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -100,19 +105,19 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
assertEquals(2, ctx.getArcs().size());
|
||||
assertEquals("Arc A", ctx.getArcs().get(0).getName());
|
||||
assertEquals("Arc B", ctx.getArcs().get(1).getName());
|
||||
assertEquals(2, ctx.arcs().size());
|
||||
assertEquals("Arc A", ctx.arcs().get(0).name());
|
||||
assertEquals("Arc B", ctx.arcs().get(1).name());
|
||||
|
||||
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
|
||||
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
|
||||
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
|
||||
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
|
||||
assertEquals(2, ctx.arcs().get(0).chapters().size());
|
||||
assertEquals("Ch B", ctx.arcs().get(0).chapters().get(0).name());
|
||||
assertEquals("Ch A", ctx.arcs().get(0).chapters().get(1).name());
|
||||
|
||||
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
|
||||
var chADto = ctx.getArcs().get(0).getChapters().get(1);
|
||||
assertEquals("Scene B", chADto.getScenes().get(0).getName());
|
||||
assertEquals("Scene A", chADto.getScenes().get(1).getName());
|
||||
var chADto = ctx.arcs().get(0).chapters().get(1);
|
||||
assertEquals("Scene B", chADto.scenes().get(0).name());
|
||||
assertEquals("Scene A", chADto.scenes().get(1).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,15 +125,8 @@ public class CampaignStructuralContextBuilderTest {
|
||||
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
|
||||
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
|
||||
|
||||
SceneBranch validBranch = SceneBranch.builder()
|
||||
.label("Si les joueurs fuient")
|
||||
.targetSceneId("s-2")
|
||||
.condition("en cas de combat perdu")
|
||||
.build();
|
||||
SceneBranch danglingBranch = SceneBranch.builder()
|
||||
.label("Vers l'inconnu")
|
||||
.targetSceneId("s-inconnu")
|
||||
.build();
|
||||
SceneBranch validBranch = new SceneBranch("Si les joueurs fuient", "s-2", "en cas de combat perdu");
|
||||
SceneBranch danglingBranch = SceneBranch.of("Vers l'inconnu", "s-inconnu");
|
||||
|
||||
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
|
||||
.order(1)
|
||||
@@ -143,12 +141,72 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
|
||||
assertEquals(2, scene1Summary.getBranches().size());
|
||||
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
|
||||
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
|
||||
var scene1Summary = ctx.arcs().get(0).chapters().get(0).scenes().get(0);
|
||||
assertEquals(2, scene1Summary.branches().size());
|
||||
assertEquals("Fuite", scene1Summary.branches().get(0).targetSceneName());
|
||||
assertEquals("en cas de combat perdu", scene1Summary.branches().get(0).condition());
|
||||
// ID inconnu → libellé de fallback
|
||||
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
|
||||
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
||||
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
||||
.name("Aragorn")
|
||||
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
|
||||
.build();
|
||||
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
||||
.name("Legolas")
|
||||
.markdownContent(null) // pas de snippet → string vide
|
||||
.build();
|
||||
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
||||
.name("Borin le forgeron")
|
||||
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
|
||||
.build();
|
||||
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
||||
.name("Dame Elara")
|
||||
.markdownContent("")
|
||||
.build();
|
||||
|
||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||
when(characterRepository.findByCampaignId("camp-1")).thenReturn(List.of(pj2, pj1));
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(npc1, npc2));
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
// PJ triés par order croissant
|
||||
assertEquals(2, ctx.characters().size());
|
||||
assertEquals("Aragorn", ctx.characters().get(0).name());
|
||||
assertEquals("Rôdeur du Nord, héritier d'Isildur.", ctx.characters().get(0).snippet());
|
||||
assertEquals("Legolas", ctx.characters().get(1).name());
|
||||
assertEquals("", ctx.characters().get(1).snippet());
|
||||
|
||||
// PNJ triés par order croissant : Elara (1) avant Borin (2)
|
||||
assertEquals(2, ctx.npcs().size());
|
||||
assertEquals("Dame Elara", ctx.npcs().get(0).name());
|
||||
assertEquals("", ctx.npcs().get(0).snippet());
|
||||
assertEquals("Borin le forgeron", ctx.npcs().get(1).name());
|
||||
assertEquals("Nain barbu au regard perçant, ancien clan Feuillefer.",
|
||||
ctx.npcs().get(1).snippet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_TruncatesLongSnippet() {
|
||||
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
||||
String longLine = "x".repeat(200);
|
||||
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
||||
.name("Verbeux").markdownContent(longLine).build();
|
||||
|
||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(longNpc));
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
String snippet = ctx.npcs().get(0).snippet();
|
||||
assertEquals(160, snippet.length());
|
||||
assertTrue(snippet.endsWith("…"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -170,9 +228,9 @@ public class CampaignStructuralContextBuilderTest {
|
||||
|
||||
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||
|
||||
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
|
||||
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
|
||||
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
|
||||
assertEquals(2, ctx.arcs().get(0).illustrationCount());
|
||||
assertEquals(0, ctx.arcs().get(0).chapters().get(0).illustrationCount());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).illustrationCount());
|
||||
assertTrue(ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,13 +89,13 @@ public class GeneratePageValuesUseCaseTest {
|
||||
verify(aiProvider).generatePage(captor.capture());
|
||||
GenerationContext ctx = captor.getValue();
|
||||
|
||||
assertEquals("Aetheria", ctx.getLoreName());
|
||||
assertEquals("monde aérien", ctx.getLoreDescription());
|
||||
assertEquals("PNJ", ctx.getFolderName());
|
||||
assertEquals("Personnage", ctx.getTemplateName());
|
||||
assertEquals("Alice", ctx.getPageTitle());
|
||||
assertEquals("Aetheria", ctx.loreName());
|
||||
assertEquals("monde aérien", ctx.loreDescription());
|
||||
assertEquals("PNJ", ctx.folderName());
|
||||
assertEquals("Personnage", ctx.templateName());
|
||||
assertEquals("Alice", ctx.pageTitle());
|
||||
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
|
||||
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
|
||||
assertEquals(List.of("Histoire", "Apparence"), ctx.templateFields());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -71,10 +71,10 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
assertEquals("Aetheria", ctx.getLoreName());
|
||||
assertEquals("Monde aérien", ctx.getLoreDescription());
|
||||
assertTrue(ctx.getFolders().isEmpty());
|
||||
assertTrue(ctx.getTags().isEmpty());
|
||||
assertEquals("Aetheria", ctx.loreName());
|
||||
assertEquals("Monde aérien", ctx.loreDescription());
|
||||
assertTrue(ctx.folders().isEmpty());
|
||||
assertTrue(ctx.tags().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -110,32 +110,32 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
assertEquals(2, ctx.getFolders().size());
|
||||
assertTrue(ctx.getFolders().containsKey("PNJ"));
|
||||
assertTrue(ctx.getFolders().containsKey("Lieux"));
|
||||
assertEquals(2, ctx.folders().size());
|
||||
assertTrue(ctx.folders().containsKey("PNJ"));
|
||||
assertTrue(ctx.folders().containsKey("Lieux"));
|
||||
|
||||
var pnjPages = ctx.getFolders().get("PNJ");
|
||||
var pnjPages = ctx.folders().get("PNJ");
|
||||
assertEquals(1, pnjPages.size());
|
||||
var aliceSummary = pnjPages.get(0);
|
||||
assertEquals("Alice", aliceSummary.getTitle());
|
||||
assertEquals("Personnage", aliceSummary.getTemplateName());
|
||||
assertEquals("Alice", aliceSummary.title());
|
||||
assertEquals("Personnage", aliceSummary.templateName());
|
||||
// Blank/null filtrés
|
||||
assertEquals(1, aliceSummary.getValues().size());
|
||||
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
|
||||
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
|
||||
assertEquals(1, aliceSummary.values().size());
|
||||
assertEquals("Il était une fois...", aliceSummary.values().get("Histoire"));
|
||||
assertEquals(List.of("hero", "magic"), aliceSummary.tags());
|
||||
// p-2 resolved into title, p-ghost dropped silently
|
||||
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
|
||||
assertEquals(List.of("La Forêt"), aliceSummary.relatedPageTitles());
|
||||
|
||||
var forestSummary = ctx.getFolders().get("Lieux").get(0);
|
||||
var forestSummary = ctx.folders().get("Lieux").get(0);
|
||||
// Template introuvable → "?"
|
||||
assertEquals("?", forestSummary.getTemplateName());
|
||||
assertTrue(forestSummary.getValues().isEmpty());
|
||||
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
|
||||
assertEquals("?", forestSummary.templateName());
|
||||
assertTrue(forestSummary.values().isEmpty());
|
||||
assertTrue(forestSummary.relatedPageTitles().isEmpty());
|
||||
|
||||
// Tags uniques entre les 2 pages
|
||||
assertEquals(2, ctx.getTags().size());
|
||||
assertTrue(ctx.getTags().contains("hero"));
|
||||
assertTrue(ctx.getTags().contains("magic"));
|
||||
assertEquals(2, ctx.tags().size());
|
||||
assertTrue(ctx.tags().contains("hero"));
|
||||
assertTrue(ctx.tags().contains("magic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -160,7 +160,7 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
|
||||
String truncated = ctx.folders().get("PNJ").get(0).values().get("Histoire");
|
||||
assertNotNull(truncated);
|
||||
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
|
||||
assertTrue(truncated.endsWith("…"));
|
||||
@@ -185,9 +185,9 @@ public class LoreStructuralContextBuilderTest {
|
||||
|
||||
LoreStructuralContext ctx = builder.build("lore-1");
|
||||
|
||||
var summary = ctx.getFolders().get("PNJ").get(0);
|
||||
assertTrue(summary.getValues().isEmpty());
|
||||
assertTrue(summary.getTags().isEmpty());
|
||||
assertTrue(summary.getRelatedPageTitles().isEmpty());
|
||||
var summary = ctx.folders().get("PNJ").get(0);
|
||||
assertTrue(summary.values().isEmpty());
|
||||
assertTrue(summary.tags().isEmpty());
|
||||
assertTrue(summary.relatedPageTitles().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -30,6 +34,8 @@ public class NarrativeEntityContextBuilderTest {
|
||||
@Mock private ArcRepository arcRepository;
|
||||
@Mock private ChapterRepository chapterRepository;
|
||||
@Mock private SceneRepository sceneRepository;
|
||||
@Mock private CharacterRepository characterRepository;
|
||||
@Mock private NpcRepository npcRepository;
|
||||
|
||||
@InjectMocks private NarrativeEntityContextBuilder builder;
|
||||
|
||||
@@ -44,14 +50,14 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
|
||||
|
||||
assertEquals("arc", ctx.getEntityType());
|
||||
assertEquals("L'arc sombre", ctx.getTitle());
|
||||
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
|
||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
||||
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
|
||||
assertEquals("pouvoir", ctx.getFields().get("rewards"));
|
||||
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
|
||||
assertEquals("secret", ctx.getFields().get("gmNotes"));
|
||||
assertEquals("arc", ctx.entityType());
|
||||
assertEquals("L'arc sombre", ctx.title());
|
||||
assertEquals("synopsis", ctx.fields().get("description (synopsis)"));
|
||||
assertEquals("trahison", ctx.fields().get("themes"));
|
||||
assertEquals("vie ou mort", ctx.fields().get("stakes"));
|
||||
assertEquals("pouvoir", ctx.fields().get("rewards"));
|
||||
assertEquals("le roi meurt", ctx.fields().get("resolution"));
|
||||
assertEquals("secret", ctx.fields().get("gmNotes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,12 +70,12 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
|
||||
|
||||
assertEquals("chapter", ctx.getEntityType());
|
||||
assertEquals("Chapitre 1", ctx.getTitle());
|
||||
assertEquals("", ctx.getFields().get("description (synopsis)"));
|
||||
assertEquals("", ctx.getFields().get("playerObjectives"));
|
||||
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
|
||||
assertEquals("", ctx.getFields().get("gmNotes"));
|
||||
assertEquals("chapter", ctx.entityType());
|
||||
assertEquals("Chapitre 1", ctx.title());
|
||||
assertEquals("", ctx.fields().get("description (synopsis)"));
|
||||
assertEquals("", ctx.fields().get("playerObjectives"));
|
||||
assertEquals("haut", ctx.fields().get("narrativeStakes"));
|
||||
assertEquals("", ctx.fields().get("gmNotes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,17 +91,17 @@ public class NarrativeEntityContextBuilderTest {
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("scene", "s-1");
|
||||
|
||||
assertEquals("scene", ctx.getEntityType());
|
||||
assertEquals("L'auberge", ctx.getTitle());
|
||||
assertEquals("lieu calme", ctx.getFields().get("description"));
|
||||
assertEquals("Taverne", ctx.getFields().get("location"));
|
||||
assertEquals("Soir", ctx.getFields().get("timing"));
|
||||
assertEquals("tendue", ctx.getFields().get("atmosphere"));
|
||||
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
|
||||
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
|
||||
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
|
||||
assertEquals("3 bandits", ctx.getFields().get("enemies"));
|
||||
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
|
||||
assertEquals("scene", ctx.entityType());
|
||||
assertEquals("L'auberge", ctx.title());
|
||||
assertEquals("lieu calme", ctx.fields().get("description"));
|
||||
assertEquals("Taverne", ctx.fields().get("location"));
|
||||
assertEquals("Soir", ctx.fields().get("timing"));
|
||||
assertEquals("tendue", ctx.fields().get("atmosphere"));
|
||||
assertEquals("Vous entrez...", ctx.fields().get("playerNarration"));
|
||||
assertEquals("option A...", ctx.fields().get("choicesConsequences"));
|
||||
assertEquals("moyen", ctx.fields().get("combatDifficulty"));
|
||||
assertEquals("3 bandits", ctx.fields().get("enemies"));
|
||||
assertEquals("trésor caché", ctx.fields().get("gmSecretNotes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -104,14 +110,62 @@ public class NarrativeEntityContextBuilderTest {
|
||||
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
|
||||
|
||||
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
|
||||
assertEquals("arc", ctx.getEntityType());
|
||||
assertEquals("arc", ctx.entityType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_Character_MarkdownProjected() {
|
||||
Character c = Character.builder()
|
||||
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
|
||||
.build();
|
||||
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("character", "c-1");
|
||||
|
||||
assertEquals("character", ctx.entityType());
|
||||
assertEquals("Aragorn", ctx.title());
|
||||
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_Npc_MarkdownProjected() {
|
||||
Npc n = Npc.builder()
|
||||
.id("n-1").name("Borin le forgeron")
|
||||
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
|
||||
.build();
|
||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||
|
||||
NarrativeEntityContext ctx = builder.build("npc", "n-1");
|
||||
|
||||
assertEquals("npc", ctx.entityType());
|
||||
assertEquals("Borin le forgeron", ctx.title());
|
||||
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
|
||||
ctx.fields().get("fiche complète (markdown)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_Npc_NormalizesCase() {
|
||||
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
|
||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||
|
||||
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
||||
assertEquals("npc", ctx.entityType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_NpcNotFoundThrows() {
|
||||
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> builder.build("npc", "missing"));
|
||||
assertTrue(ex.getMessage().contains("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuild_UnknownTypeThrows() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> builder.build("npc", "id"));
|
||||
assertTrue(ex.getMessage().contains("npc"));
|
||||
() -> builder.build("alien", "id"));
|
||||
assertTrue(ex.getMessage().contains("alien"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -55,9 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
campaignCtx = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("d")
|
||||
.build();
|
||||
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of(), List.of());
|
||||
messages = List.of();
|
||||
onUsage = mock(Consumer.class);
|
||||
onToken = mock(Consumer.class);
|
||||
@@ -85,10 +83,10 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||
ChatRequest req = captor.getValue();
|
||||
assertSame(campaignCtx, req.getCampaignContext());
|
||||
assertNull(req.getLoreContext());
|
||||
assertNull(req.getNarrativeEntity());
|
||||
assertNull(req.getPageContext());
|
||||
assertSame(campaignCtx, req.campaignContext());
|
||||
assertNull(req.loreContext());
|
||||
assertNull(req.narrativeEntity());
|
||||
assertNull(req.pageContext());
|
||||
verifyNoInteractions(loreContextBuilder);
|
||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||
}
|
||||
@@ -96,8 +94,8 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
@Test
|
||||
void testExecute_LinkedCampaign_LoadsLoreContext() {
|
||||
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
|
||||
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
|
||||
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
|
||||
LoreStructuralContext loreCtx = new LoreStructuralContext(
|
||||
"L", "d", Collections.emptyMap(), List.of());
|
||||
|
||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
|
||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||
@@ -107,7 +105,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertSame(loreCtx, captor.getValue().getLoreContext());
|
||||
assertSame(loreCtx, captor.getValue().loreContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,15 +120,14 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertNull(captor.getValue().getLoreContext());
|
||||
assertNull(captor.getValue().loreContext());
|
||||
// La requete doit tout de meme partir (pas d'exception).
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
|
||||
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
|
||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
||||
.entityType("scene").title("L'auberge").fields(Map.of()).build();
|
||||
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge", Map.of());
|
||||
|
||||
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
|
||||
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
|
||||
@@ -140,7 +137,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertSame(entity, captor.getValue().getNarrativeEntity());
|
||||
assertSame(entity, captor.getValue().narrativeEntity());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -153,7 +150,7 @@ public class StreamChatForCampaignUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertNull(captor.getValue().getNarrativeEntity());
|
||||
assertNull(captor.getValue().narrativeEntity());
|
||||
verifyNoInteractions(narrativeEntityContextBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,7 @@ public class StreamChatForLoreUseCaseTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
loreCtx = LoreStructuralContext.builder()
|
||||
.loreName("Aetheria").loreDescription("d")
|
||||
.folders(Collections.emptyMap())
|
||||
.build();
|
||||
loreCtx = new LoreStructuralContext("Aetheria", "d", Collections.emptyMap(), List.of());
|
||||
messages = List.of();
|
||||
onUsage = mock(Consumer.class);
|
||||
onToken = mock(Consumer.class);
|
||||
@@ -75,9 +72,9 @@ public class StreamChatForLoreUseCaseTest {
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
|
||||
ChatRequest req = captor.getValue();
|
||||
assertSame(loreCtx, req.getLoreContext());
|
||||
assertNull(req.getPageContext());
|
||||
assertNull(req.getCampaignContext());
|
||||
assertSame(loreCtx, req.loreContext());
|
||||
assertNull(req.pageContext());
|
||||
assertNull(req.campaignContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -88,7 +85,7 @@ public class StreamChatForLoreUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
assertNull(captor.getValue().getPageContext());
|
||||
assertNull(captor.getValue().pageContext());
|
||||
verifyNoInteractions(pageRepository);
|
||||
}
|
||||
|
||||
@@ -116,12 +113,12 @@ public class StreamChatForLoreUseCaseTest {
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
ChatRequest req = captor.getValue();
|
||||
assertNotNull(req.getPageContext());
|
||||
assertEquals("Alice", req.getPageContext().getTitle());
|
||||
assertEquals("Personnage", req.getPageContext().getTemplateName());
|
||||
assertNotNull(req.pageContext());
|
||||
assertEquals("Alice", req.pageContext().title());
|
||||
assertEquals("Personnage", req.pageContext().templateName());
|
||||
// Seuls les champs TEXT exposes
|
||||
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
|
||||
assertEquals(values, req.getPageContext().getValues());
|
||||
assertEquals(List.of("Histoire"), req.pageContext().templateFields());
|
||||
assertEquals(values, req.pageContext().values());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -137,12 +134,12 @@ public class StreamChatForLoreUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
var pageCtx = captor.getValue().getPageContext();
|
||||
var pageCtx = captor.getValue().pageContext();
|
||||
assertNotNull(pageCtx);
|
||||
assertEquals("Orphan", pageCtx.getTitle());
|
||||
assertEquals("?", pageCtx.getTemplateName());
|
||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
||||
assertTrue(pageCtx.getValues().isEmpty());
|
||||
assertEquals("Orphan", pageCtx.title());
|
||||
assertEquals("?", pageCtx.templateName());
|
||||
assertTrue(pageCtx.templateFields().isEmpty());
|
||||
assertTrue(pageCtx.values().isEmpty());
|
||||
verifyNoInteractions(templateRepository);
|
||||
}
|
||||
|
||||
@@ -160,9 +157,9 @@ public class StreamChatForLoreUseCaseTest {
|
||||
|
||||
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
|
||||
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
|
||||
var pageCtx = captor.getValue().getPageContext();
|
||||
assertEquals("?", pageCtx.getTemplateName());
|
||||
assertTrue(pageCtx.getTemplateFields().isEmpty());
|
||||
var pageCtx = captor.getValue().pageContext();
|
||||
assertEquals("?", pageCtx.templateName());
|
||||
assertTrue(pageCtx.templateFields().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -9,48 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
/**
|
||||
* Tests unitaires pour SceneBranch (Value Object).
|
||||
* Verifie :
|
||||
* - l'immuabilite (pas de setters : seul le builder permet la construction),
|
||||
* - l'egalite structurelle generee par @Value (equals/hashCode sur tous les
|
||||
* - l'immuabilite (record : aucun setter, constructeur canonique uniquement),
|
||||
* - l'egalite structurelle generee par record (equals/hashCode sur tous les
|
||||
* champs) — deux branches aux memes champs sont strictement egales,
|
||||
* - le support du champ optionnel {@code condition}.
|
||||
*/
|
||||
class SceneBranchTest {
|
||||
|
||||
@Test
|
||||
void builder_exposesAllFields() {
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.label("Si les joueurs attaquent le garde")
|
||||
.targetSceneId("sc-combat")
|
||||
.condition("initiative > 15")
|
||||
.build();
|
||||
void constructor_exposesAllFields() {
|
||||
SceneBranch branch = new SceneBranch(
|
||||
"Si les joueurs attaquent le garde",
|
||||
"sc-combat",
|
||||
"initiative > 15");
|
||||
|
||||
assertEquals("Si les joueurs attaquent le garde", branch.getLabel());
|
||||
assertEquals("sc-combat", branch.getTargetSceneId());
|
||||
assertEquals("initiative > 15", branch.getCondition());
|
||||
assertEquals("Si les joueurs attaquent le garde", branch.label());
|
||||
assertEquals("sc-combat", branch.targetSceneId());
|
||||
assertEquals("initiative > 15", branch.condition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_isOptional() {
|
||||
SceneBranch branch = SceneBranch.builder()
|
||||
.label("sortie par la porte")
|
||||
.targetSceneId("sc-corridor")
|
||||
.build();
|
||||
SceneBranch branch = SceneBranch.of("sortie par la porte", "sc-corridor");
|
||||
|
||||
assertNull(branch.getCondition());
|
||||
assertNull(branch.condition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoBranches_withSameFields_areEqual() {
|
||||
SceneBranch a = SceneBranch.builder()
|
||||
.label("fuite")
|
||||
.targetSceneId("sc-2")
|
||||
.condition(null)
|
||||
.build();
|
||||
SceneBranch b = SceneBranch.builder()
|
||||
.label("fuite")
|
||||
.targetSceneId("sc-2")
|
||||
.condition(null)
|
||||
.build();
|
||||
SceneBranch a = new SceneBranch("fuite", "sc-2", null);
|
||||
SceneBranch b = new SceneBranch("fuite", "sc-2", null);
|
||||
|
||||
assertEquals(a, b);
|
||||
assertEquals(a.hashCode(), b.hashCode());
|
||||
@@ -58,16 +46,16 @@ class SceneBranchTest {
|
||||
|
||||
@Test
|
||||
void twoBranches_differingOnTargetSceneId_areNotEqual() {
|
||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").build();
|
||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-2").build();
|
||||
SceneBranch a = SceneBranch.of("X", "sc-1");
|
||||
SceneBranch b = SceneBranch.of("X", "sc-2");
|
||||
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoBranches_differingOnCondition_areNotEqual() {
|
||||
SceneBranch a = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("A").build();
|
||||
SceneBranch b = SceneBranch.builder().label("X").targetSceneId("sc-1").condition("B").build();
|
||||
SceneBranch a = new SceneBranch("X", "sc-1", "A");
|
||||
SceneBranch b = new SceneBranch("X", "sc-1", "B");
|
||||
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
|
||||
@@ -60,15 +60,15 @@ class SceneTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesBranches_whenProvided() {
|
||||
SceneBranch b1 = SceneBranch.builder().label("fuite").targetSceneId("sc-2").build();
|
||||
SceneBranch b2 = SceneBranch.builder().label("combat").targetSceneId("sc-3").build();
|
||||
SceneBranch b1 = SceneBranch.of("fuite", "sc-2");
|
||||
SceneBranch b2 = SceneBranch.of("combat", "sc-3");
|
||||
|
||||
Scene scene = Scene.builder()
|
||||
.branches(List.of(b1, b2))
|
||||
.build();
|
||||
|
||||
assertEquals(2, scene.getBranches().size());
|
||||
assertEquals("fuite", scene.getBranches().get(0).getLabel());
|
||||
assertEquals("sc-3", scene.getBranches().get(1).getTargetSceneId());
|
||||
assertEquals("fuite", scene.getBranches().get(0).label());
|
||||
assertEquals("sc-3", scene.getBranches().get(1).targetSceneId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,108 +6,98 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour CampaignStructuralContext et ses types imbriques.
|
||||
* Focus sur les annotations @Singular (chapters/scenes/branches/arcs) qui
|
||||
* permettent une construction incrementale du graphe narratif.
|
||||
* Records purs : aucune dependance technique.
|
||||
*/
|
||||
class CampaignStructuralContextTest {
|
||||
|
||||
@Test
|
||||
void builder_constructsFullNarrativeTree() {
|
||||
BranchHint branch = BranchHint.builder()
|
||||
.label("si les PJ fuient")
|
||||
.targetSceneName("La poursuite")
|
||||
.condition("PJ < moitie des HP")
|
||||
.build();
|
||||
void constructor_buildsFullNarrativeTree() {
|
||||
BranchHint branch = new BranchHint("si les PJ fuient", "La poursuite", "PJ < moitie des HP");
|
||||
|
||||
SceneSummary scene = SceneSummary.builder()
|
||||
.name("L'auberge")
|
||||
.description("Rencontre tendue avec le tavernier")
|
||||
.illustrationCount(2)
|
||||
.branch(branch)
|
||||
.build();
|
||||
SceneSummary scene = new SceneSummary(
|
||||
"L'auberge",
|
||||
"Rencontre tendue avec le tavernier",
|
||||
2,
|
||||
List.of(branch));
|
||||
|
||||
ChapterSummary chapter = ChapterSummary.builder()
|
||||
.name("L'arrivee")
|
||||
.description("Les PJ decouvrent la ville")
|
||||
.scene(scene)
|
||||
.build();
|
||||
ChapterSummary chapter = new ChapterSummary(
|
||||
"L'arrivee",
|
||||
"Les PJ decouvrent la ville",
|
||||
0,
|
||||
List.of(scene));
|
||||
|
||||
ArcSummary arc = ArcSummary.builder()
|
||||
.name("Acte I")
|
||||
.description("Mise en place")
|
||||
.illustrationCount(1)
|
||||
.chapter(chapter)
|
||||
.build();
|
||||
ArcSummary arc = new ArcSummary(
|
||||
"Acte I",
|
||||
"Mise en place",
|
||||
1,
|
||||
List.of(chapter));
|
||||
|
||||
CampaignStructuralContext ctx = CampaignStructuralContext.builder()
|
||||
.campaignName("Les Ombres")
|
||||
.campaignDescription("Une campagne dark fantasy")
|
||||
.arc(arc)
|
||||
.build();
|
||||
CampaignStructuralContext ctx = new CampaignStructuralContext(
|
||||
"Les Ombres",
|
||||
"Une campagne dark fantasy",
|
||||
List.of(arc),
|
||||
List.of(),
|
||||
List.of());
|
||||
|
||||
assertEquals("Les Ombres", ctx.getCampaignName());
|
||||
assertEquals(1, ctx.getArcs().size());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().size());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().size());
|
||||
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().size());
|
||||
assertEquals("Les Ombres", ctx.campaignName());
|
||||
assertEquals(1, ctx.arcs().size());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().size());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().size());
|
||||
assertEquals(1, ctx.arcs().get(0).chapters().get(0).scenes().get(0).branches().size());
|
||||
}
|
||||
|
||||
// --- BranchHint ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void branchHint_preservesAllFields() {
|
||||
BranchHint b = BranchHint.builder()
|
||||
.label("combat")
|
||||
.targetSceneName("La confrontation")
|
||||
.condition("initiative > 15")
|
||||
.build();
|
||||
BranchHint b = new BranchHint("combat", "La confrontation", "initiative > 15");
|
||||
|
||||
assertEquals("combat", b.getLabel());
|
||||
assertEquals("La confrontation", b.getTargetSceneName());
|
||||
assertEquals("initiative > 15", b.getCondition());
|
||||
assertEquals("combat", b.label());
|
||||
assertEquals("La confrontation", b.targetSceneName());
|
||||
assertEquals("initiative > 15", b.condition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void branchHint_conditionIsOptional() {
|
||||
BranchHint b = BranchHint.builder()
|
||||
.label("suite normale")
|
||||
.targetSceneName("Scene 2")
|
||||
.build();
|
||||
BranchHint b = new BranchHint("suite normale", "Scene 2", null);
|
||||
|
||||
assertNull(b.getCondition());
|
||||
assertNull(b.condition());
|
||||
}
|
||||
|
||||
// --- illustrationCount --------------------------------------------------
|
||||
|
||||
@Test
|
||||
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
|
||||
ArcSummary arc = ArcSummary.builder().name("X").build();
|
||||
ChapterSummary chapter = ChapterSummary.builder().name("X").build();
|
||||
SceneSummary scene = SceneSummary.builder().name("X").build();
|
||||
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
|
||||
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
|
||||
|
||||
assertEquals(0, arc.getIllustrationCount());
|
||||
assertEquals(0, chapter.getIllustrationCount());
|
||||
assertEquals(0, scene.getIllustrationCount());
|
||||
assertEquals(0, arc.illustrationCount());
|
||||
assertEquals(0, chapter.illustrationCount());
|
||||
assertEquals(0, scene.illustrationCount());
|
||||
}
|
||||
|
||||
// --- @Singular : accumulation incrementale -----------------------------
|
||||
// --- Construction incrementale (chapitres multiples) -------------------
|
||||
|
||||
@Test
|
||||
void singular_accumulatesMultipleCalls() {
|
||||
ArcSummary arc = ArcSummary.builder()
|
||||
.name("Acte I")
|
||||
.chapter(ChapterSummary.builder().name("Ch1").build())
|
||||
.chapter(ChapterSummary.builder().name("Ch2").build())
|
||||
.chapter(ChapterSummary.builder().name("Ch3").build())
|
||||
.build();
|
||||
void multipleChapters_arePreserved() {
|
||||
ArcSummary arc = new ArcSummary(
|
||||
"Acte I",
|
||||
null,
|
||||
0,
|
||||
List.of(
|
||||
new ChapterSummary("Ch1", null, 0, List.of()),
|
||||
new ChapterSummary("Ch2", null, 0, List.of()),
|
||||
new ChapterSummary("Ch3", null, 0, List.of())));
|
||||
|
||||
assertEquals(3, arc.getChapters().size());
|
||||
assertTrue(arc.getChapters().stream().anyMatch(c -> "Ch2".equals(c.getName())));
|
||||
assertEquals(3, arc.chapters().size());
|
||||
assertEquals("Ch2", arc.chapters().get(1).name());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.loremind.domain.generationcontext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -26,57 +27,45 @@ class ChatRequestTest {
|
||||
void buildLoreOnly_leavesCampaignAndEntityNull() {
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.loreContext(LoreStructuralContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folders(java.util.Map.of())
|
||||
.build())
|
||||
.loreContext(new LoreStructuralContext("Ithoril", "Royaume sombre", Map.of(), List.of()))
|
||||
.build();
|
||||
|
||||
assertEquals(1, request.getMessages().size());
|
||||
assertNotNull(request.getLoreContext());
|
||||
assertEquals("Ithoril", request.getLoreContext().getLoreName());
|
||||
assertNull(request.getPageContext());
|
||||
assertNull(request.getCampaignContext());
|
||||
assertNull(request.getNarrativeEntity());
|
||||
assertEquals(1, request.messages().size());
|
||||
assertNotNull(request.loreContext());
|
||||
assertEquals("Ithoril", request.loreContext().loreName());
|
||||
assertNull(request.pageContext());
|
||||
assertNull(request.campaignContext());
|
||||
assertNull(request.narrativeEntity());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildLoreWithPageFocus_hasBothContexts() {
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.loreContext(LoreStructuralContext.builder().folders(java.util.Map.of()).build())
|
||||
.pageContext(PageContext.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.build())
|
||||
.loreContext(new LoreStructuralContext(null, null, Map.of(), List.of()))
|
||||
.pageContext(new PageContext("Thorin", "PNJ", null, null))
|
||||
.build();
|
||||
|
||||
assertNotNull(request.getLoreContext());
|
||||
assertNotNull(request.getPageContext());
|
||||
assertEquals("Thorin", request.getPageContext().getTitle());
|
||||
assertNotNull(request.loreContext());
|
||||
assertNotNull(request.pageContext());
|
||||
assertEquals("Thorin", request.pageContext().title());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildCampaignWithNarrativeEntity_hasBothContexts() {
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.campaignContext(CampaignStructuralContext.builder()
|
||||
.campaignName("Les Ombres")
|
||||
.campaignDescription("...")
|
||||
.build())
|
||||
.narrativeEntity(NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title("L'auberge")
|
||||
.fields(java.util.Map.of("location", "Taverne"))
|
||||
.build())
|
||||
.campaignContext(new CampaignStructuralContext(
|
||||
"Les Ombres", "...", List.of(), List.of(), List.of()))
|
||||
.narrativeEntity(new NarrativeEntityContext(
|
||||
"scene", "L'auberge", Map.of("location", "Taverne")))
|
||||
.build();
|
||||
|
||||
assertNotNull(request.getCampaignContext());
|
||||
assertNotNull(request.getNarrativeEntity());
|
||||
assertEquals("scene", request.getNarrativeEntity().getEntityType());
|
||||
assertNull(request.getLoreContext());
|
||||
assertNull(request.getPageContext());
|
||||
assertNotNull(request.campaignContext());
|
||||
assertNotNull(request.narrativeEntity());
|
||||
assertEquals("scene", request.narrativeEntity().entityType());
|
||||
assertNull(request.loreContext());
|
||||
assertNull(request.pageContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,10 +75,10 @@ class ChatRequestTest {
|
||||
.messages(sampleMessages)
|
||||
.build();
|
||||
|
||||
assertEquals(1, request.getMessages().size());
|
||||
assertNull(request.getLoreContext());
|
||||
assertNull(request.getPageContext());
|
||||
assertNull(request.getCampaignContext());
|
||||
assertNull(request.getNarrativeEntity());
|
||||
assertEquals(1, request.messages().size());
|
||||
assertNull(request.loreContext());
|
||||
assertNull(request.pageContext());
|
||||
assertNull(request.campaignContext());
|
||||
assertNull(request.narrativeEntity());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,41 +9,38 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour GenerationContext (Value Object pour la generation one-shot).
|
||||
* Verifie la construction via builder et l'egalite structurelle.
|
||||
* Verifie la construction et l'egalite structurelle (record).
|
||||
*/
|
||||
class GenerationContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesAllFields() {
|
||||
GenerationContext ctx = GenerationContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folderName("PNJ")
|
||||
.templateName("Fiche PNJ")
|
||||
.templateFields(List.of("histoire", "motto", "apparence"))
|
||||
.pageTitle("Thorin")
|
||||
.build();
|
||||
void constructor_preservesAllFields() {
|
||||
GenerationContext ctx = new GenerationContext(
|
||||
"Ithoril",
|
||||
"Royaume sombre",
|
||||
"PNJ",
|
||||
"Fiche PNJ",
|
||||
List.of("histoire", "motto", "apparence"),
|
||||
"Thorin");
|
||||
|
||||
assertEquals("Ithoril", ctx.getLoreName());
|
||||
assertEquals("PNJ", ctx.getFolderName());
|
||||
assertEquals("Fiche PNJ", ctx.getTemplateName());
|
||||
assertEquals(3, ctx.getTemplateFields().size());
|
||||
assertEquals("Thorin", ctx.getPageTitle());
|
||||
assertEquals("Ithoril", ctx.loreName());
|
||||
assertEquals("PNJ", ctx.folderName());
|
||||
assertEquals("Fiche PNJ", ctx.templateName());
|
||||
assertEquals(3, ctx.templateFields().size());
|
||||
assertEquals("Thorin", ctx.pageTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoContexts_withSameFields_areEqual() {
|
||||
GenerationContext a = GenerationContext.builder()
|
||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
||||
GenerationContext b = GenerationContext.builder()
|
||||
.loreName("X").pageTitle("A").templateFields(List.of("f1")).build();
|
||||
GenerationContext a = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||
GenerationContext b = new GenerationContext("X", null, null, null, List.of("f1"), "A");
|
||||
assertEquals(a, b);
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoContexts_differingOnPageTitle_areNotEqual() {
|
||||
GenerationContext a = GenerationContext.builder().pageTitle("A").build();
|
||||
GenerationContext b = GenerationContext.builder().pageTitle("B").build();
|
||||
GenerationContext a = new GenerationContext(null, null, null, null, null, "A");
|
||||
GenerationContext b = new GenerationContext(null, null, null, null, null, "B");
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,66 +12,61 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour LoreStructuralContext et son type imbrique PageSummary.
|
||||
* Valide le @Singular de Lombok sur {@code tags} (alimentation incrementale via
|
||||
* {@code tag(...)} vs initialisation groupee via {@code tags(...)}).
|
||||
* Records purs : aucune dependance technique.
|
||||
*/
|
||||
class LoreStructuralContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesFoldersAndTags() {
|
||||
PageSummary pnj = PageSummary.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
||||
.tags(List.of("pnj", "allie"))
|
||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
||||
.build();
|
||||
void constructor_preservesFoldersAndTags() {
|
||||
PageSummary pnj = new PageSummary(
|
||||
"Thorin",
|
||||
"PNJ",
|
||||
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||
List.of("pnj", "allie"),
|
||||
List.of("Taverne du Dragon d'Or"));
|
||||
|
||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folders(Map.of("PNJ", List.of(pnj)))
|
||||
.tag("royaume")
|
||||
.tag("dark-fantasy")
|
||||
.build();
|
||||
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||
"Ithoril",
|
||||
"Royaume sombre",
|
||||
Map.of("PNJ", List.of(pnj)),
|
||||
List.of("royaume", "dark-fantasy"));
|
||||
|
||||
assertEquals("Ithoril", ctx.getLoreName());
|
||||
assertEquals(1, ctx.getFolders().size());
|
||||
assertEquals(1, ctx.getFolders().get("PNJ").size());
|
||||
assertEquals(2, ctx.getTags().size(), "@Singular doit accumuler les appels tag()");
|
||||
assertTrue(ctx.getTags().contains("royaume"));
|
||||
assertTrue(ctx.getTags().contains("dark-fantasy"));
|
||||
assertEquals("Ithoril", ctx.loreName());
|
||||
assertEquals(1, ctx.folders().size());
|
||||
assertEquals(1, ctx.folders().get("PNJ").size());
|
||||
assertEquals(2, ctx.tags().size());
|
||||
assertTrue(ctx.tags().contains("royaume"));
|
||||
assertTrue(ctx.tags().contains("dark-fantasy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyFolders_areAllowed() {
|
||||
// Dossier vide : legitime (ex: dossier "Lieux" cree mais pas encore peuple).
|
||||
LoreStructuralContext ctx = LoreStructuralContext.builder()
|
||||
.loreName("Vide")
|
||||
.loreDescription("")
|
||||
.folders(Map.of("Lieux", List.of()))
|
||||
.build();
|
||||
LoreStructuralContext ctx = new LoreStructuralContext(
|
||||
"Vide",
|
||||
"",
|
||||
Map.of("Lieux", List.of()),
|
||||
List.of());
|
||||
|
||||
assertNotNull(ctx.getFolders().get("Lieux"));
|
||||
assertTrue(ctx.getFolders().get("Lieux").isEmpty());
|
||||
assertNotNull(ctx.folders().get("Lieux"));
|
||||
assertTrue(ctx.folders().get("Lieux").isEmpty());
|
||||
}
|
||||
|
||||
// --- PageSummary --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void pageSummary_preservesAllFields() {
|
||||
PageSummary ps = PageSummary.builder()
|
||||
.title("Le Donjon du Chaos")
|
||||
.templateName("Lieu")
|
||||
.values(Map.of("histoire", "Bati il y a 1000 ans..."))
|
||||
.tags(List.of("donjon", "ancien"))
|
||||
.relatedPageTitles(List.of("Thorin", "Garde royale"))
|
||||
.build();
|
||||
PageSummary ps = new PageSummary(
|
||||
"Le Donjon du Chaos",
|
||||
"Lieu",
|
||||
Map.of("histoire", "Bati il y a 1000 ans..."),
|
||||
List.of("donjon", "ancien"),
|
||||
List.of("Thorin", "Garde royale"));
|
||||
|
||||
assertEquals("Le Donjon du Chaos", ps.getTitle());
|
||||
assertEquals("Lieu", ps.getTemplateName());
|
||||
assertEquals(1, ps.getValues().size());
|
||||
assertEquals(2, ps.getTags().size());
|
||||
assertEquals(2, ps.getRelatedPageTitles().size());
|
||||
assertEquals("Le Donjon du Chaos", ps.title());
|
||||
assertEquals("Lieu", ps.templateName());
|
||||
assertEquals(1, ps.values().size());
|
||||
assertEquals(2, ps.tags().size());
|
||||
assertEquals(2, ps.relatedPageTitles().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,17 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
class NarrativeEntityContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesAllFields() {
|
||||
void constructor_preservesAllFields() {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("themes", "trahison");
|
||||
fields.put("stakes", "la survie du royaume");
|
||||
|
||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
||||
.entityType("arc")
|
||||
.title("Acte I")
|
||||
.fields(fields)
|
||||
.build();
|
||||
NarrativeEntityContext ctx = new NarrativeEntityContext("arc", "Acte I", fields);
|
||||
|
||||
assertEquals("arc", ctx.getEntityType());
|
||||
assertEquals("Acte I", ctx.getTitle());
|
||||
assertEquals(2, ctx.getFields().size());
|
||||
assertEquals("trahison", ctx.getFields().get("themes"));
|
||||
assertEquals("arc", ctx.entityType());
|
||||
assertEquals("Acte I", ctx.title());
|
||||
assertEquals(2, ctx.fields().size());
|
||||
assertEquals("trahison", ctx.fields().get("themes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -41,19 +37,15 @@ class NarrativeEntityContextTest {
|
||||
fields.put("timing", "Soir");
|
||||
fields.put("atmosphere", "fumee");
|
||||
|
||||
NarrativeEntityContext ctx = NarrativeEntityContext.builder()
|
||||
.entityType("scene")
|
||||
.title("L'auberge")
|
||||
.fields(fields)
|
||||
.build();
|
||||
NarrativeEntityContext ctx = new NarrativeEntityContext("scene", "L'auberge", fields);
|
||||
|
||||
assertEquals("[location, timing, atmosphere]", ctx.getFields().keySet().toString());
|
||||
assertEquals("[location, timing, atmosphere]", ctx.fields().keySet().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoContexts_differingOnEntityType_areNotEqual() {
|
||||
NarrativeEntityContext a = NarrativeEntityContext.builder().entityType("arc").title("X").build();
|
||||
NarrativeEntityContext b = NarrativeEntityContext.builder().entityType("scene").title("X").build();
|
||||
NarrativeEntityContext a = new NarrativeEntityContext("arc", "X", Map.of());
|
||||
NarrativeEntityContext b = new NarrativeEntityContext("scene", "X", Map.of());
|
||||
assertNotEquals(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,31 +14,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class PageContextTest {
|
||||
|
||||
@Test
|
||||
void builder_preservesAllFields() {
|
||||
PageContext ctx = PageContext.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.templateFields(List.of("histoire", "apparence", "motto"))
|
||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
||||
.build();
|
||||
void constructor_preservesAllFields() {
|
||||
PageContext ctx = new PageContext(
|
||||
"Thorin",
|
||||
"PNJ",
|
||||
List.of("histoire", "apparence", "motto"),
|
||||
Map.of("histoire", "Nee sous une etoile rouge"));
|
||||
|
||||
assertEquals("Thorin", ctx.getTitle());
|
||||
assertEquals("PNJ", ctx.getTemplateName());
|
||||
assertEquals(3, ctx.getTemplateFields().size());
|
||||
assertEquals(1, ctx.getValues().size());
|
||||
assertEquals("Thorin", ctx.title());
|
||||
assertEquals("PNJ", ctx.templateName());
|
||||
assertEquals(3, ctx.templateFields().size());
|
||||
assertEquals(1, ctx.values().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyValues_areAllowed() {
|
||||
// Page vierge : template defini mais aucun champ rempli (cas generation ex-nihilo).
|
||||
PageContext ctx = PageContext.builder()
|
||||
.title("Nouveau PNJ")
|
||||
.templateName("PNJ")
|
||||
.templateFields(List.of("histoire", "apparence"))
|
||||
.values(Map.of())
|
||||
.build();
|
||||
PageContext ctx = new PageContext(
|
||||
"Nouveau PNJ",
|
||||
"PNJ",
|
||||
List.of("histoire", "apparence"),
|
||||
Map.of());
|
||||
|
||||
assertTrue(ctx.getValues().isEmpty());
|
||||
assertEquals(2, ctx.getTemplateFields().size());
|
||||
assertTrue(ctx.values().isEmpty());
|
||||
assertEquals(2, ctx.templateFields().size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,8 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_loreContext_includesBasicFields() {
|
||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
||||
.loreName("Ithoril")
|
||||
.loreDescription("Royaume sombre")
|
||||
.folders(Map.of())
|
||||
.tag("dark-fantasy")
|
||||
.build();
|
||||
LoreStructuralContext lore = new LoreStructuralContext(
|
||||
"Ithoril", "Royaume sombre", Map.of(), List.of("dark-fantasy"));
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -103,17 +99,10 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_pageSummary_omitsEmptyValuesTagsAndRelated() {
|
||||
PageSummary minimal = PageSummary.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.values(Map.of())
|
||||
.tags(List.of())
|
||||
.relatedPageTitles(List.of())
|
||||
.build();
|
||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
||||
.loreName("X").loreDescription("")
|
||||
.folders(Map.of("PNJ", List.of(minimal)))
|
||||
.build();
|
||||
PageSummary minimal = new PageSummary("Thorin", "PNJ",
|
||||
Map.of(), List.of(), List.of());
|
||||
LoreStructuralContext lore = new LoreStructuralContext(
|
||||
"X", "", Map.of("PNJ", List.of(minimal)), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -132,17 +121,12 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_pageSummary_includesNonEmptyValuesTagsAndRelated() {
|
||||
PageSummary full = PageSummary.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.values(Map.of("histoire", "Nee sous une etoile rouge"))
|
||||
.tags(List.of("pnj", "allie"))
|
||||
.relatedPageTitles(List.of("Taverne du Dragon d'Or"))
|
||||
.build();
|
||||
LoreStructuralContext lore = LoreStructuralContext.builder()
|
||||
.loreName("X").loreDescription("")
|
||||
.folders(Map.of("PNJ", List.of(full)))
|
||||
.build();
|
||||
PageSummary full = new PageSummary("Thorin", "PNJ",
|
||||
Map.of("histoire", "Nee sous une etoile rouge"),
|
||||
List.of("pnj", "allie"),
|
||||
List.of("Taverne du Dragon d'Or"));
|
||||
LoreStructuralContext lore = new LoreStructuralContext(
|
||||
"X", "", Map.of("PNJ", List.of(full)), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).loreContext(lore).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -161,12 +145,8 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_pageContext_includesAllFields() {
|
||||
PageContext pc = PageContext.builder()
|
||||
.title("Thorin")
|
||||
.templateName("PNJ")
|
||||
.templateFields(List.of("histoire", "motto"))
|
||||
.values(Map.of("histoire", "..."))
|
||||
.build();
|
||||
PageContext pc = new PageContext("Thorin", "PNJ",
|
||||
List.of("histoire", "motto"), Map.of("histoire", "..."));
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).pageContext(pc).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -182,17 +162,12 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_campaignContext_serializesFullNarrativeTree() {
|
||||
BranchHint branch = BranchHint.builder()
|
||||
.label("fuite").targetSceneName("La poursuite").condition("HP < 50%").build();
|
||||
SceneSummary scene = SceneSummary.builder()
|
||||
.name("L'auberge").description("Rencontre tendue")
|
||||
.illustrationCount(3).branch(branch).build();
|
||||
ChapterSummary chapter = ChapterSummary.builder()
|
||||
.name("L'arrivee").description("...").scene(scene).build();
|
||||
ArcSummary arc = ArcSummary.builder()
|
||||
.name("Acte I").description("Mise en place").illustrationCount(1).chapter(chapter).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("Les Ombres").campaignDescription("dark fantasy").arc(arc).build();
|
||||
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
|
||||
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
|
||||
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -223,9 +198,9 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||
ArcSummary arc = ArcSummary.builder().name("A").description("").illustrationCount(0).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -238,11 +213,11 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_sceneSummary_omitsBranches_whenEmpty() {
|
||||
SceneSummary scene = SceneSummary.builder().name("S").description("").build();
|
||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
|
||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -256,12 +231,12 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_branchHint_omitsCondition_whenBlank() {
|
||||
BranchHint branch = BranchHint.builder().label("X").targetSceneName("Y").condition(" ").build();
|
||||
SceneSummary scene = SceneSummary.builder().name("S").description("").branch(branch).build();
|
||||
ChapterSummary chapter = ChapterSummary.builder().name("Ch").description("").scene(scene).build();
|
||||
ArcSummary arc = ArcSummary.builder().name("A").description("").chapter(chapter).build();
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").arc(arc).build();
|
||||
BranchHint branch = new BranchHint("X", "Y", " ");
|
||||
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
|
||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(arc), List.of(), List.of());
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -278,10 +253,8 @@ class BrainChatPayloadBuilderTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void build_narrativeEntity_includesAllFields() {
|
||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
||||
.entityType("scene").title("L'auberge")
|
||||
.fields(Map.of("location", "Taverne", "timing", "Soir"))
|
||||
.build();
|
||||
NarrativeEntityContext entity = new NarrativeEntityContext("scene", "L'auberge",
|
||||
Map.of("location", "Taverne", "timing", "Soir"));
|
||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).narrativeEntity(entity).build();
|
||||
|
||||
Map<String, Object> payload = builder.build(req);
|
||||
@@ -295,10 +268,9 @@ class BrainChatPayloadBuilderTest {
|
||||
|
||||
@Test
|
||||
void build_campaignScenario_includesBothContextsAndEntity() {
|
||||
CampaignStructuralContext camp = CampaignStructuralContext.builder()
|
||||
.campaignName("X").campaignDescription("").build();
|
||||
NarrativeEntityContext entity = NarrativeEntityContext.builder()
|
||||
.entityType("arc").title("T").fields(Map.of()).build();
|
||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||
"X", "", List.of(), List.of(), List.of());
|
||||
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
||||
ChatRequest req = ChatRequest.builder()
|
||||
.messages(sampleMessages)
|
||||
.campaignContext(camp)
|
||||
|
||||
@@ -48,27 +48,21 @@ class SceneBranchListJsonConverterTest {
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesAllBranchFields() {
|
||||
// Test critique : depend de @Jacksonized sur SceneBranch.
|
||||
// Test critique : Jackson doit reconstruire SceneBranch (record) via
|
||||
// son constructeur canonique sans aucune annotation.
|
||||
List<SceneBranch> source = List.of(
|
||||
SceneBranch.builder()
|
||||
.label("si les joueurs attaquent")
|
||||
.targetSceneId("sc-combat")
|
||||
.condition("initiative > 15")
|
||||
.build(),
|
||||
SceneBranch.builder()
|
||||
.label("si les joueurs fuient")
|
||||
.targetSceneId("sc-poursuite")
|
||||
.build()
|
||||
new SceneBranch("si les joueurs attaquent", "sc-combat", "initiative > 15"),
|
||||
SceneBranch.of("si les joueurs fuient", "sc-poursuite")
|
||||
);
|
||||
|
||||
String json = converter.convertToDatabaseColumn(source);
|
||||
List<SceneBranch> back = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertEquals(2, back.size());
|
||||
assertEquals("si les joueurs attaquent", back.get(0).getLabel());
|
||||
assertEquals("sc-combat", back.get(0).getTargetSceneId());
|
||||
assertEquals("initiative > 15", back.get(0).getCondition());
|
||||
assertEquals("sc-poursuite", back.get(1).getTargetSceneId());
|
||||
assertNull(back.get(1).getCondition(), "condition absente doit rester null apres round-trip");
|
||||
assertEquals("si les joueurs attaquent", back.get(0).label());
|
||||
assertEquals("sc-combat", back.get(0).targetSceneId());
|
||||
assertEquals("initiative > 15", back.get(0).condition());
|
||||
assertEquals("sc-poursuite", back.get(1).targetSceneId());
|
||||
assertNull(back.get(1).condition(), "condition absente doit rester null apres round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ class PostgresSceneRepositoryTest {
|
||||
|
||||
@Test
|
||||
void save_scenePreservesBranches_viaJsonbRoundTrip() {
|
||||
// Le critique : le @Jacksonized de SceneBranch doit permettre la
|
||||
// reconstruction via builder apres serialisation Jackson.
|
||||
// Le critique : SceneBranch (record) doit etre reconstructible par
|
||||
// Jackson via le constructeur canonique apres serialisation JSON.
|
||||
Scene scene = Scene.builder()
|
||||
.chapterId(chapterId).name("Decision").order(0)
|
||||
.branches(List.of(
|
||||
SceneBranch.builder().label("fuite").targetSceneId("sc-2").condition("HP bas").build(),
|
||||
SceneBranch.builder().label("combat").targetSceneId("sc-3").build()
|
||||
new SceneBranch("fuite", "sc-2", "HP bas"),
|
||||
SceneBranch.of("combat", "sc-3")
|
||||
))
|
||||
.build();
|
||||
|
||||
@@ -84,10 +84,10 @@ class PostgresSceneRepositoryTest {
|
||||
Scene r = repository.findById(saved.getId()).orElseThrow();
|
||||
|
||||
assertEquals(2, r.getBranches().size());
|
||||
assertEquals("fuite", r.getBranches().get(0).getLabel());
|
||||
assertEquals("sc-2", r.getBranches().get(0).getTargetSceneId());
|
||||
assertEquals("HP bas", r.getBranches().get(0).getCondition());
|
||||
assertEquals("combat", r.getBranches().get(1).getLabel());
|
||||
assertEquals("fuite", r.getBranches().get(0).label());
|
||||
assertEquals("sc-2", r.getBranches().get(0).targetSceneId());
|
||||
assertEquals("HP bas", r.getBranches().get(0).condition());
|
||||
assertEquals("combat", r.getBranches().get(1).label());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Test unitaire pour UpdateCheckService.
|
||||
*
|
||||
* Couvre les invariants critiques de la detection de MAJ :
|
||||
* - feature desactivee si token absent
|
||||
* - status UP_TO_DATE quand baseline == remote
|
||||
* - status UPDATE_AVAILABLE quand baseline != remote
|
||||
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant
|
||||
* central, regression historique)
|
||||
* - status UNKNOWN quand remote impossible a fetcher
|
||||
* - drapeaux top-level updateAvailable / anyUnknown coherents
|
||||
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
|
||||
*/
|
||||
public class UpdateCheckServiceTest {
|
||||
|
||||
private static UpdateCheckService newService(String token) {
|
||||
return new UpdateCheckService(
|
||||
new RestTemplateBuilder(),
|
||||
"ghcr.io",
|
||||
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
||||
"latest",
|
||||
"http://watchtower:8080",
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injecte un RestTemplate moque dans le service deja construit, et pose
|
||||
* directement les baselines pour eviter les vrais appels HTTP.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void setBaselines(UpdateCheckService svc, Map<String, String> baselines) {
|
||||
((Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests")).putAll(baselines);
|
||||
}
|
||||
|
||||
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
||||
RestTemplate http = mock(RestTemplate.class);
|
||||
ReflectionTestUtils.setField(svc, "http", http);
|
||||
return http;
|
||||
}
|
||||
|
||||
private static void stubRemoteDigest(RestTemplate http, String image, String digest) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (digest != null) headers.add("Docker-Content-Digest", digest);
|
||||
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK);
|
||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
||||
.thenReturn(resp);
|
||||
}
|
||||
|
||||
private static void stubRemoteFailure(RestTemplate http, String image) {
|
||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
||||
.thenThrow(new RuntimeException("network down"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledWhenTokenMissing() {
|
||||
UpdateCheckService svc = newService("");
|
||||
UpdateStatus status = svc.check();
|
||||
assertFalse(status.enabled());
|
||||
assertFalse(status.updateAvailable());
|
||||
assertFalse(status.anyUnknown());
|
||||
assertTrue(status.images().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void upToDate_whenBaselineEqualsRemote() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of(
|
||||
"igmlcreation/loremind-core", "sha256:aaa",
|
||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
||||
));
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.enabled());
|
||||
assertFalse(status.updateAvailable());
|
||||
assertFalse(status.anyUnknown());
|
||||
for (ImageStatus img : status.images()) {
|
||||
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
||||
assertFalse(img.updateAvailable(), "back-compat bool");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAvailable_whenRemoteDiffers() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of(
|
||||
"igmlcreation/loremind-core", "sha256:OLD",
|
||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
||||
));
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.updateAvailable());
|
||||
assertFalse(status.anyUnknown());
|
||||
ImageStatus core = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
||||
assertTrue(core.updateAvailable(), "back-compat bool");
|
||||
ImageStatus brain = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() {
|
||||
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot),
|
||||
// on NE DOIT PAS aligner lazy sur le remote courant — sinon une MAJ
|
||||
// pousse APRES le boot serait declaree "a jour" silencieusement.
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
// baseline DELIBEREMENT vide
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2");
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.enabled());
|
||||
assertFalse(status.updateAvailable());
|
||||
assertTrue(status.anyUnknown());
|
||||
for (ImageStatus img : status.images()) {
|
||||
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
||||
assertNull(img.localDigest());
|
||||
assertNotNull(img.remoteDigest()); // remote OK, baseline manquante
|
||||
}
|
||||
|
||||
// VERIFICATION CRITIQUE : la baseline ne doit PAS avoir ete posee.
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> baselines = (Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests");
|
||||
assertTrue(baselines.isEmpty(),
|
||||
"check() ne doit JAMAIS aligner lazy la baseline sur le remote — "
|
||||
+ "regression de bug historique (faux negatif silencieux).");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknown_whenRemoteFetchFails() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa",
|
||||
"igmlcreation/loremind-brain", "sha256:bbb"));
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteFailure(http, "igmlcreation/loremind-core");
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertFalse(status.updateAvailable());
|
||||
assertTrue(status.anyUnknown());
|
||||
ImageStatus core = status.images().stream()
|
||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||
assertNull(core.remoteDigest());
|
||||
assertEquals("sha256:aaa", core.localDigest()); // baseline preservee
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() {
|
||||
UpdateCheckService svc = newService("token");
|
||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD"));
|
||||
// brain n'a pas de baseline -> UNKNOWN
|
||||
RestTemplate http = stubHttp(svc);
|
||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
||||
stubRemoteFailure(http, "igmlcreation/loremind-brain");
|
||||
|
||||
UpdateStatus status = svc.check();
|
||||
|
||||
assertTrue(status.updateAvailable(), "core a une MAJ disponible");
|
||||
assertTrue(status.anyUnknown(), "brain est UNKNOWN");
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,15 @@ services:
|
||||
"
|
||||
|
||||
core:
|
||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
|
||||
# Defaut : GHCR (registry public, reputation domaine elevee).
|
||||
# Pour les anciennes installs qui pointaient sur Gitea, REGISTRY et
|
||||
# IMAGE_NAMESPACE peuvent etre overrides dans .env :
|
||||
# REGISTRY=git.igmlcreation.fr
|
||||
# IMAGE_NAMESPACE=ietm64/ (le slash final est important : voir image: ci-dessous)
|
||||
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}core:${TAG:-latest}
|
||||
container_name: loremind-core
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -79,14 +86,50 @@ services:
|
||||
MINIO_ENDPOINT: http://minio:9000
|
||||
MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin}
|
||||
MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin}
|
||||
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
||||
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
||||
# (l'UI masque le badge et le bouton).
|
||||
UPDATE_CHECK_REGISTRY: ${REGISTRY:-ghcr.io}
|
||||
UPDATE_CHECK_IMAGES: ${IMAGE_NAMESPACE:-igmlcreation/loremind-}core,${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain,${IMAGE_NAMESPACE:-igmlcreation/loremind-}web
|
||||
UPDATE_CHECK_TAG: ${TAG:-latest}
|
||||
WATCHTOWER_URL: http://watchtower:8080
|
||||
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
||||
restart: unless-stopped
|
||||
|
||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||
# Active via COMPOSE_PROFILES=local-ollama (gere par l'installeur).
|
||||
# Si l'utilisateur a deja Ollama sur l'hote, ce service reste inactif et
|
||||
# OLLAMA_BASE_URL pointe vers http://host.docker.internal:11434.
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: loremind-ollama
|
||||
profiles: ["local-ollama"]
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
# Port expose sur loopback uniquement pour debug / pull manuel de modeles.
|
||||
ports:
|
||||
- "127.0.0.1:11434:11434"
|
||||
# GPU NVIDIA si disponible (silencieusement ignore sinon).
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
restart: unless-stopped
|
||||
|
||||
brain:
|
||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
|
||||
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain:${TAG:-latest}
|
||||
container_name: loremind-brain
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
environment:
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
||||
# Defaut = Ollama embarque (service ollama du compose).
|
||||
# L'installeur reecrit cette valeur en http://host.docker.internal:11434
|
||||
# si l'utilisateur choisit le mode "Ollama deja installe sur l'hote".
|
||||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
LLM_MODEL: ${LLM_MODEL:-gemma4:26b}
|
||||
ONEMIN_API_KEY: ${ONEMIN_API_KEY:-}
|
||||
ONEMIN_MODEL: ${ONEMIN_MODEL:-gpt-4o-mini}
|
||||
@@ -100,8 +143,10 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
|
||||
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}web:${TAG:-latest}
|
||||
container_name: loremind-web
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
- core
|
||||
- brain
|
||||
@@ -109,7 +154,40 @@ services:
|
||||
- "${WEB_PORT:-8081}:80"
|
||||
restart: unless-stopped
|
||||
|
||||
# Mises a jour automatiques des images core/brain/web.
|
||||
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
||||
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
||||
# compatibilite de version a verifier manuellement).
|
||||
watchtower:
|
||||
# Fork maintenu de containrrr/watchtower (l'original est abandonne depuis
|
||||
# ~2023 et son client Docker API est trop vieux pour les versions recentes
|
||||
# de Docker Desktop -- erreur "client version 1.25 is too old").
|
||||
# nickfedor/watchtower est un drop-in : memes variables d'environnement,
|
||||
# meme API HTTP, juste l'image change.
|
||||
image: nickfedor/watchtower:latest
|
||||
container_name: loremind-watchtower
|
||||
profiles: ["autoupdate"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
WATCHTOWER_LABEL_ENABLE: "true"
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
WATCHTOWER_INCLUDE_RESTARTING: "true"
|
||||
# MONITOR_ONLY=true => detecte sans appliquer (l'UI declenche manuellement).
|
||||
# MONITOR_ONLY=false => applique automatiquement selon WATCHTOWER_SCHEDULE.
|
||||
WATCHTOWER_MONITOR_ONLY: "${WATCHTOWER_MONITOR_ONLY:-false}"
|
||||
WATCHTOWER_SCHEDULE: "${WATCHTOWER_SCHEDULE:-0 0 4 * * *}"
|
||||
# API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower).
|
||||
WATCHTOWER_HTTP_API_UPDATE: "true"
|
||||
WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true"
|
||||
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:?set WATCHTOWER_TOKEN in .env (re-run installer)}"
|
||||
WATCHTOWER_TIMEOUT: 60s
|
||||
WATCHTOWER_NOTIFICATIONS_LEVEL: info
|
||||
TZ: ${TZ:-Europe/Paris}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
minio-data:
|
||||
brain-data:
|
||||
ollama-data:
|
||||
|
||||
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.7.1
|
||||
|
||||
.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
|
||||
@@ -301,6 +301,46 @@ export async function getPageById(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SeededNpc {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedNpc(
|
||||
request: APIRequestContext,
|
||||
opts: { campaignId: string; name?: string; markdownContent?: string | null },
|
||||
): Promise<SeededNpc> {
|
||||
const name = opts.name ?? `E2E NPC ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/npcs', {
|
||||
data: {
|
||||
campaignId: opts.campaignId,
|
||||
name,
|
||||
markdownContent: opts.markdownContent ?? null,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `POST /api/npcs -> ${res.status()}`).toBeTruthy();
|
||||
const n = await res.json();
|
||||
return { id: n.id, name };
|
||||
}
|
||||
|
||||
export async function getNpcById(
|
||||
request: APIRequestContext,
|
||||
npcId: string,
|
||||
): Promise<{ id: string; name: string; markdownContent: string | null; campaignId: string; order: number }> {
|
||||
const res = await request.get(`/api/npcs/${npcId}`);
|
||||
expect(res.ok(), `GET /api/npcs/${npcId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getNpcsByCampaign(
|
||||
request: APIRequestContext,
|
||||
campaignId: string,
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const res = await request.get(`/api/npcs/campaign/${campaignId}`);
|
||||
expect(res.ok(), `GET /api/npcs/campaign -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTemplateById(
|
||||
request: APIRequestContext,
|
||||
templateId: string,
|
||||
|
||||
75
web/e2e/tests/campaign/npc-create.spec.ts
Normal file
75
web/e2e/tests/campaign/npc-create.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
deleteCampaign,
|
||||
getNpcsByCampaign,
|
||||
type SeededCampaign,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('NPC 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 NPC and redirects back to the campaign', async ({ page, request }) => {
|
||||
const npcName = `Borin le forgeron ${Date.now()}`;
|
||||
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
// Retour à la page campagne après création
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
// Persistance vérifiée via API
|
||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||
const created = npcs.find((n) => n.name === npcName);
|
||||
expect(created).toBeDefined();
|
||||
});
|
||||
|
||||
test('submit is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill('Elara');
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(' ');
|
||||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test('NPC appears in the sidebar PNJ branch', async ({ page, request }) => {
|
||||
const npcName = `Sidebar test ${Date.now()}`;
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
||||
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
||||
// puis on vérifie que le PNJ est listé.
|
||||
const pnjNode = page.getByRole('button', { name: /^PNJ\b/ }).or(
|
||||
page.locator('.tree-item', { hasText: 'PNJ' }).first(),
|
||||
);
|
||||
await expect(pnjNode.first()).toBeVisible();
|
||||
|
||||
// Vérification fallback via API : la liste contient bien le PNJ créé.
|
||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||
expect(npcs.map((n) => n.name)).toContain(npcName);
|
||||
});
|
||||
});
|
||||
69
web/e2e/tests/campaign/npc-edit.spec.ts
Normal file
69
web/e2e/tests/campaign/npc-edit.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedCampaign,
|
||||
seedNpc,
|
||||
deleteCampaign,
|
||||
getNpcById,
|
||||
type SeededCampaign,
|
||||
type SeededNpc,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('NPC edit', () => {
|
||||
let campaign: SeededCampaign;
|
||||
let npc: SeededNpc;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
npc = await seedNpc(request, {
|
||||
campaignId: campaign.id,
|
||||
markdownContent: '# Initial\n\nFiche de départ.',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('edits name + markdown content and persists via API', async ({ page, request }) => {
|
||||
const newName = `${npc.name} (renommé)`;
|
||||
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Éditer le PNJ/i })).toBeVisible();
|
||||
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
|
||||
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
|
||||
// Retour à la campagne après save
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
const persisted = await getNpcById(request, npc.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.markdownContent).toBe(newMarkdown);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
|
||||
const nameField = page.getByLabel(/Nom du PNJ/i);
|
||||
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await nameField.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await nameField.fill('OK');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('Assistant IA button is visible in edit mode', async ({ page }) => {
|
||||
// Vérifie l'intégration drawer chat IA — symétrique aux PJ.
|
||||
// Note : le drawer lui-même nécessite le Brain Python en route, donc
|
||||
// on ne teste que la présence du bouton trigger.
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
await expect(page.getByRole('button', { name: /Assistant IA/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,24 @@ server {
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# index.html : toujours revalide. Empeche un navigateur qui a precedemment
|
||||
# visite une autre instance LoreMind (demo en ligne, dev local, etc.) de
|
||||
# servir une vieille version cachee a la place de l'app reelle.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
expires 0;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Assets Angular avec hash dans le nom (main.<hash>.js, etc.) :
|
||||
# immuables, peuvent etre caches longtemps.
|
||||
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|webp|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.6.5",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.6.5",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.6.5",
|
||||
"version": "0.7.1",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -15,19 +15,21 @@ export const routes: Routes = [
|
||||
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-view/page-view.component').then(m => m.PageViewComponent) },
|
||||
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
||||
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/graph', loadComponent: () => import('./campaigns/chapter/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
|
||||
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||
|
||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
|
||||
@@ -39,6 +40,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -56,7 +58,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Arc.
|
||||
@@ -74,6 +75,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -111,7 +113,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { resolveCampaignIcon } from '../campaign-icons';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||
@@ -46,6 +47,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -68,7 +70,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
@@ -2,9 +2,11 @@ import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { TreeItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||
import { Character } from '../services/character.model';
|
||||
import { Npc } from '../services/npc.model';
|
||||
|
||||
/**
|
||||
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||
@@ -19,20 +21,23 @@ export interface CampaignTreeData {
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
characters: Character[];
|
||||
npcs: Npc[];
|
||||
}
|
||||
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string,
|
||||
characterService: CharacterService
|
||||
characterService: CharacterService,
|
||||
npcService: NpcService
|
||||
): Observable<CampaignTreeData> {
|
||||
return forkJoin({
|
||||
arcs: service.getArcs(campaignId),
|
||||
characters: characterService.getByCampaign(campaignId)
|
||||
characters: characterService.getByCampaign(campaignId),
|
||||
npcs: npcService.getByCampaign(campaignId)
|
||||
}).pipe(
|
||||
switchMap(({ arcs, characters }) => {
|
||||
switchMap(({ arcs, characters, npcs }) => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
@@ -47,7 +52,7 @@ export function loadCampaignTreeData(
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
@@ -56,7 +61,7 @@ export function loadCampaignTreeData(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters };
|
||||
return { arcs, chaptersByArc, scenesByChapter, characters, npcs };
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -83,13 +88,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
|
||||
const charactersNode: TreeItem = {
|
||||
id: 'characters-root',
|
||||
label: 'Personnages',
|
||||
label: 'PJ',
|
||||
iconKey: 'users',
|
||||
children: characterItems,
|
||||
meta: characterItems.length ? String(characterItems.length) : undefined,
|
||||
sectionHeaderBefore: 'Personnages',
|
||||
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
|
||||
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
|
||||
// Note : le section header "Personnages" est porté par le premier nœud (PJ).
|
||||
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
||||
createActions: [{
|
||||
id: 'new-character',
|
||||
label: 'Nouveau PJ',
|
||||
@@ -98,6 +103,28 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
}]
|
||||
};
|
||||
|
||||
const sortedNpcs = [...data.npcs].sort(byName);
|
||||
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
||||
id: `npc-${n.id}`,
|
||||
label: n.name,
|
||||
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
|
||||
}));
|
||||
|
||||
const npcsNode: TreeItem = {
|
||||
id: 'npcs-root',
|
||||
label: 'PNJ',
|
||||
iconKey: 'c-drama',
|
||||
children: npcItems,
|
||||
meta: npcItems.length ? String(npcItems.length) : undefined,
|
||||
// Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ.
|
||||
createActions: [{
|
||||
id: 'new-npc',
|
||||
label: 'Nouveau PNJ',
|
||||
route: `/campaigns/${campaignId}/npcs/create`,
|
||||
actionIcon: 'plus'
|
||||
}]
|
||||
};
|
||||
|
||||
const sortedArcs = [...data.arcs].sort(byName);
|
||||
|
||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||
@@ -143,5 +170,5 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
};
|
||||
});
|
||||
|
||||
return [...arcNodes, charactersNode];
|
||||
return [...arcNodes, charactersNode, npcsNode];
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { GameSystemService } from '../../services/game-system.service';
|
||||
import { GameSystem } from '../../services/game-system.model';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
|
||||
/**
|
||||
* Payload émis vers le parent à la création d'une campagne.
|
||||
@@ -70,9 +70,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="detail-section characters-section" *ngIf="!editing">
|
||||
<section class="detail-section personas-section" *ngIf="!editing">
|
||||
<div class="section-header">
|
||||
<h2>Personnages joueurs</h2>
|
||||
<h2>Personnages</h2>
|
||||
</div>
|
||||
|
||||
<!-- Sous-section : Personnages joueurs (PJ) -->
|
||||
<div class="persona-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="User" [size]="16"></lucide-icon>
|
||||
Personnages joueurs
|
||||
<span class="count-badge" *ngIf="characters.length > 0">{{ characters.length }}</span>
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PJ
|
||||
@@ -84,19 +94,52 @@
|
||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ character.name }}</span>
|
||||
<span class="character-snippet">{{ characterSnippet(character) }}</span>
|
||||
<span class="character-snippet">{{ personaSnippet(character) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="characters.length === 0">
|
||||
<lucide-icon [img]="User" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<div class="empty-state empty-state--compact" *ngIf="characters.length === 0">
|
||||
<p>Aucun personnage joueur pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createCharacter()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
|
||||
<div class="persona-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>
|
||||
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
||||
Personnages non-joueurs
|
||||
<span class="count-badge" *ngIf="npcs.length > 0">{{ npcs.length }}</span>
|
||||
</h3>
|
||||
<button class="btn-add" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau PNJ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="characters-grid" *ngIf="npcs.length > 0">
|
||||
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)">
|
||||
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
||||
<div class="character-info">
|
||||
<span class="character-name">{{ npc.name }}</span>
|
||||
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state empty-state--compact" *ngIf="npcs.length === 0">
|
||||
<p>Aucun PNJ pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="createNpc()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier PNJ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section arcs-section" *ngIf="!editing">
|
||||
@@ -197,6 +197,54 @@
|
||||
}
|
||||
|
||||
|
||||
// Encart "Personnages" qui regroupe les sous-sections PJ et PNJ.
|
||||
.personas-section {
|
||||
|
||||
.persona-subsection + .persona-subsection {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
|
||||
lucide-icon { color: #a78bfa; }
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0 0.45rem;
|
||||
background: #1f2937;
|
||||
color: #a78bfa;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
@@ -243,7 +291,22 @@
|
||||
|
||||
.empty-icon { color: #374151; }
|
||||
p { font-size: 0.95rem; }
|
||||
|
||||
// Variante condensée pour les sous-sections PJ/PNJ — pas besoin du
|
||||
// padding vertical massif quand l'encart parent en porte déjà.
|
||||
&.empty-state--compact {
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variante d'icône pour les cartes PNJ (rouge-violet pour différencier des PJ).
|
||||
.character-icon--npc { color: #c084fc !important; }
|
||||
|
||||
.btn-add-first {
|
||||
display: flex;
|
||||
@@ -2,21 +2,23 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices } from 'lucide-angular';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { GameSystemService } from '../../services/game-system.service';
|
||||
import { GameSystem } from '../../services/game-system.model';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { Character } from '../../services/character.model';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { GameSystem } from '../../../services/game-system.model';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
@@ -33,6 +35,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly User = User;
|
||||
readonly Dices = Dices;
|
||||
readonly Drama = Drama;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
@@ -48,6 +51,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
linkedGameSystem: GameSystem | null = null;
|
||||
/** Fiches de personnages (PJ) de la campagne. */
|
||||
characters: Character[] = [];
|
||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||
npcs: Npc[] = [];
|
||||
|
||||
/** Mode édition inline. */
|
||||
editing = false;
|
||||
@@ -63,6 +68,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private loreService: LoreService,
|
||||
private gameSystemService: GameSystemService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
@@ -77,8 +83,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||
)
|
||||
}))
|
||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
@@ -87,6 +93,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
@@ -111,8 +118,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.campaign = campaign;
|
||||
@@ -120,6 +127,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadLinkedLore(campaign);
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
@@ -159,11 +167,28 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
).subscribe(list => this.characters = list);
|
||||
}
|
||||
|
||||
/** Symétrique pour les PNJ. */
|
||||
private loadNpcs(campaignId: string): void {
|
||||
this.npcService.getByCampaign(campaignId).pipe(
|
||||
catchError(() => of([] as Npc[]))
|
||||
).subscribe(list => this.npcs = list);
|
||||
}
|
||||
|
||||
createCharacter(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
||||
}
|
||||
|
||||
createNpc(): void {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']);
|
||||
}
|
||||
|
||||
editNpc(npc: Npc): void {
|
||||
if (!this.campaign || !npc.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']);
|
||||
}
|
||||
|
||||
editCharacter(character: Character): void {
|
||||
if (!this.campaign || !character.id) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
||||
@@ -179,10 +204,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
|
||||
}
|
||||
|
||||
/** Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). */
|
||||
characterSnippet(c: Character): string {
|
||||
if (!c.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = c.markdownContent
|
||||
/**
|
||||
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
||||
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
||||
*/
|
||||
personaSnippet(p: { markdownContent?: string | null }): string {
|
||||
if (!p.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = p.markdownContent
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
@@ -192,6 +220,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
: firstMeaningful;
|
||||
}
|
||||
|
||||
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||
characterSnippet(c: Character): string {
|
||||
return this.personaSnippet(c);
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
const campaignId = this.campaign!.id!;
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { Campaign } from '../services/campaign.model';
|
||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign-create/campaign-create.component';
|
||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaigns',
|
||||
|
||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de création d'un nouveau chapitre rattaché à un arc.
|
||||
@@ -39,6 +40,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
@@ -57,7 +59,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Chapitre.
|
||||
@@ -67,6 +68,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
@@ -104,7 +106,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user