5 Commits

Author SHA1 Message Date
49a82d05f7 Chat persistant pour la partie lore et la partie campagne pour chaque page / scène.....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m26s
Correction du carroussel
Passage en v0.4.0
Correction du docker compose pour tout le temps utiliser le bon port que ce soit prod ou dev
2026-04-21 23:35:43 +02:00
b0fe8de708 Passage v 0.3.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-21 16:56:57 +02:00
71449bee1b Amélioration de l'UI : meilleur affichage des images que ce soit dans la partie lore ou la partie campagne (partie campagne : visualisation scrapbooking). Possibilité de réordonner les champs dans les templates...
Passage v0.3.0
2026-04-21 16:56:27 +02:00
1e34f7f954 Mise en place de tests unitaires et de jacoco pour la partie core 2026-04-21 15:19:45 +02:00
e185dabc45 Mise à jour de l'install.md 2026-04-21 15:01:32 +02:00
98 changed files with 5244 additions and 307 deletions

View File

@@ -1,75 +1,311 @@
# Installation de LoreMindMJ # Installation de LoreMindMJ
## Prerequis Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
5 a 10 minutes selon la qualite de la connexion reseau.
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/)) ## 1. Prerequis
ou **Docker Engine + Compose v2** (Linux).
- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local.
Sinon, une cle API [1min.ai](https://1min.ai) suffit.
## Installation (5 minutes) - **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`.
1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi. - **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).
2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Trois variables sont **obligatoires** : - Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
- `POSTGRES_PASSWORD` : mot de passe de la base (choisis-en un). la taille des modeles Ollama si l'option locale est retenue.
- `ADMIN_PASSWORD` : protege l'ecran Parametres de l'appli. Tu le taperas dans une popup du navigateur.
- `BRAIN_INTERNAL_SECRET` : secret interne partage entre les services. Genere une valeur aleatoire :
```
openssl rand -hex 32
```
(Sous Windows sans openssl : utilise un generateur en ligne type "random hex string 64 chars".)
Sans ces trois variables, `docker compose up` refusera de demarrer — c'est volontaire pour eviter un deploiement non-securise par defaut. ## 2. Recuperation des fichiers
3. Dans un terminal, place-toi dans le dossier et lance : Telecharger les deux fichiers suivants depuis la
``` [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
docker compose up -d les placer dans un dossier dedie (par exemple `~/loremind/` ou
``` `C:\Programs\loremind\`) :
Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes.
4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu ! - `docker-compose.yml`
- `.env.example`
## Mise a jour 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 pull
docker compose up -d docker compose up -d
``` ```
Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour. Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
stockees dans des volumes Docker et survivent aux mises a jour.
## LLM : Ollama ou 1min.ai ? ## 7. Sauvegarde
**Ollama (local, gratuit)** — Edite `.env` : Les donnees sont reparties dans trois volumes Docker :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
```
Telecharge le modele au prealable : `ollama pull gemma4:26b`.
**1min.ai (cloud, paye)** — Edite `.env` : - `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
``` campagnes, pages, templates, branches narratives, etc.).
LLM_PROVIDER=onemin - `loremindmj_minio-data` — images uploadees.
ONEMIN_API_KEY=sk-... - `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
ONEMIN_MODEL=open-mistral-nemo 1min.ai).
```
Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli. ### Export SQL de la base
## Problemes frequents
- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`.
- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge.
- **"set ADMIN_PASSWORD in .env" / "set BRAIN_INTERNAL_SECRET in .env"** au lancement : tu as oublie une des variables obligatoires de l'etape 2.
- **Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres** : c'est normal. Utilise `admin` (ou ce que tu as mis dans `ADMIN_USERNAME`) et ton `ADMIN_PASSWORD`.
- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees).
## Sauvegarde
Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`.
Sauvegarde rapide de la base :
``` ```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql 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`.

View File

@@ -67,4 +67,9 @@ docker compose up -d --build
## License ## License
[À définir] LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -81,6 +81,20 @@ class ChatUseCase:
): ):
yield token yield token
def build_system_prompt(
self,
lore_context: LoreStructuralContext | None = None,
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity
)
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
def _build_system_prompt( def _build_system_prompt(

View File

@@ -9,6 +9,7 @@ from typing import Annotated, AsyncIterator, Literal
import hmac import hmac
import httpx import httpx
import tiktoken
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -37,10 +38,27 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.2.0", version="0.4.0",
) )
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
# suffisant pour une jauge visuelle à l'utilisateur.
_TOKEN_ENCODER: tiktoken.Encoding | None = None
def _count_tokens(text: str | None) -> int:
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
if not text:
return 0
global _TOKEN_ENCODER
if _TOKEN_ENCODER is None:
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
return len(_TOKEN_ENCODER.encode(text))
# Chemins exemptes d'auth inter-service : healthcheck docker + introspection # Chemins exemptes d'auth inter-service : healthcheck docker + introspection
# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain # FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain
# n'est pas expose en dehors du reseau interne donc pas un risque). # n'est pas expose en dehors du reseau interne donc pas un risque).
@@ -335,7 +353,32 @@ async def chat_stream(
campaign_context = _to_campaign_context(body.campaign_context) campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity) narrative_entity = _to_narrative_entity(body.narrative_entity)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
# Cette duplication évite de complexifier le contrat stream() avec un
# paramètre optionnel system_prompt précalculé.
system_prompt_preview = use_case.build_system_prompt(
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
history_msgs = messages[:-1] if messages else []
settings = get_settings()
usage_payload = {
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx,
}
async def event_stream() -> AsyncIterator[str]: async def event_stream() -> AsyncIterator[str]:
# Event 'usage' émis en tout premier : le frontend peut afficher la
# jauge avant même le premier token de réponse.
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
try: try:
async for token in use_case.stream( async for token in use_case.stream(
messages, messages,
@@ -353,6 +396,60 @@ async def chat_stream(
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
# --- Auto-titre d'une conversation persistee --------------------------------
class SummarizeTitleMessageDTO(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class SummarizeTitleRequestDTO(BaseModel):
"""Premiers messages d'une conversation pour auto-generer un titre court."""
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
class SummarizeTitleResponseDTO(BaseModel):
title: str
_TITLE_SYSTEM_PROMPT = (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title(
body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation.
Appele par le core apres le 1er couple user/assistant, pour remplacer le
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
"""
if not body.messages:
raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
try:
raw = await llm.generate(prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
if len(title) > 80:
title = title[:80].rstrip()
if not title:
title = "Nouvelle conversation"
return SummarizeTitleResponseDTO(title=title)
# --- Mapping DTO → domaine (frontière HTTP) --------------------------------- # --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
@@ -449,6 +546,9 @@ class SettingsDTO(BaseModel):
onemin_model: str onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool onemin_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
class SettingsUpdateDTO(BaseModel): class SettingsUpdateDTO(BaseModel):
@@ -460,6 +560,7 @@ class SettingsUpdateDTO(BaseModel):
onemin_model: str | None = None onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement. # Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None onemin_api_key: str | None = None
llm_num_ctx: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO: def _to_settings_dto(s: Settings) -> SettingsDTO:
@@ -469,6 +570,7 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model, llm_model=s.llm_model,
onemin_model=s.onemin_model, onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key), onemin_api_key_set=bool(s.onemin_api_key),
llm_num_ctx=s.llm_num_ctx,
) )
@@ -512,6 +614,50 @@ async def list_ollama_models(
return {"models": sorted(models)} return {"models": sorted(models)}
class OllamaModelInfoDTO(BaseModel):
"""Info utile extraite de /api/show pour un modele Ollama donne.
`context_length` = fenetre de contexte max supportee par le modele
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
pour borner le slider de num_ctx dans les Parametres.
"""
context_length: int = 0
@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
async def get_ollama_model_info(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> OllamaModelInfoDTO:
"""Retourne les metadonnees d'un modele Ollama via /api/show.
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
mal dans une URL — le body JSON evite le probleme d'escaping.
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@app.get("/models/onemin") @app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]: def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur. """Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -4,3 +4,9 @@ httpx==0.27.*
pydantic-settings==2.6.* pydantic-settings==2.6.*
pydantic pydantic
# Comptage de tokens pour la jauge de contexte (UI chat drawer).
# L'encodage cl100k_base (GPT-4/3.5) donne une approximation correcte pour
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
# pour une jauge visuelle.
tiktoken==0.8.*

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.2.0</version> <version>0.4.0</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -99,6 +99,29 @@
</excludes> </excludes>
</configuration> </configuration>
</plugin> </plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires.
Rapport HTML auto-genere a chaque `mvn test` dans target/site/jacoco/. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@@ -0,0 +1,134 @@
package com.loremind.application.conversationcontext;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* Service d'application du contexte Conversation.
*
* Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat).
*
* Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine,
* tous deux non-null = niveau entite precise).
*/
@Service
public class ConversationService {
private final ConversationRepository repository;
private final ConversationTitleGenerator titleGenerator;
public ConversationService(ConversationRepository repository,
ConversationTitleGenerator titleGenerator) {
this.repository = repository;
this.titleGenerator = titleGenerator;
}
/** Donnees de creation d'une conversation. Titre optionnel — sera auto-genere si absent. */
public record CreateData(
String title,
String loreId,
String campaignId,
String entityType,
String entityId) {}
public Conversation create(CreateData data) {
validateAnchor(data.loreId(), data.campaignId(), data.entityType(), data.entityId());
String title = (data.title() == null || data.title().isBlank())
? "Nouvelle conversation"
: data.title().trim();
Conversation conv = Conversation.builder()
.title(title)
.loreId(data.loreId())
.campaignId(data.campaignId())
.entityType(data.entityType())
.entityId(data.entityId())
.build();
return repository.save(conv);
}
public Optional<Conversation> getById(String id) {
return repository.findById(id);
}
public List<Conversation> listByContext(String loreId, String campaignId, String entityType, String entityId) {
validateAnchor(loreId, campaignId, entityType, entityId);
return repository.findByContext(loreId, campaignId, entityType, entityId);
}
public void rename(String id, String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Le titre ne peut pas etre vide");
}
if (repository.findById(id).isEmpty()) {
throw new IllegalArgumentException("Conversation introuvable : " + id);
}
repository.updateTitle(id, title.trim());
}
public void delete(String id) {
repository.deleteById(id);
}
/**
* Auto-genere un titre a partir des premiers messages et le persiste.
* Appele typiquement apres le 1er couple user/assistant pour remplacer
* le titre provisoire. Echec silencieux (fallback dans l'adaptateur) —
* on n'empeche pas la conversation de fonctionner si le Brain est down.
*/
public String autoGenerateTitle(String conversationId) {
Conversation conv = repository.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation introuvable : " + conversationId));
List<ConversationMessage> seeds = conv.getMessages();
if (seeds == null || seeds.isEmpty()) {
return conv.getTitle();
}
String title = titleGenerator.generate(seeds);
repository.updateTitle(conversationId, title);
return title;
}
/**
* Ajoute un message (user ou assistant) a une conversation existante.
* L'horodatage et l'id sont assignes par la couche persistance.
*/
public ConversationMessage appendMessage(String conversationId, String role, String content) {
if (role == null || (!role.equals("user") && !role.equals("assistant") && !role.equals("system"))) {
throw new IllegalArgumentException("Role invalide : " + role);
}
if (content == null || content.isEmpty()) {
throw new IllegalArgumentException("Contenu vide interdit");
}
ConversationMessage msg = ConversationMessage.builder()
.role(role)
.content(content)
.build();
return repository.appendMessage(conversationId, msg);
}
// ---------- Validation ----------
private void validateAnchor(String loreId, String campaignId, String entityType, String entityId) {
boolean hasLore = loreId != null && !loreId.isBlank();
boolean hasCamp = campaignId != null && !campaignId.isBlank();
if (hasLore == hasCamp) {
throw new IllegalArgumentException("Exactement un parent attendu : loreId XOR campaignId");
}
boolean hasType = entityType != null && !entityType.isBlank();
boolean hasEntId = entityId != null && !entityId.isBlank();
if (hasType != hasEntId) {
throw new IllegalArgumentException("entityType et entityId doivent etre tous deux null ou tous deux non-null");
}
}
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -65,6 +66,7 @@ public class StreamChatForCampaignUseCase {
String entityType, String entityType,
String entityId, String entityId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -84,7 +86,7 @@ public class StreamChatForCampaignUseCase {
.narrativeEntity(narrativeEntity) .narrativeEntity(narrativeEntity)
.build(); .build();
aiChatProvider.streamChat(request, onToken, onComplete, onError); aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
} }
/** /**

View File

@@ -2,6 +2,7 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.PageContext; import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
@@ -60,6 +61,7 @@ public class StreamChatForLoreUseCase {
String loreId, String loreId,
String pageId, String pageId,
List<ChatMessage> messages, List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -75,7 +77,7 @@ public class StreamChatForLoreUseCase {
.pageContext(pageContext) .pageContext(pageContext)
.build(); .build();
aiChatProvider.streamChat(request, onToken, onComplete, onError); aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
} }
/** /**

View File

@@ -38,12 +38,18 @@ public class Arc {
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** /**
* IDs des images (Shared Kernel) servant d'illustrations a cet arc. * IDs des images (Shared Kernel) servant d'illustrations a cet arc (ambiance).
* Galerie ordonnee : la 1ere image est l'illustration principale. * Galerie ordonnee : la 1ere image est l'illustration principale.
*/ */
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -34,11 +34,17 @@ public class Chapter {
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** /**
* IDs des images (Shared Kernel) illustrant ce chapitre. * IDs des images (Shared Kernel) illustrant ce chapitre (ambiance).
*/ */
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -48,11 +48,19 @@ public class Scene {
/** /**
* IDs des images (Shared Kernel) illustrant cette scene. * IDs des images (Shared Kernel) illustrant cette scene.
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance. * Vocation "ambiance" : portraits, decors, moodboard. Rendu facon editorial.
*/ */
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans.
* Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique.
* Rendu different des illustrations : vignettes plus grandes, ratio natif preserve.
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
/** /**
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre). * Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
* Chaque branche décrit un choix des joueurs et la scène de destination. * Chaque branche décrit un choix des joueurs et la scène de destination.

View File

@@ -0,0 +1,47 @@
package com.loremind.domain.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Agregat d'une conversation de chat IA persistee.
*
* Une conversation est ancree sur exactement un niveau de contexte :
* - un Lore (optionnellement une page precise)
* - une Campagne (optionnellement une entite narrative : arc/chapitre/scene)
*
* C'est cet ancrage qui permet au drawer de filtrer les conversations
* a afficher dans la sidebar selon l'ecran en cours.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Conversation {
private String id;
private String title;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Un seul des deux est non-null. */
private String loreId;
private String campaignId;
/**
* Type d'entite focus, null si la conversation est ancree au niveau
* Lore/Campagne racine (pas sur une page/scene precise).
* Valeurs : "page", "arc", "chapter", "scene".
*/
private String entityType;
private String entityId;
@Builder.Default
private List<ConversationMessage> messages = new ArrayList<>();
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Un message persiste d'une conversation.
*
* Distinct de {@link com.loremind.domain.generationcontext.ChatMessage}
* qui reste un simple record role+content pour le streaming LLM. Ici
* on ajoute id et horodatage, necessaires pour l'affichage / l'ordre.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessage {
private String id;
/** "user" | "assistant" | "system". */
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,44 @@
package com.loremind.domain.conversationcontext.ports;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import java.util.List;
import java.util.Optional;
/**
* Port de persistance des conversations de chat IA.
*
* Les methodes de lecture par contexte acceptent des filtres nullables :
* - `loreId` OU `campaignId` doit etre non-null (mais pas les deux)
* - `entityType` + `entityId` : soit tous les deux null (niveau racine),
* soit tous les deux non-null (niveau entite precise).
*/
public interface ConversationRepository {
Conversation save(Conversation conversation);
Optional<Conversation> findById(String id);
/**
* Liste les conversations filtrees par contexte strict, triees par
* updatedAt desc. Les messages ne sont PAS chargees (liste vide) pour
* garder la payload legere — la sidebar n'affiche que les titres.
*/
List<Conversation> findByContext(
String loreId,
String campaignId,
String entityType,
String entityId);
void deleteById(String id);
/**
* Ajoute un message a une conversation existante. Met a jour updatedAt
* de la conversation parent. Renvoie le message persiste (avec id + ts).
*/
ConversationMessage appendMessage(String conversationId, ConversationMessage message);
/** Rename atomique — ne touche pas aux messages. */
void updateTitle(String conversationId, String title);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.conversationcontext.ports;
import com.loremind.domain.conversationcontext.ConversationMessage;
import java.util.List;
/**
* Port : generation d'un titre court a partir des premiers echanges d'une
* conversation. Implemente via un appel Brain /summarize/conversation-title.
*/
public interface ConversationTitleGenerator {
/** Renvoie un titre en francais (4-7 mots max). Jamais null ni vide. */
String generate(List<ConversationMessage> firstMessages);
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.generationcontext;
/**
* Instantané d'occupation de la fenêtre de contexte à l'instant t du chat.
* <p>
* Émis une fois par tour de chat (juste avant le streaming des tokens) pour
* alimenter la jauge de contexte côté frontend. Les unités sont des tokens
* (approximés via tiktoken côté Brain — ±10% vs le tokenizer réel du modèle).
*
* @param system tokens consommés par le system prompt (contextes Lore/campagne injectés)
* @param history tokens consommés par l'historique de la conversation (hors dernier message)
* @param current tokens du dernier message utilisateur en attente de réponse
* @param max taille maximale configurée de la fenêtre de contexte
*/
public record ChatUsage(int system, int history, int current, int max) {
}

View File

@@ -1,6 +1,7 @@
package com.loremind.domain.generationcontext.ports; package com.loremind.domain.generationcontext.ports;
import com.loremind.domain.generationcontext.ChatRequest; import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -26,6 +27,10 @@ public interface AiChatProvider {
* HTTP côté controller SSE). * HTTP côté controller SSE).
* *
* @param request messages + contexte Lore * @param request messages + contexte Lore
* @param onUsage invoqué une fois au début du stream avec le bilan
* d'occupation de la fenêtre de contexte (tokens system /
* history / current / max). Peut ne jamais être invoqué
* si le provider ne supporte pas le comptage.
* @param onToken invoqué à chaque token reçu du LLM (peut être appelé * @param onToken invoqué à chaque token reçu du LLM (peut être appelé
* de nombreuses fois) * de nombreuses fois)
* @param onComplete invoqué une fois le stream terminé avec succès * @param onComplete invoqué une fois le stream terminé avec succès
@@ -34,6 +39,7 @@ public interface AiChatProvider {
*/ */
void streamChat( void streamChat(
ChatRequest request, ChatRequest request,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError Consumer<Throwable> onError

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.lorecontext;
/**
* Variante de rendu pour un champ de type IMAGE.
* <p>
* - GALLERY : grille de vignettes (defaut, comportement historique)
* - HERO : premiere image en banniere pleine largeur, suivantes en petit
* - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal
* <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
*/
public enum ImageLayout {
GALLERY,
HERO,
MASONRY,
CAROUSEL
}

View File

@@ -12,10 +12,9 @@ import lombok.NoArgsConstructor;
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET * Le type pilote le rendu cote front (textarea vs galerie d'images) ET
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation). * la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
* <p> * <p>
* Evolution de `List<String> fields` vers `List<TemplateField> fields` : * Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter * (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans * Ignore pour les champs TEXT.
* casser le contrat.
*/ */
@Data @Data
@Builder @Builder
@@ -26,14 +25,26 @@ public class TemplateField {
private String name; private String name;
/** Type du champ, pilote le rendu et la generation IA. */ /** Type du champ, pilote le rendu et la generation IA. */
private FieldType type; private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
/** Constructeur de retrocompat : type seul, layout=null. */
public TemplateField(String name, FieldType type) {
this(name, type, null);
}
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */ /** Raccourci : construit un champ de type TEXT (cas le plus courant). */
public static TemplateField text(String name) { public static TemplateField text(String name) {
return new TemplateField(name, FieldType.TEXT); return new TemplateField(name, FieldType.TEXT, null);
} }
/** Raccourci : construit un champ de type IMAGE. */ /** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) { public static TemplateField image(String name) {
return new TemplateField(name, FieldType.IMAGE); return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
return new TemplateField(name, FieldType.IMAGE, layout);
} }
} }

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSu
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;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -62,6 +63,7 @@ public class BrainAiChatClient implements AiChatProvider {
@Override @Override
public void streamChat( public void streamChat(
ChatRequest request, ChatRequest request,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Runnable onComplete, Runnable onComplete,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -81,7 +83,7 @@ public class BrainAiChatClient implements AiChatProvider {
// au contrat synchrone du port. L'appelant choisit le thread. // au contrat synchrone du port. L'appelant choisit le thread.
flux flux
.timeout(Duration.ofSeconds(120)) .timeout(Duration.ofSeconds(120))
.doOnNext(sse -> handleEvent(sse, onToken, onError)) .doOnNext(sse -> handleEvent(sse, onUsage, onToken, onError))
.blockLast(); .blockLast();
onComplete.run(); onComplete.run();
} catch (Exception e) { } catch (Exception e) {
@@ -90,9 +92,10 @@ public class BrainAiChatClient implements AiChatProvider {
} }
} }
/** Dispatch selon le type d'événement SSE (data par défaut, done, error). */ /** Dispatch selon le type d'événement SSE (data par défaut, done, error, usage). */
private void handleEvent( private void handleEvent(
ServerSentEvent<String> sse, ServerSentEvent<String> sse,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken, Consumer<String> onToken,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
@@ -106,6 +109,11 @@ public class BrainAiChatClient implements AiChatProvider {
if ("done".equals(event)) { if ("done".equals(event)) {
return; // la fin est gérée par blockLast + onComplete return; // la fin est gérée par blockLast + onComplete
} }
if ("usage".equals(event)) {
ChatUsage usage = extractUsage(data);
if (usage != null) onUsage.accept(usage);
return;
}
// Défaut : événement data avec JSON {"token":"..."}. // Défaut : événement data avec JSON {"token":"..."}.
String token = extractToken(data); String token = extractToken(data);
if (token != null && !token.isEmpty()) { if (token != null && !token.isEmpty()) {
@@ -113,6 +121,39 @@ public class BrainAiChatClient implements AiChatProvider {
} }
} }
/**
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
* Renvoie null si le payload est illisible — dans ce cas on ne propage
* simplement pas d'usage, le stream token continue normalement.
*/
private ChatUsage extractUsage(String json) {
if (json == null) return null;
try {
int system = extractIntField(json, "system");
int history = extractIntField(json, "history");
int current = extractIntField(json, "current");
int max = extractIntField(json, "max");
return new ChatUsage(system, history, current, max);
} catch (Exception e) {
return null;
}
}
/** Parse minimaliste d'un champ entier JSON sans dépendre de Jackson. */
private int extractIntField(String json, String field) {
String needle = "\"" + field + "\"";
int idx = json.indexOf(needle);
if (idx < 0) return 0;
int colon = json.indexOf(':', idx);
if (colon < 0) return 0;
int start = colon + 1;
while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
if (end == start) return 0;
return Integer.parseInt(json.substring(start, end));
}
/** /**
* Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici. * Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
* Si le format se complexifie, on remplacera par un DTO Jackson. * Si le format se complexifie, on remplacera par un DTO Jackson.

View File

@@ -0,0 +1,68 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour
* obtenir un titre court a partir des premiers messages.
*
* Fallback volontairement silencieux : si le Brain est indisponible, on
* renvoie un titre par defaut plutot que de casser l'UX chat.
*/
@Component
public class BrainConversationTitleClient implements ConversationTitleGenerator {
private static final String PATH = "/summarize/conversation-title";
private static final String FALLBACK = "Nouvelle conversation";
private final WebClient webClient;
public BrainConversationTitleClient(
WebClient.Builder builder,
@Value("${brain.base-url}") String baseUrl) {
this.webClient = builder.baseUrl(baseUrl).build();
}
@Override
public String generate(List<ConversationMessage> firstMessages) {
if (firstMessages == null || firstMessages.isEmpty()) {
return FALLBACK;
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("messages", firstMessages.stream()
.map(m -> Map.<String, Object>of(
"role", m.getRole(),
"content", m.getContent() == null ? "" : m.getContent()))
.collect(Collectors.toList()));
try {
@SuppressWarnings("unchecked")
Map<String, Object> resp = webClient.post()
.uri(PATH)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(20))
.block();
if (resp == null) return FALLBACK;
Object title = resp.get("title");
if (title == null) return FALLBACK;
String s = title.toString().trim();
return s.isEmpty() ? FALLBACK : s;
} catch (Exception e) {
return FALLBACK;
}
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.lorecontext.TemplateField;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter; import jakarta.persistence.Converter;
@@ -72,8 +73,20 @@ public class TemplateFieldListJsonConverter
// Type inconnu (ajoute par une version future) : fallback TEXT. // Type inconnu (ajoute par une version future) : fallback TEXT.
type = FieldType.TEXT; type = FieldType.TEXT;
} }
ImageLayout layout = null;
if (type == FieldType.IMAGE) {
String layoutStr = item.path("layout").asText(null);
if (layoutStr != null && !layoutStr.isBlank()) {
try {
layout = ImageLayout.valueOf(layoutStr);
} catch (IllegalArgumentException ex) {
// Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI.
layout = null;
}
}
}
if (name != null && !name.isBlank()) { if (name != null && !name.isBlank()) {
result.add(new TemplateField(name, type)); result.add(new TemplateField(name, type, layout));
} }
} }
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement. // Autres types de noeuds (nombre, booleen...) : ignores silencieusement.

View File

@@ -68,6 +68,12 @@ public class ArcJpaEntity {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images "cartes / plans". */
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -57,6 +57,11 @@ public class ChapterJpaEntity {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -0,0 +1,89 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Persistance d'une conversation de chat IA.
*
* Les refs loreId / campaignId / entityId sont des weak references (String,
* pas de FK) — coherent avec la politique inter-contexte du reste du code.
* Indexes compose pour accelerer le listing par contexte dans la sidebar.
*/
@Entity
@Table(name = "conversations", indexes = {
@Index(name = "idx_conv_lore_entity", columnList = "lore_id,entity_type,entity_id,updated_at"),
@Index(name = "idx_conv_campaign_entity", columnList = "campaign_id,entity_type,entity_id,updated_at")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(name = "lore_id")
private String loreId;
@Column(name = "campaign_id")
private String campaignId;
@Column(name = "entity_type")
private String entityType;
@Column(name = "entity_id")
private String entityId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Messages enfants. Charges a la demande (fetch=LAZY) pour ne pas plomber
* le listing sidebar. Cascade ALL + orphanRemoval : la suppression d'une
* conversation efface ses messages.
*/
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("createdAt ASC, id ASC")
@Builder.Default
private List<ConversationMessageJpaEntity> messages = new ArrayList<>();
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,59 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
* Les messages sont ordonnes par createdAt ASC (ordre d'ajout = ordre lu).
*/
@Entity
@Table(name = "conversation_messages")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** "user" | "assistant" | "system". */
@Column(nullable = false, length = 16)
private String role;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* Reference vers la conversation parent. ToString exclu pour eviter une
* boucle infinie quand Lombok genere toString() (conv -> messages -> conv...).
*/
@ManyToOne(optional = false)
@JoinColumn(name = "conversation_id", nullable = false)
@ToString.Exclude
private ConversationJpaEntity conversation;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@@ -80,6 +80,11 @@ public class SceneJpaEntity {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes. // Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes). // Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
@Column(name = "branches", columnDefinition = "TEXT") @Column(name = "branches", columnDefinition = "TEXT")

View File

@@ -0,0 +1,64 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Repository Spring Data JPA pour ConversationJpaEntity.
*
* Les requetes de listing par contexte gerent explicitement les NULL parce
* que JPQL `=` ne matche pas NULL. On combine `IS NULL` / `=` selon si le
* filtre est fourni — plus simple qu'une Specification Criteria API.
*/
@Repository
public interface ConversationJpaRepository extends JpaRepository<ConversationJpaEntity, Long> {
/** Listing Lore racine (entity_type IS NULL). */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.loreId = :loreId
AND c.entityType IS NULL
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByLoreRoot(@Param("loreId") String loreId);
/** Listing Lore + entite precise. */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.loreId = :loreId
AND c.entityType = :entityType
AND c.entityId = :entityId
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByLoreAndEntity(
@Param("loreId") String loreId,
@Param("entityType") String entityType,
@Param("entityId") String entityId);
/** Listing Campagne racine. */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.campaignId = :campaignId
AND c.entityType IS NULL
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByCampaignRoot(@Param("campaignId") String campaignId);
/** Listing Campagne + entite precise. */
@Query("""
SELECT c FROM ConversationJpaEntity c
WHERE c.campaignId = :campaignId
AND c.entityType = :entityType
AND c.entityId = :entityId
ORDER BY c.updatedAt DESC
""")
List<ConversationJpaEntity> findByCampaignAndEntity(
@Param("campaignId") String campaignId,
@Param("entityType") String entityType,
@Param("entityId") String entityId);
}

View File

@@ -83,6 +83,9 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds()) ? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt()) .createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt()) .updatedAt(jpaEntity.getUpdatedAt())
.build(); .build();
@@ -107,6 +110,9 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(arc.getIllustrationImageIds() != null .illustrationImageIds(arc.getIllustrationImageIds() != null
? new ArrayList<>(arc.getIllustrationImageIds()) ? new ArrayList<>(arc.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(arc.getMapImageIds() != null
? new ArrayList<>(arc.getMapImageIds())
: new ArrayList<>())
.createdAt(arc.getCreatedAt()) .createdAt(arc.getCreatedAt())
.updatedAt(arc.getUpdatedAt()) .updatedAt(arc.getUpdatedAt())
.build(); .build();

View File

@@ -80,6 +80,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds()) ? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt()) .createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt()) .updatedAt(jpaEntity.getUpdatedAt())
.build(); .build();
@@ -102,6 +105,9 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(chapter.getIllustrationImageIds() != null .illustrationImageIds(chapter.getIllustrationImageIds() != null
? new ArrayList<>(chapter.getIllustrationImageIds()) ? new ArrayList<>(chapter.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(chapter.getMapImageIds() != null
? new ArrayList<>(chapter.getMapImageIds())
: new ArrayList<>())
.createdAt(chapter.getCreatedAt()) .createdAt(chapter.getCreatedAt())
.updatedAt(chapter.getUpdatedAt()) .updatedAt(chapter.getUpdatedAt())
.build(); .build();

View File

@@ -0,0 +1,148 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationRepository;
import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur Postgres pour ConversationRepository.
*
* Les methodes de listing ne chargent PAS les messages (messages LAZY,
* liste vide renvoyee cote domaine) — la sidebar n'a besoin que des
* meta-donnees. findById charge les messages via fetch explicite de la
* collection dans une transaction.
*/
@Repository
public class PostgresConversationRepository implements ConversationRepository {
private final ConversationJpaRepository jpa;
public PostgresConversationRepository(ConversationJpaRepository jpa) {
this.jpa = jpa;
}
@Override
@Transactional
public Conversation save(Conversation conversation) {
ConversationJpaEntity entity = toJpaEntity(conversation);
ConversationJpaEntity saved = jpa.save(entity);
return toDomain(saved, true);
}
@Override
@Transactional(readOnly = true)
public Optional<Conversation> findById(String id) {
return jpa.findById(Long.parseLong(id))
.map(e -> {
// Force l'initialisation LAZY avant de sortir de la transaction.
e.getMessages().size();
return toDomain(e, true);
});
}
@Override
@Transactional(readOnly = true)
public List<Conversation> findByContext(String loreId, String campaignId, String entityType, String entityId) {
List<ConversationJpaEntity> rows;
if (loreId != null) {
rows = (entityType == null)
? jpa.findByLoreRoot(loreId)
: jpa.findByLoreAndEntity(loreId, entityType, entityId);
} else if (campaignId != null) {
rows = (entityType == null)
? jpa.findByCampaignRoot(campaignId)
: jpa.findByCampaignAndEntity(campaignId, entityType, entityId);
} else {
return Collections.emptyList();
}
return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList());
}
@Override
@Transactional
public void deleteById(String id) {
jpa.deleteById(Long.parseLong(id));
}
@Override
@Transactional
public ConversationMessage appendMessage(String conversationId, ConversationMessage message) {
ConversationJpaEntity conv = jpa.findById(Long.parseLong(conversationId))
.orElseThrow(() -> new EntityNotFoundException("Conversation " + conversationId));
ConversationMessageJpaEntity msg = ConversationMessageJpaEntity.builder()
.role(message.getRole())
.content(message.getContent())
.conversation(conv)
.build();
conv.getMessages().add(msg);
// Force updatedAt via @PreUpdate en modifiant la conv (touch).
conv.setUpdatedAt(java.time.LocalDateTime.now());
ConversationJpaEntity saved = jpa.save(conv);
ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1);
return toDomainMessage(persisted);
}
@Override
@Transactional
public void updateTitle(String conversationId, String title) {
ConversationJpaEntity conv = jpa.findById(Long.parseLong(conversationId))
.orElseThrow(() -> new EntityNotFoundException("Conversation " + conversationId));
conv.setTitle(title);
jpa.save(conv);
}
// ---------- Mapping ----------
private ConversationJpaEntity toJpaEntity(Conversation c) {
Long id = c.getId() != null ? Long.parseLong(c.getId()) : null;
return ConversationJpaEntity.builder()
.id(id)
.title(c.getTitle())
.loreId(c.getLoreId())
.campaignId(c.getCampaignId())
.entityType(c.getEntityType())
.entityId(c.getEntityId())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.build();
}
private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) {
List<ConversationMessage> msgs = withMessages
? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList())
: new java.util.ArrayList<>();
return Conversation.builder()
.id(e.getId().toString())
.title(e.getTitle())
.loreId(e.getLoreId())
.campaignId(e.getCampaignId())
.entityType(e.getEntityType())
.entityId(e.getEntityId())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.messages(msgs)
.build();
}
private ConversationMessage toDomainMessage(ConversationMessageJpaEntity e) {
return ConversationMessage.builder()
.id(e.getId() != null ? e.getId().toString() : null)
.role(e.getRole())
.content(e.getContent())
.createdAt(e.getCreatedAt())
.build();
}
}

View File

@@ -85,6 +85,9 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds()) ? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.branches(jpaEntity.getBranches() != null .branches(jpaEntity.getBranches() != null
? new ArrayList<>(jpaEntity.getBranches()) ? new ArrayList<>(jpaEntity.getBranches())
: new ArrayList<>()) : new ArrayList<>())
@@ -115,6 +118,9 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(scene.getIllustrationImageIds() != null .illustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds()) ? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(scene.getMapImageIds() != null
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>())
.branches(scene.getBranches() != null .branches(scene.getBranches() != null
? new ArrayList<>(scene.getBranches()) ? new ArrayList<>(scene.getBranches())
: new ArrayList<>()) : new ArrayList<>())

View File

@@ -3,6 +3,7 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase; import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
import com.loremind.application.generationcontext.StreamChatForLoreUseCase; import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
@@ -80,6 +81,7 @@ public class AiChatController {
try { try {
streamChatForLoreUseCase.execute( streamChatForLoreUseCase.execute(
loreId, pageId, messages, loreId, pageId, messages,
usage -> sendUsage(emitter, usage),
token -> sendToken(emitter, token), token -> sendToken(emitter, token),
() -> complete(emitter), () -> complete(emitter),
error -> fail(emitter, error)); error -> fail(emitter, error));
@@ -100,6 +102,7 @@ public class AiChatController {
try { try {
streamChatForCampaignUseCase.execute( streamChatForCampaignUseCase.execute(
campaignId, entityType, entityId, messages, campaignId, entityType, entityId, messages,
usage -> sendUsage(emitter, usage),
token -> sendToken(emitter, token), token -> sendToken(emitter, token),
() -> complete(emitter), () -> complete(emitter),
error -> fail(emitter, error)); error -> fail(emitter, error));
@@ -110,6 +113,18 @@ public class AiChatController {
// --- Helpers SSE (un seul point d'écriture par type d'événement) -------- // --- Helpers SSE (un seul point d'écriture par type d'événement) --------
private void sendUsage(SseEmitter emitter, ChatUsage usage) {
try {
String payload = "{\"system\":" + usage.system()
+ ",\"history\":" + usage.history()
+ ",\"current\":" + usage.current()
+ ",\"max\":" + usage.max() + "}";
emitter.send(SseEmitter.event().name("usage").data(payload));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void sendToken(SseEmitter emitter, String token) { private void sendToken(SseEmitter emitter, String token) {
try { try {
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()

View File

@@ -0,0 +1,100 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.conversationcontext.ConversationService;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.infrastructure.web.dto.conversationcontext.AppendMessageDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationMessageDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.CreateConversationDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.RenameConversationDTO;
import com.loremind.infrastructure.web.mapper.ConversationMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* API REST des conversations persistees.
*
* GET /api/conversations?loreId=...&entityType=...&entityId=... (listing filtre)
* GET /api/conversations?campaignId=...&entityType=...&entityId=...
* GET /api/conversations/{id} (detail + messages)
* POST /api/conversations (create)
* PATCH /api/conversations/{id}/title (rename)
* DELETE /api/conversations/{id}
*
* L'ajout de messages est piloje cote chat stream (use case dedie),
* pas par ce controller.
*/
@RestController
@RequestMapping("/api/conversations")
public class ConversationController {
private final ConversationService service;
private final ConversationMapper mapper;
public ConversationController(ConversationService service, ConversationMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@GetMapping
public ResponseEntity<List<ConversationDTO>> list(
@RequestParam(required = false) String loreId,
@RequestParam(required = false) String campaignId,
@RequestParam(required = false) String entityType,
@RequestParam(required = false) String entityId) {
List<Conversation> rows = service.listByContext(loreId, campaignId, entityType, entityId);
return ResponseEntity.ok(rows.stream().map(mapper::toListDTO).collect(Collectors.toList()));
}
@GetMapping("/{id}")
public ResponseEntity<ConversationDTO> getById(@PathVariable String id) {
return service.getById(id)
.map(c -> ResponseEntity.ok(mapper.toDTO(c)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ConversationDTO> create(@RequestBody CreateConversationDTO dto) {
Conversation created = service.create(new ConversationService.CreateData(
dto.getTitle(),
dto.getLoreId(),
dto.getCampaignId(),
dto.getEntityType(),
dto.getEntityId()));
return ResponseEntity.ok(mapper.toDTO(created));
}
@PatchMapping("/{id}/title")
public ResponseEntity<Void> rename(@PathVariable String id, @RequestBody RenameConversationDTO dto) {
service.rename(id, dto.getTitle());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/messages")
public ResponseEntity<ConversationMessageDTO> appendMessage(
@PathVariable String id,
@RequestBody AppendMessageDTO dto) {
ConversationMessage saved = service.appendMessage(id, dto.getRole(), dto.getContent());
return ResponseEntity.ok(mapper.toMessageDTO(saved));
}
/**
* Auto-genere et persiste un titre base sur les premiers messages.
* Appele par le front apres le 1er couple user/assistant.
*/
@PostMapping("/{id}/auto-title")
public ResponseEntity<RenameConversationDTO> autoTitle(@PathVariable String id) {
String title = service.autoGenerateTitle(id);
return ResponseEntity.ok(RenameConversationDTO.builder().title(title).build());
}
}

View File

@@ -79,6 +79,10 @@ public class ImageController {
.contentType(MediaType.parseMediaType(img.getContentType())) .contentType(MediaType.parseMediaType(img.getContentType()))
.contentLength(img.getSizeBytes()) .contentLength(img.getSizeBytes())
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable") .header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
// Autorise explicitement l'utilisation cross-origin du binaire dans une <img>.
// Sans ce header, Firefox 109+ applique ORB (Opaque Response Blocking) et
// bloque l'image quand le front (localhost:4200) la charge depuis l'API (localhost:8080).
.header("Cross-Origin-Resource-Policy", "cross-origin")
.body(new InputStreamResource(stream)); .body(new InputStreamResource(stream));
} }

View File

@@ -7,6 +7,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -53,6 +54,11 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/ollama", null); return forward(HttpMethod.GET, "/models/ollama", null);
} }
@PostMapping("/models/ollama/info")
public ResponseEntity<Map<String, Object>> getOllamaModelInfo(@RequestBody Map<String, Object> body) {
return forward(HttpMethod.POST, "/models/ollama/info", body);
}
@GetMapping("/models/onemin") @GetMapping("/models/onemin")
public ResponseEntity<Map<String, Object>> listOneMinModels() { public ResponseEntity<Map<String, Object>> listOneMinModels() {
return forward(HttpMethod.GET, "/models/onemin", null); return forward(HttpMethod.GET, "/models/onemin", null);

View File

@@ -27,6 +27,9 @@ public class ArcDTO {
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */ /** IDs des pages du Lore liées à cet arc (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cet arc. */ /** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
} }

View File

@@ -25,6 +25,9 @@ public class ChapterDTO {
/** IDs des pages du Lore liées (weak cross-context references). */ /** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant ce chapitre. */ /** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
} }

View File

@@ -30,9 +30,12 @@ public class SceneDTO {
/** IDs des pages du Lore liées (weak cross-context references). */ /** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();
/** IDs des images (Shared Kernel) illustrant cette scene. */ /** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans (outil de table). */
private List<String> mapImageIds = new ArrayList<>();
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */ /** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
private List<SceneBranchDTO> branches = new ArrayList<>(); private List<SceneBranchDTO> branches = new ArrayList<>();
} }

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AppendMessageDTO {
/** "user" | "assistant" | "system". */
private String role;
private String content;
}

View File

@@ -0,0 +1,29 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* DTO d'une conversation. Les messages sont inclus uniquement sur GET /{id}
* (null pour les reponses de listing afin d'alleger la sidebar).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationDTO {
private String id;
private String title;
private String loreId;
private String campaignId;
private String entityType;
private String entityId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ConversationMessageDTO> messages;
}

View File

@@ -0,0 +1,19 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationMessageDTO {
private String id;
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,23 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Payload de creation. Le client fournit l'ancrage (lore ou campagne, +/-
* entite focus). Le titre est optionnel — sera auto-genere apres le 1er
* echange IA si absent.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateConversationDTO {
private String title;
private String loreId;
private String campaignId;
private String entityType;
private String entityId;
}

View File

@@ -0,0 +1,14 @@
package com.loremind.infrastructure.web.dto.conversationcontext;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RenameConversationDTO {
private String title;
}

View File

@@ -9,6 +9,8 @@ import lombok.NoArgsConstructor;
* <p> * <p>
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}. * Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular. * Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
* le rendu visuel des champs image cote front.
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@@ -17,4 +19,11 @@ public class TemplateFieldDTO {
private String name; private String name;
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */ /** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
private String type; private String type;
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
private String layout;
/** Retrocompat : constructeur sans layout. */
public TemplateFieldDTO(String name, String type) {
this(name, type, null);
}
} }

View File

@@ -31,6 +31,7 @@ public class ArcMapper {
dto.setResolution(arc.getResolution()); dto.setResolution(arc.getResolution());
dto.setRelatedPageIds(copyList(arc.getRelatedPageIds())); dto.setRelatedPageIds(copyList(arc.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds())); dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds()));
dto.setMapImageIds(copyList(arc.getMapImageIds()));
return dto; return dto;
} }
@@ -52,6 +53,7 @@ public class ArcMapper {
.resolution(dto.getResolution()) .resolution(dto.getResolution())
.relatedPageIds(copyList(dto.getRelatedPageIds())) .relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds())) .illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build(); .build();
} }

View File

@@ -29,6 +29,7 @@ public class ChapterMapper {
dto.setNarrativeStakes(chapter.getNarrativeStakes()); dto.setNarrativeStakes(chapter.getNarrativeStakes());
dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds())); dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds())); dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds()));
dto.setMapImageIds(copyList(chapter.getMapImageIds()));
return dto; return dto;
} }
@@ -48,6 +49,7 @@ public class ChapterMapper {
.narrativeStakes(dto.getNarrativeStakes()) .narrativeStakes(dto.getNarrativeStakes())
.relatedPageIds(copyList(dto.getRelatedPageIds())) .relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds())) .illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build(); .build();
} }

View File

@@ -0,0 +1,61 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.conversationcontext.Conversation;
import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationDTO;
import com.loremind.infrastructure.web.dto.conversationcontext.ConversationMessageDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Conversion Domaine <-> DTO pour le contexte Conversation.
*
* {@link #toListDTO(Conversation)} omet les messages — utilise pour le
* listing sidebar ou on n'expose que les metadonnees.
*/
@Component
public class ConversationMapper {
public ConversationDTO toDTO(Conversation c) {
List<ConversationMessageDTO> msgs = c.getMessages() == null
? List.of()
: c.getMessages().stream().map(this::toMessageDTO).collect(Collectors.toList());
return ConversationDTO.builder()
.id(c.getId())
.title(c.getTitle())
.loreId(c.getLoreId())
.campaignId(c.getCampaignId())
.entityType(c.getEntityType())
.entityId(c.getEntityId())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.messages(msgs)
.build();
}
/** Variante listing : pas de messages pour alleger la payload. */
public ConversationDTO toListDTO(Conversation c) {
return ConversationDTO.builder()
.id(c.getId())
.title(c.getTitle())
.loreId(c.getLoreId())
.campaignId(c.getCampaignId())
.entityType(c.getEntityType())
.entityId(c.getEntityId())
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.messages(null)
.build();
}
public ConversationMessageDTO toMessageDTO(ConversationMessage m) {
return ConversationMessageDTO.builder()
.id(m.getId())
.role(m.getRole())
.content(m.getContent())
.createdAt(m.getCreatedAt())
.build();
}
}

View File

@@ -41,6 +41,9 @@ public class SceneMapper {
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds()) ? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>()); : new ArrayList<>());
dto.setMapImageIds(scene.getMapImageIds() != null
? new ArrayList<>(scene.getMapImageIds())
: new ArrayList<>());
dto.setBranches(toBranchDTOs(scene.getBranches())); dto.setBranches(toBranchDTOs(scene.getBranches()));
return dto; return dto;
} }
@@ -70,6 +73,9 @@ public class SceneMapper {
.illustrationImageIds(dto.getIllustrationImageIds() != null .illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds()) ? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(dto.getMapImageIds() != null
? new ArrayList<>(dto.getMapImageIds())
: new ArrayList<>())
.branches(toBranchDomain(dto.getBranches())) .branches(toBranchDomain(dto.getBranches()))
.build(); .build();
} }

View File

@@ -1,6 +1,7 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -11,6 +12,8 @@ import org.springframework.stereotype.Component;
* <p> * <p>
* Tolerance : un type inconnu recu du client est interprete comme TEXT * Tolerance : un type inconnu recu du client est interprete comme TEXT
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde). * (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
* Le layout est force a null pour les champs TEXT.
*/ */
@Component @Component
public class TemplateFieldMapper { public class TemplateFieldMapper {
@@ -18,7 +21,12 @@ public class TemplateFieldMapper {
public TemplateFieldDTO toDTO(TemplateField field) { public TemplateFieldDTO toDTO(TemplateField field) {
if (field == null) return null; if (field == null) return null;
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name(); String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
return new TemplateFieldDTO(field.getName(), typeStr); String layoutStr = null;
if (field.getType() == FieldType.IMAGE) {
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
layoutStr = layout.name();
}
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
} }
public TemplateField toDomain(TemplateFieldDTO dto) { public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -29,6 +37,16 @@ public class TemplateFieldMapper {
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
type = FieldType.TEXT; type = FieldType.TEXT;
} }
return new TemplateField(dto.getName(), type); ImageLayout layout = null;
if (type == FieldType.IMAGE) {
try {
layout = dto.getLayout() != null
? ImageLayout.valueOf(dto.getLayout())
: ImageLayout.GALLERY;
} catch (IllegalArgumentException ex) {
layout = ImageLayout.GALLERY;
}
}
return new TemplateField(dto.getName(), type, layout);
} }
} }

View File

@@ -0,0 +1,175 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.Mockito.*;
/**
* Test unitaire pour CampaignStructuralContextBuilder.
* Vérifie la projection Campaign Context → Generation Context (arcs → chapitres → scènes),
* le tri par `order`, la résolution des branches via la map id→nom, et le comptage
* null-safe des illustrations.
*/
@ExtendWith(MockitoExtension.class)
public class CampaignStructuralContextBuilderTest {
@Mock
private CampaignRepository campaignRepository;
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;
private Campaign campaign;
@BeforeEach
void setUp() {
campaign = Campaign.builder()
.id("camp-1")
.name("Les Terres Brisées")
.description("Campagne dark fantasy")
.build();
}
@Test
void testBuild_CampaignNotFound() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("missing"));
assertTrue(ex.getMessage().contains("missing"));
}
@Test
void testBuild_EmptyCampaign() {
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals("Les Terres Brisées", ctx.getCampaignName());
assertEquals("Campagne dark fantasy", ctx.getCampaignDescription());
assertTrue(ctx.getArcs().isEmpty());
}
@Test
void testBuild_SortsArcsChaptersScenesByOrder() {
Arc arc1 = Arc.builder().id("arc-1").name("Arc A").description("first").order(1).build();
Arc arc2 = Arc.builder().id("arc-2").name("Arc B").description("second").order(2).build();
Chapter ch1 = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch A").description("d").order(2).build();
Chapter ch2 = Chapter.builder().id("ch-2").arcId("arc-1").name("Ch B").description("d").order(1).build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Scene A").description("d").order(2).build();
Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Scene B").description("d").order(1).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
// Volontairement inverse pour verifier le tri.
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc2, arc1));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch1, ch2));
when(chapterRepository.findByArcId("arc-2")).thenReturn(List.of());
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2));
when(sceneRepository.findByChapterId("ch-2")).thenReturn(List.of());
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().size());
assertEquals("Arc A", ctx.getArcs().get(0).getName());
assertEquals("Arc B", ctx.getArcs().get(1).getName());
// Chapitres tries : ch2 (order 1) avant ch1 (order 2)
assertEquals(2, ctx.getArcs().get(0).getChapters().size());
assertEquals("Ch B", ctx.getArcs().get(0).getChapters().get(0).getName());
assertEquals("Ch A", ctx.getArcs().get(0).getChapters().get(1).getName());
// Scenes dans ch-1 : s2 (order 1) avant s1 (order 2)
var chADto = ctx.getArcs().get(0).getChapters().get(1);
assertEquals("Scene B", chADto.getScenes().get(0).getName());
assertEquals("Scene A", chADto.getScenes().get(1).getName());
}
@Test
void testBuild_ResolvesBranchTargetSceneName() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1).build();
SceneBranch validBranch = SceneBranch.builder()
.label("Si les joueurs fuient")
.targetSceneId("s-2")
.condition("en cas de combat perdu")
.build();
SceneBranch danglingBranch = SceneBranch.builder()
.label("Vers l'inconnu")
.targetSceneId("s-inconnu")
.build();
Scene s1 = Scene.builder().id("s-1").chapterId("ch-1").name("Entrée").description("")
.order(1)
.branches(List.of(validBranch, danglingBranch))
.build();
Scene s2 = Scene.builder().id("s-2").chapterId("ch-1").name("Fuite").description("").order(2).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch));
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s1, s2));
CampaignStructuralContext ctx = builder.build("camp-1");
var scene1Summary = ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0);
assertEquals(2, scene1Summary.getBranches().size());
assertEquals("Fuite", scene1Summary.getBranches().get(0).getTargetSceneName());
assertEquals("en cas de combat perdu", scene1Summary.getBranches().get(0).getCondition());
// ID inconnu → libellé de fallback
assertEquals("(scène inconnue)", scene1Summary.getBranches().get(1).getTargetSceneName());
}
@Test
void testBuild_CountsIllustrationsNullSafe() {
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
.illustrationImageIds(List.of("img-1", "img-2")).build();
Chapter ch = Chapter.builder().id("ch-1").arcId("arc-1").name("Ch").description("").order(1)
.illustrationImageIds(null) // null-safe attendu
.build();
Scene s = Scene.builder().id("s-1").chapterId("ch-1").name("S").description("").order(1)
.illustrationImageIds(List.of("img-3"))
.branches(null)
.build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(ch));
when(sceneRepository.findByChapterId("ch-1")).thenReturn(List.of(s));
CampaignStructuralContext ctx = builder.build("camp-1");
assertEquals(2, ctx.getArcs().get(0).getIllustrationCount());
assertEquals(0, ctx.getArcs().get(0).getChapters().get(0).getIllustrationCount());
assertEquals(1, ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getIllustrationCount());
assertTrue(ctx.getArcs().get(0).getChapters().get(0).getScenes().get(0).getBranches().isEmpty());
}
}

View File

@@ -0,0 +1,164 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
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.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour GeneratePageValuesUseCase.
* Couvre : le happy path (contexte IA correctement assemblé avec uniquement
* les champs TEXT), les erreurs d'intégrité (Page/Template/Lore/Folder
* introuvables), et la validation métier (template sans champ texte).
*/
@ExtendWith(MockitoExtension.class)
public class GeneratePageValuesUseCaseTest {
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private AiProvider aiProvider;
@InjectMocks private GeneratePageValuesUseCase useCase;
private Page page;
private Template template;
private Lore lore;
private LoreNode folder;
@BeforeEach
void setUp() {
page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("node-1").templateId("tpl-1")
.title("Alice")
.build();
template = Template.builder()
.id("tpl-1").loreId("lore-1").name("Personnage")
.fields(List.of(
TemplateField.text("Histoire"),
TemplateField.text("Apparence"),
TemplateField.image("Portrait")))
.build();
lore = Lore.builder().id("lore-1").name("Aetheria").description("monde aérien").build();
folder = LoreNode.builder().id("node-1").name("PNJ").loreId("lore-1").build();
}
@Test
void testExecute_HappyPath_OnlyTextFieldsSentToAi() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder));
Map<String, String> generated = Map.of(
"Histoire", "Alice est une...",
"Apparence", "Cheveux roux");
when(aiProvider.generatePage(any())).thenReturn(new GenerationResult(generated));
Map<String, String> result = useCase.execute("p-1");
assertEquals(generated, result);
ArgumentCaptor<GenerationContext> captor = ArgumentCaptor.forClass(GenerationContext.class);
verify(aiProvider).generatePage(captor.capture());
GenerationContext ctx = captor.getValue();
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("monde aérien", ctx.getLoreDescription());
assertEquals("PNJ", ctx.getFolderName());
assertEquals("Personnage", ctx.getTemplateName());
assertEquals("Alice", ctx.getPageTitle());
// Seuls les champs TEXT doivent etre envoyes (pas "Portrait" IMAGE).
assertEquals(List.of("Histoire", "Apparence"), ctx.getTemplateFields());
}
@Test
void testExecute_PageNotFound() {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> useCase.execute("missing"));
verifyNoInteractions(aiProvider);
}
@Test
void testExecute_PageWithoutTemplateId() {
Page orphan = Page.builder().id("p-1").loreId("lore-1").nodeId("node-1")
.templateId(null).title("Orphan").build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(orphan));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> useCase.execute("p-1"));
assertTrue(ex.getMessage().contains("template"));
}
@Test
void testExecute_TemplateNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_LoreNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_FolderNotFound() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(template));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.empty());
assertThrows(IllegalStateException.class, () -> useCase.execute("p-1"));
}
@Test
void testExecute_TemplateWithoutTextFields() {
Template imageOnly = Template.builder()
.id("tpl-1").loreId("lore-1").name("Galerie")
.fields(List.of(new TemplateField("Portrait", FieldType.IMAGE)))
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(imageOnly));
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findById("node-1")).thenReturn(Optional.of(folder));
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> useCase.execute("p-1"));
assertTrue(ex.getMessage().contains("Galerie"));
verifyNoInteractions(aiProvider);
}
}

View File

@@ -0,0 +1,193 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour LoreStructuralContextBuilder.
* Couvre la projection LoreContext → GenerationContext : construction du
* dossier→pages, résolution template/relatedPages, troncature des valeurs,
* filtrage des valeurs vides, et extraction unique des tags.
*/
@ExtendWith(MockitoExtension.class)
public class LoreStructuralContextBuilderTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@InjectMocks private LoreStructuralContextBuilder builder;
private Lore lore;
@BeforeEach
void setUp() {
lore = Lore.builder().id("lore-1").name("Aetheria").description("Monde aérien").build();
}
@Test
void testBuild_LoreNotFound_ThrowsOnStrict() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> builder.build("missing"));
}
@Test
void testBuildOptional_LoreNotFound_ReturnsEmpty() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(builder.buildOptional("missing").isEmpty());
}
@Test
void testBuild_EmptyLore() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of());
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of());
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of());
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals("Aetheria", ctx.getLoreName());
assertEquals("Monde aérien", ctx.getLoreDescription());
assertTrue(ctx.getFolders().isEmpty());
assertTrue(ctx.getTags().isEmpty());
}
@Test
void testBuild_FoldersAndPagesMapping() {
LoreNode nodePnj = LoreNode.builder().id("n-1").name("PNJ").loreId("lore-1").build();
LoreNode nodeLieux = LoreNode.builder().id("n-2").name("Lieux").loreId("lore-1").build();
Template tpl = Template.builder().id("tpl-1").name("Personnage").build();
Map<String, String> values = new LinkedHashMap<>();
values.put("Histoire", "Il était une fois...");
values.put("VideField", " "); // blank → filtré
values.put("NullField", null); // null → filtré
Page p1 = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.tags(List.of("hero", "magic"))
.relatedPageIds(List.of("p-2", "p-ghost"))
.build();
Page p2 = Page.builder()
.id("p-2").loreId("lore-1").nodeId("n-2")
.templateId("tpl-missing").title("La Forêt")
.values(Map.of())
.tags(List.of("magic"))
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(nodePnj, nodeLieux));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p1, p2));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl));
LoreStructuralContext ctx = builder.build("lore-1");
assertEquals(2, ctx.getFolders().size());
assertTrue(ctx.getFolders().containsKey("PNJ"));
assertTrue(ctx.getFolders().containsKey("Lieux"));
var pnjPages = ctx.getFolders().get("PNJ");
assertEquals(1, pnjPages.size());
var aliceSummary = pnjPages.get(0);
assertEquals("Alice", aliceSummary.getTitle());
assertEquals("Personnage", aliceSummary.getTemplateName());
// Blank/null filtrés
assertEquals(1, aliceSummary.getValues().size());
assertEquals("Il était une fois...", aliceSummary.getValues().get("Histoire"));
assertEquals(List.of("hero", "magic"), aliceSummary.getTags());
// p-2 resolved into title, p-ghost dropped silently
assertEquals(List.of("La Forêt"), aliceSummary.getRelatedPageTitles());
var forestSummary = ctx.getFolders().get("Lieux").get(0);
// Template introuvable → "?"
assertEquals("?", forestSummary.getTemplateName());
assertTrue(forestSummary.getValues().isEmpty());
assertTrue(forestSummary.getRelatedPageTitles().isEmpty());
// Tags uniques entre les 2 pages
assertEquals(2, ctx.getTags().size());
assertTrue(ctx.getTags().contains("hero"));
assertTrue(ctx.getTags().contains("magic"));
}
@Test
void testBuild_TruncatesLongValues() {
LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build();
Template tpl = Template.builder().id("tpl-1").name("Personnage").build();
String longText = "a".repeat(600); // au-dessus du plafond 500
Map<String, String> values = new HashMap<>();
values.put("Histoire", longText);
Page p = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(tpl));
LoreStructuralContext ctx = builder.build("lore-1");
String truncated = ctx.getFolders().get("PNJ").get(0).getValues().get("Histoire");
assertNotNull(truncated);
assertEquals(500 + 1, truncated.length()); // 500 + ellipse
assertTrue(truncated.endsWith(""));
}
@Test
void testBuild_HandlesNullValuesAndTags() {
LoreNode node = LoreNode.builder().id("n-1").name("PNJ").build();
Page p = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId(null).title("Alice")
.values(null)
.tags(null)
.relatedPageIds(null)
.build();
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(lore));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(p));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of());
LoreStructuralContext ctx = builder.build("lore-1");
var summary = ctx.getFolders().get("PNJ").get(0);
assertTrue(summary.getValues().isEmpty());
assertTrue(summary.getTags().isEmpty());
assertTrue(summary.getRelatedPageTitles().isEmpty());
}
}

View File

@@ -0,0 +1,130 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour NarrativeEntityContextBuilder.
* Vérifie la projection Arc/Chapter/Scene → NarrativeEntityContext pour
* les 3 types, la normalisation du type (casse/whitespace), la gestion des
* champs null (remplacés par ""), et les erreurs (type inconnu, entité absente).
*/
@ExtendWith(MockitoExtension.class)
public class NarrativeEntityContextBuilderTest {
@Mock private ArcRepository arcRepository;
@Mock private ChapterRepository chapterRepository;
@Mock private SceneRepository sceneRepository;
@InjectMocks private NarrativeEntityContextBuilder builder;
@Test
void testBuild_Arc() {
Arc arc = Arc.builder()
.id("arc-1").name("L'arc sombre").description("synopsis")
.themes("trahison").stakes("vie ou mort").rewards("pouvoir")
.resolution("le roi meurt").gmNotes("secret")
.build();
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build("arc", "arc-1");
assertEquals("arc", ctx.getEntityType());
assertEquals("L'arc sombre", ctx.getTitle());
assertEquals("synopsis", ctx.getFields().get("description (synopsis)"));
assertEquals("trahison", ctx.getFields().get("themes"));
assertEquals("vie ou mort", ctx.getFields().get("stakes"));
assertEquals("pouvoir", ctx.getFields().get("rewards"));
assertEquals("le roi meurt", ctx.getFields().get("resolution"));
assertEquals("secret", ctx.getFields().get("gmNotes"));
}
@Test
void testBuild_Chapter_WithNullFieldsReplacedByEmptyString() {
Chapter ch = Chapter.builder()
.id("ch-1").name("Chapitre 1").description(null)
.playerObjectives(null).narrativeStakes("haut").gmNotes(null)
.build();
when(chapterRepository.findById("ch-1")).thenReturn(Optional.of(ch));
NarrativeEntityContext ctx = builder.build("chapter", "ch-1");
assertEquals("chapter", ctx.getEntityType());
assertEquals("Chapitre 1", ctx.getTitle());
assertEquals("", ctx.getFields().get("description (synopsis)"));
assertEquals("", ctx.getFields().get("playerObjectives"));
assertEquals("haut", ctx.getFields().get("narrativeStakes"));
assertEquals("", ctx.getFields().get("gmNotes"));
}
@Test
void testBuild_Scene_AllFieldsMapped() {
Scene sc = Scene.builder()
.id("s-1").name("L'auberge").description("lieu calme")
.location("Taverne").timing("Soir").atmosphere("tendue")
.playerNarration("Vous entrez...").choicesConsequences("option A...")
.combatDifficulty("moyen").enemies("3 bandits")
.gmSecretNotes("trésor caché")
.build();
when(sceneRepository.findById("s-1")).thenReturn(Optional.of(sc));
NarrativeEntityContext ctx = builder.build("scene", "s-1");
assertEquals("scene", ctx.getEntityType());
assertEquals("L'auberge", ctx.getTitle());
assertEquals("lieu calme", ctx.getFields().get("description"));
assertEquals("Taverne", ctx.getFields().get("location"));
assertEquals("Soir", ctx.getFields().get("timing"));
assertEquals("tendue", ctx.getFields().get("atmosphere"));
assertEquals("Vous entrez...", ctx.getFields().get("playerNarration"));
assertEquals("option A...", ctx.getFields().get("choicesConsequences"));
assertEquals("moyen", ctx.getFields().get("combatDifficulty"));
assertEquals("3 bandits", ctx.getFields().get("enemies"));
assertEquals("trésor caché", ctx.getFields().get("gmSecretNotes"));
}
@Test
void testBuild_NormalizesTypeCaseAndWhitespace() {
Arc arc = Arc.builder().id("arc-1").name("A").build();
when(arcRepository.findById("arc-1")).thenReturn(Optional.of(arc));
NarrativeEntityContext ctx = builder.build(" ARC ", "arc-1");
assertEquals("arc", ctx.getEntityType());
}
@Test
void testBuild_UnknownTypeThrows() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("npc", "id"));
assertTrue(ex.getMessage().contains("npc"));
}
@Test
void testBuild_NullTypeThrows() {
assertThrows(IllegalArgumentException.class, () -> builder.build(null, "id"));
}
@Test
void testBuild_EntityNotFound() {
when(sceneRepository.findById("missing")).thenReturn(Optional.empty());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> builder.build("scene", "missing"));
assertTrue(ex.getMessage().contains("missing"));
}
}

View File

@@ -0,0 +1,159 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.NarrativeEntityContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
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.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour StreamChatForCampaignUseCase.
* Vérifie l'orchestration : chargement Campaign, chargement optionnel du
* Lore lié (avec tolérance d'un Lore supprimé), chargement optionnel de
* l'entité narrative focus, et délégation au port AiChatProvider avec la
* ChatRequest correcte.
*/
@ExtendWith(MockitoExtension.class)
public class StreamChatForCampaignUseCaseTest {
@Mock private CampaignRepository campaignRepository;
@Mock private CampaignStructuralContextBuilder campaignContextBuilder;
@Mock private LoreStructuralContextBuilder loreContextBuilder;
@Mock private NarrativeEntityContextBuilder narrativeEntityContextBuilder;
@Mock private AiChatProvider aiChatProvider;
@InjectMocks private StreamChatForCampaignUseCase useCase;
private CampaignStructuralContext campaignCtx;
private List<ChatMessage> messages;
private Consumer<ChatUsage> onUsage;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
campaignCtx = CampaignStructuralContext.builder()
.campaignName("X").campaignDescription("d")
.build();
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
}
@Test
void testExecute_CampaignNotFound_Throws() {
when(campaignRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("missing", null, null, messages, onUsage, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
@Test
void testExecute_StandaloneCampaign_NoLoreNoEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", null, null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(campaignCtx, req.getCampaignContext());
assertNull(req.getLoreContext());
assertNull(req.getNarrativeEntity());
assertNull(req.getPageContext());
verifyNoInteractions(loreContextBuilder);
verifyNoInteractions(narrativeEntityContextBuilder);
}
@Test
void testExecute_LinkedCampaign_LoadsLoreContext() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-1").build();
LoreStructuralContext loreCtx = LoreStructuralContext.builder()
.loreName("L").loreDescription("d").folders(Collections.emptyMap()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-1")).thenReturn(Optional.of(loreCtx));
useCase.execute("c-1", null, null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(loreCtx, captor.getValue().getLoreContext());
}
@Test
void testExecute_LinkedCampaignButLoreDeleted_ContinuesWithNullLore() {
Campaign linked = Campaign.builder().id("c-1").name("C").loreId("lore-ghost").build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(linked));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(loreContextBuilder.buildOptional("lore-ghost")).thenReturn(Optional.empty());
useCase.execute("c-1", null, null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getLoreContext());
// La requete doit tout de meme partir (pas d'exception).
}
@Test
void testExecute_WithEntityFocus_BuildsNarrativeEntity() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
NarrativeEntityContext entity = NarrativeEntityContext.builder()
.entityType("scene").title("L'auberge").fields(Map.of()).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
when(narrativeEntityContextBuilder.build("scene", "s-1")).thenReturn(entity);
useCase.execute("c-1", "scene", "s-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertSame(entity, captor.getValue().getNarrativeEntity());
}
@Test
void testExecute_BlankEntityTypeOrId_NoNarrativeEntityLoaded() {
Campaign standalone = Campaign.builder().id("c-1").name("C").loreId(null).build();
when(campaignRepository.findById("c-1")).thenReturn(Optional.of(standalone));
when(campaignContextBuilder.build("c-1")).thenReturn(campaignCtx);
useCase.execute("c-1", "scene", " ", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getNarrativeEntity());
verifyNoInteractions(narrativeEntityContextBuilder);
}
}

View File

@@ -0,0 +1,177 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
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.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour StreamChatForLoreUseCase.
* Vérifie l'orchestration : chargement du LoreStructuralContext obligatoire,
* construction conditionnelle du PageContext (sans / avec page / page sans
* template), et délégation au port AiChatProvider avec la bonne ChatRequest.
*/
@ExtendWith(MockitoExtension.class)
public class StreamChatForLoreUseCaseTest {
@Mock private LoreStructuralContextBuilder loreContextBuilder;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private AiChatProvider aiChatProvider;
@InjectMocks private StreamChatForLoreUseCase useCase;
private LoreStructuralContext loreCtx;
private List<ChatMessage> messages;
private Consumer<ChatUsage> onUsage;
private Consumer<String> onToken;
private Runnable onComplete;
private Consumer<Throwable> onError;
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
loreCtx = LoreStructuralContext.builder()
.loreName("Aetheria").loreDescription("d")
.folders(Collections.emptyMap())
.build();
messages = List.of();
onUsage = mock(Consumer.class);
onToken = mock(Consumer.class);
onComplete = mock(Runnable.class);
onError = mock(Consumer.class);
}
@Test
void testExecute_NoPageId_SendsRequestWithoutPageContext() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", null, messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), eq(onUsage), eq(onToken), eq(onComplete), eq(onError));
ChatRequest req = captor.getValue();
assertSame(loreCtx, req.getLoreContext());
assertNull(req.getPageContext());
assertNull(req.getCampaignContext());
}
@Test
void testExecute_BlankPageId_TreatedAsNoPage() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
useCase.execute("lore-1", " ", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
assertNull(captor.getValue().getPageContext());
verifyNoInteractions(pageRepository);
}
@Test
void testExecute_WithPageAndTemplate_BuildsPageContext() {
Template tpl = Template.builder()
.id("tpl-1").name("Personnage")
.fields(List.of(
TemplateField.text("Histoire"),
new TemplateField("Portrait", FieldType.IMAGE)))
.build();
Map<String, String> values = Map.of("Histoire", "...");
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-1").title("Alice")
.values(values)
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(tpl));
useCase.execute("lore-1", "p-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
ChatRequest req = captor.getValue();
assertNotNull(req.getPageContext());
assertEquals("Alice", req.getPageContext().getTitle());
assertEquals("Personnage", req.getPageContext().getTemplateName());
// Seuls les champs TEXT exposes
assertEquals(List.of("Histoire"), req.getPageContext().getTemplateFields());
assertEquals(values, req.getPageContext().getValues());
}
@Test
void testExecute_PageWithoutTemplate_FallbackContext() {
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId(null).title("Orphan").values(null)
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
useCase.execute("lore-1", "p-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertNotNull(pageCtx);
assertEquals("Orphan", pageCtx.getTitle());
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
assertTrue(pageCtx.getValues().isEmpty());
verifyNoInteractions(templateRepository);
}
@Test
void testExecute_PageWithTemplateIdButTemplateMissing_FallbackToQuestionMark() {
Page page = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1")
.templateId("tpl-ghost").title("Alice").values(Map.of("k", "v"))
.build();
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("p-1")).thenReturn(Optional.of(page));
when(templateRepository.findById("tpl-ghost")).thenReturn(Optional.empty());
useCase.execute("lore-1", "p-1", messages, onUsage, onToken, onComplete, onError);
ArgumentCaptor<ChatRequest> captor = ArgumentCaptor.forClass(ChatRequest.class);
verify(aiChatProvider).streamChat(captor.capture(), any(), any(), any(), any());
var pageCtx = captor.getValue().getPageContext();
assertEquals("?", pageCtx.getTemplateName());
assertTrue(pageCtx.getTemplateFields().isEmpty());
}
@Test
void testExecute_PageNotFound_Throws() {
when(loreContextBuilder.build("lore-1")).thenReturn(loreCtx);
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> useCase.execute("lore-1", "missing", messages, onUsage, onToken, onComplete, onError));
verifyNoInteractions(aiChatProvider);
}
}

View File

@@ -0,0 +1,198 @@
package com.loremind.application.images;
import com.loremind.domain.images.Image;
import com.loremind.domain.images.ports.ImageRepository;
import com.loremind.domain.images.ports.ImageStorage;
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.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Test unitaire pour ImageService.
* Couvre : validation upload (filename/MIME/size), happy path upload, compensation
* en cas d'échec DB après upload MinIO réussi, download et delete.
*/
@ExtendWith(MockitoExtension.class)
public class ImageServiceTest {
@Mock private ImageRepository imageRepository;
@Mock private ImageStorage imageStorage;
@InjectMocks private ImageService imageService;
private InputStream data;
@BeforeEach
void setUp() {
data = new ByteArrayInputStream(new byte[]{1, 2, 3});
}
@Test
void testUpload_HappyPath_PersistsMetadata() {
when(imageStorage.upload(eq("portrait.jpg"), eq("image/jpeg"), any(), eq(1024L)))
.thenReturn("images/abc.jpg");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> {
Image i = inv.getArgument(0);
i.setId("img-1");
return i;
});
Image result = imageService.upload("portrait.jpg", "image/jpeg", data, 1024L);
assertEquals("img-1", result.getId());
assertEquals("images/abc.jpg", result.getStorageKey());
assertNotNull(result.getUploadedAt());
ArgumentCaptor<Image> captor = ArgumentCaptor.forClass(Image.class);
verify(imageRepository).save(captor.capture());
Image saved = captor.getValue();
assertEquals("portrait.jpg", saved.getFilename());
assertEquals("image/jpeg", saved.getContentType());
assertEquals(1024L, saved.getSizeBytes());
}
@Test
void testUpload_NormalizesContentTypeCase() {
when(imageStorage.upload(anyString(), anyString(), any(), anyLong())).thenReturn("k");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0));
// MIME en majuscules doit etre accepte (normalisation en lowercase lors de la validation)
assertDoesNotThrow(() -> imageService.upload("a.png", "IMAGE/PNG", data, 100L));
}
@Test
void testUpload_DbFailure_CompensatesByDeletingBinary() {
when(imageStorage.upload(anyString(), anyString(), any(), anyLong()))
.thenReturn("images/orphan.jpg");
when(imageRepository.save(any(Image.class))).thenThrow(new RuntimeException("DB down"));
RuntimeException ex = assertThrows(RuntimeException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, 500L));
assertEquals("DB down", ex.getMessage());
// Compensation : suppression du binaire orphelin
verify(imageStorage).delete("images/orphan.jpg");
}
@Test
void testUpload_BlankFilename_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload(" ", "image/jpeg", data, 100L));
verifyNoInteractions(imageStorage);
verifyNoInteractions(imageRepository);
}
@Test
void testUpload_NullFilename_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload(null, "image/jpeg", data, 100L));
}
@Test
void testUpload_UnsupportedMime_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.pdf", "application/pdf", data, 100L));
verifyNoInteractions(imageStorage);
}
@Test
void testUpload_NullMime_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", null, data, 100L));
}
@Test
void testUpload_ZeroSize_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, 0L));
}
@Test
void testUpload_NegativeSize_Throws() {
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, -1L));
}
@Test
void testUpload_TooLarge_Throws() {
long tooBig = 10L * 1024 * 1024 + 1;
assertThrows(IllegalArgumentException.class,
() -> imageService.upload("a.jpg", "image/jpeg", data, tooBig));
verifyNoInteractions(imageStorage);
}
@Test
void testUpload_ExactMaxSize_Accepted() {
long max = 10L * 1024 * 1024;
when(imageStorage.upload(anyString(), anyString(), any(), eq(max))).thenReturn("k");
when(imageRepository.save(any(Image.class))).thenAnswer(inv -> inv.getArgument(0));
assertDoesNotThrow(() -> imageService.upload("a.jpg", "image/jpeg", data, max));
}
@Test
void testGetById_DelegatesToRepository() {
Image img = Image.builder().id("img-1").build();
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
assertEquals(Optional.of(img), imageService.getById("img-1"));
}
@Test
void testDownloadById_FoundReturnsStream() {
Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build();
InputStream stream = new ByteArrayInputStream(new byte[]{9});
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
when(imageStorage.download("images/k.jpg")).thenReturn(stream);
Optional<InputStream> result = imageService.downloadById("img-1");
assertTrue(result.isPresent());
assertSame(stream, result.get());
}
@Test
void testDownloadById_NotFoundReturnsEmpty() {
when(imageRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(imageService.downloadById("missing").isEmpty());
verifyNoInteractions(imageStorage);
}
@Test
void testDeleteById_RemovesBinaryThenMetadata() {
Image img = Image.builder().id("img-1").storageKey("images/k.jpg").build();
when(imageRepository.findById("img-1")).thenReturn(Optional.of(img));
imageService.deleteById("img-1");
// Ordre important : binaire d'abord, metadata ensuite.
var order = inOrder(imageStorage, imageRepository);
order.verify(imageStorage).delete("images/k.jpg");
order.verify(imageRepository).deleteById("img-1");
}
@Test
void testDeleteById_NotFound_NoOp() {
when(imageRepository.findById("missing")).thenReturn(Optional.empty());
imageService.deleteById("missing");
verifyNoInteractions(imageStorage);
verify(imageRepository, never()).deleteById(anyString());
}
}

View File

@@ -0,0 +1,125 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
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 LoreNodeService.
* Vérifie le CRUD, le pattern Parameter Object pour update/create, et
* l'immuabilité de loreId en update.
*/
@ExtendWith(MockitoExtension.class)
public class LoreNodeServiceTest {
@Mock private LoreNodeRepository loreNodeRepository;
@InjectMocks private LoreNodeService loreNodeService;
private LoreNode existing;
@BeforeEach
void setUp() {
existing = LoreNode.builder()
.id("n-1").name("PNJ").icon("users")
.parentId(null).loreId("lore-1")
.build();
}
@Test
void testCreateLoreNode_CopiesChanges() {
LoreNode changes = LoreNode.builder()
.name("Lieux").icon("map-pin").parentId("n-parent").loreId("lore-1")
.build();
when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0));
loreNodeService.createLoreNode(changes);
ArgumentCaptor<LoreNode> captor = ArgumentCaptor.forClass(LoreNode.class);
verify(loreNodeRepository).save(captor.capture());
LoreNode saved = captor.getValue();
assertEquals("Lieux", saved.getName());
assertEquals("map-pin", saved.getIcon());
assertEquals("n-parent", saved.getParentId());
assertEquals("lore-1", saved.getLoreId());
}
@Test
void testGetLoreNodeById() {
when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing));
assertTrue(loreNodeService.getLoreNodeById("n-1").isPresent());
}
@Test
void testGetAll_AndByLoreId_AndByParentId() {
when(loreNodeRepository.findAll()).thenReturn(List.of(existing));
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
when(loreNodeRepository.findByParentId("n-parent")).thenReturn(List.of(existing));
assertEquals(1, loreNodeService.getAllLoreNodes().size());
assertEquals(1, loreNodeService.getLoreNodesByLoreId("lore-1").size());
assertEquals(1, loreNodeService.getLoreNodesByParentId("n-parent").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(loreNodeService.searchLoreNodes(null).isEmpty());
assertTrue(loreNodeService.searchLoreNodes(" ").isEmpty());
verifyNoInteractions(loreNodeRepository);
}
@Test
void testSearch_TrimsQuery() {
when(loreNodeRepository.searchByName("pnj")).thenReturn(List.of(existing));
loreNodeService.searchLoreNodes(" pnj ");
verify(loreNodeRepository).searchByName("pnj");
}
@Test
void testUpdateLoreNode_AppliesChangesButKeepsLoreId() {
LoreNode changes = LoreNode.builder()
.name("Villes").icon("castle").parentId("n-parent")
.loreId("lore-2") // tentative de migration - doit etre ignoree
.build();
when(loreNodeRepository.findById("n-1")).thenReturn(Optional.of(existing));
when(loreNodeRepository.save(any(LoreNode.class))).thenAnswer(inv -> inv.getArgument(0));
LoreNode result = loreNodeService.updateLoreNode("n-1", changes);
assertEquals("Villes", result.getName());
assertEquals("castle", result.getIcon());
assertEquals("n-parent", result.getParentId());
// loreId doit rester inchange (immutable)
assertEquals("lore-1", result.getLoreId());
}
@Test
void testUpdateLoreNode_NotFoundThrows() {
when(loreNodeRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> loreNodeService.updateLoreNode("missing", existing));
}
@Test
void testDelete() {
loreNodeService.deleteLoreNode("n-1");
verify(loreNodeRepository).deleteById("n-1");
}
}

View File

@@ -0,0 +1,141 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
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 LoreService.
* Vérifie le CRUD, l'enrichissement à la volée des compteurs nodeCount/pageCount,
* et le comportement défensif de la recherche.
*/
@ExtendWith(MockitoExtension.class)
public class LoreServiceTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@InjectMocks private LoreService loreService;
private Lore testLore;
@BeforeEach
void setUp() {
testLore = Lore.builder().id("lore-1").name("Aetheria").description("d").build();
}
@Test
void testCreateLore_InitialCountsZero() {
when(loreRepository.save(any(Lore.class))).thenReturn(testLore);
loreService.createLore("Aetheria", "desc");
ArgumentCaptor<Lore> captor = ArgumentCaptor.forClass(Lore.class);
verify(loreRepository).save(captor.capture());
Lore saved = captor.getValue();
assertEquals("Aetheria", saved.getName());
assertEquals("desc", saved.getDescription());
assertEquals(0, saved.getNodeCount());
assertEquals(0, saved.getPageCount());
}
@Test
void testGetLoreById_EnrichesWithCounts() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore));
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(5L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(42L);
Optional<Lore> result = loreService.getLoreById("lore-1");
assertTrue(result.isPresent());
assertEquals(5, result.get().getNodeCount());
assertEquals(42, result.get().getPageCount());
}
@Test
void testGetLoreById_NotFound() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertTrue(loreService.getLoreById("missing").isEmpty());
verifyNoInteractions(loreNodeRepository);
verifyNoInteractions(pageRepository);
}
@Test
void testGetAllLores_EnrichesEach() {
Lore lore2 = Lore.builder().id("lore-2").name("B").build();
when(loreRepository.findAll()).thenReturn(List.of(testLore, lore2));
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(3L);
when(loreNodeRepository.countByLoreId("lore-2")).thenReturn(7L);
when(pageRepository.countByLoreId("lore-1")).thenReturn(10L);
when(pageRepository.countByLoreId("lore-2")).thenReturn(20L);
List<Lore> result = loreService.getAllLores();
assertEquals(2, result.size());
assertEquals(3, result.get(0).getNodeCount());
assertEquals(10, result.get(0).getPageCount());
assertEquals(7, result.get(1).getNodeCount());
assertEquals(20, result.get(1).getPageCount());
}
@Test
void testSearchLores_NullOrBlankReturnsEmpty() {
assertTrue(loreService.searchLores(null).isEmpty());
assertTrue(loreService.searchLores(" ").isEmpty());
verifyNoInteractions(loreRepository);
}
@Test
void testSearchLores_TrimsQuery() {
when(loreRepository.searchByName("aet")).thenReturn(List.of(testLore));
List<Lore> result = loreService.searchLores(" aet ");
assertEquals(1, result.size());
verify(loreRepository).searchByName("aet");
}
@Test
void testUpdateLore_Success() {
when(loreRepository.findById("lore-1")).thenReturn(Optional.of(testLore));
when(loreRepository.save(any(Lore.class))).thenAnswer(inv -> inv.getArgument(0));
Lore updated = loreService.updateLore("lore-1", "New Name", "New Desc");
assertEquals("New Name", updated.getName());
assertEquals("New Desc", updated.getDescription());
verify(loreRepository).save(testLore);
}
@Test
void testUpdateLore_NotFoundThrows() {
when(loreRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> loreService.updateLore("missing", "n", "d"));
verify(loreRepository, never()).save(any());
}
@Test
void testDeleteLore_DelegatesToRepository() {
loreService.deleteLore("lore-1");
verify(loreRepository).deleteById("lore-1");
}
}

View File

@@ -0,0 +1,170 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.PageRepository;
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.HashMap;
import java.util.List;
import java.util.Map;
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 PageService.
* Vérifie la création MVP (collections initialisées vides), le CRUD, les
* copies défensives sur update, l'immuabilité de loreId/templateId, et
* la gestion null-safe des collections.
*/
@ExtendWith(MockitoExtension.class)
public class PageServiceTest {
@Mock private PageRepository pageRepository;
@InjectMocks private PageService pageService;
private Page existing;
@BeforeEach
void setUp() {
existing = Page.builder()
.id("p-1").loreId("lore-1").nodeId("n-1").templateId("tpl-1")
.title("Alice")
.values(new HashMap<>())
.build();
}
@Test
void testCreatePage_InitializesEmptyCollections() {
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
pageService.createPage("lore-1", "n-1", "tpl-1", "Alice");
ArgumentCaptor<Page> captor = ArgumentCaptor.forClass(Page.class);
verify(pageRepository).save(captor.capture());
Page saved = captor.getValue();
assertEquals("Alice", saved.getTitle());
assertEquals("lore-1", saved.getLoreId());
assertEquals("n-1", saved.getNodeId());
assertEquals("tpl-1", saved.getTemplateId());
assertNotNull(saved.getValues());
assertTrue(saved.getValues().isEmpty());
assertNotNull(saved.getTags());
assertTrue(saved.getTags().isEmpty());
assertNotNull(saved.getRelatedPageIds());
assertTrue(saved.getRelatedPageIds().isEmpty());
}
@Test
void testGetById_And_All_And_ByLore_And_ByNode() {
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.findAll()).thenReturn(List.of(existing));
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(existing));
assertTrue(pageService.getPageById("p-1").isPresent());
assertEquals(1, pageService.getAllPages().size());
assertEquals(1, pageService.getPagesByLoreId("lore-1").size());
assertEquals(1, pageService.getPagesByNodeId("n-1").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(pageService.searchPages(null).isEmpty());
assertTrue(pageService.searchPages(" ").isEmpty());
verifyNoInteractions(pageRepository);
}
@Test
void testSearch_TrimsQuery() {
when(pageRepository.searchByTitle("alice")).thenReturn(List.of(existing));
pageService.searchPages(" alice ");
verify(pageRepository).searchByTitle("alice");
}
@Test
void testUpdatePage_AppliesChangesAndKeepsImmutables() {
Map<String, String> newValues = Map.of("Histoire", "Il...");
Map<String, List<String>> newImages = Map.of("Portrait", List.of("img-1"));
List<String> newTags = List.of("hero");
List<String> newRelated = List.of("p-2");
Page changes = Page.builder()
.loreId("lore-OTHER") // doit etre ignore
.templateId("tpl-OTHER") // doit etre ignore
.nodeId("n-2") // mutable : deplacement
.title("Alice v2")
.values(newValues)
.imageValues(newImages)
.notes("notes MJ")
.tags(newTags)
.relatedPageIds(newRelated)
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
Page result = pageService.updatePage("p-1", changes);
assertEquals("Alice v2", result.getTitle());
assertEquals("n-2", result.getNodeId());
// loreId et templateId immuables
assertEquals("lore-1", result.getLoreId());
assertEquals("tpl-1", result.getTemplateId());
assertEquals(newValues, result.getValues());
assertNotSame(newValues, result.getValues()); // copie defensive
assertEquals(newImages, result.getImageValues());
assertNotSame(newImages, result.getImageValues());
assertEquals("notes MJ", result.getNotes());
assertEquals(newTags, result.getTags());
assertNotSame(newTags, result.getTags());
assertEquals(newRelated, result.getRelatedPageIds());
assertNotSame(newRelated, result.getRelatedPageIds());
}
@Test
void testUpdatePage_NullCollectionsBecomeEmpty() {
Page changes = Page.builder()
.nodeId("n-1").title("t")
.values(null).imageValues(null).tags(null).relatedPageIds(null)
.build();
when(pageRepository.findById("p-1")).thenReturn(Optional.of(existing));
when(pageRepository.save(any(Page.class))).thenAnswer(inv -> inv.getArgument(0));
Page result = pageService.updatePage("p-1", changes);
assertNotNull(result.getValues());
assertTrue(result.getValues().isEmpty());
assertNotNull(result.getImageValues());
assertTrue(result.getImageValues().isEmpty());
assertNotNull(result.getTags());
assertTrue(result.getTags().isEmpty());
assertNotNull(result.getRelatedPageIds());
assertTrue(result.getRelatedPageIds().isEmpty());
}
@Test
void testUpdatePage_NotFoundThrows() {
when(pageRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> pageService.updatePage("missing", existing));
}
@Test
void testDelete() {
pageService.deletePage("p-1");
verify(pageRepository).deleteById("p-1");
}
}

View File

@@ -0,0 +1,146 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
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 TemplateService.
* Vérifie le CRUD, la copie défensive des champs en create/update, le
* comportement null-safe de fields, et l'immuabilité de loreId.
*/
@ExtendWith(MockitoExtension.class)
public class TemplateServiceTest {
@Mock private TemplateRepository templateRepository;
@InjectMocks private TemplateService templateService;
private Template existing;
private List<TemplateField> originalFields;
@BeforeEach
void setUp() {
originalFields = List.of(TemplateField.text("Histoire"), TemplateField.image("Portrait"));
existing = Template.builder()
.id("tpl-1").loreId("lore-1").name("Personnage")
.description("desc").defaultNodeId("n-1")
.fields(List.of(TemplateField.text("Old")))
.build();
}
@Test
void testCreateTemplate_CopiesFieldsDefensively() {
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
templateService.createTemplate("lore-1", "T", "d", "n-1", originalFields);
ArgumentCaptor<Template> captor = ArgumentCaptor.forClass(Template.class);
verify(templateRepository).save(captor.capture());
Template saved = captor.getValue();
assertEquals("T", saved.getName());
assertEquals(2, saved.getFields().size());
assertNotSame(originalFields, saved.getFields()); // copie defensive
}
@Test
void testCreateTemplate_NullFieldsBecomesEmptyList() {
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
templateService.createTemplate("lore-1", "T", "d", "n-1", null);
ArgumentCaptor<Template> captor = ArgumentCaptor.forClass(Template.class);
verify(templateRepository).save(captor.capture());
assertNotNull(captor.getValue().getFields());
assertTrue(captor.getValue().getFields().isEmpty());
}
@Test
void testGetTemplateById_AndByLoreId_AndAll() {
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.findAll()).thenReturn(List.of(existing));
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(existing));
assertTrue(templateService.getTemplateById("tpl-1").isPresent());
assertEquals(1, templateService.getAllTemplates().size());
assertEquals(1, templateService.getTemplatesByLoreId("lore-1").size());
}
@Test
void testSearch_NullOrBlankReturnsEmpty() {
assertTrue(templateService.searchTemplates(null).isEmpty());
assertTrue(templateService.searchTemplates(" ").isEmpty());
verifyNoInteractions(templateRepository);
}
@Test
void testSearch_TrimsQuery() {
when(templateRepository.searchByName("perso")).thenReturn(List.of(existing));
templateService.searchTemplates(" perso ");
verify(templateRepository).searchByName("perso");
}
@Test
void testUpdateTemplate_AppliesChangesKeepsLoreId() {
Template changes = Template.builder()
.loreId("lore-OTHER") // doit etre ignore
.name("Nouveau").description("nd").defaultNodeId("n-2")
.fields(originalFields)
.build();
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
Template result = templateService.updateTemplate("tpl-1", changes);
assertEquals("Nouveau", result.getName());
assertEquals("nd", result.getDescription());
assertEquals("n-2", result.getDefaultNodeId());
assertEquals(2, result.getFields().size());
assertNotSame(originalFields, result.getFields()); // copie defensive
// loreId immuable
assertEquals("lore-1", result.getLoreId());
}
@Test
void testUpdateTemplate_NullFieldsBecomesEmpty() {
Template changes = Template.builder().name("N").description("d")
.defaultNodeId("n-1").fields(null).build();
when(templateRepository.findById("tpl-1")).thenReturn(Optional.of(existing));
when(templateRepository.save(any(Template.class))).thenAnswer(inv -> inv.getArgument(0));
Template result = templateService.updateTemplate("tpl-1", changes);
assertNotNull(result.getFields());
assertTrue(result.getFields().isEmpty());
}
@Test
void testUpdateTemplate_NotFoundThrows() {
when(templateRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> templateService.updateTemplate("missing", existing));
}
@Test
void testDelete() {
templateService.deleteTemplate("tpl-1");
verify(templateRepository).deleteById("tpl-1");
}
}

View File

@@ -31,6 +31,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
volumes: volumes:
- minio-data:/data - minio-data:/data
# Mapping bind sur loopback pour autoriser un core/web lance en local (mode dev)
# a atteindre MinIO. Invisible sur le LAN donc non-exploitable depuis l'exterieur.
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
@@ -55,7 +60,7 @@ services:
" "
core: core:
image: ${REGISTRY:-gitea.example.com}/ietm64/core:${TAG:-latest} image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
container_name: loremind-core container_name: loremind-core
depends_on: depends_on:
postgres: postgres:
@@ -77,7 +82,7 @@ services:
restart: unless-stopped restart: unless-stopped
brain: brain:
image: ${REGISTRY:-gitea.example.com}/ietm64/brain:${TAG:-latest} image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
container_name: loremind-brain container_name: loremind-brain
environment: environment:
LLM_PROVIDER: ${LLM_PROVIDER:-ollama} LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
@@ -95,7 +100,7 @@ services:
restart: unless-stopped restart: unless-stopped
web: web:
image: ${REGISTRY:-gitea.example.com}/ietm64/web:${TAG:-latest} image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
container_name: loremind-web container_name: loremind-web
depends_on: depends_on:
- core - core

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.2.0", "version": "0.4.0",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) --> <!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field"> <div class="field">
<label>Illustrations</label> <label>Illustrations</label>
<app-image-gallery <app-image-gallery
[imageIds]="illustrationImageIds" [imageIds]="illustrationImageIds"
[editable]="true" [editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event"> (imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery> </app-image-gallery>
<small class="field-hint">Glisse-depose ou clique sur "+ Ajouter" pour uploader. JPEG, PNG, WebP ou GIF, 10 Mo max.</small> <small class="field-hint">Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales et plans utiles aux joueurs pour situer l'action.</small>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -60,6 +60,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */ /** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
illustrationImageIds: string[] = []; illustrationImageIds: string[] = [];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds: string[] = [];
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
@@ -119,6 +121,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(arc.relatedPageIds ?? [])]; this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name); this.pageTitleService.set(arc.name);
this.form.patchValue({ this.form.patchValue({
name: arc.name, name: arc.name,
@@ -161,7 +164,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
rewards: this.form.value.rewards, rewards: this.form.value.rewards,
resolution: this.form.value.resolution, resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
error: () => console.error('Erreur lors de la sauvegarde') error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -13,9 +13,15 @@
</div> </div>
</header> </header>
<!-- Illustrations en tete de page (si presentes) --> <!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0"> <section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []"></app-image-gallery> <app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section> </section>
<section class="view-section"> <section class="view-section">

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) --> <!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field"> <div class="field">
<label>Illustrations</label> <label>Illustrations</label>
<app-image-gallery <app-image-gallery
[imageIds]="illustrationImageIds" [imageIds]="illustrationImageIds"
[editable]="true" [editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event"> (imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery> </app-image-gallery>
<small class="field-hint">Ajoute des cartes, portraits ou ambiances pour illustrer ce chapitre.</small> <small class="field-hint">Portraits, ambiances, scenes marquantes du chapitre.</small>
</div>
<!-- Cartes & plans -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Cartes regionales, plans de donjon, schemas utiles a la table.</small>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -54,6 +54,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
loreId: string | null = null; loreId: string | null = null;
relatedPageIds: string[] = []; relatedPageIds: string[] = [];
illustrationImageIds: string[] = []; illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
@@ -111,6 +112,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])]; this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.form.patchValue({ this.form.patchValue({
name: chapter.name, name: chapter.name,
description: chapter.description ?? '', description: chapter.description ?? '',
@@ -148,7 +150,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
playerObjectives: this.form.value.playerObjectives, playerObjectives: this.form.value.playerObjectives,
narrativeStakes: this.form.value.narrativeStakes, narrativeStakes: this.form.value.narrativeStakes,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
error: () => console.error('Erreur lors de la sauvegarde') error: () => console.error('Erreur lors de la sauvegarde')

View File

@@ -18,9 +18,15 @@
</div> </div>
</header> </header>
<!-- Illustrations --> <!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0"> <section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []"></app-image-gallery> <app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section> </section>
<section class="view-section"> <section class="view-section">

View File

@@ -18,15 +18,28 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Illustrations (galerie editable) --> <!-- Illustrations (galerie editable, rendu editorial) -->
<div class="field"> <div class="field">
<label>Illustrations</label> <label>Illustrations</label>
<app-image-gallery <app-image-gallery
[imageIds]="illustrationImageIds" [imageIds]="illustrationImageIds"
[editable]="true" [editable]="true"
[layout]="'EDITORIAL'"
(imageIdsChange)="illustrationImageIds = $event"> (imageIdsChange)="illustrationImageIds = $event">
</app-image-gallery> </app-image-gallery>
<small class="field-hint">Carte du lieu, portrait des PNJ presents, ambiance visuelle...</small> <small class="field-hint">Portraits des PNJ, ambiance visuelle, scenes evocatrices...</small>
</div>
<!-- Cartes & plans (galerie editable, rendu maps) -->
<div class="field">
<label>Cartes &amp; plans</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">Plans du lieu, cartes tactiques, schemas utilisables a la table.</small>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -53,6 +53,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
loreId: string | null = null; loreId: string | null = null;
relatedPageIds: string[] = []; relatedPageIds: string[] = [];
illustrationImageIds: string[] = []; illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */ /** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
siblingScenes: Scene[] = []; siblingScenes: Scene[] = [];
@@ -129,6 +130,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.availablePages = pages; this.availablePages = pages;
this.relatedPageIds = [...(scene.relatedPageIds ?? [])]; this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
this.mapImageIds = [...(scene.mapImageIds ?? [])];
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId); this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b })); this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.form.patchValue({ this.form.patchValue({
@@ -179,6 +181,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
enemies: this.form.value.enemies, enemies: this.form.value.enemies,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
branches: this.branches branches: this.branches
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),

View File

@@ -13,9 +13,15 @@
</div> </div>
</header> </header>
<!-- Illustrations --> <!-- Illustrations (rendu editorial magazine) -->
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0"> <section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []"></app-image-gallery> <app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section>
<!-- Cartes & plans -->
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section> </section>
<!-- Description courte --> <!-- Description courte -->

View File

@@ -78,6 +78,7 @@
<app-ai-chat-drawer <app-ai-chat-drawer
[loreId]="loreId" [loreId]="loreId"
[isOpen]="chatOpen" [isOpen]="chatOpen"
[persistent]="false"
[welcomeMessage]="wizardWelcome" [welcomeMessage]="wizardWelcome"
[systemPromptAddon]="wizardSystemPrompt" [systemPromptAddon]="wizardSystemPrompt"
[quickSuggestions]="wizardSuggestions" [quickSuggestions]="wizardSuggestions"

View File

@@ -65,6 +65,7 @@
<app-image-gallery <app-image-gallery
[imageIds]="imageValues[field.name] || []" [imageIds]="imageValues[field.name] || []"
[editable]="true" [editable]="true"
[layout]="field.layout ?? 'GALLERY'"
(imageIdsChange)="imageValues[field.name] = $event"> (imageIdsChange)="imageValues[field.name] = $event">
</app-image-gallery> </app-image-gallery>
</div> </div>

View File

@@ -28,7 +28,10 @@
</section> </section>
<section class="view-section" *ngIf="field.type === 'IMAGE'"> <section class="view-section" *ngIf="field.type === 'IMAGE'">
<h2 class="view-section-title">{{ field.name }}</h2> <h2 class="view-section-title">{{ field.name }}</h2>
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery> <app-image-gallery
[imageIds]="imageIdsOf(field.name)"
[layout]="field.layout ?? 'GALLERY'">
</app-image-gallery>
</section> </section>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@@ -37,7 +37,21 @@
<label class="section-label">Champs du template *</label> <label class="section-label">Champs du template *</label>
<ul class="fields-list"> <ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index"> <li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
<div class="reorder-stack">
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, -1)"
[disabled]="first"
aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'"> <span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon> <lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }} {{ f.name }}
@@ -49,6 +63,17 @@
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'"> [title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }} {{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button> </button>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer"> <button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>

View File

@@ -124,7 +124,8 @@
&:hover { background: #363650; color: white; } &:hover { background: #363650; color: white; }
} }
.type-select { .type-select,
.layout-select {
background: #1a1a2e; background: #1a1a2e;
border: 1px solid #2a2a3d; border: 1px solid #2a2a3d;
color: white; color: white;
@@ -137,6 +138,12 @@
&:focus { outline: none; border-color: #6c63ff; } &:focus { outline: none; border-color: #6c63ff; }
} }
.layout-select {
height: 28px;
font-size: 0.72rem;
padding: 0 0.45rem;
}
input { input {
flex: 1; flex: 1;
background: #1a1a2e; background: #1a1a2e;
@@ -153,6 +160,35 @@
} }
&.add-row { margin-top: 0.5rem; } &.add-row { margin-top: 0.5rem; }
.reorder-stack {
display: flex;
flex-direction: column;
gap: 2px;
.btn-reorder {
width: 22px;
height: 16px;
background: transparent;
color: #6b7280;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
border-color: #6c63ff;
}
&:disabled { opacity: 0.3; cursor: not-allowed; }
}
}
} }
.btn-icon { .btn-icon {

View File

@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon } from 'lucide-angular'; import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service'; import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model'; import { LoreNode } from '../../services/lore.model';
import { FieldType, TemplateField } from '../../services/template.model'; import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/** /**
@@ -29,6 +29,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Type = Type; readonly Type = Type;
readonly ImageIcon = ImageIcon; readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup; form: FormGroup;
loreId = ''; loreId = '';
@@ -75,7 +77,10 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
if (!name) return; if (!name) return;
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage). // Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
if (this.fields.some(f => f.name === name)) return; if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }]; const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = ''; this.newFieldName = '';
// Le type reste sur la derniere valeur choisie : pratique pour enchainer // Le type reste sur la derniere valeur choisie : pratique pour enchainer
// plusieurs champs du meme type. // plusieurs champs du meme type.
@@ -85,12 +90,33 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
this.fields = this.fields.filter((_, i) => i !== index); this.fields = this.fields.filter((_, i) => i !== index);
} }
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */ /** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
toggleFieldType(index: number): void { toggleFieldType(index: number): void {
const field = this.fields[index]; const field = this.fields[index];
if (!field) return; if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT'; const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f); this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
}
/** Met a jour le layout d'un champ IMAGE. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
} }
submit(): void { submit(): void {

View File

@@ -43,7 +43,21 @@
<label class="section-label">Champs du template</label> <label class="section-label">Champs du template</label>
<ul class="fields-list"> <ul class="fields-list">
<li class="field-row" *ngFor="let f of fields; let i = index"> <li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
<div class="reorder-stack">
<button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, -1)"
[disabled]="first"
aria-label="Monter d'un cran" title="Monter">
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon-ghost btn-reorder"
(click)="moveField(i, 1)"
[disabled]="last"
aria-label="Descendre d'un cran" title="Descendre">
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'"> <span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon> <lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
{{ f.name }} {{ f.name }}
@@ -54,6 +68,17 @@
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'"> [title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }} {{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
</button> </button>
<select *ngIf="f.type === 'IMAGE'"
class="layout-select"
[ngModel]="f.layout ?? 'GALLERY'"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="setFieldLayout(i, $event)"
title="Mise en page des images">
<option value="GALLERY">Grille</option>
<option value="HERO">Heros</option>
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer"> <button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="X" [size]="14"></lucide-icon> <lucide-icon [img]="X" [size]="14"></lucide-icon>
</button> </button>

View File

@@ -125,7 +125,8 @@
&:hover { color: #a5b4fc; background: #1f1b3a; } &:hover { color: #a5b4fc; background: #1f1b3a; }
} }
.type-select { .type-select,
.layout-select {
background: #1a1a2e; background: #1a1a2e;
border: 1px solid #2a2a3d; border: 1px solid #2a2a3d;
color: white; color: white;
@@ -138,6 +139,12 @@
&:focus { outline: none; border-color: #6c63ff; } &:focus { outline: none; border-color: #6c63ff; }
} }
.layout-select {
height: 28px;
font-size: 0.72rem;
padding: 0 0.45rem;
}
input { input {
flex: 1; flex: 1;
background: #1a1a2e; background: #1a1a2e;
@@ -167,6 +174,35 @@
&:focus { border: none; } &:focus { border: none; }
} }
} }
.reorder-stack {
display: flex;
flex-direction: column;
gap: 2px;
.btn-reorder {
width: 22px;
height: 16px;
background: transparent;
color: #6b7280;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
&:hover:not(:disabled) {
background: #2a2a3d;
color: white;
border-color: #6c63ff;
}
&:disabled { opacity: 0.3; cursor: not-allowed; }
}
}
} }
.btn-icon-ghost { .btn-icon-ghost {

View File

@@ -3,14 +3,14 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon } from 'lucide-angular'; import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service'; import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service'; import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model'; import { LoreNode } from '../../services/lore.model';
import { FieldType, Template, TemplateField } from '../../services/template.model'; import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/** /**
@@ -30,6 +30,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Type = Type; readonly Type = Type;
readonly ImageIcon = ImageIcon; readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup; form: FormGroup;
loreId = ''; loreId = '';
@@ -75,10 +77,12 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.template = template; this.template = template;
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant, // Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
// utile pour les templates legacy cote frontend meme si le backend le fait aussi). // utile pour les templates legacy cote frontend meme si le backend le fait aussi).
this.fields = (template.fields ?? []).map(f => ({ this.fields = (template.fields ?? []).map(f => {
name: f.name, const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT' return type === 'IMAGE'
})); ? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
: { name: f.name, type };
});
this.form.patchValue({ this.form.patchValue({
name: template.name, name: template.name,
description: template.description, description: template.description,
@@ -91,7 +95,10 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
const name = this.newFieldName.trim(); const name = this.newFieldName.trim();
if (!name) return; if (!name) return;
if (this.fields.some(f => f.name === name)) return; if (this.fields.some(f => f.name === name)) return;
this.fields = [...this.fields, { name, type: this.newFieldType }]; const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = ''; this.newFieldName = '';
} }
@@ -99,12 +106,33 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.fields = this.fields.filter((_, i) => i !== index); this.fields = this.fields.filter((_, i) => i !== index);
} }
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ (TEXT <-> IMAGE). */ /** Bascule le type d'un champ (TEXT <-> IMAGE). */
toggleFieldType(index: number): void { toggleFieldType(index: number): void {
const field = this.fields[index]; const field = this.fields[index];
if (!field) return; if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT'; const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f); this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
}
/** Met a jour le layout d'un champ IMAGE. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
} }
save(): void { save(): void {

View File

@@ -16,7 +16,19 @@ export interface ChatMessage {
* - done : le stream s'est terminé proprement (l'observable va compléter). * - done : le stream s'est terminé proprement (l'observable va compléter).
* - error : une erreur s'est produite côté serveur (l'observable va erreur-compléter). * - error : une erreur s'est produite côté serveur (l'observable va erreur-compléter).
*/ */
/**
* Instantané d'occupation de la fenêtre de contexte (émis 1x par tour, avant le streaming).
* Les valeurs sont exprimées en tokens (~cl100k_base, ±10% vs tokenizer natif du modèle).
*/
export interface ChatUsage {
system: number;
history: number;
current: number;
max: number;
}
export type ChatStreamEvent = export type ChatStreamEvent =
| { type: 'usage'; usage: ChatUsage }
| { type: 'token'; value: string } | { type: 'token'; value: string }
| { type: 'done' } | { type: 'done' }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
@@ -128,12 +140,19 @@ export class AiChatService {
const dispatchCurrentEvent = () => { const dispatchCurrentEvent = () => {
const eventName = currentEvent ?? 'message'; const eventName = currentEvent ?? 'message';
// DEBUG jauge de contexte — à retirer une fois stabilisé.
if (eventName !== 'message') {
console.log('[AiChatService] SSE event:', eventName, 'data:', currentData);
}
if (eventName === 'error') { if (eventName === 'error') {
const message = this.safeParseMessage(currentData); const message = this.safeParseMessage(currentData);
subscriber.error(new Error(message)); subscriber.error(new Error(message));
} else if (eventName === 'done') { } else if (eventName === 'done') {
subscriber.next({ type: 'done' }); subscriber.next({ type: 'done' });
subscriber.complete(); subscriber.complete();
} else if (eventName === 'usage') {
const usage = this.safeParseUsage(currentData);
if (usage) subscriber.next({ type: 'usage', usage });
} else { } else {
// Événement 'message' (défaut) : JSON {"token": "..."} // Événement 'message' (défaut) : JSON {"token": "..."}
const token = this.safeParseToken(currentData); const token = this.safeParseToken(currentData);
@@ -188,6 +207,23 @@ export class AiChatService {
} }
} }
private safeParseUsage(json: string): ChatUsage | null {
try {
const obj = JSON.parse(json) as Partial<ChatUsage>;
if (
typeof obj.system === 'number' &&
typeof obj.history === 'number' &&
typeof obj.current === 'number' &&
typeof obj.max === 'number'
) {
return { system: obj.system, history: obj.history, current: obj.current, max: obj.max };
}
return null;
} catch {
return null;
}
}
private safeParseMessage(json: string): string { private safeParseMessage(json: string): string {
try { try {
const obj = JSON.parse(json) as { message?: string }; const obj = JSON.parse(json) as { message?: string };

View File

@@ -36,8 +36,11 @@ export interface Arc {
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */ /** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
relatedPageIds?: string[]; relatedPageIds?: string[];
/** IDs des images (Shared Kernel) illustrant cet arc. */ /** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
illustrationImageIds?: string[]; illustrationImageIds?: string[];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds?: string[];
} }
// Payload pour la création d'un Arc (pas d'id) // Payload pour la création d'un Arc (pas d'id)
@@ -55,6 +58,7 @@ export interface ArcCreate {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
} }
export interface Chapter { export interface Chapter {
@@ -71,6 +75,7 @@ export interface Chapter {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
} }
export interface ChapterCreate { export interface ChapterCreate {
@@ -85,6 +90,7 @@ export interface ChapterCreate {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
} }
/** /**
@@ -116,6 +122,7 @@ export interface Scene {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
/** Sorties narratives (graphe intra-chapitre). */ /** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[]; branches?: SceneBranch[];
@@ -138,5 +145,6 @@ export interface SceneCreate {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
branches?: SceneBranch[]; branches?: SceneBranch[];
} }

View File

@@ -0,0 +1,35 @@
export type ConversationRole = 'user' | 'assistant' | 'system';
export interface ConversationMessage {
id?: string;
role: ConversationRole;
content: string;
createdAt?: string;
}
export interface Conversation {
id: string;
title: string;
loreId?: string | null;
campaignId?: string | null;
entityType?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
messages?: ConversationMessage[];
}
/**
* Filtre strict pour le listing sidebar. Fournir soit loreId soit campaignId.
* entityType + entityId vont ensemble — tous deux null = niveau racine.
*/
export interface ConversationContext {
loreId?: string | null;
campaignId?: string | null;
entityType?: 'page' | 'arc' | 'chapter' | 'scene' | null;
entityId?: string | null;
}
export interface CreateConversationPayload extends ConversationContext {
title?: string;
}

View File

@@ -0,0 +1,64 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
Conversation,
ConversationContext,
ConversationMessage,
CreateConversationPayload,
} from './conversation.model';
/**
* Service HTTP des conversations persistees.
*
* Le streaming (chat/stream) reste pris en charge par AiChatService. Ce
* service ne gere que la persistance (metadonnees + messages) et
* l'auto-titre declenche apres le 1er echange.
*/
@Injectable({ providedIn: 'root' })
export class ConversationService {
private readonly apiUrl = 'http://localhost:8080/api/conversations';
constructor(private http: HttpClient) {}
list(ctx: ConversationContext): Observable<Conversation[]> {
let params = new HttpParams();
if (ctx.loreId) params = params.set('loreId', ctx.loreId);
if (ctx.campaignId) params = params.set('campaignId', ctx.campaignId);
if (ctx.entityType) params = params.set('entityType', ctx.entityType);
if (ctx.entityId) params = params.set('entityId', ctx.entityId);
return this.http.get<Conversation[]>(this.apiUrl, { params });
}
getById(id: string): Observable<Conversation> {
return this.http.get<Conversation>(`${this.apiUrl}/${id}`);
}
create(payload: CreateConversationPayload): Observable<Conversation> {
return this.http.post<Conversation>(this.apiUrl, payload);
}
rename(id: string, title: string): Observable<void> {
return this.http.patch<void>(`${this.apiUrl}/${id}/title`, { title });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
appendMessage(
id: string,
role: 'user' | 'assistant' | 'system',
content: string,
): Observable<ConversationMessage> {
return this.http.post<ConversationMessage>(`${this.apiUrl}/${id}/messages`, {
role,
content,
});
}
/** Declenche la generation auto du titre cote Brain. */
autoTitle(id: string): Observable<{ title: string }> {
return this.http.post<{ title: string }>(`${this.apiUrl}/${id}/auto-title`, {});
}
}

View File

@@ -12,6 +12,7 @@ export interface AppSettings {
llm_model: string; llm_model: string;
onemin_model: string; onemin_model: string;
onemin_api_key_set: boolean; onemin_api_key_set: boolean;
llm_num_ctx: number;
} }
/** /**
@@ -24,6 +25,13 @@ export interface AppSettingsUpdate {
llm_model?: string; llm_model?: string;
onemin_model?: string; onemin_model?: string;
onemin_api_key?: string; onemin_api_key?: string;
llm_num_ctx?: number;
}
/** Metadonnees d'un modele Ollama (issues de /api/show). */
export interface OllamaModelInfo {
/** Fenetre de contexte max du modele (en tokens). 0 si inconnue. */
context_length: number;
} }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -49,6 +57,11 @@ export class SettingsService {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions); return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
} }
getOllamaModelInfo(name: string): Observable<OllamaModelInfo> {
return this.http.post<OllamaModelInfo>(
`${this.apiUrl}/models/ollama/info`, { name }, this.authOptions);
}
listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> { listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions); return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
} }

View File

@@ -7,6 +7,16 @@
*/ */
export type FieldType = 'TEXT' | 'IMAGE'; export type FieldType = 'TEXT' | 'IMAGE';
/**
* Variante de rendu pour un champ IMAGE. Miroir de
* com.loremind.domain.lorecontext.ImageLayout. Ignore pour TEXT.
* - 'GALLERY' : grille de vignettes (defaut)
* - 'HERO' : premiere image en banniere, suivantes en petit
* - 'MASONRY' : mosaique hauteurs variables
* - 'CAROUSEL' : defilement horizontal
*/
export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL' | 'EDITORIAL' | 'MAPS';
/** /**
* Champ d'un Template : nom + type discriminant. * Champ d'un Template : nom + type discriminant.
* Miroir de TemplateFieldDTO (backend). * Miroir de TemplateFieldDTO (backend).
@@ -14,6 +24,8 @@ export type FieldType = 'TEXT' | 'IMAGE';
export interface TemplateField { export interface TemplateField {
name: string; name: string;
type: FieldType; type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null;
} }
export interface Template { export interface Template {

View File

@@ -48,7 +48,7 @@
<div class="form-row"> <div class="form-row">
<label for="ollama-model">Modele</label> <label for="ollama-model">Modele</label>
<div class="inline-select"> <div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model"> <select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option> <option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option>
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option> <option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option>
</select> </select>
@@ -93,6 +93,54 @@
</div> </div>
</section> </section>
<!-- Bloc Fenetre de contexte -->
<section class="card" *ngIf="settings">
<h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele -->
<div class="form-row" *ngIf="settings.llm_provider === 'ollama'">
<label for="llm-num-ctx">
Tokens alloues au modele
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
<span class="ctx-max" *ngIf="ollamaModelMaxContext > 0">
/ {{ ollamaModelMaxContext | number }} max
</span>
</label>
<input
id="llm-num-ctx"
type="range"
[min]="CTX_MIN"
[max]="effectiveMaxContext"
step="1024"
[(ngModel)]="settings.llm_num_ctx"
class="ctx-slider">
<p class="hint" *ngIf="ollamaModelMaxContext > 0">
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus
l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.
</p>
<p class="hint" *ngIf="ollamaModelMaxContext === 0">
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite.
</p>
</div>
<!-- 1min.ai : saisie libre (pas d'introspection possible) -->
<div class="form-row" *ngIf="settings.llm_provider === 'onemin'">
<label for="llm-num-ctx-onemin">Fenetre de contexte (tokens)</label>
<input
id="llm-num-ctx-onemin"
type="number"
min="2048"
step="1024"
[(ngModel)]="settings.llm_num_ctx">
<p class="hint">
A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o,
200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat.
</p>
</div>
</section>
<div class="actions" *ngIf="settings"> <div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving"> <button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon> <lucide-icon [img]="Save" [size]="16"></lucide-icon>

View File

@@ -136,3 +136,20 @@
} }
.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; }
/* --- Slider fenetre de contexte -------------------------------------- */
.ctx-value {
margin-left: 8px;
font-variant-numeric: tabular-nums;
color: #a5b4fc;
font-weight: 600;
}
.ctx-max {
color: #9ca3af;
font-size: 0.85em;
font-variant-numeric: tabular-nums;
}
.ctx-slider {
width: 100%;
accent-color: #6c63ff;
}

View File

@@ -42,6 +42,18 @@ export class SettingsComponent implements OnInit {
errorMessage = ''; errorMessage = '';
successMessage = ''; successMessage = '';
/**
* Fenetre de contexte max supportee par le modele Ollama actuellement
* selectionne (extraite des metadonnees GGUF via /api/show). 0 si inconnue
* — dans ce cas on laisse un fallback de 131072 cote UI.
*/
ollamaModelMaxContext = 0;
/** Minimum raisonnable pour num_ctx (defaut Ollama = 2048). */
readonly CTX_MIN = 2048;
/** Fallback si Ollama ne renvoie pas le context_length (modele exotique). */
readonly CTX_FALLBACK_MAX = 131072;
/** Cle 1min.ai saisie — vide = on ne touche pas a la cle persistee. */ /** Cle 1min.ai saisie — vide = on ne touche pas a la cle persistee. */
oneminApiKeyInput = ''; oneminApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle". */ /** True si l'utilisateur a coche "effacer la cle". */
@@ -61,6 +73,7 @@ export class SettingsComponent implements OnInit {
next: (s) => { next: (s) => {
this.settings = { ...s }; this.settings = { ...s };
this.refreshModels(); this.refreshModels();
this.fetchOllamaModelInfo();
}, },
error: (err) => this.errorMessage = this.extractError(err, 'Impossible de charger les parametres.') error: (err) => this.errorMessage = this.extractError(err, 'Impossible de charger les parametres.')
}); });
@@ -99,6 +112,31 @@ export class SettingsComponent implements OnInit {
return group ? group.models : []; return group ? group.models : [];
} }
/**
* Recupere la fenetre max supportee par le modele Ollama selectionne.
* Si la valeur courante de num_ctx depasse ce max, on la clamp.
*/
fetchOllamaModelInfo(): void {
if (!this.settings || this.settings.llm_provider !== 'ollama') return;
const modelName = this.settings.llm_model;
if (!modelName) return;
this.settingsService.getOllamaModelInfo(modelName).subscribe({
next: (info) => {
this.ollamaModelMaxContext = info.context_length;
const max = this.effectiveMaxContext;
if (this.settings && this.settings.llm_num_ctx > max) {
this.settings.llm_num_ctx = max;
}
},
error: () => this.ollamaModelMaxContext = 0
});
}
/** Max effectif a afficher pour le slider (modele Ollama ou fallback). */
get effectiveMaxContext(): number {
return this.ollamaModelMaxContext > 0 ? this.ollamaModelMaxContext : this.CTX_FALLBACK_MAX;
}
/** Quand on change de fournisseur, bascule automatiquement sur son premier modele. */ /** Quand on change de fournisseur, bascule automatiquement sur son premier modele. */
onProviderChange(): void { onProviderChange(): void {
if (!this.settings) return; if (!this.settings) return;
@@ -118,7 +156,8 @@ export class SettingsComponent implements OnInit {
llm_provider: this.settings.llm_provider, llm_provider: this.settings.llm_provider,
ollama_base_url: this.settings.ollama_base_url, ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model, llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model onemin_model: this.settings.onemin_model,
llm_num_ctx: this.settings.llm_num_ctx
}; };
if (this.clearApiKey) { if (this.clearApiKey) {
patch.onemin_api_key = ''; patch.onemin_api_key = '';

View File

@@ -1,81 +1,148 @@
<aside class="drawer" [class.drawer-open]="isOpen" aria-label="Assistant IA"> <aside class="drawer" [class.drawer-open]="isOpen" [class.with-sidebar]="persistent && sidebarOpen" aria-label="Assistant IA">
<header class="drawer-header"> <!-- Sidebar conversations (mode persistent uniquement) -->
<h2>Assistant IA</h2> <section class="conv-sidebar" *ngIf="persistent && sidebarOpen" aria-label="Conversations">
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer"> <div class="conv-sidebar-header">
<lucide-icon [img]="X" [size]="18"></lucide-icon> <span class="conv-sidebar-title">Conversations</span>
</button> <button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation">
</header> <lucide-icon [img]="MessageSquarePlus" [size]="16"></lucide-icon>
<div #messagesContainer class="messages">
<!-- Message d'accueil (non-stocké dans `messages`, toujours visible tant que la conversation est vide). -->
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
</div>
<!-- Historique -->
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
</ng-container>
<!-- Bulle en cours de streaming -->
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
</div>
<!-- Indicateur pendant la phase "en train de réfléchir" (avant le premier token) -->
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<!-- Erreur locale au drawer -->
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<!-- Action primaire (optionnelle) : ne passe PAS par le chat -->
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<!-- Suggestions rapides -->
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button
type="button"
class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
</button> </button>
</div> </div>
</div> <ul class="conv-list">
<li *ngIf="conversations.length === 0" class="conv-empty">Aucune conversation</li>
<li
*ngFor="let c of conversations"
class="conv-item"
[class.active]="c.id === currentConversationId"
(click)="selectConversation(c)">
<span class="conv-item-title">{{ c.title }}</span>
<button
type="button"
class="conv-item-del"
(click)="deleteConversation(c, $event)"
[disabled]="isStreaming"
title="Supprimer">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</li>
</ul>
</section>
<!-- Zone de saisie --> <section class="conv-main">
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
<header class="drawer-header">
<button
*ngIf="persistent"
type="button"
class="sidebar-toggle"
(click)="toggleSidebar()"
[attr.aria-label]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'"
[title]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'">
<lucide-icon [img]="sidebarOpen ? PanelLeftClose : PanelLeftOpen" [size]="16"></lucide-icon>
</button>
<div class="header-title-wrap">
<ng-container *ngIf="persistent && currentConversationId; else defaultTitle">
<ng-container *ngIf="!editingTitle; else editingTpl">
<h2 class="header-title" [title]="currentTitle">{{ currentTitle || 'Nouvelle conversation' }}</h2>
<button type="button" class="rename-btn" (click)="startRenameTitle()" title="Renommer" aria-label="Renommer">
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
</button>
</ng-container>
<ng-template #editingTpl>
<input
class="rename-input"
type="text"
[(ngModel)]="titleDraft"
(keyup.enter)="submitRenameTitle()"
(keyup.escape)="cancelRenameTitle()"
(blur)="submitRenameTitle()"
autofocus />
</ng-template>
</ng-container>
<ng-template #defaultTitle>
<h2 class="header-title">Assistant IA</h2>
</ng-template>
</div>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
<div class="gauge-bar">
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div>
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
<span class="gauge-percent">{{ usagePercent }}%</span>
</div>
</div>
<div #messagesContainer class="messages">
<div class="msg msg-assistant" *ngIf="messages.length === 0 && !currentAssistantText">
{{ welcomeMessage }}
</div>
<ng-container *ngFor="let m of messages">
<div class="msg" [class.msg-user]="m.role === 'user'" [class.msg-assistant]="m.role === 'assistant'">
{{ m.content }}
</div>
</ng-container>
<div class="msg msg-assistant msg-streaming" *ngIf="currentAssistantText">
{{ currentAssistantText }}<span class="caret"></span>
</div>
<div class="typing-indicator" *ngIf="isStreaming && !currentAssistantText" aria-live="polite">
<span></span><span></span><span></span>
</div>
<div class="msg msg-error" *ngIf="errorMessage" role="alert">
{{ errorMessage }}
</div>
</div>
<div class="primary-action" *ngIf="primaryAction">
<button
type="button"
class="primary-btn"
(click)="onPrimaryAction()"
[disabled]="isStreaming">
<lucide-icon [img]="Wand2" [size]="14"></lucide-icon>
{{ primaryAction.label }}
</button>
</div>
<div class="quick-suggestions" *ngIf="quickSuggestions.length">
<p class="quick-label">Suggestions rapides :</p>
<div class="quick-list">
<button
type="button"
class="quick-btn"
*ngFor="let s of quickSuggestions"
(click)="useQuickSuggestion(s)"
[disabled]="isStreaming">
<lucide-icon [img]="Lightbulb" [size]="12"></lucide-icon>
{{ s }}
</button>
</div>
</div>
<form class="input-row" (ngSubmit)="send()">
<input
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>
</section>
</aside> </aside>

View File

@@ -12,13 +12,173 @@
background: #0f0f1a; background: #0f0f1a;
border-left: 1px solid #1e1e3a; border-left: 1px solid #1e1e3a;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
transform: translateX(100%); transform: translateX(100%);
transition: transform 0.25s ease; transition: transform 0.25s ease, width 0.25s ease;
z-index: 1000; z-index: 1000;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4); box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
} }
.drawer.with-sidebar {
width: 600px;
}
.conv-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #0b0b15;
border-right: 1px solid #1e1e3a;
}
.conv-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid #1e1e3a;
.conv-sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.conv-new-btn {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
display: flex;
&:hover:not(:disabled) {
background: #1e1e3a;
color: white;
}
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
}
.conv-list {
list-style: none;
margin: 0;
padding: 0.4rem 0;
overflow-y: auto;
flex: 1;
}
.conv-empty {
padding: 1rem 0.9rem;
font-size: 0.78rem;
color: #6b7280;
font-style: italic;
}
.conv-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-size: 0.82rem;
color: #d1d5db;
border-left: 2px solid transparent;
&:hover {
background: #14142a;
}
&.active {
background: #1a1a2e;
border-left-color: #6c63ff;
color: white;
}
.conv-item-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-item-del {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.2rem;
border-radius: 3px;
display: flex;
opacity: 0;
transition: opacity 0.15s;
&:hover:not(:disabled) { color: #f87171; background: #1f0f0f; }
}
&:hover .conv-item-del { opacity: 1; }
&.active .conv-item-del { opacity: 1; }
}
.conv-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.sidebar-toggle {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
display: flex;
&:hover { background: #1e1e3a; color: white; }
}
.header-title-wrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.35rem;
.header-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.rename-btn {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.2rem;
border-radius: 3px;
display: flex;
&:hover { color: white; background: #1e1e3a; }
}
.rename-input {
flex: 1;
background: #1a1a2e;
border: 1px solid #6c63ff;
color: white;
padding: 0.3rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
}
.drawer-open { .drawer-open {
transform: translateX(0); transform: translateX(0);
} }
@@ -259,3 +419,44 @@
} }
} }
} }
/* --- Jauge de contexte ------------------------------------------------- */
.context-gauge {
padding: 0.5rem 1rem 0.75rem;
border-bottom: 1px solid #1e1e3a;
background: #141428;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.context-gauge .gauge-bar {
height: 6px;
border-radius: 3px;
background: #2a2a45;
overflow: hidden;
}
.context-gauge .gauge-fill {
height: 100%;
transition: width 0.3s ease, background-color 0.3s ease;
border-radius: 3px;
}
.context-gauge[data-level="low"] .gauge-fill { background: #10b981; }
.context-gauge[data-level="mid"] .gauge-fill { background: #f59e0b; }
.context-gauge[data-level="high"] .gauge-fill { background: #ef4444; }
.context-gauge .gauge-label {
display: flex;
justify-content: space-between;
font-size: 0.72rem;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
.context-gauge[data-level="high"] .gauge-percent {
color: #f87171;
font-weight: 600;
}

View File

@@ -1,105 +1,246 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Send, Sparkles, Lightbulb, Wand2 } from 'lucide-angular'; import { LucideAngularModule, Lightbulb, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage, NarrativeEntityType } from '../../services/ai-chat.service'; import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../../services/ai-chat.service';
import { Conversation, ConversationContext } from '../../services/conversation.model';
import { ConversationService } from '../../services/conversation.service';
/** /**
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions. * Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
* Utilisée pour les actions "spéciales" qui NE passent PAS par le chat * Utilisee pour les actions "speciales" qui NE passent PAS par le chat
* (ex: "Remplir automatiquement tous les champs" → déclenche le one-shot b4). * (ex: "Remplir automatiquement tous les champs" → declenche le one-shot b4).
*/ */
export interface ChatPrimaryAction { export interface ChatPrimaryAction {
label: string; label: string;
} }
/** /**
* Drawer de chat IA réutilisable — panneau fixe à droite de l'écran. * Drawer de chat IA reutilisable — panneau fixe a droite de l'ecran.
* *
* Usage minimal : * Deux modes :
* <app-ai-chat-drawer * - `persistent = true` (defaut) : sidebar + conversations persistees en base,
* [loreId]="loreId" * filtrees par contexte (loreId/campaignId + optionnellement entityType+Id).
* [isOpen]="chatOpen" * Les messages sont persistes en base au fil du chat et un titre automatique
* [quickSuggestions]="['Développe l'histoire', ...]" * est genere apres le 1er echange.
* (close)="chatOpen = false"> * - `persistent = false` : mode ephemere (pour le wizard de generation de page,
* </app-ai-chat-drawer> * ou la conversation n'a aucune valeur au-dela de l'usage immediat).
*
* Contrainte de design : conversation éphémère (on perd tout à la fermeture
* ou à la destruction du composant — choix MVP assumé).
*/ */
@Component({ @Component({
selector: 'app-ai-chat-drawer', selector: 'app-ai-chat-drawer',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './ai-chat-drawer.component.html', templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss'] styleUrls: ['./ai-chat-drawer.component.scss'],
}) })
export class AiChatDrawerComponent implements OnDestroy { export class AiChatDrawerComponent implements OnChanges, OnDestroy {
readonly X = X; readonly X = X;
readonly Send = Send; readonly Send = Send;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Lightbulb = Lightbulb; readonly Lightbulb = Lightbulb;
readonly Wand2 = Wand2; readonly Wand2 = Wand2;
readonly MessageSquarePlus = MessageSquarePlus;
readonly PanelLeftClose = PanelLeftClose;
readonly PanelLeftOpen = PanelLeftOpen;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
/**
* Mode Lore : fournir `loreId` (et optionnellement `pageId`).
* Mode Campagne : fournir `campaignId` (et optionnellement `entityType`+`entityId`).
* Les deux modes sont exclusifs — si `campaignId` est non-vide, on route
* vers l'endpoint Campagne, sinon vers l'endpoint Lore.
*/
@Input() loreId = ''; @Input() loreId = '';
/**
* Optionnel : ID d'une page précise en cours d'édition. Si fourni, le
* backend focalise l'IA sur cette page (template, champs, valeurs) via
* un bloc "PAGE EN COURS" dans le system prompt. Sans cet ID, le chat
* reste générique au Lore.
*/
@Input() pageId: string | null = null; @Input() pageId: string | null = null;
/** ID de la Campagne — active le mode chat Campagne si non-vide. */
@Input() campaignId: string | null = null; @Input() campaignId: string | null = null;
/** Optionnel : "arc"|"chapter"|"scene" — focalise l'IA sur une entité narrative. */
@Input() entityType: NarrativeEntityType | null = null; @Input() entityType: NarrativeEntityType | null = null;
/** Optionnel : ID de l'entité narrative en cours d'édition. */
@Input() entityId: string | null = null; @Input() entityId: string | null = null;
@Input() isOpen = false; @Input() isOpen = false;
/** Texte accueil affiché au premier ouverture (avant tout échange). */ @Input() welcomeMessage = 'Bonjour ! Je peux vous aider a developper cette page. Que souhaitez-vous creer ?';
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider à développer cette page. Que souhaitez-vous créer ?';
/** Suggestions rapides cliquables en bas (hardcodées par le parent, MVP). */
@Input() quickSuggestions: string[] = []; @Input() quickSuggestions: string[] = [];
/** Action primaire optionnelle (ex: "Remplir automatiquement") — ne passe PAS par le chat. */
@Input() primaryAction: ChatPrimaryAction | null = null; @Input() primaryAction: ChatPrimaryAction | null = null;
/**
* Instructions système supplémentaires injectées en tête de la conversation
* envoyée au backend, INVISIBLES côté UI. Usage : mode wizard, où on veut
* contextualiser l'IA (template cible, format JSON attendu) sans polluer
* l'historique visuel.
*/
@Input() systemPromptAddon: string | null = null; @Input() systemPromptAddon: string | null = null;
/** Persistance activee ? false = mode wizard ephemere. */
@Input() persistent = true;
@Output() close = new EventEmitter<void>(); @Output() close = new EventEmitter<void>();
/** Émis au clic sur l'action primaire — le parent gère entièrement (one-shot, etc.). */
@Output() primaryActionClick = new EventEmitter<void>(); @Output() primaryActionClick = new EventEmitter<void>();
/** Émis à chaque fin de réponse assistant — utile pour parser côté parent (ex: bloc <values> du wizard). */
@Output() assistantReply = new EventEmitter<string>(); @Output() assistantReply = new EventEmitter<string>();
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>; @ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
/** Conversation en cours (user + assistant). Le welcome n'est pas dedans — rendu séparément. */
messages: ChatMessage[] = []; messages: ChatMessage[] = [];
/** Texte en cours de streaming (écrit token par token, pas encore poussé dans `messages`). */
currentAssistantText = ''; currentAssistantText = '';
/** Champ de saisie. */
input = ''; input = '';
/** Stream en cours ? Désactive le bouton envoyer + les suggestions rapides. */
isStreaming = false; isStreaming = false;
/** Dernier message d'erreur (affiché dans une bannière locale au drawer). */
errorMessage: string | null = null; errorMessage: string | null = null;
usage: ChatUsage | null = null;
// --- Persistance --------------------------------------------------------
/** Liste visible dans la sidebar pour le contexte courant. */
conversations: Conversation[] = [];
/** Conversation actuellement chargee (null = nouvelle conversation vierge). */
currentConversationId: string | null = null;
/** Titre de la conversation courante (affiche dans le header). */
currentTitle = '';
/** Mode edition inline du titre. */
editingTitle = false;
titleDraft = '';
/** Etat repliable de la sidebar. */
sidebarOpen = true;
private streamSub: Subscription | null = null; private streamSub: Subscription | null = null;
constructor(private readonly chatService: AiChatService) {} constructor(
private readonly chatService: AiChatService,
private readonly conversationService: ConversationService,
) {}
// --- Jauge de contexte --------------------------------------------------
get usageTotal(): number {
if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current;
}
get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max);
}
get usagePercent(): number {
return Math.round(this.usageRatio * 100);
}
get usageLevel(): 'low' | 'mid' | 'high' {
const r = this.usageRatio;
if (r > 0.8) return 'high';
if (r >= 0.5) return 'mid';
return 'low';
}
// --- Cycle de vie -------------------------------------------------------
ngOnChanges(changes: SimpleChanges): void {
if (!this.persistent) return;
const contextChanged =
changes['loreId'] || changes['pageId'] || changes['campaignId'] || changes['entityType'] || changes['entityId'];
const openedNow = changes['isOpen'] && this.isOpen;
if (contextChanged || openedNow) {
this.resetConversationState();
this.reloadConversations();
}
}
ngOnDestroy(): void {
this.abortStream();
}
// --- Sidebar : listing / nouveau / select / rename / delete ------------
private buildContext(): ConversationContext {
// Cote Lore : pageId joue le role de focus entite (entityType="page").
// Cote Campagne : entityType + entityId sont deja fournis directement.
if (this.loreId) {
return {
loreId: this.loreId,
campaignId: null,
entityType: this.pageId ? 'page' : null,
entityId: this.pageId ?? null,
};
}
return {
loreId: null,
campaignId: this.campaignId || null,
entityType: this.entityType,
entityId: this.entityId,
};
}
reloadConversations(): void {
if (!this.persistent) return;
const ctx = this.buildContext();
if (!ctx.loreId && !ctx.campaignId) {
this.conversations = [];
return;
}
this.conversationService.list(ctx).subscribe({
next: (rows) => (this.conversations = rows),
error: () => (this.conversations = []),
});
}
startNewConversation(): void {
if (this.isStreaming) return;
this.resetConversationState();
}
private resetConversationState(): void {
this.currentConversationId = null;
this.currentTitle = '';
this.messages = [];
this.currentAssistantText = '';
this.errorMessage = null;
this.usage = null;
this.editingTitle = false;
}
selectConversation(conv: Conversation): void {
if (this.isStreaming) return;
this.conversationService.getById(conv.id).subscribe({
next: (full) => {
this.currentConversationId = full.id;
this.currentTitle = full.title;
this.messages = (full.messages ?? [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
this.currentAssistantText = '';
this.errorMessage = null;
this.usage = null;
this.scrollToBottom();
},
error: () => (this.errorMessage = 'Impossible de charger la conversation.'),
});
}
deleteConversation(conv: Conversation, event: Event): void {
event.stopPropagation();
if (this.isStreaming) return;
if (!confirm(`Supprimer la conversation "${conv.title}" ?`)) return;
this.conversationService.delete(conv.id).subscribe({
next: () => {
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
if (this.currentConversationId === conv.id) this.resetConversationState();
},
});
}
startRenameTitle(): void {
if (!this.currentConversationId) return;
this.titleDraft = this.currentTitle;
this.editingTitle = true;
}
cancelRenameTitle(): void {
this.editingTitle = false;
this.titleDraft = '';
}
submitRenameTitle(): void {
const t = this.titleDraft.trim();
if (!t || !this.currentConversationId) {
this.cancelRenameTitle();
return;
}
const id = this.currentConversationId;
this.conversationService.rename(id, t).subscribe({
next: () => {
this.currentTitle = t;
this.conversations = this.conversations.map((c) =>
c.id === id ? { ...c, title: t } : c,
);
this.editingTitle = false;
},
});
}
toggleSidebar(): void {
this.sidebarOpen = !this.sidebarOpen;
}
// --- Handlers UI -------------------------------------------------------- // --- Handlers UI --------------------------------------------------------
@@ -108,7 +249,6 @@ export class AiChatDrawerComponent implements OnDestroy {
this.close.emit(); this.close.emit();
} }
/** Envoi explicite depuis le formulaire (Entrée ou bouton envoyer). */
send(): void { send(): void {
const text = this.input.trim(); const text = this.input.trim();
if (!text || this.isStreaming) return; if (!text || this.isStreaming) return;
@@ -116,45 +256,114 @@ export class AiChatDrawerComponent implements OnDestroy {
this.input = ''; this.input = '';
} }
/** Envoi depuis une suggestion rapide (bouton cliquable en bas). */
useQuickSuggestion(suggestion: string): void { useQuickSuggestion(suggestion: string): void {
if (this.isStreaming) return; if (this.isStreaming) return;
this.sendUserMessage(suggestion); this.sendUserMessage(suggestion);
} }
/** Clic sur l'action primaire — on délègue entièrement au parent. */
onPrimaryAction(): void { onPrimaryAction(): void {
if (this.isStreaming) return; if (this.isStreaming) return;
this.primaryActionClick.emit(); this.primaryActionClick.emit();
} }
// --- Logique envoi + streaming ----------------------------------------- // --- Envoi + streaming --------------------------------------------------
private sendUserMessage(text: string): void { private sendUserMessage(text: string): void {
if (this.persistent) {
this.ensureConversation().then((convId) => {
if (convId) this.streamAndPersist(text, convId);
});
} else {
this.streamEphemeral(text);
}
}
/**
* Cree la conversation cote serveur si elle n'existe pas encore. Resolu
* avec l'id, ou null sur erreur (auquel cas on n'envoie pas).
*/
private ensureConversation(): Promise<string | null> {
if (this.currentConversationId) return Promise.resolve(this.currentConversationId);
const ctx = this.buildContext();
if (!ctx.loreId && !ctx.campaignId) {
this.errorMessage = 'Contexte manquant pour creer une conversation.';
return Promise.resolve(null);
}
return new Promise((resolve) => {
this.conversationService.create(ctx).subscribe({
next: (conv) => {
this.currentConversationId = conv.id;
this.currentTitle = conv.title;
this.conversations = [conv, ...this.conversations];
resolve(conv.id);
},
error: () => {
this.errorMessage = 'Impossible de creer la conversation.';
resolve(null);
},
});
});
}
private streamAndPersist(text: string, convId: string): void {
const wasEmpty = this.messages.length === 0;
this.errorMessage = null; this.errorMessage = null;
this.messages.push({ role: 'user', content: text }); this.messages.push({ role: 'user', content: text });
this.currentAssistantText = ''; this.currentAssistantText = '';
this.isStreaming = true; this.isStreaming = true;
this.scrollToBottom(); this.scrollToBottom();
// Construit la liste effectivement envoyée au backend : systemPromptAddon // Persiste le message user immediatement — evite toute perte si stream interrompu.
// (si fourni) préfixé, puis l'historique visible. Le system n'est PAS stocké this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} });
// dans this.messages → reste invisible côté UI.
const payload = this.systemPromptAddon
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
const stream$ = this.campaignId this.streamSub = this.buildStream().subscribe({
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
: this.chatService.streamChat(this.loreId, payload, this.pageId);
this.streamSub = stream$.subscribe({
next: (event) => { next: (event) => {
if (event.type === 'token') { if (event.type === 'token') {
this.currentAssistantText += event.value; this.currentAssistantText += event.value;
this.scrollToBottom(); this.scrollToBottom();
} else if (event.type === 'usage') {
this.usage = event.usage;
}
},
error: (err) => {
this.isStreaming = false;
this.errorMessage = err?.message ?? 'Erreur inconnue.';
this.currentAssistantText = '';
},
complete: () => {
const reply = this.currentAssistantText;
if (reply) {
this.messages.push({ role: 'assistant', content: reply });
this.assistantReply.emit(reply);
this.conversationService.appendMessage(convId, 'assistant', reply).subscribe({
next: () => {
if (wasEmpty) this.triggerAutoTitle(convId);
},
error: () => {},
});
}
this.currentAssistantText = '';
this.isStreaming = false;
this.scrollToBottom();
},
});
}
private streamEphemeral(text: string): void {
this.errorMessage = null;
this.messages.push({ role: 'user', content: text });
this.currentAssistantText = '';
this.isStreaming = true;
this.scrollToBottom();
this.streamSub = this.buildStream().subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottom();
} else if (event.type === 'usage') {
this.usage = event.usage;
} }
// 'done' : l'Observable va compléter → géré par complete()
}, },
error: (err) => { error: (err) => {
this.isStreaming = false; this.isStreaming = false;
@@ -162,7 +371,6 @@ export class AiChatDrawerComponent implements OnDestroy {
this.currentAssistantText = ''; this.currentAssistantText = '';
}, },
complete: () => { complete: () => {
// On fige le texte streamé en message assistant réel, puis on reset le buffer.
const reply = this.currentAssistantText; const reply = this.currentAssistantText;
if (reply) { if (reply) {
this.messages.push({ role: 'assistant', content: reply }); this.messages.push({ role: 'assistant', content: reply });
@@ -171,7 +379,28 @@ export class AiChatDrawerComponent implements OnDestroy {
this.currentAssistantText = ''; this.currentAssistantText = '';
this.isStreaming = false; this.isStreaming = false;
this.scrollToBottom(); this.scrollToBottom();
} },
});
}
private buildStream() {
const payload = this.systemPromptAddon
? [{ role: 'system' as const, content: this.systemPromptAddon }, ...this.messages]
: this.messages;
return this.campaignId
? this.chatService.streamChatForCampaign(this.campaignId, payload, this.entityType, this.entityId)
: this.chatService.streamChat(this.loreId, payload, this.pageId);
}
private triggerAutoTitle(convId: string): void {
this.conversationService.autoTitle(convId).subscribe({
next: ({ title }) => {
this.currentTitle = title;
this.conversations = this.conversations.map((c) =>
c.id === convId ? { ...c, title } : c,
);
},
error: () => {},
}); });
} }
@@ -182,18 +411,10 @@ export class AiChatDrawerComponent implements OnDestroy {
this.currentAssistantText = ''; this.currentAssistantText = '';
} }
/**
* Scroll différé au prochain tick : donne à Angular le temps de rendre
* le nouveau contenu avant qu'on mesure/ajuste la position du scroll.
*/
private scrollToBottom(): void { private scrollToBottom(): void {
queueMicrotask(() => { queueMicrotask(() => {
const el = this.messagesContainer?.nativeElement; const el = this.messagesContainer?.nativeElement;
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}); });
} }
ngOnDestroy(): void {
this.abortStream();
}
} }

View File

@@ -1,29 +1,183 @@
<!-- Grille de vignettes + uploader si editable. --> <!-- Container avec classe dynamique selon le layout choisi. -->
<div class="gallery" <div [ngSwitch]="effectiveLayout" class="gallery-root">
*ngIf="imageIds.length > 0 || editable; else empty">
<div class="gallery-tile" <!-- =================== HERO =================== -->
*ngFor="let id of imageIds" <ng-container *ngSwitchCase="'HERO'">
(click)="openLightbox(id)" <div class="hero" *ngIf="imageIds.length > 0 || editable; else empty">
role="button" <div class="hero-main"
tabindex="0"> *ngIf="heroId"
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" /> (click)="openLightbox(heroId)"
role="button"
tabindex="0">
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(heroId, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<button type="button" <div class="hero-rest" *ngIf="restIds.length > 0 || editable">
class="gallery-remove" <div class="gallery-tile hero-thumb"
*ngIf="editable" *ngFor="let id of restIds"
(click)="remove(id, $event)" (click)="openLightbox(id)"
aria-label="Retirer cette image"> role="button"
<lucide-icon [img]="X" [size]="14"></lucide-icon> tabindex="0">
</button> <img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
</div> <button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
<!-- Si pas de hero mais editable, on montre au moins l'uploader. -->
<div class="hero-rest" *ngIf="!heroId && editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MASONRY =================== -->
<ng-container *ngSwitchCase="'MASONRY'">
<div class="masonry" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="masonry-item"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="masonry-item masonry-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== CAROUSEL =================== -->
<ng-container *ngSwitchCase="'CAROUSEL'">
<div class="carousel" *ngIf="imageIds.length > 0 || editable; else empty">
<button type="button"
class="carousel-nav carousel-prev"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(-1)"
aria-label="Precedent">
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
<div class="carousel-track" #carouselTrack>
<div class="carousel-slide"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="carousel-slide carousel-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
<button type="button"
class="carousel-nav carousel-next"
*ngIf="imageIds.length > 1"
(click)="scrollCarousel(1)"
aria-label="Suivant">
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
</div>
</ng-container>
<!-- =================== EDITORIAL =================== -->
<!-- Rendu adaptatif facon magazine : 1 image → hero, 2 → diptyque, 3 → feature + 2 satellites, 4+ → feature + 3 satellites. -->
<ng-container *ngSwitchCase="'EDITORIAL'">
<div class="editorial" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="editorial-item"
*ngFor="let id of imageIds; let i = index"
[class.editorial-feature]="i === 0"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="editorial-item editorial-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== MAPS =================== -->
<!-- Cartes / plans : grandes vignettes, ratio natif preserve (pas de crop). -->
<ng-container *ngSwitchCase="'MAPS'">
<div class="maps" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="map-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette carte">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<div class="map-tile map-uploader" *ngIf="editable">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</div>
</ng-container>
<!-- =================== GALLERY (default) =================== -->
<ng-container *ngSwitchDefault>
<div class="gallery" *ngIf="imageIds.length > 0 || editable; else empty">
<div class="gallery-tile"
*ngFor="let id of imageIds"
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<button type="button"
class="gallery-remove"
*ngIf="editable"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
</ng-container>
<!-- Bouton + (uploader compact), uniquement en mode edition -->
<app-image-uploader
*ngIf="editable"
[compact]="true"
(uploaded)="onUploaded($event)">
</app-image-uploader>
</div> </div>
<!-- Etat vide (lecture uniquement). --> <!-- Etat vide (lecture uniquement). -->

View File

@@ -1,33 +1,36 @@
.gallery { // =============== Common tile / remove-button ===============
display: flex; // Partage par tous les layouts : vignette, survol, bouton X.
flex-wrap: wrap;
gap: 0.8rem;
align-items: flex-start;
}
.gallery-tile { .gallery-tile,
.masonry-item,
.hero-thumb,
.carousel-slide,
.hero-main,
.map-tile {
position: relative; position: relative;
width: 120px; border-radius: 8px;
height: 120px;
border-radius: 6px;
overflow: hidden; overflow: hidden;
background: #1a1a2e; background: #1a1a2e;
border: 1px solid #2a2a3d; border: 1px solid #2a2a3d;
cursor: zoom-in; cursor: zoom-in;
transition: border-color 0.15s, transform 0.15s; transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s;
&:hover { &:hover {
border-color: #6c63ff; border-color: #6c63ff;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.18);
.gallery-remove { opacity: 1; } .gallery-remove { opacity: 1; }
img { transform: scale(1.04); }
} }
img { img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; transition: transform 0.3s ease;
} }
} }
@@ -47,6 +50,7 @@
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
transition: opacity 0.15s, background 0.15s; transition: opacity 0.15s, background 0.15s;
z-index: 2;
&:hover { background: #7f1d1d; color: white; } &:hover { background: #7f1d1d; color: white; }
} }
@@ -60,7 +64,338 @@
font-style: italic; font-style: italic;
} }
// Lightbox plein ecran // =============== Layout: GALLERY (planche de contact) ===============
// Grille stricte de carres identiques, effet "contact sheet" photo.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.35rem;
padding: 0.35rem;
background: #12121f;
border-radius: 6px;
max-width: 720px; // contient la grille pour ne pas etaler sur tout l'ecran
}
.gallery-tile {
width: auto;
aspect-ratio: 1 / 1;
border-radius: 2px; // carres vifs, presque sans radius
}
// =============== Layout: HERO ===============
.hero {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.hero-main {
width: 100%;
aspect-ratio: 21 / 9;
max-height: 360px;
cursor: zoom-in;
img { object-position: center; }
}
.hero-rest {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.hero-thumb {
width: 90px;
height: 90px;
}
// =============== Layout: MASONRY (Pinterest) ===============
// Colonnes larges, hauteurs naturelles preservees. Effet tres visible si les
// images n'ont pas toutes le meme ratio. Le border-radius genereux et les
// ombres accentuent le cote "tableau d'inspiration".
.masonry {
column-count: 3;
column-gap: 1.2rem;
padding: 0.5rem 0;
@media (max-width: 900px) { column-count: 2; }
@media (max-width: 500px) { column-count: 1; }
}
.masonry-item {
display: inline-block;
width: 100%;
margin-bottom: 1.2rem;
break-inside: avoid;
border-radius: 14px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
// Override de la transition par defaut pour un feel plus doux.
transition: transform 0.25s ease, box-shadow 0.25s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(108, 99, 255, 0.35);
}
img {
height: auto; // ratio natif preserve → hauteur variable entre les tuiles
border-radius: 14px;
}
}
.masonry-uploader {
aspect-ratio: 3 / 4; // slot vertical, bien different d'une tuile simple
border-style: dashed;
border-width: 2px;
cursor: default;
box-shadow: none;
&:hover { transform: none; box-shadow: none; }
}
// =============== Layout: CAROUSEL (cinema) ===============
// Bande horizontale facon affiche de film : grandes slides 16/9, ombres
// marquees, fade sur les bords pour suggerer le defilement infini.
.carousel {
position: relative;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0;
}
.carousel-track {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
padding: 0.5rem 0.25rem;
flex: 1;
min-width: 0;
&::-webkit-scrollbar { height: 6px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb {
background: #2a2a3d;
border-radius: 3px;
}
}
.carousel-slide {
flex: 0 0 auto;
width: 360px;
aspect-ratio: 16 / 9;
height: auto;
scroll-snap-align: start;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
&:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 16px 40px rgba(108, 99, 255, 0.4);
}
}
.carousel-uploader {
width: 220px;
aspect-ratio: 16 / 9;
border-style: dashed;
border-width: 2px;
cursor: default;
box-shadow: none;
&:hover { transform: none; box-shadow: none; }
}
.carousel-nav {
flex: 0 0 auto;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #2a2a3d;
border-radius: 50%;
background: rgba(26, 26, 46, 0.9);
color: #9ca3af;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover {
color: white;
border-color: #6c63ff;
background: #1f1b3a;
}
}
// =============== Layout: EDITORIAL (scrapbook polaroid) ===============
// Rendu carnet de campagne : vignettes facon polaroid, legerement inclinees,
// avec bande de papier collant (::before) et ombre portee. Au survol, la photo
// se redresse et se souleve. Pas de grille rigide : flex-wrap laisse respirer.
.editorial {
display: flex;
flex-wrap: wrap;
gap: 2rem 1.25rem;
padding: 1.25rem 0.5rem 0.5rem;
align-items: flex-start;
justify-content: flex-start;
// Fond kraft/parchemin tres discret pour suggerer le carnet.
background:
radial-gradient(ellipse at 20% 20%, rgba(180, 150, 100, 0.05), transparent 60%),
radial-gradient(ellipse at 80% 70%, rgba(160, 120, 80, 0.04), transparent 60%);
border-radius: 8px;
}
.editorial-item {
position: relative;
flex: 0 0 220px;
max-width: 100%;
background: #f5efe0; // papier blanc casse
padding: 10px 10px 34px 10px; // bas = bande blanche facon polaroid
border-radius: 2px;
cursor: zoom-in;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.35),
0 14px 28px rgba(0, 0, 0, 0.45);
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.3, 1),
box-shadow 0.3s ease;
// Rotations pseudo-aleatoires pour casser l'effet grille.
transform: rotate(-2deg);
&:nth-child(2n) { transform: rotate(1.8deg); }
&:nth-child(3n) { transform: rotate(-1.2deg); }
&:nth-child(4n) { transform: rotate(2.5deg); }
&:nth-child(5n) { transform: rotate(-2.8deg); }
&:nth-child(7n) { transform: rotate(0.9deg); }
// Ruban adhesif en haut de la photo.
&::before {
content: '';
position: absolute;
top: -9px;
left: 50%;
width: 68px;
height: 18px;
transform: translateX(-50%) rotate(-4deg);
background: rgba(255, 238, 200, 0.55);
border-left: 1px dashed rgba(0, 0, 0, 0.08);
border-right: 1px dashed rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
&:nth-child(2n)::before { transform: translateX(-50%) rotate(3deg); }
&:nth-child(3n)::before { transform: translateX(-50%) rotate(-7deg); left: 58%; }
&:nth-child(4n)::before { transform: translateX(-50%) rotate(5deg); left: 42%; }
&:hover {
transform: rotate(0deg) scale(1.05) translateY(-4px);
z-index: 10;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.5),
0 24px 48px rgba(0, 0, 0, 0.6);
.gallery-remove { opacity: 1; }
}
img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
background: #1a1a2e;
border-radius: 0;
}
}
// La premiere image (feature) est plus grande et en ratio 4/3 pour jouer le role d'affiche.
.editorial-feature {
flex: 0 0 420px;
img { aspect-ratio: 4 / 3; }
}
// Bouton X : sur polaroid blanc, on renforce le contraste.
.editorial-item .gallery-remove {
top: 14px;
right: 14px;
background: rgba(17, 17, 30, 0.92);
color: #fecaca;
&:hover { background: #7f1d1d; color: white; }
}
// Uploader : meme cadre polaroid mais en "coller une photo ici" dashed.
.editorial-uploader {
background: rgba(245, 239, 224, 0.06);
border: 2px dashed rgba(108, 99, 255, 0.7);
padding: 0;
cursor: default;
&::before { display: none; } // pas de scotch sur le slot vide
&:hover {
transform: rotate(0deg);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.35),
0 14px 28px rgba(0, 0, 0, 0.45);
}
// L'uploader interne doit remplir le slot.
app-image-uploader { display: block; width: 100%; height: 100%; min-height: 180px; }
}
// Responsive : on reduit la taille et on supprime les rotations sur mobile.
@media (max-width: 780px) {
.editorial-item { flex: 0 0 calc(50% - 0.75rem); }
.editorial-feature { flex: 0 0 calc(100% - 0.5rem); }
}
@media (max-width: 480px) {
.editorial { gap: 1.25rem 0.75rem; }
.editorial-item,
.editorial-feature {
flex: 0 0 100%;
transform: rotate(0deg) !important;
&::before { display: none; }
}
}
// =============== Layout: MAPS ===============
// Plans et cartes : on ne CROP pas (une carte croppee ne sert a rien).
// Grandes vignettes, ratio natif preserve via object-fit: contain.
.maps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.map-tile {
aspect-ratio: 4 / 3;
background:
linear-gradient(45deg, #15152440 25%, transparent 25%),
linear-gradient(-45deg, #15152440 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #15152440 75%),
linear-gradient(-45deg, transparent 75%, #15152440 75%),
#1a1a2e;
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
img {
object-fit: contain; // Preserve le ratio natif, ajoute un padding visuel via le fond.
padding: 4px;
}
}
.map-uploader {
border-style: dashed;
cursor: default;
background: #1a1a2e;
&:hover { transform: none; box-shadow: none; }
}
// =============== Lightbox (inchange) ===============
.lightbox-backdrop { .lightbox-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@@ -1,8 +1,9 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular'; import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
import { ImageService } from '../../services/image.service'; import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model'; import { Image } from '../../services/image.model';
import { ImageLayout } from '../../services/template.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component'; import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/** /**
@@ -34,6 +35,8 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
export class ImageGalleryComponent { export class ImageGalleryComponent {
readonly X = X; readonly X = X;
readonly ImageIcon = ImageIcon; readonly ImageIcon = ImageIcon;
readonly ChevronLeft = ChevronLeft;
readonly ChevronRight = ChevronRight;
/** IDs d'images a afficher. */ /** IDs d'images a afficher. */
@Input() imageIds: string[] = []; @Input() imageIds: string[] = [];
@@ -41,14 +44,45 @@ export class ImageGalleryComponent {
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */ /** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
@Input() editable = false; @Input() editable = false;
/**
* Variante de mise en page. Null/undefined = GALLERY (rendu historique).
* HERO : premiere image en banniere pleine largeur, suivantes en petit dessous.
* MASONRY : mosaique a hauteurs variables.
* CAROUSEL : defilement horizontal avec fleches.
*/
@Input() layout: ImageLayout | null | undefined = 'GALLERY';
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */ /** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
@Output() imageIdsChange = new EventEmitter<string[]>(); @Output() imageIdsChange = new EventEmitter<string[]>();
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */ /** ID de l'image actuellement ouverte en lightbox (null = ferme). */
lightboxId: string | null = null; lightboxId: string | null = null;
@ViewChild('carouselTrack') carouselTrack?: ElementRef<HTMLDivElement>;
constructor(private imageService: ImageService) {} constructor(private imageService: ImageService) {}
/** Layout effectif (null → GALLERY). */
get effectiveLayout(): ImageLayout {
return this.layout ?? 'GALLERY';
}
/** Premiere image (pour le layout HERO). */
get heroId(): string | null {
return this.imageIds[0] ?? null;
}
/** Images restantes apres la hero (pour le layout HERO). */
get restIds(): string[] {
return this.imageIds.slice(1);
}
scrollCarousel(direction: -1 | 1): void {
const el = this.carouselTrack?.nativeElement;
if (!el) return;
el.scrollBy({ left: direction * Math.max(240, el.clientWidth * 0.8), behavior: 'smooth' });
}
/** URL absolue du binaire d'une image. */ /** URL absolue du binaire d'une image. */
urlFor(id: string): string { urlFor(id: string): string {
return this.imageService.contentUrl(id); return this.imageService.contentUrl(id);

View File

@@ -60,7 +60,7 @@
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<span class="version">Version 0.2.0</span> <span class="version">Version 0.4.0</span>
</div> </div>
</aside> </aside>