Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b06c77a1eb | |||
| 03bc669efe | |||
| c3873ddd84 | |||
| d7ceeac1b0 | |||
| cdbd3cd9b4 | |||
| a708c74425 | |||
| 9ad7651c44 | |||
| 389392fd1d | |||
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 | |||
| efe6f6c2b0 |
@@ -6,8 +6,10 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.igmlcreation.fr
|
GITEA_REGISTRY: git.igmlcreation.fr
|
||||||
REGISTRY_USER: ietm64
|
GITEA_REGISTRY_USER: ietm64
|
||||||
|
GHCR_REGISTRY: ghcr.io
|
||||||
|
GHCR_NAMESPACE: igmlcreation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -26,19 +28,39 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
username: ${{ env.REGISTRY_USER }}
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|
||||||
|
# Login to GHCR (GitHub Container Registry) pour distribuer les images
|
||||||
|
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
|
||||||
|
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Push vers les deux registries en un seul build (build-push-action
|
||||||
|
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||||
|
# Naming :
|
||||||
|
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||||
|
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||||
|
# dans leur .env.
|
||||||
|
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||||
|
# la collision avec d'autres projets de l'org.
|
||||||
- name: Build & push ${{ matrix.component }}
|
- name: Build & push ${{ matrix.component }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,6 +91,7 @@ Thumbs.db
|
|||||||
# Documentation hors-code (conservee hors du repo)
|
# Documentation hors-code (conservee hors du repo)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
docs/
|
docs/
|
||||||
|
loremind-docs/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
# 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
|
# 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
|
## Fonctionnalités
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- 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
|
- 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
|
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.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.domain.models import (
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
CharacterSummary,
|
CharacterSummary,
|
||||||
|
NpcSummary,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
NarrativeEntityContext,
|
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.)"
|
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)
|
characters_block = ChatUseCase._format_characters(ctx.characters)
|
||||||
|
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
|
||||||
return (
|
return (
|
||||||
"--- CAMPAGNE COURANTE ---\n"
|
"--- CAMPAGNE COURANTE ---\n"
|
||||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\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 "
|
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||||
"déclenchées par un choix des joueurs) :\n"
|
"déclenchées par un choix des joueurs) :\n"
|
||||||
f"{arcs_block}"
|
f"{arcs_block}"
|
||||||
@@ -231,6 +234,33 @@ class ChatUseCase:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines) + "\n"
|
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
|
@staticmethod
|
||||||
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||||
if not arcs:
|
if not arcs:
|
||||||
@@ -319,7 +349,8 @@ class ChatUseCase:
|
|||||||
"arc": "ARC",
|
"arc": "ARC",
|
||||||
"chapter": "CHAPITRE",
|
"chapter": "CHAPITRE",
|
||||||
"scene": "SCÈNE",
|
"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())
|
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
||||||
if ne.fields:
|
if ne.fields:
|
||||||
fields_block = "\n".join(
|
fields_block = "\n".join(
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class CampaignStructuralContext:
|
|||||||
campaign_description: str | None
|
campaign_description: str | None
|
||||||
arcs: list[ArcSummary]
|
arcs: list[ArcSummary]
|
||||||
characters: list["CharacterSummary"] = field(default_factory=list)
|
characters: list["CharacterSummary"] = field(default_factory=list)
|
||||||
|
npcs: list["NpcSummary"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -185,6 +186,19 @@ class CharacterSummary:
|
|||||||
snippet: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class NarrativeEntityContext:
|
class NarrativeEntityContext:
|
||||||
"""Contexte d'une entité narrative précise en cours d'édition.
|
"""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:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
body = response.text
|
||||||
|
try:
|
||||||
|
err_obj = json.loads(body)
|
||||||
|
err_msg = err_obj.get("error") or body
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err_msg = body
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
|
)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
async with client.stream("POST", url, json=payload) as response:
|
async with client.stream("POST", url, json=payload) as response:
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
# On lit le body d'erreur pour le remonter a l'utilisateur,
|
||||||
|
# sinon on ne voit que "500 Internal Server Error" sans
|
||||||
|
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
|
||||||
|
# num_ctx trop grand pour la VRAM, etc.).
|
||||||
|
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
err_obj = json.loads(body)
|
||||||
|
err_msg = err_obj.get("error") or body
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err_msg = body
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
|
)
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.domain.models import (
|
|||||||
CampaignStructuralContext,
|
CampaignStructuralContext,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
CharacterSummary,
|
CharacterSummary,
|
||||||
|
NpcSummary,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
@@ -205,6 +206,13 @@ class CharacterSummaryDTO(BaseModel):
|
|||||||
snippet: str = ""
|
snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class NpcSummaryDTO(BaseModel):
|
||||||
|
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CampaignContextDTO(BaseModel):
|
class CampaignContextDTO(BaseModel):
|
||||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||||
|
|
||||||
@@ -212,12 +220,13 @@ class CampaignContextDTO(BaseModel):
|
|||||||
campaign_description: str | None = None
|
campaign_description: str | None = None
|
||||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||||
|
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class NarrativeEntityDTO(BaseModel):
|
class NarrativeEntityDTO(BaseModel):
|
||||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
"""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
|
title: str
|
||||||
fields: dict[str, str] = Field(default_factory=dict)
|
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)
|
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||||
for c in dto.characters
|
for c in dto.characters
|
||||||
]
|
]
|
||||||
|
npcs = [
|
||||||
|
NpcSummary(name=n.name, snippet=n.snippet)
|
||||||
|
for n in dto.npcs
|
||||||
|
]
|
||||||
return CampaignStructuralContext(
|
return CampaignStructuralContext(
|
||||||
campaign_name=dto.campaign_name,
|
campaign_name=dto.campaign_name,
|
||||||
campaign_description=dto.campaign_description,
|
campaign_description=dto.campaign_description,
|
||||||
arcs=arcs,
|
arcs=arcs,
|
||||||
characters=characters,
|
characters=characters,
|
||||||
|
npcs=npcs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.11</version>
|
<version>0.7.2</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc;
|
|||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
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.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
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.CampaignStructuralContext.SceneSummary;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder {
|
|||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
public CampaignStructuralContextBuilder(
|
public CampaignStructuralContextBuilder(
|
||||||
CampaignRepository campaignRepository,
|
CampaignRepository campaignRepository,
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository) {
|
CharacterRepository characterRepository,
|
||||||
|
NpcRepository npcRepository) {
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
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;
|
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,11 +85,17 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.map(this::toCharacterSummary)
|
.map(this::toCharacterSummary)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||||
|
.map(this::toNpcSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return new CampaignStructuralContext(
|
return new CampaignStructuralContext(
|
||||||
campaign.getName(),
|
campaign.getName(),
|
||||||
campaign.getDescription(),
|
campaign.getDescription(),
|
||||||
arcs,
|
arcs,
|
||||||
characters);
|
characters,
|
||||||
|
npcs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +107,11 @@ public class CampaignStructuralContextBuilder {
|
|||||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
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) {
|
private static String extractSnippet(String markdown) {
|
||||||
if (markdown == null || markdown.isBlank()) return "";
|
if (markdown == null || markdown.isBlank()) return "";
|
||||||
String firstLine = markdown.lines()
|
String firstLine = markdown.lines()
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
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.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
|
|||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
public NarrativeEntityContextBuilder(
|
public NarrativeEntityContextBuilder(
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository) {
|
CharacterRepository characterRepository,
|
||||||
|
NpcRepository npcRepository) {
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
* 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é
|
* @param entityId l'ID de l'entité
|
||||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||||
case "scene" -> fromScene(loadScene(entityId));
|
case "scene" -> fromScene(loadScene(entityId));
|
||||||
case "character" -> fromCharacter(loadCharacter(entityId));
|
case "character" -> fromCharacter(loadCharacter(entityId));
|
||||||
|
case "npc" -> fromNpc(loadNpc(entityId));
|
||||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
.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 ------------------------------------------------
|
// --- Mapping entité → VO ------------------------------------------------
|
||||||
|
|
||||||
private NarrativeEntityContext fromArc(Arc a) {
|
private NarrativeEntityContext fromArc(Arc a) {
|
||||||
@@ -123,6 +134,12 @@ public class NarrativeEntityContextBuilder {
|
|||||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
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. */
|
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||||
private static void putField(Map<String, String> target, String key, String value) {
|
private static void putField(Map<String, String> target, String key, String value) {
|
||||||
target.put(key, value == null ? "" : value);
|
target.put(key, value == null ? "" : value);
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import java.time.LocalDateTime;
|
|||||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
* 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).
|
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
|
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
||||||
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
|
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
||||||
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
|
* É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
|
@Data
|
||||||
@Builder
|
@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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -22,12 +22,14 @@ import java.util.List;
|
|||||||
* Record Java : pur domaine, aucune dépendance technique.
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
*
|
*
|
||||||
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
||||||
|
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
|
||||||
*/
|
*/
|
||||||
public record CampaignStructuralContext(
|
public record CampaignStructuralContext(
|
||||||
String campaignName,
|
String campaignName,
|
||||||
String campaignDescription,
|
String campaignDescription,
|
||||||
List<ArcSummary> arcs,
|
List<ArcSummary> arcs,
|
||||||
List<CharacterSummary> characters) {
|
List<CharacterSummary> characters,
|
||||||
|
List<NpcSummary> npcs) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||||
@@ -39,6 +41,14 @@ public record CampaignStructuralContext(
|
|||||||
public record CharacterSummary(String name, String snippet) {
|
public record CharacterSummary(String name, String snippet) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
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.CampaignStructuralContext.SceneSummary;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
@@ -132,6 +133,12 @@ public class BrainChatPayloadBuilder {
|
|||||||
.map(this::characterSummaryToMap)
|
.map(this::characterSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +151,15 @@ public class BrainChatPayloadBuilder {
|
|||||||
return map;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
|
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
|
||||||
* avec name, description et illustration_count conditionnel.
|
* avec name, description et illustration_count conditionnel.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,13 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
||||||
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
||||||
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
* 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
|
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
||||||
* change, une mise a jour est disponible.
|
* 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
|
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
||||||
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
||||||
*
|
*
|
||||||
@@ -84,6 +89,9 @@ public class UpdateCheckService {
|
|||||||
this.watchtowerToken = watchtowerToken;
|
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
|
@PostConstruct
|
||||||
void initBaseline() {
|
void initBaseline() {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
@@ -91,7 +99,19 @@ public class UpdateCheckService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
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) {
|
for (String image : images) {
|
||||||
|
if (baselineDigests.containsKey(image)) continue;
|
||||||
try {
|
try {
|
||||||
String digest = fetchRemoteDigest(image);
|
String digest = fetchRemoteDigest(image);
|
||||||
if (digest != null) {
|
if (digest != null) {
|
||||||
@@ -102,6 +122,33 @@ public class UpdateCheckService {
|
|||||||
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
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() {
|
public boolean isEnabled() {
|
||||||
@@ -110,10 +157,11 @@ public class UpdateCheckService {
|
|||||||
|
|
||||||
public UpdateStatus check() {
|
public UpdateStatus check() {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
return new UpdateStatus(false, false, List.of(), Instant.now());
|
return new UpdateStatus(false, false, false, List.of(), Instant.now());
|
||||||
}
|
}
|
||||||
List<ImageStatus> statuses = new ArrayList<>();
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
boolean anyUpdate = false;
|
boolean anyUpdate = false;
|
||||||
|
boolean anyUnknown = false;
|
||||||
for (String image : images) {
|
for (String image : images) {
|
||||||
String baseline = baselineDigests.get(image);
|
String baseline = baselineDigests.get(image);
|
||||||
String remote = null;
|
String remote = null;
|
||||||
@@ -122,17 +170,21 @@ public class UpdateCheckService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Check failed for {}: {}", image, e.getMessage());
|
log.warn("Check failed for {}: {}", image, e.getMessage());
|
||||||
}
|
}
|
||||||
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
|
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
|
||||||
// pour eviter un faux positif "MAJ dispo".
|
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
|
||||||
if (baseline == null && remote != null) {
|
ImageStatusKind kind;
|
||||||
baselineDigests.put(image, remote);
|
if (baseline == null || remote == null) {
|
||||||
baseline = remote;
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
|
anyUnknown = true;
|
||||||
|
} else if (baseline.equals(remote)) {
|
||||||
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
|
} else {
|
||||||
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
}
|
}
|
||||||
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
|
statuses.add(new ImageStatus(image, baseline, remote, kind));
|
||||||
if (updateAvailable) anyUpdate = true;
|
|
||||||
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
|
|
||||||
}
|
}
|
||||||
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
|
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void apply() {
|
public void apply() {
|
||||||
@@ -198,9 +250,18 @@ public class UpdateCheckService {
|
|||||||
for (String key : new String[]{"service", "scope"}) {
|
for (String key : new String[]{"service", "scope"}) {
|
||||||
String v = params.get(key);
|
String v = params.get(key);
|
||||||
if (v != null) {
|
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 ? '&' : '?')
|
url.append(hasQuery ? '&' : '?')
|
||||||
.append(key).append('=')
|
.append(key).append('=')
|
||||||
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
|
.append(encoded);
|
||||||
hasQuery = true;
|
hasQuery = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,15 +330,38 @@ public class UpdateCheckService {
|
|||||||
// Records de retour (sortis sous forme JSON par Jackson)
|
// 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(
|
public record UpdateStatus(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
boolean updateAvailable,
|
boolean updateAvailable,
|
||||||
|
boolean anyUnknown,
|
||||||
List<ImageStatus> images,
|
List<ImageStatus> images,
|
||||||
Instant checkedAt) {}
|
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(
|
public record ImageStatus(
|
||||||
String image,
|
String image,
|
||||||
String localDigest,
|
String localDigest,
|
||||||
String remoteDigest,
|
String remoteDigest,
|
||||||
boolean updateAvailable) {}
|
ImageStatusKind status,
|
||||||
|
boolean updateAvailable) {
|
||||||
|
|
||||||
|
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) {
|
||||||
|
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ server.port=8080
|
|||||||
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
# de WebFlux (utilisé uniquement pour WebClient côté adapter SSE vers le Brain).
|
||||||
spring.main.web-application-type=servlet
|
spring.main.web-application-type=servlet
|
||||||
|
|
||||||
|
# Pas de timeout sur les requetes async (StreamingResponseBody, SSE).
|
||||||
|
# Le defaut Tomcat coupe a 30s, ce qui interrompt le streaming d'un pull
|
||||||
|
# de modele Ollama (peut durer des dizaines de minutes pour un GGUF de 10+ Go).
|
||||||
|
# -1 = pas de timeout, on s'appuie sur la fermeture cote client ou cote upstream.
|
||||||
|
spring.mvc.async.request-timeout=-1
|
||||||
|
|
||||||
# Configuration de la base de donnees PostgreSQL
|
# Configuration de la base de donnees PostgreSQL
|
||||||
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
# Valeurs surchargeables via variables d'env (cf. docker-compose.yml).
|
||||||
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
|
# En dev local, creez un fichier .env a la racine de core/ OU definissez les
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
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.Scene;
|
||||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
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.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -43,6 +46,8 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
private SceneRepository sceneRepository;
|
private SceneRepository sceneRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private CharacterRepository characterRepository;
|
private CharacterRepository characterRepository;
|
||||||
|
@Mock
|
||||||
|
private NpcRepository npcRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignStructuralContextBuilder builder;
|
private CampaignStructuralContextBuilder builder;
|
||||||
@@ -144,6 +149,66 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
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
|
@Test
|
||||||
void testBuild_CountsIllustrationsNullSafe() {
|
void testBuild_CountsIllustrationsNullSafe() {
|
||||||
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
|
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
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.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
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.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -30,6 +34,8 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
@Mock private ArcRepository arcRepository;
|
@Mock private ArcRepository arcRepository;
|
||||||
@Mock private ChapterRepository chapterRepository;
|
@Mock private ChapterRepository chapterRepository;
|
||||||
@Mock private SceneRepository sceneRepository;
|
@Mock private SceneRepository sceneRepository;
|
||||||
|
@Mock private CharacterRepository characterRepository;
|
||||||
|
@Mock private NpcRepository npcRepository;
|
||||||
|
|
||||||
@InjectMocks private NarrativeEntityContextBuilder builder;
|
@InjectMocks private NarrativeEntityContextBuilder builder;
|
||||||
|
|
||||||
@@ -107,11 +113,59 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
assertEquals("arc", ctx.entityType());
|
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
|
@Test
|
||||||
void testBuild_UnknownTypeThrows() {
|
void testBuild_UnknownTypeThrows() {
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> builder.build("npc", "id"));
|
() -> builder.build("alien", "id"));
|
||||||
assertTrue(ex.getMessage().contains("npc"));
|
assertTrue(ex.getMessage().contains("alien"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
|
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of(), List.of());
|
||||||
messages = List.of();
|
messages = List.of();
|
||||||
onUsage = mock(Consumer.class);
|
onUsage = mock(Consumer.class);
|
||||||
onToken = mock(Consumer.class);
|
onToken = mock(Consumer.class);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class CampaignStructuralContextTest {
|
|||||||
"Les Ombres",
|
"Les Ombres",
|
||||||
"Une campagne dark fantasy",
|
"Une campagne dark fantasy",
|
||||||
List.of(arc),
|
List.of(arc),
|
||||||
|
List.of(),
|
||||||
List.of());
|
List.of());
|
||||||
|
|
||||||
assertEquals("Les Ombres", ctx.campaignName());
|
assertEquals("Les Ombres", ctx.campaignName());
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class ChatRequestTest {
|
|||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.campaignContext(new CampaignStructuralContext(
|
.campaignContext(new CampaignStructuralContext(
|
||||||
"Les Ombres", "...", List.of(), List.of()))
|
"Les Ombres", "...", List.of(), List.of(), List.of()))
|
||||||
.narrativeEntity(new NarrativeEntityContext(
|
.narrativeEntity(new NarrativeEntityContext(
|
||||||
"scene", "L'auberge", Map.of("location", "Taverne")))
|
"scene", "L'auberge", Map.of("location", "Taverne")))
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"Les Ombres", "dark fantasy", List.of(arc), List.of());
|
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -200,7 +200,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -217,7 +217,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -236,7 +236,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -269,7 +269,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
void build_campaignScenario_includesBothContextsAndEntity() {
|
void build_campaignScenario_includesBothContextsAndEntity() {
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(), List.of());
|
"X", "", List.of(), List.of(), List.of());
|
||||||
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
||||||
ChatRequest req = ChatRequest.builder()
|
ChatRequest req = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
|
|||||||
@@ -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,7 +60,12 @@ services:
|
|||||||
"
|
"
|
||||||
|
|
||||||
core:
|
core:
|
||||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
|
# Defaut : GHCR (registry public, reputation domaine elevee).
|
||||||
|
# Pour les anciennes installs qui pointaient sur Gitea, REGISTRY et
|
||||||
|
# IMAGE_NAMESPACE peuvent etre overrides dans .env :
|
||||||
|
# REGISTRY=git.igmlcreation.fr
|
||||||
|
# IMAGE_NAMESPACE=ietm64/ (le slash final est important : voir image: ci-dessous)
|
||||||
|
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}core:${TAG:-latest}
|
||||||
container_name: loremind-core
|
container_name: loremind-core
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -84,8 +89,8 @@ services:
|
|||||||
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
||||||
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
||||||
# (l'UI masque le badge et le bouton).
|
# (l'UI masque le badge et le bouton).
|
||||||
UPDATE_CHECK_REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
|
UPDATE_CHECK_REGISTRY: ${REGISTRY:-ghcr.io}
|
||||||
UPDATE_CHECK_IMAGES: ietm64/core,ietm64/brain,ietm64/web
|
UPDATE_CHECK_IMAGES: ${IMAGE_NAMESPACE:-igmlcreation/loremind-}core,${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain,${IMAGE_NAMESPACE:-igmlcreation/loremind-}web
|
||||||
UPDATE_CHECK_TAG: ${TAG:-latest}
|
UPDATE_CHECK_TAG: ${TAG:-latest}
|
||||||
WATCHTOWER_URL: http://watchtower:8080
|
WATCHTOWER_URL: http://watchtower:8080
|
||||||
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
||||||
@@ -115,7 +120,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
brain:
|
brain:
|
||||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
|
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain:${TAG:-latest}
|
||||||
container_name: loremind-brain
|
container_name: loremind-brain
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -138,7 +143,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
|
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}web:${TAG:-latest}
|
||||||
container_name: loremind-web
|
container_name: loremind-web
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
# LoreMindMJ — Installation rapide
|
|
||||||
|
|
||||||
Ces scripts installent Docker (si nécessaire), génèrent un `.env` sécurisé
|
|
||||||
et lancent la stack. Aucune configuration manuelle requise.
|
|
||||||
|
|
||||||
## Windows 10 / 11
|
|
||||||
|
|
||||||
**Procédure recommandée :**
|
|
||||||
|
|
||||||
1. Téléchargez les trois fichiers suivants dans un même dossier
|
|
||||||
(par ex. `Téléchargements\LoreMind\`) :
|
|
||||||
- [`install.bat`](install.bat) — lanceur
|
|
||||||
- [`install.ps1`](install.ps1) — script principal
|
|
||||||
- [`secure-host-ollama.ps1`](secure-host-ollama.ps1) — *uniquement si vous avez déjà Ollama sur votre PC*
|
|
||||||
2. **Clic-droit** sur `install.bat` → **Exécuter en tant qu'administrateur**.
|
|
||||||
3. Acceptez le prompt UAC.
|
|
||||||
|
|
||||||
Le script :
|
|
||||||
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
|
|
||||||
2. Vérifie / installe **Docker Desktop** via `winget`.
|
|
||||||
3. Vous demande quelques choix (admin, fournisseur LLM, mode Ollama, mises à jour auto).
|
|
||||||
4. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
|
|
||||||
5. Lance la stack et ouvre `http://localhost:8081`.
|
|
||||||
|
|
||||||
Le `install.bat` sert juste à lancer `install.ps1` proprement (avec UAC + ExecutionPolicy
|
|
||||||
adaptée à la session, sans modifier les paramètres système). Il est purement
|
|
||||||
déclaratif et auditable en quelques lignes.
|
|
||||||
|
|
||||||
## Linux (Debian / Ubuntu / Fedora / Arch)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Le script :
|
|
||||||
1. Installe **Docker** via le script officiel `get.docker.com` si absent.
|
|
||||||
2. Ajoute l'utilisateur courant au groupe `docker` (relogin nécessaire la 1ʳᵉ fois).
|
|
||||||
3. Installe dans `~/.local/share/loremind`.
|
|
||||||
4. Lance la stack et ouvre `http://localhost:8081`.
|
|
||||||
|
|
||||||
## Mode Ollama (moteur LLM local)
|
|
||||||
|
|
||||||
Pendant l'installation, l'installeur pose deux questions successives pour
|
|
||||||
déterminer comment LoreMind utilisera Ollama :
|
|
||||||
|
|
||||||
### 1. *« Avez-vous déjà Ollama installé sur cette machine ? »*
|
|
||||||
|
|
||||||
#### Réponse : **Oui** → mode **hôte sécurisé**
|
|
||||||
|
|
||||||
L'installeur appelle automatiquement le helper `secure-host-ollama.{sh,ps1}`
|
|
||||||
qui configure votre Ollama existant pour qu'il soit joignable par le conteneur
|
|
||||||
Docker LoreMind **sans être exposé sur le réseau local ni Internet**.
|
|
||||||
|
|
||||||
- **Linux** : Ollama écoute sur l'IP de la passerelle Docker (`172.17.0.1`
|
|
||||||
par défaut). Cette IP n'est jamais routée hors de la machine. Override
|
|
||||||
systemd écrit dans `/etc/systemd/system/ollama.service.d/loremind-host.conf`.
|
|
||||||
- **Windows** : Ollama écoute sur `0.0.0.0` (techniquement nécessaire avec
|
|
||||||
Docker Desktop) mais le pare-feu Windows est configuré pour ne **laisser
|
|
||||||
passer que** le loopback et les sous-réseaux Docker Desktop. Règles
|
|
||||||
ajoutées préfixées `LoreMind-Ollama-*`.
|
|
||||||
|
|
||||||
L'URL configurée dans `.env` est `OLLAMA_BASE_URL=http://host.docker.internal:11434`.
|
|
||||||
|
|
||||||
#### Réponse : **Non** → l'installeur pose la question 2.
|
|
||||||
|
|
||||||
### 2. *« Voulez-vous installer Ollama via Docker maintenant ? »*
|
|
||||||
|
|
||||||
#### Réponse : **Oui (défaut)** → mode **embarqué**
|
|
||||||
|
|
||||||
Un service `ollama` est ajouté à la stack via le profile Docker `local-ollama`.
|
|
||||||
Ollama tourne dans un conteneur dédié, sur le réseau interne Docker, **jamais
|
|
||||||
exposé au LAN ni à Internet**. Les modèles sont stockés dans le volume
|
|
||||||
Docker `ollama-data` (persistants entre redémarrages et mises à jour).
|
|
||||||
|
|
||||||
- URL : `OLLAMA_BASE_URL=http://ollama:11434` (DNS interne Docker).
|
|
||||||
- Aucune configuration réseau ou pare-feu requise.
|
|
||||||
- Support GPU NVIDIA automatique si disponible.
|
|
||||||
|
|
||||||
Pour télécharger un modèle :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it loremind-ollama ollama pull gemma3:27b
|
|
||||||
docker exec -it loremind-ollama ollama list
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Réponse : **Non** → mode **différé**
|
|
||||||
|
|
||||||
Aucune configuration Ollama n'est appliquée. L'installeur termine sans
|
|
||||||
Ollama. Vous configurez Ollama plus tard via la page **Paramètres** de LoreMind
|
|
||||||
en y indiquant l'URL de votre serveur Ollama.
|
|
||||||
|
|
||||||
### Lancer le helper de sécurisation manuellement
|
|
||||||
|
|
||||||
Si vous avez choisi le mode différé puis installé Ollama plus tard sur votre
|
|
||||||
poste, ou si vous voulez basculer du mode embarqué vers le mode hôte :
|
|
||||||
|
|
||||||
**Linux :**
|
|
||||||
```bash
|
|
||||||
bash secure-host-ollama.sh
|
|
||||||
# Puis dans .env du dossier d'installation :
|
|
||||||
# OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
# Et : docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell admin) :**
|
|
||||||
```powershell
|
|
||||||
.\secure-host-ollama.ps1
|
|
||||||
# Puis editez .env (dans %LOCALAPPDATA%\LoreMind\) :
|
|
||||||
# OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
# Et : docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Les helpers sont **réexécutables sans risque** : ils suppriment leurs
|
|
||||||
anciennes règles avant de les recréer. Utile par exemple si vous avez
|
|
||||||
réinitialisé Docker Desktop et que les sous-réseaux ont changé.
|
|
||||||
|
|
||||||
### Annuler la configuration de sécurisation
|
|
||||||
|
|
||||||
**Linux :**
|
|
||||||
```bash
|
|
||||||
sudo rm /etc/systemd/system/ollama.service.d/loremind-host.conf
|
|
||||||
sudo systemctl daemon-reload && sudo systemctl restart ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell admin) :**
|
|
||||||
```powershell
|
|
||||||
Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule
|
|
||||||
[Environment]::SetEnvironmentVariable("OLLAMA_HOST", $null, "User")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variables disponibles
|
|
||||||
|
|
||||||
| Variable | Défaut | Effet |
|
|
||||||
|-------------------|---------------------------------|----------------------------------------|
|
|
||||||
| `WEB_PORT` | `8081` | Port HTTP de l'UI |
|
|
||||||
| `INSTALL_DIR` | `~/.local/share/loremind` (Lin) | Dossier d'installation |
|
|
||||||
| `NON_INTERACTIVE` | `0` | `1` = aucune question, valeurs par défaut |
|
|
||||||
|
|
||||||
Exemple Linux non-interactif sur port 9000 :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
WEB_PORT=9000 NON_INTERACTIVE=1 bash install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mises à jour automatiques (Watchtower)
|
|
||||||
|
|
||||||
Si vous avez répondu **oui** à la question "Activer les mises à jour auto",
|
|
||||||
un container [Watchtower](https://containrrr.dev/watchtower/) est lancé en
|
|
||||||
parallèle. Il vérifie chaque nuit à 4h les nouvelles versions de
|
|
||||||
`core`, `brain` et `web` sur le registry, télécharge et redémarre les
|
|
||||||
conteneurs concernés. **Postgres et MinIO sont volontairement exclus**
|
|
||||||
(données persistantes — montée de version à valider manuellement).
|
|
||||||
|
|
||||||
### Activer / désactiver après coup
|
|
||||||
|
|
||||||
Éditer `.env` dans le dossier d'installation :
|
|
||||||
|
|
||||||
```env
|
|
||||||
COMPOSE_PROFILES=autoupdate # active
|
|
||||||
COMPOSE_PROFILES= # desactive
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d # applique le changement
|
|
||||||
docker compose stop watchtower # si on vient de le desactiver
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changer l'horaire
|
|
||||||
|
|
||||||
`WATCHTOWER_SCHEDULE` dans `.env` accepte la syntaxe
|
|
||||||
[cron 6 champs](https://pkg.go.dev/github.com/robfig/cron) (sec min h jour mois j-sem).
|
|
||||||
Exemples : `0 0 4 * * *` (4h du matin, défaut), `0 30 3 * * 0` (dimanche 3h30).
|
|
||||||
|
|
||||||
### Mode "notification seulement" (sans auto-apply)
|
|
||||||
|
|
||||||
Si vous préférez être notifié *sans* que les conteneurs redémarrent
|
|
||||||
automatiquement la nuit, éditez `.env` :
|
|
||||||
|
|
||||||
```env
|
|
||||||
WATCHTOWER_MONITOR_ONLY=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis `docker compose up -d watchtower`. Watchtower continuera à vérifier
|
|
||||||
le registry chaque nuit, le badge **MAJ** apparaîtra dans la sidebar de
|
|
||||||
l'UI, et un bouton **Mettre à jour maintenant** sera disponible dans
|
|
||||||
*Paramètres → Mises à jour*.
|
|
||||||
|
|
||||||
### Mise à jour manuelle (à tout moment)
|
|
||||||
|
|
||||||
Depuis l'interface : *Paramètres → Mises à jour → Mettre à jour maintenant*.
|
|
||||||
|
|
||||||
Ou en CLI :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Désinstallation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <dossier d'install>
|
|
||||||
docker compose down -v # -v supprime aussi les volumes (données effacées !)
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis supprimer le dossier d'installation.
|
|
||||||
@@ -40,16 +40,16 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.6.11
|
Version : 0.7.2
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://git.igmlcreation.fr/ietm64/loremind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
|
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
|
||||||
[string]$ComposeUrl = "https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml",
|
[string]$ComposeUrl = "https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml",
|
||||||
[int]$WebPort = 8081,
|
[int]$WebPort = 8081,
|
||||||
[switch]$NonInteractive
|
[switch]$NonInteractive
|
||||||
)
|
)
|
||||||
@@ -316,7 +316,8 @@ $composeProfiles = $profilesList -join ','
|
|||||||
|
|
||||||
$envContent = @"
|
$envContent = @"
|
||||||
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
|
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
|
||||||
REGISTRY=git.igmlcreation.fr
|
REGISTRY=ghcr.io
|
||||||
|
IMAGE_NAMESPACE=igmlcreation/loremind-
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
WEB_PORT=$WebPort
|
WEB_PORT=$WebPort
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
|
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
|
||||||
# Usage :
|
# Usage :
|
||||||
# curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
|
# curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
|
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
|
||||||
COMPOSE_URL="${COMPOSE_URL:-https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml}"
|
COMPOSE_URL="${COMPOSE_URL:-https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml}"
|
||||||
WEB_PORT="${WEB_PORT:-8081}"
|
WEB_PORT="${WEB_PORT:-8081}"
|
||||||
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
||||||
|
|
||||||
@@ -190,7 +190,8 @@ COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
|
|||||||
|
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
|
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
|
||||||
REGISTRY=git.igmlcreation.fr
|
REGISTRY=ghcr.io
|
||||||
|
IMAGE_NAMESPACE=igmlcreation/loremind-
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
WEB_PORT=${WEB_PORT}
|
WEB_PORT=${WEB_PORT}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
faciliter leur identification et suppression ulterieure.
|
faciliter leur identification et suppression ulterieure.
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://git.igmlcreation.fr/ietm64/loremind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-bookworm-slim AS build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
RUN npm install -g npm@latest
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Neutralise les URLs absolues hardcodees dans les services (dette assumee :
|
# Neutralise les URLs absolues hardcodees dans les services (dette assumee :
|
||||||
|
|||||||
@@ -301,6 +301,46 @@ export async function getPageById(
|
|||||||
return res.json();
|
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(
|
export async function getTemplateById(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
templateId: string,
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.11",
|
"version": "0.7.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.11",
|
"version": "0.7.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.11",
|
"version": "0.7.2",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"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', 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: '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', 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/:id', loadComponent: () => import('./campaigns/campaign/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/create', loadComponent: () => import('./campaigns/character/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/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/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/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
{ 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/:chapterId/graph', loadComponent: () => import('./campaigns/chapter-graph/chapter-graph.component').then(m => m.ChapterGraphComponent) },
|
{ 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/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
{ 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/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
{ 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/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
{ 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/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
{ 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', 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/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) },
|
{ 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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Campaign } from '../../../services/campaign.model';
|
||||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
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).
|
* É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 router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -56,7 +58,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
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 }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.existingArcCount = treeData.arcs.length;
|
this.existingArcCount = treeData.arcs.length;
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
order: this.existingArcCount + 1,
|
order: this.existingArcCount + 1,
|
||||||
icon: this.selectedIcon
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
|
||||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Arc } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Page } from '../../services/page.model';
|
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Page } from '../../../services/page.model';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Arc.
|
* Écran de détail/modification d'un Arc.
|
||||||
@@ -74,6 +75,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -111,7 +113,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
arc: this.campaignService.getArcById(this.arcId),
|
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(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { resolveCampaignIcon } from '../campaign-icons';
|
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Arc } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Page } from '../../services/page.model';
|
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Page } from '../../../services/page.model';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
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).
|
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||||
@@ -46,6 +47,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -68,7 +70,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
arc: this.campaignService.getArcById(this.arcId),
|
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(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
@@ -2,9 +2,11 @@ import { Observable, forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap, map } from 'rxjs/operators';
|
import { switchMap, map } from 'rxjs/operators';
|
||||||
import { CampaignService } from '../services/campaign.service';
|
import { CampaignService } from '../services/campaign.service';
|
||||||
import { CharacterService } from '../services/character.service';
|
import { CharacterService } from '../services/character.service';
|
||||||
|
import { NpcService } from '../services/npc.service';
|
||||||
import { TreeItem } from '../services/layout.service';
|
import { TreeItem } from '../services/layout.service';
|
||||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||||
import { Character } from '../services/character.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)
|
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||||
@@ -19,20 +21,23 @@ export interface CampaignTreeData {
|
|||||||
chaptersByArc: Record<string, Chapter[]>;
|
chaptersByArc: Record<string, Chapter[]>;
|
||||||
scenesByChapter: Record<string, Scene[]>;
|
scenesByChapter: Record<string, Scene[]>;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
|
npcs: Npc[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCampaignTreeData(
|
export function loadCampaignTreeData(
|
||||||
service: CampaignService,
|
service: CampaignService,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
characterService: CharacterService
|
characterService: CharacterService,
|
||||||
|
npcService: NpcService
|
||||||
): Observable<CampaignTreeData> {
|
): Observable<CampaignTreeData> {
|
||||||
return forkJoin({
|
return forkJoin({
|
||||||
arcs: service.getArcs(campaignId),
|
arcs: service.getArcs(campaignId),
|
||||||
characters: characterService.getByCampaign(campaignId)
|
characters: characterService.getByCampaign(campaignId),
|
||||||
|
npcs: npcService.getByCampaign(campaignId)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(({ arcs, characters }) => {
|
switchMap(({ arcs, characters, npcs }) => {
|
||||||
if (arcs.length === 0) {
|
if (arcs.length === 0) {
|
||||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
|
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs });
|
||||||
}
|
}
|
||||||
const chapterCalls = arcs.map(a =>
|
const chapterCalls = arcs.map(a =>
|
||||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||||
@@ -47,7 +52,7 @@ export function loadCampaignTreeData(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allChapters.length === 0) {
|
if (allChapters.length === 0) {
|
||||||
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
|
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs });
|
||||||
}
|
}
|
||||||
const sceneCalls = allChapters.map(c =>
|
const sceneCalls = allChapters.map(c =>
|
||||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||||
@@ -56,7 +61,7 @@ export function loadCampaignTreeData(
|
|||||||
map(sceneResults => {
|
map(sceneResults => {
|
||||||
const scenesByChapter: Record<string, Scene[]> = {};
|
const scenesByChapter: Record<string, Scene[]> = {};
|
||||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
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 = {
|
const charactersNode: TreeItem = {
|
||||||
id: 'characters-root',
|
id: 'characters-root',
|
||||||
label: 'Personnages',
|
label: 'PJ',
|
||||||
iconKey: 'users',
|
iconKey: 'users',
|
||||||
children: characterItems,
|
children: characterItems,
|
||||||
meta: characterItems.length ? String(characterItems.length) : undefined,
|
meta: characterItems.length ? String(characterItems.length) : undefined,
|
||||||
sectionHeaderBefore: 'Personnages',
|
sectionHeaderBefore: 'Personnages',
|
||||||
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
|
// Note : le section header "Personnages" est porté par le premier nœud (PJ).
|
||||||
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
|
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
|
||||||
createActions: [{
|
createActions: [{
|
||||||
id: 'new-character',
|
id: 'new-character',
|
||||||
label: 'Nouveau PJ',
|
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 sortedArcs = [...data.arcs].sort(byName);
|
||||||
|
|
||||||
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
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 { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../../services/lore.service';
|
||||||
import { Lore } from '../../services/lore.model';
|
import { Lore } from '../../../services/lore.model';
|
||||||
import { GameSystemService } from '../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
import { GameSystem } from '../../services/game-system.model';
|
import { GameSystem } from '../../../services/game-system.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload émis vers le parent à la création d'une campagne.
|
* Payload émis vers le parent à la création d'une campagne.
|
||||||
@@ -70,9 +70,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="detail-section characters-section" *ngIf="!editing">
|
<section class="detail-section personas-section" *ngIf="!editing">
|
||||||
<div class="section-header">
|
<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()">
|
<button class="btn-add" (click)="createCharacter()">
|
||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
Nouveau PJ
|
Nouveau PJ
|
||||||
@@ -84,19 +94,52 @@
|
|||||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||||
<div class="character-info">
|
<div class="character-info">
|
||||||
<span class="character-name">{{ character.name }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="empty-state" *ngIf="characters.length === 0">
|
<div class="empty-state empty-state--compact" *ngIf="characters.length === 0">
|
||||||
<lucide-icon [img]="User" [size]="40" class="empty-icon"></lucide-icon>
|
|
||||||
<p>Aucun personnage joueur pour le moment.</p>
|
<p>Aucun personnage joueur pour le moment.</p>
|
||||||
<button class="btn-add-first" (click)="createCharacter()">
|
<button class="btn-add-first" (click)="createCharacter()">
|
||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
Créer votre premier PJ
|
Créer votre premier PJ
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="detail-section arcs-section" *ngIf="!editing">
|
<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 {
|
.characters-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
@@ -243,7 +291,22 @@
|
|||||||
|
|
||||||
.empty-icon { color: #374151; }
|
.empty-icon { color: #374151; }
|
||||||
p { font-size: 0.95rem; }
|
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 {
|
.btn-add-first {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2,21 +2,23 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
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 { Router, RouterLink } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../../services/lore.service';
|
||||||
import { GameSystemService } from '../../services/game-system.service';
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
import { GameSystem } from '../../services/game-system.model';
|
import { GameSystem } from '../../../services/game-system.model';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { Character } from '../../services/character.model';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { Character } from '../../../services/character.model';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { Npc } from '../../../services/npc.model';
|
||||||
import { Campaign, Arc } from '../../services/campaign.model';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Lore } from '../../services/lore.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper';
|
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||||
|
import { Lore } from '../../../services/lore.model';
|
||||||
|
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-detail',
|
selector: 'app-campaign-detail',
|
||||||
@@ -33,6 +35,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly User = User;
|
readonly User = User;
|
||||||
readonly Dices = Dices;
|
readonly Dices = Dices;
|
||||||
|
readonly Drama = Drama;
|
||||||
|
|
||||||
campaign: Campaign | null = null;
|
campaign: Campaign | null = null;
|
||||||
arcs: Arc[] = [];
|
arcs: Arc[] = [];
|
||||||
@@ -48,6 +51,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
linkedGameSystem: GameSystem | null = null;
|
linkedGameSystem: GameSystem | null = null;
|
||||||
/** Fiches de personnages (PJ) de la campagne. */
|
/** Fiches de personnages (PJ) de la campagne. */
|
||||||
characters: Character[] = [];
|
characters: Character[] = [];
|
||||||
|
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||||
|
npcs: Npc[] = [];
|
||||||
|
|
||||||
/** Mode édition inline. */
|
/** Mode édition inline. */
|
||||||
editing = false;
|
editing = false;
|
||||||
@@ -63,6 +68,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
private loreService: LoreService,
|
private loreService: LoreService,
|
||||||
private gameSystemService: GameSystemService,
|
private gameSystemService: GameSystemService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
) {}
|
) {}
|
||||||
@@ -77,8 +83,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
switchMap(id => forkJoin({
|
switchMap(id => forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
@@ -87,6 +93,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.loadLinkedLore(campaign);
|
this.loadLinkedLore(campaign);
|
||||||
this.loadLinkedGameSystem(campaign);
|
this.loadLinkedGameSystem(campaign);
|
||||||
this.loadCharacters(campaign.id!);
|
this.loadCharacters(campaign.id!);
|
||||||
|
this.loadNpcs(campaign.id!);
|
||||||
this.arcs = treeData.arcs;
|
this.arcs = treeData.arcs;
|
||||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||||
this.showLayout(allCampaigns, treeData);
|
this.showLayout(allCampaigns, treeData);
|
||||||
@@ -111,8 +118,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
|
||||||
)
|
)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.campaign = campaign;
|
this.campaign = campaign;
|
||||||
@@ -120,6 +127,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.loadLinkedLore(campaign);
|
this.loadLinkedLore(campaign);
|
||||||
this.loadLinkedGameSystem(campaign);
|
this.loadLinkedGameSystem(campaign);
|
||||||
this.loadCharacters(campaign.id!);
|
this.loadCharacters(campaign.id!);
|
||||||
|
this.loadNpcs(campaign.id!);
|
||||||
this.arcs = treeData.arcs;
|
this.arcs = treeData.arcs;
|
||||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||||
this.showLayout(allCampaigns, treeData);
|
this.showLayout(allCampaigns, treeData);
|
||||||
@@ -159,11 +167,28 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
).subscribe(list => this.characters = list);
|
).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 {
|
createCharacter(): void {
|
||||||
if (!this.campaign) return;
|
if (!this.campaign) return;
|
||||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
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 {
|
editCharacter(character: Character): void {
|
||||||
if (!this.campaign || !character.id) return;
|
if (!this.campaign || !character.id) return;
|
||||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
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]);
|
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 {
|
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
||||||
if (!c.markdownContent) return '(Fiche vide)';
|
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
||||||
const firstMeaningful = c.markdownContent
|
*/
|
||||||
|
personaSnippet(p: { markdownContent?: string | null }): string {
|
||||||
|
if (!p.markdownContent) return '(Fiche vide)';
|
||||||
|
const firstMeaningful = p.markdownContent
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(l => l.trim())
|
.map(l => l.trim())
|
||||||
.find(l => l && !l.startsWith('#'));
|
.find(l => l && !l.startsWith('#'));
|
||||||
@@ -192,6 +220,11 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
: firstMeaningful;
|
: firstMeaningful;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||||
|
characterSnippet(c: Character): string {
|
||||||
|
return this.personaSnippet(c);
|
||||||
|
}
|
||||||
|
|
||||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||||
const campaignId = this.campaign!.id!;
|
const campaignId = this.campaign!.id!;
|
||||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
||||||
import { CampaignService } from '../services/campaign.service';
|
import { CampaignService } from '../services/campaign.service';
|
||||||
import { Campaign } from '../services/campaign.model';
|
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({
|
@Component({
|
||||||
selector: 'app-campaigns',
|
selector: 'app-campaigns',
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule } from 'lucide-angular';
|
import { LucideAngularModule } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Campaign } from '../../../services/campaign.model';
|
||||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
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.
|
* É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 router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -57,7 +59,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
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 }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||||
this.arcName = currentArc?.name ?? '';
|
this.arcName = currentArc?.name ?? '';
|
||||||
@@ -5,19 +5,20 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Chapter } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Page } from '../../services/page.model';
|
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Page } from '../../../services/page.model';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Chapitre.
|
* Écran de détail/modification d'un Chapitre.
|
||||||
@@ -67,6 +68,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -104,7 +106,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
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(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
@@ -3,12 +3,13 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
|
||||||
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
|
|
||||||
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
||||||
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
||||||
@@ -68,6 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
) {}
|
) {}
|
||||||
@@ -87,7 +89,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
scenes: this.campaignService.getScenes(this.chapterId),
|
scenes: this.campaignService.getScenes(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||||
this.chapter = chapter;
|
this.chapter = chapter;
|
||||||
this.scenes = scenes;
|
this.scenes = scenes;
|
||||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||||
import { resolveCampaignIcon } from '../campaign-icons';
|
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Chapter } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Page } from '../../services/page.model';
|
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Page } from '../../../services/page.model';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'un Chapitre (lecture seule).
|
* Écran de consultation d'un Chapitre (lecture seule).
|
||||||
@@ -45,6 +46,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -71,7 +73,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
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(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
@@ -3,9 +3,9 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { Character } from '../../services/character.model';
|
import { Character } from '../../../services/character.model';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Éditeur plein écran d'une fiche de personnage (PJ).
|
* Éditeur plein écran d'une fiche de personnage (PJ).
|
||||||
82
web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
Normal file
82
web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<div class="ne-page">
|
||||||
|
|
||||||
|
<div class="ne-header">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
Retour à la campagne
|
||||||
|
</button>
|
||||||
|
<div class="header-row">
|
||||||
|
<h1>
|
||||||
|
<lucide-icon [img]="Drama" [size]="22"></lucide-icon>
|
||||||
|
{{ npcId ? 'Éditer le PNJ' : 'Nouveau PNJ' }}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
*ngIf="npcId"
|
||||||
|
type="button"
|
||||||
|
class="btn-ai"
|
||||||
|
(click)="toggleChat()"
|
||||||
|
[class.active]="chatOpen"
|
||||||
|
title="Ouvrir l'Assistant IA pour dialoguer autour de ce PNJ">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Assistant IA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ne-form">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Nom du PNJ *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Ex: Borin le forgeron, Dame Elara, Kael l'aubergiste..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field content-field">
|
||||||
|
<label>Fiche (markdown)</label>
|
||||||
|
<p class="hint">
|
||||||
|
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ…
|
||||||
|
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="markdownContent"
|
||||||
|
name="markdownContent"
|
||||||
|
rows="22"
|
||||||
|
placeholder="# Borin le forgeron **Race :** Nain **Faction :** Clan Feuillefer **Statut :** Vivant ## Apparence Barbe rousse tressée, tablier de cuir brûlé... ## Motivations Venger son clan décimé par les orcs il y a 10 hivers. ## Notes MJ (secret) Connaît l'emplacement du marteau de Durin..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||||
|
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||||
|
{{ npcId ? 'Enregistrer' : 'Créer' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button
|
||||||
|
*ngIf="npcId"
|
||||||
|
type="button"
|
||||||
|
class="btn-danger"
|
||||||
|
(click)="deleteNpc()">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
*ngIf="npcId && campaignId"
|
||||||
|
[campaignId]="campaignId"
|
||||||
|
entityType="npc"
|
||||||
|
[entityId]="npcId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
welcomeMessage="Je vois cette fiche de PNJ. Demande-moi de proposer apparence, motivations, secrets, ou répliques signatures."
|
||||||
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
|
(close)="chatOpen = false">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
157
web/src/app/campaigns/npc/npc-edit/npc-edit.component.scss
Normal file
157
web/src/app/campaigns/npc/npc-edit/npc-edit.component.scss
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.ne-page {
|
||||||
|
padding: 2rem 3rem;
|
||||||
|
color: #e5e7eb;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: white;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ai {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: rgba(167, 139, 250, 0.08);
|
||||||
|
border: 1px solid rgba(167, 139, 250, 0.4);
|
||||||
|
color: #a78bfa;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover { background: rgba(167, 139, 250, 0.15); border-color: #a78bfa; }
|
||||||
|
&.active { background: #a78bfa; color: #0b1220; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
&:hover { color: #e5e7eb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ne-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.4rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], textarea {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e5e7eb;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #a78bfa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-field textarea {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #a78bfa;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
&:hover:not(:disabled) { background: #c4b5fd; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { border-color: #374151; color: #e5e7eb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
Normal file
109
web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
|
||||||
|
import { NpcService } from '../../../services/npc.service';
|
||||||
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Éditeur plein écran d'une fiche de PNJ.
|
||||||
|
* Double rôle création/édition :
|
||||||
|
* - `/campaigns/:campaignId/npcs/create` → POST
|
||||||
|
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT
|
||||||
|
*
|
||||||
|
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
|
||||||
|
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-npc-edit',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||||
|
templateUrl: './npc-edit.component.html',
|
||||||
|
styleUrls: ['./npc-edit.component.scss']
|
||||||
|
})
|
||||||
|
export class NpcEditComponent implements OnInit {
|
||||||
|
readonly Save = Save;
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Drama = Drama;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
|
/** État drawer chat IA focalisé sur ce PNJ. */
|
||||||
|
chatOpen = false;
|
||||||
|
readonly chatQuickSuggestions = [
|
||||||
|
'Propose une apparence et une posture marquantes',
|
||||||
|
'Suggère 2 motivations et un secret pour ce PNJ',
|
||||||
|
'Imagine 3 répliques signatures qui le caractérisent'
|
||||||
|
];
|
||||||
|
|
||||||
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
|
campaignId: string | null = null;
|
||||||
|
npcId: string | null = null;
|
||||||
|
|
||||||
|
name = '';
|
||||||
|
markdownContent = '';
|
||||||
|
private order = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private service: NpcService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const params = this.route.snapshot.paramMap;
|
||||||
|
this.campaignId = params.get('campaignId');
|
||||||
|
this.npcId = params.get('npcId');
|
||||||
|
|
||||||
|
if (this.npcId) {
|
||||||
|
this.service.getById(this.npcId).subscribe({
|
||||||
|
next: (n) => {
|
||||||
|
this.name = n.name;
|
||||||
|
this.markdownContent = n.markdownContent ?? '';
|
||||||
|
this.order = n.order ?? 0;
|
||||||
|
},
|
||||||
|
error: () => this.back()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
|
const req = this.npcId
|
||||||
|
? this.service.update(this.npcId, {
|
||||||
|
id: this.npcId,
|
||||||
|
name: this.name.trim(),
|
||||||
|
markdownContent: this.markdownContent || null,
|
||||||
|
campaignId: this.campaignId,
|
||||||
|
order: this.order
|
||||||
|
})
|
||||||
|
: this.service.create({
|
||||||
|
name: this.name.trim(),
|
||||||
|
markdownContent: this.markdownContent || null,
|
||||||
|
campaignId: this.campaignId
|
||||||
|
});
|
||||||
|
req.subscribe({
|
||||||
|
next: () => this.back(),
|
||||||
|
error: () => console.error('Erreur sauvegarde Npc')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNpc(): void {
|
||||||
|
if (!this.npcId) return;
|
||||||
|
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||||
|
this.service.delete(this.npcId).subscribe({
|
||||||
|
next: () => this.back(),
|
||||||
|
error: () => console.error('Erreur suppression Npc')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/campaigns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule } from 'lucide-angular';
|
import { LucideAngularModule } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Campaign } from '../../../services/campaign.model';
|
||||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'une nouvelle scène rattachée à un chapitre.
|
* Écran de création d'une nouvelle scène rattachée à un chapitre.
|
||||||
@@ -40,6 +41,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -59,7 +61,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
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 }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||||
this.chapterName = currentChapter?.name ?? '';
|
this.chapterName = currentChapter?.name ?? '';
|
||||||
@@ -92,7 +94,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
order: this.existingSceneCount + 1,
|
order: this.existingSceneCount + 1,
|
||||||
icon: this.selectedIcon
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
|
||||||
error: () => console.error('Erreur lors de la création de la scène')
|
error: () => console.error('Erreur lors de la création de la scène')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,20 +5,21 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Scene, SceneBranch } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Page } from '../../services/page.model';
|
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Page } from '../../../services/page.model';
|
||||||
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'une Scène.
|
* Écran de détail/modification d'une Scène.
|
||||||
@@ -71,6 +72,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -122,7 +124,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
scene: this.campaignService.getSceneById(this.sceneId),
|
scene: this.campaignService.getSceneById(this.sceneId),
|
||||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
@@ -4,16 +4,17 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { resolveCampaignIcon } from '../campaign-icons';
|
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { PageService } from '../../../services/page.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||||
import { Campaign, Scene } from '../../services/campaign.model';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { Page } from '../../services/page.model';
|
import { Campaign, Scene } from '../../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { Page } from '../../../services/page.model';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||||
|
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de consultation d'une Scène (lecture seule).
|
* Écran de consultation d'une Scène (lecture seule).
|
||||||
@@ -45,6 +46,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
private characterService: CharacterService,
|
private characterService: CharacterService,
|
||||||
|
private npcService: NpcService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -74,7 +76,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
scene: this.campaignService.getSceneById(this.sceneId),
|
scene: this.campaignService.getSceneById(this.sceneId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
@@ -254,7 +254,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
next: (created) => {
|
next: (created) => {
|
||||||
const updated = { ...created, values };
|
const updated = { ...created, values };
|
||||||
this.pageService.update(created.id!, updated).subscribe({
|
this.pageService.update(created.id!, updated).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
next: () => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']),
|
||||||
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
|
error: () => this.wizardError = 'Page créée, mais impossible d\'appliquer les valeurs.'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type ChatStreamEvent =
|
|||||||
* décode ligne par ligne pour extraire les événements SSE.
|
* décode ligne par ligne pour extraire les événements SSE.
|
||||||
*/
|
*/
|
||||||
/** Type d'entité narrative focus pour le chat Campagne. */
|
/** Type d'entité narrative focus pour le chat Campagne. */
|
||||||
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
|
export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'npc';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AiChatService {
|
export class AiChatService {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface Conversation {
|
|||||||
export interface ConversationContext {
|
export interface ConversationContext {
|
||||||
loreId?: string | null;
|
loreId?: string | null;
|
||||||
campaignId?: string | null;
|
campaignId?: string | null;
|
||||||
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | null;
|
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | 'character' | 'npc' | null;
|
||||||
entityId?: string | null;
|
entityId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
web/src/app/services/npc.model.ts
Normal file
18
web/src/app/services/npc.model.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||||
|
* MVP : markdownContent libre (description, motivation, stats, notes MJ).
|
||||||
|
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
|
||||||
|
*/
|
||||||
|
export interface Npc {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
markdownContent?: string | null;
|
||||||
|
campaignId: string;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NpcCreate {
|
||||||
|
name: string;
|
||||||
|
markdownContent?: string | null;
|
||||||
|
campaignId: string;
|
||||||
|
}
|
||||||
34
web/src/app/services/npc.service.ts
Normal file
34
web/src/app/services/npc.service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Npc, NpcCreate } from './npc.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service HTTP pour les fiches de PNJ d'une campagne.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NpcService {
|
||||||
|
private apiUrl = '/api/npcs';
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
getByCampaign(campaignId: string): Observable<Npc[]> {
|
||||||
|
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Observable<Npc> {
|
||||||
|
return this.http.get<Npc>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(payload: NpcCreate): Observable<Npc> {
|
||||||
|
return this.http.post<Npc>(this.apiUrl, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, payload: Npc): Observable<Npc> {
|
||||||
|
return this.http.put<Npc>(`${this.apiUrl}/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,29 @@ import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflet de UpdateCheckService.UpdateStatus cote backend.
|
* Reflet de UpdateCheckService.UpdateStatus cote backend.
|
||||||
|
*
|
||||||
|
* Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
|
||||||
|
* UNKNOWN signale que la comparaison est impossible (baseline absente ou
|
||||||
|
* remote injoignable) — l'UI doit afficher un avertissement plutot que
|
||||||
|
* d'annoncer "a jour" silencieusement.
|
||||||
*/
|
*/
|
||||||
|
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||||
|
|
||||||
export interface ImageStatus {
|
export interface ImageStatus {
|
||||||
image: string;
|
image: string;
|
||||||
localDigest: string | null;
|
localDigest: string | null;
|
||||||
remoteDigest: string | null;
|
remoteDigest: string | null;
|
||||||
|
status: ImageStatusKind;
|
||||||
|
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus {
|
export interface UpdateStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** True si au moins une image a status === 'UPDATE_AVAILABLE'. */
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
|
/** True si au moins une image a status === 'UNKNOWN'. */
|
||||||
|
anyUnknown: boolean;
|
||||||
images: ImageStatus[];
|
images: ImageStatus[];
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,16 +244,21 @@
|
|||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
<span>Une mise a jour est disponible.</span>
|
<span>Une mise a jour est disponible.</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!updateStatus?.updateAvailable" class="hint">
|
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
||||||
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
|
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
||||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="update-images" *ngIf="updateStatus?.images?.length">
|
<ul class="update-images" *ngIf="updateStatus?.images?.length">
|
||||||
<li *ngFor="let img of updateStatus?.images">
|
<li *ngFor="let img of updateStatus?.images">
|
||||||
<strong>{{ img.image }}</strong>
|
<strong>{{ img.image }}</strong>
|
||||||
<span *ngIf="img.updateAvailable" class="badge-update">MAJ dispo</span>
|
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
|
||||||
<span *ngIf="!img.updateAvailable && img.remoteDigest" class="badge-ok">a jour</span>
|
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
|
||||||
<span *ngIf="!img.remoteDigest" class="badge-warn">indisponible</span>
|
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
|
||||||
|
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,7 @@
|
|||||||
}
|
}
|
||||||
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
|
.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; }
|
||||||
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; }
|
.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; }
|
||||||
|
.alert-warn { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
|
||||||
|
|
||||||
/* --- Slider fenetre de contexte -------------------------------------- */
|
/* --- Slider fenetre de contexte -------------------------------------- */
|
||||||
.ctx-value {
|
.ctx-value {
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export class SettingsComponent implements OnInit {
|
|||||||
pullTotal = 0;
|
pullTotal = 0;
|
||||||
/** Souscription au flux de pull pour pouvoir l'annuler. */
|
/** Souscription au flux de pull pour pouvoir l'annuler. */
|
||||||
private pullSubscription: Subscription | null = null;
|
private pullSubscription: Subscription | null = null;
|
||||||
|
/** True si on a recu un evenement {status:"success"} d'Ollama. Sans ca,
|
||||||
|
* une fermeture de stream (timeout proxy, perte reseau) ne doit PAS etre
|
||||||
|
* interpretee comme une reussite. */
|
||||||
|
private pullSucceeded = false;
|
||||||
|
|
||||||
/** Modele en cours de suppression (nom) pour disabler son bouton. */
|
/** Modele en cours de suppression (nom) pour disabler son bouton. */
|
||||||
deletingModel: string | null = null;
|
deletingModel: string | null = null;
|
||||||
@@ -293,6 +297,9 @@ export class SettingsComponent implements OnInit {
|
|||||||
if (event.status) this.pullStatus = event.status;
|
if (event.status) this.pullStatus = event.status;
|
||||||
if (event.completed != null) this.pullCompleted = event.completed;
|
if (event.completed != null) this.pullCompleted = event.completed;
|
||||||
if (event.total != null) this.pullTotal = event.total;
|
if (event.total != null) this.pullTotal = event.total;
|
||||||
|
// Marqueur explicite : Ollama emet "success" en derniere ligne quand
|
||||||
|
// le pull est reellement complet (manifest + layers + verify).
|
||||||
|
if (event.status === 'success') this.pullSucceeded = true;
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
|
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
|
||||||
@@ -300,6 +307,14 @@ export class SettingsComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
this.pullInProgress = false;
|
this.pullInProgress = false;
|
||||||
|
if (!this.pullSucceeded) {
|
||||||
|
// Stream ferme sans 'success' final = connexion coupee
|
||||||
|
// (timeout proxy, perte reseau, ...). Le modele est probablement
|
||||||
|
// partiellement telecharge ; Ollama gardera les couches deja DL.
|
||||||
|
this.errorMessage = `Telechargement de ${name} interrompu avant la fin. Relancez pour reprendre.`;
|
||||||
|
this.refreshModels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.successMessage = `Modele ${name} telecharge.`;
|
this.successMessage = `Modele ${name} telecharge.`;
|
||||||
this.refreshModels();
|
this.refreshModels();
|
||||||
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
|
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
|
||||||
@@ -326,6 +341,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.pullStatus = '';
|
this.pullStatus = '';
|
||||||
this.pullCompleted = 0;
|
this.pullCompleted = 0;
|
||||||
this.pullTotal = 0;
|
this.pullTotal = 0;
|
||||||
|
this.pullSucceeded = false;
|
||||||
if (this.pullSubscription) {
|
if (this.pullSubscription) {
|
||||||
this.pullSubscription.unsubscribe();
|
this.pullSubscription.unsubscribe();
|
||||||
this.pullSubscription = null;
|
this.pullSubscription = null;
|
||||||
|
|||||||
@@ -31,5 +31,9 @@
|
|||||||
"strictInjectionParameters": true,
|
"strictInjectionParameters": true,
|
||||||
"strictInputAccessModifiers": true,
|
"strictInputAccessModifiers": true,
|
||||||
"strictTemplates": true
|
"strictTemplates": true
|
||||||
}
|
},
|
||||||
|
"exclude": [
|
||||||
|
"e2e/**/*",
|
||||||
|
"playwright.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user