8 Commits

Author SHA1 Message Date
694f687fec Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (core) (push) Successful in 1m50s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 1m47s
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
87865338a0 Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
586ddceff6 mise à jour vers 0.8.7-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m2s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m33s
2026-05-19 18:45:56 +02:00
4b9b7f0995 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m42s
Build & Push Images / build (web) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 1m48s
2026-05-19 18:36:00 +02:00
3d73b1e6a7 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build-switcher (push) Successful in 43s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-05-19 18:25:54 +02:00
759e47fc1f Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
2026-05-19 18:05:17 +02:00
f71bf3fcad Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 2m27s
Build & Push Images / build (core) (push) Successful in 3m15s
Build & Push Images / build (web) (push) Successful in 2m49s
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
0cd99dfb32 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m51s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-05-19 14:06:57 +02:00
72 changed files with 4731 additions and 78 deletions

View File

@@ -85,3 +85,57 @@ jobs:
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }} ${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta ${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }} ${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Job separe pour le sidecar `switcher`.
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Detect channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea Registry
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Login to GHCR
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push switcher (stable only)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./switcher
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}

View File

@@ -27,6 +27,7 @@ from app.domain.models import (
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
PageSummary, PageSummary,
SessionContext,
) )
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -67,6 +68,7 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user. """Streame les tokens de la réponse assistant pour le dernier message user.
@@ -76,7 +78,7 @@ class ChatUseCase:
cette règle à la frontière HTTP. cette règle à la frontière HTTP.
""" """
system_prompt = self._build_system_prompt( system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -92,12 +94,13 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None, campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
) -> str: ) -> str:
"""Version publique — utilisée par le controller HTTP pour compter """Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte). les tokens du system prompt avant de streamer (jauge de contexte).
""" """
return self._build_system_prompt( return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
) )
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
@@ -109,6 +112,7 @@ class ChatUseCase:
campaign: CampaignStructuralContext | None, campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None, game_system: GameSystemContext | None = None,
session: SessionContext | None = None,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [_BASE_SYSTEM]
if lore is not None: if lore is not None:
@@ -121,6 +125,8 @@ class ChatUseCase:
sections.append(self._format_page(page)) sections.append(self._format_page(page))
if narrative is not None: if narrative is not None:
sections.append(self._format_narrative_entity(narrative)) sections.append(self._format_narrative_entity(narrative))
if session is not None:
sections.append(self._format_session(session))
return "\n\n".join(sections) return "\n\n".join(sections)
# --- Blocs Lore --------------------------------------------------------- # --- Blocs Lore ---------------------------------------------------------
@@ -342,6 +348,53 @@ class ChatUseCase:
f"{sections_block}" f"{sections_block}"
) )
# --- Bloc Session de jeu (Play Context) ---------------------------------
_ENTRY_TYPE_LABELS = {
"NOTE": "Note du MJ",
"EVENT": "Évènement",
"DICE_ROLL": "Jet de dés",
"PLAYER_ACTION": "Action joueur",
}
@staticmethod
def _format_session(sc: SessionContext) -> str:
"""Bloc journal de la session en cours.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
if not sc.entries:
entries_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
# Indentation sur les contenus multi-lignes pour préserver la lisibilité
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
entries_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
f"Statut : {status}{started}\n\n"
"Journal chronologique (du plus ancien au plus récent) :\n"
f"{entries_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
"- Tenir compte des évènements déjà capturés dans le journal ci-dessus.\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
"il a besoin d'idées immédiatement actionnables."
)
@staticmethod @staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str: def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene.""" """Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""

View File

@@ -229,3 +229,30 @@ class GameSystemContext:
system_name: str system_name: str
system_description: str | None system_description: str | None
sections: dict[str, str] sections: dict[str, str]
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session : type + contenu + horodatage."""
type: str
content: str
occurred_at: str | None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Injecté dans le system prompt pendant qu'une partie est jouée pour que
l'IA voit le nom de la session, son statut, et un historique chronologique
des évènements/notes/jets capturés par le MJ.
Le journal a déjà été tronqué côté Core (cap à ~80 entrées récentes)
pour ne pas saturer le contexte LLM sur les sessions très longues.
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]

View File

@@ -26,6 +26,7 @@ from app.domain.models import (
NpcSummary, NpcSummary,
ChatMessage, ChatMessage,
GameSystemContext, GameSystemContext,
JournalEntrySummary,
LoreStructuralContext, LoreStructuralContext,
NarrativeEntityContext, NarrativeEntityContext,
PageContext, PageContext,
@@ -33,6 +34,7 @@ from app.domain.models import (
PageSummary, PageSummary,
SceneBranchHint, SceneBranchHint,
SceneSummary, SceneSummary,
SessionContext,
) )
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.ollama_adapter import OllamaLLMProvider
@@ -41,7 +43,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.8.4-beta", version="0.9.0-beta",
) )
@@ -243,13 +245,34 @@ class GameSystemContextDTO(BaseModel):
sections: dict[str, str] = Field(default_factory=dict) sections: dict[str, str] = Field(default_factory=dict)
class JournalEntrySummaryDTO(BaseModel):
"""Une entrée du journal de session — type + contenu + horodatage."""
type: str
content: str
occurred_at: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
Injecté par le Core quand le chat est ancré sur une Session.
Contient le journal chronologique (déjà plafonné côté Core).
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel): class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels. """Requête de chat streamé : historique + contextes structurels.
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels, Les contextes (lore, page, campaign, narrative_entity, session) sont
mais au moins l'un des deux "niveaux haut" (lore_context ou optionnels, mais au moins l'un des contextes "racines" (lore_context,
campaign_context) doit être fourni. Le validateur `check_scope` applique campaign_context ou session_context) doit être fourni. Le validateur
cette règle à la frontière HTTP. `check_scope` applique cette règle à la frontière HTTP.
""" """
messages: list[ChatMessageDTO] = Field(min_length=1) messages: list[ChatMessageDTO] = Field(min_length=1)
@@ -258,10 +281,15 @@ class ChatStreamRequestDTO(BaseModel):
campaign_context: CampaignContextDTO | None = None campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None game_system_context: GameSystemContextDTO | None = None
session_context: SessionContextDTO | None = None
def has_scope(self) -> bool: def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni.""" """Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
return self.lore_context is not None or self.campaign_context is not None return (
self.lore_context is not None
or self.campaign_context is not None
or self.session_context is not None
)
# --- Factories d'injection de dépendance --- # --- Factories d'injection de dépendance ---
@@ -385,6 +413,7 @@ 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)
game_system_context = _to_game_system_context(body.game_system_context) game_system_context = _to_game_system_context(body.game_system_context)
session_context = _to_session_context(body.session_context)
# --- Comptage tokens pour la jauge de contexte frontend --- # --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case # On construit le system prompt une fois ici pour le compter — le use case
@@ -397,6 +426,7 @@ async def chat_stream(
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_context,
) )
# Dernier message = "current" (souvent user), le reste = historique accumulé. # Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None current_msg = messages[-1] if messages else None
@@ -421,6 +451,7 @@ async def chat_stream(
campaign_context=campaign_context, campaign_context=campaign_context,
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_context,
): ):
# json.dumps avec ensure_ascii=False pour préserver les accents # json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
@@ -867,3 +898,22 @@ def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemConte
system_description=dto.system_description, system_description=dto.system_description,
sections=dict(dto.sections), sections=dict(dto.sections),
) )
def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
entries = [
JournalEntrySummary(
type=e.type,
content=e.content,
occurred_at=e.occurred_at,
)
for e in dto.entries
]
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
entries=entries,
)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.8.4-beta</version> <version>0.9.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -96,6 +96,15 @@
<artifactId>bcprov-jdk18on</artifactId> <artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version> <version>1.78.1</version>
</dependency> </dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -0,0 +1,73 @@
package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
*
* <p>Charge la Session + les N dernières entrées du journal et les mappe vers
* le Value Object {@link SessionContext}. La limite d'entrées évite de saturer
* la fenêtre de contexte du LLM sur des sessions très longues.</p>
*/
@Component
public class SessionStructuralContextBuilder {
/**
* Plafond du nombre d'entrées remontées au LLM.
* Choisi pour rester dans des limites raisonnables (≈ 5-10k tokens max
* pour des entrées moyennes de 200 chars). Si la session déborde,
* on garde les entrées les plus récentes (fin de chronologie).
*/
private static final int MAX_ENTRIES = 80;
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
SessionEntryRepository entryRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
}
public Optional<SessionContext> buildOptional(String sessionId) {
return sessionRepository.findById(sessionId).map(this::toContext);
}
public SessionContext build(String sessionId) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
return toContext(session);
}
private SessionContext toContext(Session session) {
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
// findBySessionId renvoie en ASC. On garde la fin si la liste dépasse le plafond
// — c'est l'info récente qui aide le plus l'IA pendant la partie.
List<SessionEntry> kept = allEntries.size() <= MAX_ENTRIES
? allEntries
: allEntries.subList(allEntries.size() - MAX_ENTRIES, allEntries.size());
List<JournalEntrySummary> summaries = kept.stream()
.map(e -> new JournalEntrySummary(
e.getType() != null ? e.getType().name() : "NOTE",
e.getContent(),
e.getOccurredAt()))
.collect(Collectors.toList());
return new SessionContext(
session.getName(),
session.isActive(),
session.getStartedAt(),
summaries);
}
}

View File

@@ -0,0 +1,111 @@
package com.loremind.application.generationcontext;
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.gamesystemcontext.GenerationIntent;
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.GameSystemContext;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.function.Consumer;
/**
* Use case applicatif : chat IA pendant une Session de jeu.
* <p>
* Orchestre la composition des contextes :
* 1. Charge la Session puis la Campagne associée (weak reference).
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
* 4. Construit le GameSystemContext si elle a un système de JDR.
* 5. Construit le SessionContext (journal horodaté, statut).
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
* </p>
*
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
*/
@Service
public class StreamChatForSessionUseCase {
private final SessionRepository sessionRepository;
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final GameSystemContextBuilder gameSystemContextBuilder;
private final SessionStructuralContextBuilder sessionContextBuilder;
private final AiChatProvider aiChatProvider;
public StreamChatForSessionUseCase(
SessionRepository sessionRepository,
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
GameSystemContextBuilder gameSystemContextBuilder,
SessionStructuralContextBuilder sessionContextBuilder,
AiChatProvider aiChatProvider) {
this.sessionRepository = sessionRepository;
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.gameSystemContextBuilder = gameSystemContextBuilder;
this.sessionContextBuilder = sessionContextBuilder;
this.aiChatProvider = aiChatProvider;
}
public void execute(
String sessionId,
List<ChatMessage> messages,
Consumer<ChatUsage> onUsage,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Session session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
Campaign campaign = campaignRepository.findById(session.getCampaignId())
.orElseThrow(() -> new IllegalArgumentException(
"Campagne associée à la session introuvable : " + session.getCampaignId()));
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId());
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
ChatRequest request = ChatRequest.builder()
.messages(messages)
.loreContext(loreContext)
.campaignContext(campaignContext)
.gameSystemContext(gameSystemContext)
.sessionContext(sessionContext)
.build();
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
}
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToLore()) return null;
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
}
/**
* Pendant une session active, on injecte les sections les plus utiles en partie
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
*/
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
if (!campaign.isLinkedToGameSystem()) return null;
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
.orElse(null);
}
}

View File

@@ -0,0 +1,135 @@
package com.loremind.application.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
*
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
* dans un volume partage. Quand on depose une commande, il :
* <ol>
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
* <li>Lance docker compose pull + up -d</li>
* <li>Ecrit son resultat dans {@code result.json}</li>
* </ol>
*
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
* sur l'hote. Le sidecar valide strictement le contenu de la commande
* (channel ∈ {stable, beta} uniquement).
*
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
* presence de "loremind-beta-" => canal beta, sinon stable.
*/
@Service
public class ChannelSwitcherService {
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
public enum Channel { STABLE, BETA }
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SwitchResult(
String id,
SwitchStatus status,
Channel channel,
String message,
Instant completedAt) {}
private final Path switcherDataPath;
private final String imageNamespace;
private final ObjectMapper json = new ObjectMapper();
public ChannelSwitcherService(
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
// qui peut etre absente dans les .env legacy).
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
this.switcherDataPath = Path.of(switcherDataPath);
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
switcherDataPath, this.imageNamespace);
}
/**
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
*/
public Channel getCurrentChannel() {
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
}
/**
* Indique si le sidecar est disponible (volume partage accessible).
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
* avec instructions manuelles).
*/
public boolean isSwitcherAvailable() {
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
}
/**
* Depose une commande de switch dans le volume partage. Renvoie l'ID
* de la commande, que le client peut utiliser pour poller le status.
*
* @throws IllegalStateException si le sidecar n'est pas disponible
* @throws IOException si l'ecriture du fichier echoue
*/
public String requestSwitch(Channel target) throws IOException {
if (!isSwitcherAvailable()) {
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
}
String id = UUID.randomUUID().toString();
Map<String, Object> command = new LinkedHashMap<>();
command.put("id", id);
command.put("channel", target.name().toLowerCase());
command.put("requestedAt", Instant.now().toString());
Path commandFile = switcherDataPath.resolve("command.json");
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
try {
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
// Cleanup au cas ou move aurait echoue avant le rename.
Files.deleteIfExists(tmp);
}
log.info("Switch command written: id={} channel={}", id, target);
return id;
}
/**
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
*/
public SwitchResult getLastResult() {
Path resultFile = switcherDataPath.resolve("result.json");
if (!Files.exists(resultFile)) return null;
try {
return json.readValue(resultFile.toFile(), SwitchResult.class);
} catch (IOException e) {
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.application.playcontext;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le journal d'une Session.
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
*/
@Service
public class SessionEntryService {
private final SessionEntryRepository entryRepository;
private final SessionRepository sessionRepository;
public SessionEntryService(SessionEntryRepository entryRepository,
SessionRepository sessionRepository) {
this.entryRepository = entryRepository;
this.sessionRepository = sessionRepository;
}
/** Données fournies par l'API pour créer ou éditer une entrée. */
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
public SessionEntry createEntry(String sessionId, EntryData data) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId est requis.");
}
if (!sessionRepository.existsById(sessionId)) {
throw new IllegalArgumentException("Session introuvable : " + sessionId);
}
validateContent(data.content());
LocalDateTime now = LocalDateTime.now();
SessionEntry entry = SessionEntry.builder()
.sessionId(sessionId)
.type(data.type() != null ? data.type() : EntryType.NOTE)
.content(data.content().trim())
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
.build();
return entryRepository.save(entry);
}
public SessionEntry updateEntry(String id, EntryData data) {
validateContent(data.content());
SessionEntry existing = entryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
if (data.type() != null) existing.setType(data.type());
existing.setContent(data.content().trim());
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
return entryRepository.save(existing);
}
public Optional<SessionEntry> getById(String id) {
return entryRepository.findById(id);
}
public List<SessionEntry> getBySessionId(String sessionId) {
return entryRepository.findBySessionId(sessionId);
}
public void deleteEntry(String id) {
if (!entryRepository.existsById(id)) {
throw new IllegalArgumentException("Entrée introuvable : " + id);
}
entryRepository.deleteById(id);
}
private void validateContent(String content) {
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
}
}
}

View File

@@ -0,0 +1,117 @@
package com.loremind.application.playcontext;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.domain.playcontext.ports.SessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour le Play Context.
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
* Fait partie de la couche Application de l'Architecture Hexagonale.
*
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois
* dans l'application.</p>
*/
@Service
public class SessionService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final SessionRepository sessionRepository;
private final SessionEntryRepository entryRepository;
private final CampaignRepository campaignRepository;
public SessionService(SessionRepository sessionRepository,
SessionEntryRepository entryRepository,
CampaignRepository campaignRepository) {
this.sessionRepository = sessionRepository;
this.entryRepository = entryRepository;
this.campaignRepository = campaignRepository;
}
/**
* Lance une nouvelle session sur la campagne donnée.
* Échoue si une session est déjà active ou si la campagne n'existe pas.
*/
public Session startSession(String campaignId) {
if (campaignId == null || campaignId.isBlank()) {
throw new IllegalArgumentException("campaignId est requis pour démarrer une session.");
}
if (!campaignRepository.existsById(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
sessionRepository.findActive().ifPresent(s -> {
throw new IllegalStateException("Une session est déjà en cours (id=" + s.getId() + "). Termine-la avant d'en lancer une nouvelle.");
});
LocalDateTime now = LocalDateTime.now();
Session session = Session.builder()
.name(generateDefaultName(now))
.campaignId(campaignId)
.startedAt(now)
.build();
return sessionRepository.save(session);
}
/** Termine la session active si elle correspond à l'id donné. */
public Session endSession(String id) {
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
if (!session.isActive()) {
throw new IllegalStateException("Cette session est déjà terminée.");
}
session.setEndedAt(LocalDateTime.now());
return sessionRepository.save(session);
}
public Session renameSession(String id, String newName) {
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
}
Session session = sessionRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
session.setName(newName.trim());
return sessionRepository.save(session);
}
public Optional<Session> getById(String id) {
return sessionRepository.findById(id);
}
public Optional<Session> getActive() {
return sessionRepository.findActive();
}
public List<Session> getAll() {
return sessionRepository.findAll();
}
public List<Session> getByCampaignId(String campaignId) {
return sessionRepository.findByCampaignId(campaignId);
}
/**
* Supprime une session et toutes ses entrées de journal en cascade.
* Transactionnel : soit tout disparaît, soit rien.
*/
@Transactional
public void deleteSession(String id) {
if (!sessionRepository.existsById(id)) {
throw new IllegalArgumentException("Session introuvable : " + id);
}
entryRepository.deleteBySessionId(id);
sessionRepository.deleteById(id);
}
private String generateDefaultName(LocalDateTime startedAt) {
return "Session du " + startedAt.format(DATE_FORMATTER);
}
}

View File

@@ -36,7 +36,8 @@ public record ChatRequest(
PageContext pageContext, PageContext pageContext,
CampaignStructuralContext campaignContext, CampaignStructuralContext campaignContext,
NarrativeEntityContext narrativeEntity, NarrativeEntityContext narrativeEntity,
GameSystemContext gameSystemContext) { GameSystemContext gameSystemContext,
SessionContext sessionContext) {
public static Builder builder() { public static Builder builder() {
return new Builder(); return new Builder();
@@ -50,6 +51,7 @@ public record ChatRequest(
private CampaignStructuralContext campaignContext; private CampaignStructuralContext campaignContext;
private NarrativeEntityContext narrativeEntity; private NarrativeEntityContext narrativeEntity;
private GameSystemContext gameSystemContext; private GameSystemContext gameSystemContext;
private SessionContext sessionContext;
private Builder() {} private Builder() {}
@@ -83,9 +85,14 @@ public record ChatRequest(
return this; return this;
} }
public Builder sessionContext(SessionContext sessionContext) {
this.sessionContext = sessionContext;
return this;
}
public ChatRequest build() { public ChatRequest build() {
return new ChatRequest(messages, loreContext, pageContext, return new ChatRequest(messages, loreContext, pageContext,
campaignContext, narrativeEntity, gameSystemContext); campaignContext, narrativeEntity, gameSystemContext, sessionContext);
} }
} }
} }

View File

@@ -0,0 +1,33 @@
package com.loremind.domain.generationcontext;
import java.time.LocalDateTime;
import java.util.List;
/**
* Contexte structurel d'une Session de jeu — injecté dans le system prompt
* de l'IA pour qu'elle ait conscience de la partie en cours et de son journal.
*
* <p>Pendant qu'une session se joue, l'IA reçoit en plus du Lore/Campagne/GameSystem :
* le nom de la session, son statut (en cours / terminée) et un résumé chronologique
* des entrées du journal (notes, évènements, jets, actions joueurs).</p>
*
* <p>Value Object du Generation Context — record Java immutable.</p>
*
* @param sessionName Nom de la session telle qu'affichée au MJ.
* @param active True si la session est en cours, false si terminée.
* @param startedAt Horodatage de démarrage.
* @param entries Entrées du journal triées chronologiquement (anciennes → récentes).
* Limité côté builder pour éviter de saturer le contexte LLM.
*/
public record SessionContext(
String sessionName,
boolean active,
LocalDateTime startedAt,
List<JournalEntrySummary> entries) {
/** Résumé d'une entrée de journal — type + contenu + horodatage. */
public record JournalEntrySummary(
String type,
String content,
LocalDateTime occurredAt) {}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.playcontext;
/**
* Type d'entrée du journal de session.
* Permet à l'UI de catégoriser visuellement la timeline (icône, couleur).
*/
public enum EntryType {
/** Note libre du MJ (défaut). */
NOTE,
/** Moment marquant du scénario (combat gagné, décision majeure...). */
EVENT,
/** Jet de dés / test de caractéristique. */
DICE_ROLL,
/** Action déclarée par un joueur. */
PLAYER_ACTION
}

View File

@@ -0,0 +1,42 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entité de domaine représentant une Session de jeu en cours ou passée.
*
* <p>Une Session est une instance jouée d'une Campaign. La Campaign reste
* un scénario générique réutilisable ; la Session capture une partie réelle
* (date, journal, etc.) sans polluer le scénario d'origine.</p>
*
* <p>Fait partie du Play Context. Référence la Campaign par weak reference
* (campaignId) pour respecter la séparation des Bounded Contexts.</p>
*
* <p>{@code endedAt == null} signifie que la session est en cours.
* Une seule session peut être en cours dans l'application à la fois.</p>
*/
@Data
@Builder
public class Session {
private String id;
private String name;
/** Weak reference vers Campaign — pas de dépendance directe inter-contexte. */
private String campaignId;
private LocalDateTime startedAt;
/** Null = session en cours ; renseigné = session terminée. */
private LocalDateTime endedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public boolean isActive() {
return this.endedAt == null;
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.playcontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Entrée du journal d'une Session.
* Représente un évènement horodaté capturé pendant ou après une partie :
* note libre du MJ, évènement marquant, jet de dés, action de joueur.
*
* <p>Fait partie du Play Context. Référence la Session par weak reference
* (sessionId) — l'orchestration en cascade est gérée par le service applicatif.</p>
*/
@Data
@Builder
public class SessionEntry {
private String id;
/** Weak reference vers Session (intra-contexte mais reste découplée). */
private String sessionId;
private EntryType type;
/** Contenu texte brut saisi par le MJ. */
private String content;
/**
* Horodatage métier de l'évènement.
* Distinct de {@code createdAt} : utile si le MJ rédige a posteriori
* une note rétroactive sur quelque chose qui s'est passé plus tôt.
*/
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.SessionEntry;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des entrées de journal de session.
*/
public interface SessionEntryRepository {
SessionEntry save(SessionEntry entry);
Optional<SessionEntry> findById(String id);
/** Renvoie les entrées d'une session, triées par occurredAt croissant (chronologique). */
List<SessionEntry> findBySessionId(String sessionId);
void deleteById(String id);
/** Supprime toutes les entrées d'une session — utilisé pour la cascade à la suppression. */
void deleteBySessionId(String sessionId);
boolean existsById(String id);
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.playcontext.ports;
import com.loremind.domain.playcontext.Session;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des Sessions.
* Interface définie dans le domaine, implémentée par l'infrastructure.
*/
public interface SessionRepository {
Session save(Session session);
Optional<Session> findById(String id);
List<Session> findAll();
List<Session> findByCampaignId(String campaignId);
/** Retourne la session en cours (endedAt null) s'il y en a une. */
Optional<Session> findActive();
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -14,6 +14,8 @@ 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;
import com.loremind.domain.generationcontext.PageContext; import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.SessionContext;
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -58,9 +60,35 @@ public class BrainChatPayloadBuilder {
if (request.gameSystemContext() != null) { if (request.gameSystemContext() != null) {
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext())); root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
} }
if (request.sessionContext() != null) {
root.put("session_context", sessionContextToMap(request.sessionContext()));
}
return root; return root;
} }
private Map<String, Object> sessionContextToMap(SessionContext sc) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("session_name", sc.sessionName());
map.put("active", sc.active());
if (sc.startedAt() != null) {
map.put("started_at", sc.startedAt().toString());
}
map.put("entries", sc.entries() != null
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
: List.of());
return map;
}
private Map<String, Object> journalEntryToMap(JournalEntrySummary e) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("type", e.type());
map.put("content", e.content());
if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString());
}
return map;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) { private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.systemName()); map.put("system_name", gs.systemName());

View File

@@ -0,0 +1,60 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.playcontext.EntryType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des entrées de journal de session.
*/
@Entity
@Table(name = "session_entries", indexes = {
@Index(name = "idx_session_entries_session_id", columnList = "session_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionEntryJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Weak reference — pas de FK DB pour rester cohérent avec le reste du projet. */
@Column(name = "session_id", nullable = false)
private String sessionId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private EntryType type;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "occurred_at", nullable = false)
private LocalDateTime occurredAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,61 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Entité JPA pour la persistance des Sessions en PostgreSQL.
* Adaptateur d'infrastructure — n'est PAS dans le domaine.
*/
@Entity
@Table(name = "sessions")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
/**
* ID de la Campaign associée. Pas de @ManyToOne / pas de FK : c'est une
* weak reference inter-contexte (Play Context ↔ Campaign Context).
*/
@Column(name = "campaign_id", nullable = false)
private String campaignId;
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;
/** Null = session en cours. */
@Column(name = "ended_at")
private LocalDateTime endedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,15 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface SessionEntryJpaRepository extends JpaRepository<SessionEntryJpaEntity, Long> {
List<SessionEntryJpaEntity> findBySessionIdOrderByOccurredAtAsc(String sessionId);
void deleteBySessionId(String sessionId);
}

View File

@@ -0,0 +1,19 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Repository Spring Data JPA pour SessionJpaEntity.
*/
@Repository
public interface SessionJpaRepository extends JpaRepository<SessionJpaEntity, Long> {
List<SessionJpaEntity> findByCampaignIdOrderByStartedAtDesc(String campaignId);
Optional<SessionJpaEntity> findFirstByEndedAtIsNull();
}

View File

@@ -0,0 +1,85 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity;
import com.loremind.infrastructure.persistence.jpa.SessionEntryJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure : implémente le Port SessionEntryRepository.
*/
@Repository
public class PostgresSessionEntryRepository implements SessionEntryRepository {
private final SessionEntryJpaRepository jpaRepository;
public PostgresSessionEntryRepository(SessionEntryJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public SessionEntry save(SessionEntry entry) {
SessionEntryJpaEntity saved = jpaRepository.save(toJpaEntity(entry));
return toDomain(saved);
}
@Override
public Optional<SessionEntry> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public List<SessionEntry> findBySessionId(String sessionId) {
return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
/** {@code @Transactional} requis : Spring Data exige une transaction pour les deleteByXxx dérivés. */
@Override
@Transactional
public void deleteBySessionId(String sessionId) {
jpaRepository.deleteBySessionId(sessionId);
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private SessionEntry toDomain(SessionEntryJpaEntity jpa) {
return SessionEntry.builder()
.id(jpa.getId().toString())
.sessionId(jpa.getSessionId())
.type(jpa.getType())
.content(jpa.getContent())
.occurredAt(jpa.getOccurredAt())
.createdAt(jpa.getCreatedAt())
.updatedAt(jpa.getUpdatedAt())
.build();
}
private SessionEntryJpaEntity toJpaEntity(SessionEntry entry) {
Long id = entry.getId() != null ? Long.parseLong(entry.getId()) : null;
return SessionEntryJpaEntity.builder()
.id(id)
.sessionId(entry.getSessionId())
.type(entry.getType())
.content(entry.getContent())
.occurredAt(entry.getOccurredAt())
.createdAt(entry.getCreatedAt())
.updatedAt(entry.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,90 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.playcontext.Session;
import com.loremind.domain.playcontext.ports.SessionRepository;
import com.loremind.infrastructure.persistence.entity.SessionJpaEntity;
import com.loremind.infrastructure.persistence.jpa.SessionJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Adaptateur d'infrastructure qui implémente le Port SessionRepository.
* Convertit Session (domaine pur) ↔ SessionJpaEntity (persistance).
*/
@Repository
public class PostgresSessionRepository implements SessionRepository {
private final SessionJpaRepository jpaRepository;
public PostgresSessionRepository(SessionJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Session save(Session session) {
SessionJpaEntity saved = jpaRepository.save(toJpaEntity(session));
return toDomain(saved);
}
@Override
public Optional<Session> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public List<Session> findAll() {
return jpaRepository.findAll().stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Session> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByStartedAtDesc(campaignId).stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Session> findActive() {
return jpaRepository.findFirstByEndedAtIsNull().map(this::toDomain);
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private Session toDomain(SessionJpaEntity jpa) {
return Session.builder()
.id(jpa.getId().toString())
.name(jpa.getName())
.campaignId(jpa.getCampaignId())
.startedAt(jpa.getStartedAt())
.endedAt(jpa.getEndedAt())
.createdAt(jpa.getCreatedAt())
.updatedAt(jpa.getUpdatedAt())
.build();
}
private SessionJpaEntity toJpaEntity(Session session) {
Long id = session.getId() != null ? Long.parseLong(session.getId()) : null;
return SessionJpaEntity.builder()
.id(id)
.name(session.getName())
.campaignId(session.getCampaignId())
.startedAt(session.getStartedAt())
.endedAt(session.getEndedAt())
.createdAt(session.getCreatedAt())
.updatedAt(session.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.web;
import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Intercepteur global d'exceptions pour TOUS les @RestController.
*
* <p>Role :
* <ul>
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
* par defaut de Spring — utile pour debug cote frontend (visible directement
* dans la DevTools reseau).</li>
* <li>Mapper les exceptions courantes vers des status HTTP appropries
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
* </ul>
*
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
* Ce handler n'attrape QUE ce qui a echappe au catch local.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
}
/** Entite JPA introuvable -> 404. */
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
}
/** JSON malforme dans le body de la requete -> 400. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
}
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e ->
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
return ResponseEntity.badRequest().body(Map.of(
"error", "Validation failed",
"fields", fields
));
}
/**
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
* cote client. C'est LE filet de securite.
*
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
*/
@ExceptionHandler(Throwable.class)
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
Map<String, String> body = new LinkedHashMap<>();
body.put("error", "Internal server error");
body.put("type", ex.getClass().getSimpleName());
String msg = safeMessage(ex);
if (!msg.isEmpty()) body.put("message", msg);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
private static String safeMessage(Throwable ex) {
return ex.getMessage() != null ? ex.getMessage() : "";
}
}

View File

@@ -2,11 +2,13 @@ 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.application.generationcontext.StreamChatForSessionUseCase;
import com.loremind.domain.generationcontext.ChatMessage; import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatUsage; 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;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -42,14 +44,17 @@ public class AiChatController {
private final StreamChatForLoreUseCase streamChatForLoreUseCase; private final StreamChatForLoreUseCase streamChatForLoreUseCase;
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase; private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
private final StreamChatForSessionUseCase streamChatForSessionUseCase;
private final TaskExecutor taskExecutor; private final TaskExecutor taskExecutor;
public AiChatController( public AiChatController(
StreamChatForLoreUseCase streamChatForLoreUseCase, StreamChatForLoreUseCase streamChatForLoreUseCase,
StreamChatForCampaignUseCase streamChatForCampaignUseCase, StreamChatForCampaignUseCase streamChatForCampaignUseCase,
StreamChatForSessionUseCase streamChatForSessionUseCase,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) { @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
this.streamChatForLoreUseCase = streamChatForLoreUseCase; this.streamChatForLoreUseCase = streamChatForLoreUseCase;
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase; this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
this.streamChatForSessionUseCase = streamChatForSessionUseCase;
this.taskExecutor = taskExecutor; this.taskExecutor = taskExecutor;
} }
@@ -74,6 +79,19 @@ public class AiChatController {
return emitter; return emitter;
} }
/**
* Chat IA ancré sur une Session de jeu : récupère automatiquement la
* Campagne / Lore / GameSystem associés + injecte le journal horodaté.
*/
@PostMapping(value = "/chat/stream-session", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStreamSession(@RequestBody ChatStreamSessionRequestDTO body) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
List<ChatMessage> messages = toDomainMessages(body.getMessages());
taskExecutor.execute(() -> runSessionStreaming(emitter, body.getSessionId(), messages));
return emitter;
}
// --- Exécution du streaming dans un thread dédié ------------------------ // --- Exécution du streaming dans un thread dédié ------------------------
private void runLoreStreaming( private void runLoreStreaming(
@@ -111,6 +129,22 @@ public class AiChatController {
} }
} }
private void runSessionStreaming(
SseEmitter emitter,
String sessionId,
List<ChatMessage> messages) {
try {
streamChatForSessionUseCase.execute(
sessionId, messages,
usage -> sendUsage(emitter, usage),
token -> sendToken(emitter, token),
() -> complete(emitter),
error -> fail(emitter, error));
} catch (Exception e) {
fail(emitter, e);
}
}
// --- 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) { private void sendUsage(SseEmitter emitter, ChatUsage usage) {

View File

@@ -7,7 +7,6 @@ import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper; import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -72,12 +71,6 @@ public class GameSystemController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) { private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData( return new GameSystemService.GameSystemData(
dto.getName(), dto.getName(),

View File

@@ -1,12 +1,16 @@
package com.loremind.infrastructure.web.controller; package com.loremind.infrastructure.web.controller;
import com.loremind.application.licensing.ChannelSwitcherService;
import com.loremind.application.licensing.LicenseService; import com.loremind.application.licensing.LicenseService;
import com.loremind.application.licensing.LicenseService.InstallException; import com.loremind.application.licensing.LicenseService.InstallException;
import com.loremind.domain.licensing.LicenseSnapshot; import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO; import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Locale;
import java.util.Map; import java.util.Map;
/** /**
@@ -26,9 +30,11 @@ import java.util.Map;
public class LicenseController { public class LicenseController {
private final LicenseService licenseService; private final LicenseService licenseService;
private final ChannelSwitcherService channelSwitcher;
public LicenseController(LicenseService licenseService) { public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
this.licenseService = licenseService; this.licenseService = licenseService;
this.channelSwitcher = channelSwitcher;
} }
@GetMapping @GetMapping
@@ -82,6 +88,68 @@ public class LicenseController {
} }
} }
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
//
// Le flux :
// 1. UI POST /api/license/channel/switch { channel: "beta" }
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
// 3. Core depose une commande dans le volume partage
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
// 5. UI poll GET /api/license/channel pour suivre le status
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
@GetMapping("/channel")
public ChannelStatusDTO getChannel() {
return ChannelStatusDTO.from(channelSwitcher);
}
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
@PostMapping("/channel/switch")
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
if (request == null || request.channel() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
}
ChannelSwitcherService.Channel target;
try {
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid channel (allowed: stable, beta)"));
}
// Garde : pas de switch vers beta sans licence Patreon valide.
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
// format), donc c'est ici qu'on doit refuser cote metier.
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
if (target == ChannelSwitcherService.Channel.BETA) {
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
if (!allowed) {
return ResponseEntity.status(403).body(Map.of(
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
}
}
if (!channelSwitcher.isSwitcherAvailable()) {
return ResponseEntity.status(503).body(Map.of(
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
}
try {
String id = channelSwitcher.requestSwitch(target);
return ResponseEntity.accepted().body(Map.of(
"id", id,
"channel", target.name().toLowerCase(Locale.ROOT)));
} catch (IOException e) {
return ResponseEntity.status(500).body(Map.of(
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
}
}
public record InstallRequest(String jwt) {} public record InstallRequest(String jwt) {}
public record BetaChannelRequest(boolean enabled) {} public record BetaChannelRequest(boolean enabled) {}
public record ChannelSwitchRequest(String channel) {}
} }

View File

@@ -0,0 +1,82 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.SessionService;
import com.loremind.domain.playcontext.Session;
import com.loremind.infrastructure.web.dto.playcontext.SessionDTO;
import com.loremind.infrastructure.web.mapper.SessionMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* REST Controller pour le Play Context.
* Adaptateur d'infrastructure qui expose l'API REST des Sessions.
*/
@RestController
@RequestMapping("/api/sessions")
public class SessionController {
private final SessionService sessionService;
private final SessionMapper sessionMapper;
public SessionController(SessionService sessionService, SessionMapper sessionMapper) {
this.sessionService = sessionService;
this.sessionMapper = sessionMapper;
}
public record StartSessionRequest(String campaignId) {}
public record RenameSessionRequest(String name) {}
@PostMapping
public ResponseEntity<SessionDTO> startSession(@RequestBody StartSessionRequest request) {
Session session = sessionService.startSession(request.campaignId());
return ResponseEntity.ok(sessionMapper.toDTO(session));
}
@GetMapping("/active")
public ResponseEntity<SessionDTO> getActiveSession() {
return sessionService.getActive()
.map(s -> ResponseEntity.ok(sessionMapper.toDTO(s)))
.orElse(ResponseEntity.noContent().build());
}
@GetMapping
public ResponseEntity<List<SessionDTO>> getSessions(@RequestParam(value = "campaignId", required = false) String campaignId) {
List<Session> sessions = (campaignId == null || campaignId.isBlank())
? sessionService.getAll()
: sessionService.getByCampaignId(campaignId);
List<SessionDTO> dtos = sessions.stream()
.map(sessionMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@GetMapping("/{id}")
public ResponseEntity<SessionDTO> getSessionById(@PathVariable String id) {
return sessionService.getById(id)
.map(s -> ResponseEntity.ok(sessionMapper.toDTO(s)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/end")
public ResponseEntity<SessionDTO> endSession(@PathVariable String id) {
Session ended = sessionService.endSession(id);
return ResponseEntity.ok(sessionMapper.toDTO(ended));
}
@PatchMapping("/{id}")
public ResponseEntity<SessionDTO> renameSession(@PathVariable String id,
@RequestBody RenameSessionRequest request) {
Session renamed = sessionService.renameSession(id, request.name());
return ResponseEntity.ok(sessionMapper.toDTO(renamed));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteSession(@PathVariable String id) {
sessionService.deleteSession(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,68 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.playcontext.SessionEntryService;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.infrastructure.web.dto.playcontext.SessionEntryDTO;
import com.loremind.infrastructure.web.mapper.SessionEntryMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* REST Controller pour les entrées de journal d'une Session.
* Endpoints imbriqués sous /api/sessions/{sessionId}/entries.
*/
@RestController
@RequestMapping("/api/sessions/{sessionId}/entries")
public class SessionEntryController {
private final SessionEntryService entryService;
private final SessionEntryMapper entryMapper;
public SessionEntryController(SessionEntryService entryService, SessionEntryMapper entryMapper) {
this.entryService = entryService;
this.entryMapper = entryMapper;
}
public record EntryRequest(EntryType type, String content, LocalDateTime occurredAt) {}
@GetMapping
public ResponseEntity<List<SessionEntryDTO>> getEntries(@PathVariable String sessionId) {
List<SessionEntryDTO> dtos = entryService.getBySessionId(sessionId).stream()
.map(entryMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PostMapping
public ResponseEntity<SessionEntryDTO> createEntry(@PathVariable String sessionId,
@RequestBody EntryRequest request) {
SessionEntry created = entryService.createEntry(
sessionId,
new SessionEntryService.EntryData(request.type(), request.content(), request.occurredAt())
);
return ResponseEntity.ok(entryMapper.toDTO(created));
}
@PutMapping("/{entryId}")
public ResponseEntity<SessionEntryDTO> updateEntry(@PathVariable String sessionId,
@PathVariable String entryId,
@RequestBody EntryRequest request) {
SessionEntry updated = entryService.updateEntry(
entryId,
new SessionEntryService.EntryData(request.type(), request.content(), request.occurredAt())
);
return ResponseEntity.ok(entryMapper.toDTO(updated));
}
@DeleteMapping("/{entryId}")
public ResponseEntity<Void> deleteEntry(@PathVariable String sessionId,
@PathVariable String entryId) {
entryService.deleteEntry(entryId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.generationcontext;
import lombok.Data;
import java.util.List;
/**
* DTO de requête pour le chat IA d'une Session de jeu.
* Le contexte (lore, campagne, gamesystem, journal) est dérivé du sessionId
* côté serveur — l'appelant n'a qu'à fournir l'id et les messages.
*/
@Data
public class ChatStreamSessionRequestDTO {
private String sessionId;
private List<ChatMessageDTO> messages;
}

View File

@@ -0,0 +1,30 @@
package com.loremind.infrastructure.web.dto.licensing;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.loremind.application.licensing.ChannelSwitcherService;
/**
* Etat du canal courant + dernier resultat de switch.
*
* <p>{@code currentChannel} : detecte au demarrage de Core a partir du prefixe
* d'image. {@code switcherAvailable} : indique si le sidecar de switch est
* monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
* par les instructions manuelles.
*
* <p>{@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
* instance. Sinon, contient le resultat du dernier appel (en cours / succes /
* erreur), utilise par l'UI pour suivre la progression apres clic.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ChannelStatusDTO(
String currentChannel,
boolean switcherAvailable,
ChannelSwitcherService.SwitchResult lastSwitch) {
public static ChannelStatusDTO from(ChannelSwitcherService service) {
return new ChannelStatusDTO(
service.getCurrentChannel().name().toLowerCase(),
service.isSwitcherAvailable(),
service.getLastResult());
}
}

View File

@@ -0,0 +1,22 @@
package com.loremind.infrastructure.web.dto.playcontext;
import lombok.Data;
import java.time.LocalDateTime;
/**
* DTO pour l'entité Session — objet de transfert de l'API REST.
*/
@Data
public class SessionDTO {
private String id;
private String name;
private String campaignId;
private LocalDateTime startedAt;
/** Null = session en cours. */
private LocalDateTime endedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private boolean active;
}

View File

@@ -0,0 +1,21 @@
package com.loremind.infrastructure.web.dto.playcontext;
import com.loremind.domain.playcontext.EntryType;
import lombok.Data;
import java.time.LocalDateTime;
/**
* DTO d'une entrée de journal de session.
*/
@Data
public class SessionEntryDTO {
private String id;
private String sessionId;
private EntryType type;
private String content;
private LocalDateTime occurredAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.playcontext.SessionEntry;
import com.loremind.infrastructure.web.dto.playcontext.SessionEntryDTO;
import org.springframework.stereotype.Component;
@Component
public class SessionEntryMapper {
public SessionEntryDTO toDTO(SessionEntry entry) {
if (entry == null) return null;
SessionEntryDTO dto = new SessionEntryDTO();
dto.setId(entry.getId());
dto.setSessionId(entry.getSessionId());
dto.setType(entry.getType());
dto.setContent(entry.getContent());
dto.setOccurredAt(entry.getOccurredAt());
dto.setCreatedAt(entry.getCreatedAt());
dto.setUpdatedAt(entry.getUpdatedAt());
return dto;
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.playcontext.Session;
import com.loremind.infrastructure.web.dto.playcontext.SessionDTO;
import org.springframework.stereotype.Component;
/**
* Mapper Session (domaine) ↔ SessionDTO (transport REST).
*/
@Component
public class SessionMapper {
public SessionDTO toDTO(Session session) {
if (session == null) return null;
SessionDTO dto = new SessionDTO();
dto.setId(session.getId());
dto.setName(session.getName());
dto.setCampaignId(session.getCampaignId());
dto.setStartedAt(session.getStartedAt());
dto.setEndedAt(session.getEndedAt());
dto.setCreatedAt(session.getCreatedAt());
dto.setUpdatedAt(session.getUpdatedAt());
dto.setActive(session.isActive());
return dto;
}
}

View File

@@ -102,11 +102,18 @@ services:
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr} LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
# Chemin du docker config.json partage avec Watchtower # Chemin du docker config.json partage avec Watchtower
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
# Chemin du repertoire partage avec le switcher (commande + resultat).
# Doit matcher le volume `switcher-data` monte ci-dessous.
SWITCHER_DATA_PATH: /shared/switcher
volumes: volumes:
# Volume partage avec Watchtower : Core ecrit les credentials registry # Volume partage avec Watchtower : Core ecrit les credentials registry
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images # GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
# privees du canal beta. Pas de creds = no-op. # privees du canal beta. Pas de creds = no-op.
- docker-config:/shared/docker - docker-config:/shared/docker
# Volume partage avec le switcher : Core ecrit une commande de switch
# de canal ici (command.json), le switcher la traite et y depose son
# resultat (result.json). Cf. service `switcher` ci-dessous.
- switcher-data:/shared/switcher
restart: unless-stopped restart: unless-stopped
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe). # Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
@@ -167,6 +174,51 @@ services:
- "${WEB_PORT:-8081}:80" - "${WEB_PORT:-8081}:80"
restart: unless-stopped restart: unless-stopped
# Sidecar de bascule de canal (stable <-> beta).
#
# Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
# loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
# des images, pas leur reference. Ce sidecar fait le `sed .env` + le
# `docker compose pull/up -d` quand le Core depose une commande JSON.
#
# Securite : pas de port expose. La commande arrive via volume partage
# (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
# le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
# compromission du Core.
#
# L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
# `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
# se tuerait lui-meme pendant le `docker compose up -d` (race condition).
switcher:
image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
container_name: loremind-switcher
# PAS de label watchtower : la maj du switcher se fait via le canal
# stable uniquement, et hors du flow d'auto-update.
volumes:
# Socket Docker du host : permet de lancer docker compose pull/up.
- /var/run/docker.sock:/var/run/docker.sock
# Repertoire compose du host (docker-compose.yml + .env) — RW pour
# pouvoir sed la ligne IMAGE_NAMESPACE.
- ${COMPOSE_PROJECT_DIR:-./}:/compose
# Volume partage avec le Core pour la commande + le resultat.
- switcher-data:/data
# Volume partage avec le Core + Watchtower : contient config.json avec
# les creds GHCR (ecrits par le Core a partir du token Patreon).
# Indispensable pour pull les images privees du canal beta.
- docker-config:/shared/docker
environment:
# Repertoire interne ou trouver docker-compose.yml et .env. Bind au
# volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
COMPOSE_DIR: /compose
# Nom de projet docker compose : fixe ici pour que le switcher cible
# le MEME stack que celui qui tourne (sinon il creerait un duplicate).
# Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
# Indique au CLI Docker du switcher ou trouver config.json (auth GHCR
# pour les images privees beta). Meme mecanisme que sur Watchtower.
DOCKER_CONFIG: /shared/docker
restart: unless-stopped
# Mises a jour automatiques des images core/brain/web. # Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur). # Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes, # Postgres et MinIO sont volontairement exclus (donnees persistantes,
@@ -214,3 +266,5 @@ volumes:
# Volume partage Core <-> Watchtower : config.json Docker pour # Volume partage Core <-> Watchtower : config.json Docker pour
# l'authentification au registry prive GHCR (canal beta Patreon). # l'authentification au registry prive GHCR (canal beta Patreon).
docker-config: docker-config:
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
switcher-data:

26
switcher/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
# dans le volume partage par le Core.
#
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
FROM alpine:3.20
# docker-cli : pour parler au socket Docker du host
# docker-cli-compose : pour `docker compose pull/up`
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
# jq : parsing JSON de la commande
# coreutils : pour `date -u --iso-8601=seconds`
RUN apk add --no-cache \
docker-cli \
docker-cli-compose \
bash \
jq \
coreutils
WORKDIR /switcher
COPY watch.sh switch.sh ./
RUN chmod +x watch.sh switch.sh
# Tourne en permanence en mode polling.
ENTRYPOINT ["/switcher/watch.sh"]

66
switcher/README.md Normal file
View File

@@ -0,0 +1,66 @@
# LoreMind channel switcher
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
sans manipulation manuelle du `.env` ni de docker-compose.
## Principe
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
1. Watch un fichier `command.json` dans un volume partagé avec le Core
2. Quand une commande arrive :
- Valide le canal cible (`stable` | `beta`)
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
## Sécurité
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
un RCE sur l'hôte :
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
fichier, point.
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
`stable` ou `beta` (case statement, pas de regex laxiste).
- Aucun port n'est exposé. La communication se fait uniquement via volume
partagé.
## Architecture
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
│ User clique │ │ Core │ │ switcher │ │ Docker │
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
┌─────────────┐
│ result.json │ ◄── Core poll
└─────────────┘
```
## Upgrade pour les installs existantes
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
**dernière** manipulation :
1. Récupérer le nouveau `docker-compose.yml` du repo
2. Lancer `docker compose pull && docker compose up -d`
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
switcher exécute `docker compose up -d`. Si son propre image faisait partie
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
condition fatale.
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
explicitement `core brain web --no-deps` — jamais le switcher lui-même.

123
switcher/switch.sh Normal file
View File

@@ -0,0 +1,123 @@
#!/bin/bash
# switch.sh — execute le switch de canal pour LoreMind.
#
# Usage interne (appele par watch.sh) :
# ./switch.sh stable
# ./switch.sh beta
#
# Ce que ca fait, dans l'ordre :
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
# contre command injection si le Core etait compromis)
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
# 3. docker compose pull (recupere les nouvelles images du canal cible)
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
#
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
# sans interruption (cf. docker-compose.yml).
set -euo pipefail
CHANNEL="${1:-}"
# --- Validation stricte -----------------------------------------------------
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
# C'est le filet de securite si le JSON depose dans /data/command.json
# contenait un payload exotique (Core compromis = on ne laisse PAS
# executer du code arbitraire sur l'hote).
case "${CHANNEL}" in
stable|beta) ;;
*)
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
exit 2
;;
esac
# --- Configuration ---------------------------------------------------------
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
ENV_FILE="${COMPOSE_DIR}/.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
exit 3
fi
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
exit 3
fi
# --- Detection du nom de projet compose ------------------------------------
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
# ne correspond pas au nom du projet sous lequel les containers tournent),
# on lit le label compose du container core en cours d'execution.
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
# la source de verite.
PROJECT_NAME=$(docker inspect loremind-core \
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
2>/dev/null || echo "")
if [[ -z "${PROJECT_NAME}" ]]; then
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
# (loremind-core tourne forcement quand l'UI declenche un switch).
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
fi
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
echo "→ Projet compose cible: ${PROJECT_NAME}"
# --- Mapping canal -> (namespace, tag) -------------------------------------
# Le slash final du namespace est important : concatene avec le suffixe image
# (core/brain/web) dans le docker-compose.yml.
# Cote tag : le workflow CI pousse :latest pour le canal stable, :beta pour
# le canal beta. Le switcher doit donc forcer ces deux variables ensemble.
case "${CHANNEL}" in
stable)
NAMESPACE="igmlcreation/loremind-"
TAG="latest"
;;
beta)
NAMESPACE="igmlcreation/loremind-beta-"
TAG="beta"
;;
esac
# Helper : met a jour (ou ajoute) une variable key=value dans le .env.
update_env_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "${ENV_FILE}"; then
# Sur Alpine, sed -i sans backup. Le pattern '/' dans la valeur impose
# un delimiter alternatif (|).
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
else
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
{
echo ""
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
echo "${key}=${value}"
} >> "${ENV_FILE}"
fi
}
# --- Etape 1 : sed le .env -------------------------------------------------
echo "→ Mise a jour de IMAGE_NAMESPACE + TAG dans .env (canal: ${CHANNEL})"
update_env_var "IMAGE_NAMESPACE" "${NAMESPACE}"
update_env_var "TAG" "${TAG}"
# --- Etape 2 : docker compose pull -----------------------------------------
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
# --no-deps inutile ici : pull n'a pas de notion de deps.
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
# pour avoir la derniere version disponible — c'est le but du switch.
cd "${COMPOSE_DIR}"
docker compose pull core brain web
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
echo "→ Recreation des containers avec les nouvelles images"
docker compose up -d --no-deps core brain web
echo ""
echo "Switch vers le canal ${CHANNEL} termine avec succes."
echo "Containers core/brain/web recrees avec ${NAMESPACE}*:${TAG}."

88
switcher/watch.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
# watch.sh — boucle principale du switcher.
#
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
# d'idempotence : on ne traite pas deux fois la meme requete.
#
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
# remonter a l'UI via son endpoint de status.
set -euo pipefail
DATA_DIR="/data"
COMMAND_FILE="${DATA_DIR}/command.json"
RESULT_FILE="${DATA_DIR}/result.json"
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
mkdir -p "${DATA_DIR}"
log() {
echo "[$(date -u --iso-8601=seconds)] $*"
}
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
write_result() {
local status="$1" # "in-progress" | "success" | "error"
local channel="$2" # "stable" | "beta" | ""
local message="$3"
local id="$4"
local tmp
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
cat > "${tmp}" <<EOF
{
"id": "${id}",
"status": "${status}",
"channel": "${channel}",
"message": $(printf '%s' "${message}" | jq -Rs .),
"completedAt": "$(date -u --iso-8601=seconds)"
}
EOF
mv "${tmp}" "${RESULT_FILE}"
}
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
# (un test de fichier) et l'utilisateur attend une reaction rapide.
while true; do
if [[ -f "${COMMAND_FILE}" ]]; then
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
sleep 1
continue
fi
# Idempotence : skip si on a deja traite cet ID.
last_id=""
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
if [[ "${id}" == "${last_id}" ]]; then
sleep 1
continue
fi
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
log "New command received: id=${id} channel=${channel}"
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
# Lance le switch. On capture stdout+stderr et le code de sortie.
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
log "Switch SUCCESS for id=${id} channel=${channel}"
# Log la sortie sur plusieurs lignes pour faciliter le debug
# (ce qu'on voit en docker logs).
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
write_result "success" "${channel}" "${output}" "${id}"
else
rc=$?
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
write_result "error" "${channel}" "${output}" "${id}"
fi
# Marque l'ID comme traite — empeche les replays.
echo "${id}" > "${LAST_PROCESSED_FILE}"
fi
sleep 1
done

View File

@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { seedLoreWithFolder, deleteLore, type SeededLore } from '../fixtures/api';
/**
* Regression : la secondary sidebar fuyait entre sections.
*
* Bug initial (2026-05-19) : on est sur /lore/:id (la sidebar affiche l'arbre
* du Lore), on clique sur "Campagne" dans la sidebar principale → on arrive
* sur /campaigns, MAIS la sidebar secondaire continuait d'afficher l'arbre
* du Lore precedent.
*
* Cause : les composants top-level (campaigns.component, lore.component,
* game-systems.component, settings.component) ne nettoyaient pas la sidebar
* heritee d'une section precedente. Fix : appel a layoutService.hide() dans
* leur ngOnInit.
*/
test.describe('Secondary sidebar — isolation entre sections', () => {
let seededLore: SeededLore;
test.beforeEach(async ({ request }) => {
seededLore = await seedLoreWithFolder(request);
});
test.afterEach(async ({ request }) => {
if (seededLore?.id) await deleteLore(request, seededLore.id);
});
test('Lore detail → /campaigns : la sidebar secondaire disparait', async ({ page }) => {
// 1. Sur le detail d'un Lore, la sidebar secondaire est affichee avec
// le nom du Lore comme titre.
await page.goto(`/lore/${seededLore.id}`);
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
await expect(page.locator('app-secondary-sidebar')).toContainText(seededLore.name);
// 2. Navigation vers la liste des campagnes (top-level).
await page.goto('/campaigns');
await expect(page.getByRole('heading', { name: /Vos Campagnes/i })).toBeVisible();
// 3. La sidebar secondaire ne doit PAS persister (sinon elle afficherait
// encore l'arbre du Lore precedent). Le *ngIf au niveau d'AppComponent
// la retire completement du DOM quand layoutService est en etat hidden.
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
});
test('Lore detail → /game-systems : la sidebar secondaire disparait', async ({ page }) => {
await page.goto(`/lore/${seededLore.id}`);
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
await page.goto('/game-systems');
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
});
test('Lore detail → /settings : la sidebar secondaire disparait', async ({ page }) => {
await page.goto(`/lore/${seededLore.id}`);
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
await page.goto('/settings');
// Settings n'a pas de h1 forcement evident, on se base sur l'URL + l'absence
// de sidebar secondaire (objet du test).
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
});
test('Lore detail → /lore (liste racine) : la sidebar secondaire disparait', async ({ page }) => {
// Sur le detail, la sidebar est visible
await page.goto(`/lore/${seededLore.id}`);
await expect(page.locator('app-secondary-sidebar')).toBeVisible();
// Retour a la liste racine du Lore
await page.goto('/lore');
await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
// La sidebar ne doit plus apparaitre sur la liste racine.
await expect(page.locator('app-secondary-sidebar')).toHaveCount(0);
});
});

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.4-beta", "version": "0.9.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.4-beta", "version": "0.9.0-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.8.4-beta", "version": "0.9.0-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -32,6 +32,7 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
{ path: 'sessions/:id', loadComponent: () => import('./sessions/session-detail/session-detail.component').then(m => m.SessionDetailComponent) },
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) }, { path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) }, { path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },

View File

@@ -196,4 +196,60 @@
</div> </div>
</section> </section>
<!-- ============ Sessions de jeu ============ -->
<section class="detail-section sessions-section" *ngIf="!editing">
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Sessions de jeu
</h2>
<!-- Cas 1 : aucune session active dans l'app → on peut lancer -->
<button *ngIf="!activeSessionGlobal"
class="btn-add"
[disabled]="startingSession"
(click)="startSession()">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Lancer une nouvelle session
</button>
<!-- Cas 2 : session active sur cette campagne → reprendre -->
<button *ngIf="activeSessionOnCurrentCampaign"
class="btn-add"
(click)="openSession(activeSessionOnCurrentCampaign)">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Reprendre la session en cours
</button>
<!-- Cas 3 : session active sur une autre campagne → bloqué -->
<button *ngIf="isLaunchBlockedByOtherCampaign"
class="btn-add"
disabled
title="Une session est déjà en cours sur une autre campagne. Termine-la d'abord.">
<lucide-icon [img]="Play" [size]="14"></lucide-icon>
Session en cours ailleurs
</button>
</div>
<div class="sessions-grid" *ngIf="sessions.length > 0">
<div class="session-card"
*ngFor="let session of sessions"
[class.session-card--active]="session.active"
(click)="openSession(session)">
<lucide-icon [img]="Dices" [size]="20" class="session-icon"></lucide-icon>
<div class="session-info">
<span class="session-name">{{ session.name }}</span>
<span class="session-meta">
<span class="session-status" *ngIf="session.active">● En cours</span>
<span *ngIf="!session.active">Terminée le {{ session.endedAt | date:'dd/MM/yyyy' }}</span>
</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="sessions.length === 0">
<p>Aucune session de jeu pour le moment.</p>
</div>
</section>
</div> </div>

View File

@@ -382,3 +382,57 @@
&:hover { background: #5b52e0; } &:hover { background: #5b52e0; }
} }
// ─────────────── Sessions de jeu (Play Context) ───────────────
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.session-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: #6c63ff; transform: translateY(-1px); }
&--active {
border-color: #10b981;
background: linear-gradient(180deg, #0d1f1a 0%, #111827 100%);
}
.session-icon { color: #6c63ff; flex-shrink: 0; }
.session-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.session-name {
color: white;
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
color: #6b7280;
font-size: 0.75rem;
}
.session-status {
color: #10b981;
font-weight: 600;
}
}

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular'; import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check, Play } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators'; import { catchError, switchMap, filter, map } from 'rxjs/operators';
@@ -12,6 +12,8 @@ import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model'; import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { SessionService } from '../../../services/session.service';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model'; import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model'; import { Npc } from '../../../services/npc.model';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
@@ -38,6 +40,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Dices = Dices; readonly Dices = Dices;
readonly Drama = Drama; readonly Drama = Drama;
readonly Check = Check; readonly Check = Check;
readonly Play = Play;
campaign: Campaign | null = null; campaign: Campaign | null = null;
arcs: Arc[] = []; arcs: Arc[] = [];
@@ -55,6 +58,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
characters: Character[] = []; characters: Character[] = [];
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */ /** Fiches de personnages non-joueurs (PNJ) de la campagne. */
npcs: Npc[] = []; npcs: Npc[] = [];
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
sessions: Session[] = [];
/**
* Session active globale (toutes campagnes confondues).
* Sert à désactiver le bouton "Lancer" si une session tourne déjà ailleurs.
* Null si aucune session active dans l'app.
*/
activeSessionGlobal: Session | null = null;
/** Indicateur de lancement en cours pour éviter les double-clics. */
startingSession = false;
/** Mode édition inline. */ /** Mode édition inline. */
editing = false; editing = false;
@@ -78,6 +91,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private gameSystemService: GameSystemService, private gameSystemService: GameSystemService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private sessionService: SessionService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService private confirmDialog: ConfirmDialogService
@@ -104,6 +118,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedGameSystem(campaign); this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!); this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!); this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs; this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData); this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData); this.showLayout(allCampaigns, treeData);
@@ -138,6 +153,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.loadLinkedGameSystem(campaign); this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!); this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!); this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs; this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData); this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData); this.showLayout(allCampaigns, treeData);
@@ -184,6 +200,55 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(list => this.npcs = list); ).subscribe(list => this.npcs = list);
} }
/**
* Charge les sessions de cette campagne ET la session active globale.
* La session globale conditionne si le bouton "Lancer" est activable
* (règle métier : une seule session active simultanément dans l'app).
*/
private loadSessions(campaignId: string): void {
this.sessionService.getSessions(campaignId).pipe(
catchError(() => of([] as Session[]))
).subscribe(list => this.sessions = list);
this.sessionService.getActiveSession().pipe(
catchError(() => of(null))
).subscribe(active => this.activeSessionGlobal = active);
}
/** True si une session est active mais sur une AUTRE campagne (lancement bloqué). */
get isLaunchBlockedByOtherCampaign(): boolean {
return !!this.activeSessionGlobal
&& !!this.campaign
&& this.activeSessionGlobal.campaignId !== this.campaign.id;
}
/** Session active sur la campagne courante (le MJ joue déjà ici). */
get activeSessionOnCurrentCampaign(): Session | null {
if (!this.activeSessionGlobal || !this.campaign) return null;
return this.activeSessionGlobal.campaignId === this.campaign.id
? this.activeSessionGlobal
: null;
}
startSession(): void {
if (!this.campaign || this.startingSession || this.isLaunchBlockedByOtherCampaign) return;
this.startingSession = true;
this.sessionService.startSession(this.campaign.id!).subscribe({
next: session => {
this.startingSession = false;
this.router.navigate(['/sessions', session.id]);
},
error: () => {
this.startingSession = false;
console.error('Erreur lors du lancement de la session');
}
});
}
openSession(session: Session): void {
this.router.navigate(['/sessions', session.id]);
}
createCharacter(): void { createCharacter(): void {
if (!this.campaign) return; if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, Map, Plus } from 'lucide-angular'; import { LucideAngularModule, Map, Plus } from 'lucide-angular';
import { CampaignService } from '../services/campaign.service'; import { CampaignService } from '../services/campaign.service';
import { LayoutService } from '../services/layout.service';
import { Campaign } from '../services/campaign.model'; import { Campaign } from '../services/campaign.model';
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component'; import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
@@ -22,10 +23,15 @@ export class CampaignsComponent implements OnInit {
constructor( constructor(
private router: Router, private router: Router,
private campaignService: CampaignService private campaignService: CampaignService,
private layoutService: LayoutService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
// Liste racine de la section Campagnes : aucune sidebar secondaire ne
// doit subsister (ex: si on arrive depuis une page Lore qui en affichait
// une, elle persisterait sans ce hide() — cf. bug rapporte 2026-05-19).
this.layoutService.hide();
this.loadCampaigns(); this.loadCampaigns();
} }

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
import { GameSystemService } from '../services/game-system.service'; import { GameSystemService } from '../services/game-system.service';
import { LayoutService } from '../services/layout.service';
import { GameSystem } from '../services/game-system.model'; import { GameSystem } from '../services/game-system.model';
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
@@ -24,10 +25,14 @@ export class GameSystemsComponent implements OnInit {
constructor( constructor(
private router: Router, private router: Router,
private gameSystemService: GameSystemService, private gameSystemService: GameSystemService,
private confirmDialog: ConfirmDialogService private confirmDialog: ConfirmDialogService,
private layoutService: LayoutService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
// Page racine : on s'assure de ne pas heriter de la sidebar d'une
// section precedente (cf. fix CampaignsComponent / LoreComponent).
this.layoutService.hide();
this.load(); this.load();
} }

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular'; import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
import { LoreService } from '../services/lore.service'; import { LoreService } from '../services/lore.service';
import { LayoutService } from '../services/layout.service';
import { Lore } from '../services/lore.model'; import { Lore } from '../services/lore.model';
import { LoreCreateComponent } from './lore-create/lore-create.component'; import { LoreCreateComponent } from './lore-create/lore-create.component';
@@ -26,10 +27,15 @@ export class LoreComponent implements OnInit {
constructor( constructor(
private loreService: LoreService, private loreService: LoreService,
private layoutService: LayoutService,
private router: Router private router: Router
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
// Liste racine de la section Lore : aucune sidebar secondaire ne doit
// subsister (sinon elle persiste depuis la section precedente — bug
// symetrique a celui de CampaignsComponent).
this.layoutService.hide();
this.loadLores(); this.loadLores();
} }

View File

@@ -47,6 +47,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n
export class AiChatService { export class AiChatService {
private readonly loreEndpoint = '/api/ai/chat/stream'; private readonly loreEndpoint = '/api/ai/chat/stream';
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign'; private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
private readonly sessionEndpoint = '/api/ai/chat/stream-session';
/** /**
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore). * Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).
@@ -89,7 +90,16 @@ export class AiChatService {
return this.streamSse(this.campaignEndpoint, body); return this.streamSse(this.campaignEndpoint, body);
} }
/** Plumbing SSE mutualisé entre les 2 endpoints (Lore et Campaign). */ /**
* Streame la réponse de l'IA pour un chat pendant une Session de jeu.
* Le backend reconstitue automatiquement le contexte complet (lore +
* campagne + système de JDR + journal de session).
*/
streamChatForSession(sessionId: string, messages: ChatMessage[]): Observable<ChatStreamEvent> {
return this.streamSse(this.sessionEndpoint, { sessionId, messages });
}
/** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */
private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> { private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> {
return new Observable<ChatStreamEvent>((subscriber) => { return new Observable<ChatStreamEvent>((subscriber) => {
const controller = new AbortController(); const controller = new AbortController();

View File

@@ -19,6 +19,24 @@ export interface LicenseStatusDTO {
betaChannelEnabled: boolean; betaChannelEnabled: boolean;
} }
/**
* Etat du canal courant + dernier resultat de bascule (cf. ChannelStatusDTO cote backend).
*/
export type ChannelName = 'stable' | 'beta';
export type SwitchStatus = 'IN_PROGRESS' | 'SUCCESS' | 'ERROR';
export interface ChannelStatusDTO {
currentChannel: ChannelName;
switcherAvailable: boolean;
lastSwitch: {
id: string;
status: SwitchStatus;
channel: ChannelName;
message: string;
completedAt: string;
} | null;
}
/** /**
* Reflet de UpdateCheckService.BetaStatus. * Reflet de UpdateCheckService.BetaStatus.
*/ */
@@ -91,4 +109,23 @@ export class LicenseService {
catchError(() => of(null)) catchError(() => of(null))
); );
} }
/** Etat du canal courant et dernier resultat de switch (pour polling UI). */
getChannelStatus(): Observable<ChannelStatusDTO | null> {
return this.http.get<ChannelStatusDTO>(`${this.apiUrl}/channel`, this.authOptions).pipe(
catchError(() => of(null))
);
}
/**
* Declenche un switch de canal. 202 + { id, channel } si accepte,
* sinon erreur (403 = pas de licence, 503 = sidecar indispo, etc.).
*/
switchChannel(channel: ChannelName): Observable<{ id: string; channel: ChannelName } | { error: string }> {
return this.http.post<{ id: string; channel: ChannelName }>(
`${this.apiUrl}/channel/switch`, { channel }, this.authOptions
).pipe(
catchError((err) => of({ error: err?.error?.error ?? 'Echec du switch de canal' }))
);
}
} }

View File

@@ -0,0 +1,35 @@
/** Type d'une entrée de journal de session — miroir de l'enum Java EntryType. */
export type EntryType = 'NOTE' | 'EVENT' | 'DICE_ROLL' | 'PLAYER_ACTION';
/** Entrée du journal d'une Session (note, évènement, jet, action joueur). */
export interface SessionEntry {
id: string;
sessionId: string;
type: EntryType;
content: string;
occurredAt: string;
createdAt: string;
updatedAt: string;
}
/** Payload de création/édition d'une entrée. */
export interface SessionEntryInput {
type: EntryType;
content: string;
/** Optionnel : si absent, le backend utilisera "maintenant". */
occurredAt?: string;
}
/** Métadonnées d'affichage par type — utilisées par la timeline. */
export interface EntryTypeMeta {
label: string;
icon: 'StickyNote' | 'Sparkles' | 'Dices' | 'UserCheck';
color: string;
}
export const ENTRY_TYPE_META: Record<EntryType, EntryTypeMeta> = {
NOTE: { label: 'Note', icon: 'StickyNote', color: '#9ca3af' },
EVENT: { label: 'Évènement', icon: 'Sparkles', color: '#f59e0b' },
DICE_ROLL: { label: 'Jet de dés', icon: 'Dices', color: '#6c63ff' },
PLAYER_ACTION: { label: 'Action joueur', icon: 'UserCheck', color: '#10b981' },
};

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SessionEntry, SessionEntryInput } from './session-entry.model';
/**
* Service HTTP pour le journal d'une Session.
* Endpoints imbriqués : /api/sessions/{sessionId}/entries.
*/
@Injectable({
providedIn: 'root'
})
export class SessionEntryService {
private base(sessionId: string): string {
return `/api/sessions/${sessionId}/entries`;
}
constructor(private http: HttpClient) {}
getEntries(sessionId: string): Observable<SessionEntry[]> {
return this.http.get<SessionEntry[]>(this.base(sessionId));
}
createEntry(sessionId: string, input: SessionEntryInput): Observable<SessionEntry> {
return this.http.post<SessionEntry>(this.base(sessionId), input);
}
updateEntry(sessionId: string, entryId: string, input: SessionEntryInput): Observable<SessionEntry> {
return this.http.put<SessionEntry>(`${this.base(sessionId)}/${entryId}`, input);
}
deleteEntry(sessionId: string, entryId: string): Observable<void> {
return this.http.delete<void>(`${this.base(sessionId)}/${entryId}`);
}
}

View File

@@ -0,0 +1,15 @@
/**
* Modèle Session côté Frontend.
* Miroir du SessionDTO Java exposé par /api/sessions.
*/
export interface Session {
id: string;
name: string;
campaignId: string;
startedAt: string;
/** Null/undefined = session en cours. */
endedAt: string | null;
createdAt: string;
updatedAt: string;
active: boolean;
}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session } from './session.model';
/**
* Service HTTP pour le Play Context (gestion des Sessions de jeu).
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*/
@Injectable({
providedIn: 'root'
})
export class SessionService {
private apiUrl = '/api/sessions';
constructor(private http: HttpClient) {}
/** Lance une nouvelle session sur la campagne donnée. */
startSession(campaignId: string): Observable<Session> {
return this.http.post<Session>(this.apiUrl, { campaignId });
}
/** Récupère la session active (204 No Content si aucune). */
getActiveSession(): Observable<Session | null> {
return this.http.get<Session | null>(`${this.apiUrl}/active`, { observe: 'body' });
}
getSessions(campaignId?: string): Observable<Session[]> {
let params = new HttpParams();
if (campaignId) {
params = params.set('campaignId', campaignId);
}
return this.http.get<Session[]>(this.apiUrl, { params });
}
getSessionById(id: string): Observable<Session> {
return this.http.get<Session>(`${this.apiUrl}/${id}`);
}
endSession(id: string): Observable<Session> {
return this.http.post<Session>(`${this.apiUrl}/${id}/end`, {});
}
renameSession(id: string, name: string): Observable<Session> {
return this.http.patch<Session>(`${this.apiUrl}/${id}`, { name });
}
deleteSession(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}

View File

@@ -0,0 +1,72 @@
<div class="ai-chat-panel">
<div #messagesContainer class="messages-area">
<div class="welcome-hint" *ngIf="messages.length === 0 && !currentAssistantText && !error">
<lucide-icon [img]="Sparkles" [size]="18"></lucide-icon>
<p>Pose une question à l'IA pendant la partie.</p>
<p class="welcome-sub">
Elle connaît ton univers, ta campagne, les règles du système et tout ce qui a été noté dans le journal.
</p>
</div>
<div *ngFor="let m of messages" class="msg" [class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-content">{{ m.content }}</div>
<button *ngIf="m.role === 'assistant'"
type="button"
class="msg-action"
[disabled]="!canSaveToJournal"
[title]="canSaveToJournal ? 'Ajouter cette réponse au journal' : 'Session terminée'"
(click)="onSaveToJournal(m.content)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
Au journal
</button>
</div>
<!-- Stream en cours : on affiche les tokens au fil de l'eau. -->
<div *ngIf="currentAssistantText" class="msg msg--assistant msg--streaming">
<div class="msg-content">{{ currentAssistantText }}<span class="cursor"></span></div>
</div>
<p class="error-hint" *ngIf="error">{{ error }}</p>
</div>
<div class="composer">
<textarea
class="composer-input"
[(ngModel)]="input"
name="aiChatInput"
rows="2"
[placeholder]="isStreaming ? 'LIA répond' : 'Demande une idée, un rebondissement, une description'"
[disabled]="isStreaming"
(keydown.control.enter)="send()"></textarea>
<div class="composer-actions">
<button type="button"
class="btn-link"
[disabled]="messages.length === 0 && !currentAssistantText"
(click)="clearConversation()"
title="Effacer la conversation">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
<button *ngIf="!isStreaming"
type="button"
class="btn-primary btn-send"
[disabled]="!input.trim()"
(click)="send()">
<lucide-icon [img]="Send" [size]="14"></lucide-icon>
Envoyer
</button>
<button *ngIf="isStreaming"
type="button"
class="btn-secondary btn-send"
(click)="cancelStream()">
<lucide-icon [img]="Square" [size]="14"></lucide-icon>
Stop
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,168 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 0.5rem;
}
.messages-area {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-right: 0.25rem;
}
.welcome-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1rem 0.5rem;
p { margin: 0; }
.welcome-sub { font-size: 0.75rem; color: #6b7280; font-style: italic; }
}
.msg {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.55rem 0.7rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.45;
word-break: break-word;
white-space: pre-wrap;
}
.msg--user {
align-self: flex-end;
max-width: 90%;
background: #1e3a5f;
color: #dbeafe;
}
.msg--assistant {
align-self: flex-start;
max-width: 95%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.msg--streaming {
opacity: 0.95;
}
.msg-content {
white-space: pre-wrap;
}
.msg-action {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px dashed #374151;
color: #9ca3af;
font-size: 0.7rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover:not(:disabled) {
border-color: #6c63ff;
color: #c4bdff;
background: rgba(108, 99, 255, 0.08);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cursor {
color: #6c63ff;
animation: blink 1s steps(2) infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.error-hint {
color: #f87171;
font-size: 0.8rem;
font-style: italic;
margin: 0.25rem 0;
}
// ─────────────── Composer ───────────────
.composer {
display: flex;
flex-direction: column;
gap: 0.4rem;
border-top: 1px solid #1f2937;
padding-top: 0.5rem;
}
.composer-input {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-family: inherit;
font-size: 0.85rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
resize: vertical;
min-height: 50px;
&:focus { outline: none; border-color: #6c63ff; }
&:disabled { opacity: 0.7; }
}
.composer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.45rem;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: none;
padding: 0.4rem 0.75rem;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } &:disabled { opacity: 0.5; cursor: not-allowed; } }
.btn-secondary{ background: #374151; color: #e5e7eb; &:hover { background: #4b5563; } }
.btn-link {
background: transparent;
border: none;
color: #6b7280;
padding: 0;
cursor: pointer;
margin-right: auto;
&:hover:not(:disabled) { color: #e5e7eb; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}

View File

@@ -0,0 +1,147 @@
import {
Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges,
ElementRef, ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, Send, Sparkles, Trash2, BookmarkPlus, Square
} from 'lucide-angular';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage } from '../../services/ai-chat.service';
/**
* Panneau de chat IA pour le mode jeu.
*
* <p>Diffère du {@link AiChatDrawerComponent} :
* - conversation 100% éphémère (le journal joue le rôle de mémoire persistante)
* - intégré dans le panneau latéral, pas en drawer
* - chaque réponse peut être ajoutée au journal en un clic (event {@link saveToJournal})</p>
*
* <p>Le backend reçoit le contexte complet via {@code /api/ai/chat/stream-session} :
* lore + campagne + GameSystem + journal — l'IA "sait" tout ce qui s'est passé.</p>
*/
@Component({
selector: 'app-session-ai-chat-panel',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './session-ai-chat-panel.component.html',
styleUrls: ['./session-ai-chat-panel.component.scss']
})
export class SessionAiChatPanelComponent implements OnChanges, OnDestroy {
readonly Send = Send;
readonly Sparkles = Sparkles;
readonly Trash2 = Trash2;
readonly BookmarkPlus = BookmarkPlus;
readonly Square = Square;
@Input() sessionId!: string;
@Input() canSaveToJournal = true;
/** Émis quand le MJ clique "Ajouter au journal" sur une réponse. */
@Output() saveToJournal = new EventEmitter<string>();
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
messages: ChatMessage[] = [];
currentAssistantText = '';
input = '';
isStreaming = false;
error: string | null = null;
private streamSub: Subscription | null = null;
constructor(private aiChat: AiChatService) {}
ngOnChanges(changes: SimpleChanges): void {
// Reset complet si on change de session (changement d'instance jouée).
if (changes['sessionId'] && !changes['sessionId'].firstChange) {
this.cancelStream();
this.messages = [];
this.currentAssistantText = '';
this.error = null;
}
}
send(): void {
const text = this.input.trim();
if (!text || this.isStreaming || !this.sessionId) return;
this.messages = [...this.messages, { role: 'user', content: text }];
this.input = '';
this.error = null;
this.startStream();
}
private startStream(): void {
this.isStreaming = true;
this.currentAssistantText = '';
this.scrollToBottomSoon();
this.streamSub = this.aiChat.streamChatForSession(this.sessionId, this.messages).subscribe({
next: (event) => {
if (event.type === 'token') {
this.currentAssistantText += event.value;
this.scrollToBottomSoon();
} else if (event.type === 'done') {
this.finishAssistantMessage();
}
},
error: (err: unknown) => {
const message = err instanceof Error ? err.message : 'Erreur inconnue';
this.error = `Erreur IA : ${message}`;
this.isStreaming = false;
this.streamSub = null;
},
complete: () => {
this.finishAssistantMessage();
}
});
}
private finishAssistantMessage(): void {
if (this.currentAssistantText.trim()) {
this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText }];
}
this.currentAssistantText = '';
this.isStreaming = false;
this.streamSub = null;
this.scrollToBottomSoon();
}
cancelStream(): void {
if (this.streamSub) {
this.streamSub.unsubscribe();
this.streamSub = null;
}
// On garde ce qui a déjà été streamé : utile si l'IA partait dans le mur.
if (this.currentAssistantText.trim()) {
this.messages = [...this.messages, { role: 'assistant', content: this.currentAssistantText + ' [interrompu]' }];
}
this.currentAssistantText = '';
this.isStreaming = false;
}
clearConversation(): void {
this.cancelStream();
this.messages = [];
this.error = null;
}
onSaveToJournal(content: string): void {
if (!this.canSaveToJournal) return;
this.saveToJournal.emit(content);
}
/** Scroll vers le bas après cycle de change detection — preuve d'affichage du dernier token. */
private scrollToBottomSoon(): void {
queueMicrotask(() => {
const el = this.messagesContainer?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
ngOnDestroy(): void {
this.cancelStream();
}
}

View File

@@ -0,0 +1,188 @@
<div class="session-detail" *ngIf="session">
<a class="back-link" [routerLink]="['/campaigns', session.campaignId]">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</a>
<div class="detail-header">
<div class="header-texts">
<div class="title-row" *ngIf="!editingName">
<h1>
<lucide-icon [img]="Dices" [size]="24"></lucide-icon>
{{ session.name }}
</h1>
<button type="button" class="btn-icon" (click)="startRename()" title="Renommer la session">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
</button>
</div>
<div class="title-row edit-mode" *ngIf="editingName">
<input type="text"
[(ngModel)]="editName"
name="editName"
(keydown.enter)="saveRename()"
(keydown.escape)="cancelRename()"
autofocus />
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" title="Valider">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-icon" (click)="cancelRename()" title="Annuler">
Annuler
</button>
</div>
<div class="meta">
<span class="badge" [class.badge-active]="session.active">
{{ session.active ? 'En cours' : 'Terminée' }}
</span>
<span class="badge badge-muted">Démarrée le {{ session.startedAt | date:'dd/MM/yyyy HH:mm' }}</span>
<span class="badge badge-muted" *ngIf="session.endedAt">
Terminée le {{ session.endedAt | date:'dd/MM/yyyy HH:mm' }}
</span>
</div>
</div>
<div class="header-actions">
<button *ngIf="session.active" type="button" class="btn-secondary" (click)="endSession()">
<lucide-icon [img]="Square" [size]="14"></lucide-icon>
Terminer la session
</button>
<button type="button" class="btn-danger" (click)="deleteSession()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</div>
<!-- ============ Mode jeu : 2 colonnes (journal + panneau référence) ============ -->
<div class="play-grid">
<!-- Colonne gauche : journal -->
<div class="play-main">
<!-- Ajouter une entrée -->
<section class="detail-section add-entry-section" *ngIf="session.active">
<div class="type-selector">
<button *ngFor="let type of entryTypes"
type="button"
class="type-chip"
[class.type-chip--active]="newEntryType === type"
[style.--type-color]="entryTypeMeta[type].color"
(click)="newEntryType = type">
<lucide-icon [img]="typeIcons[type]" [size]="14"></lucide-icon>
{{ entryTypeMeta[type].label }}
</button>
</div>
<textarea class="entry-input"
[(ngModel)]="newEntryContent"
name="newEntryContent"
rows="3"
[placeholder]="'Ajouter une ' + entryTypeMeta[newEntryType].label.toLowerCase() + ''"
(keydown.control.enter)="submitNewEntry()"></textarea>
<div class="entry-input-footer">
<span class="hint">Ctrl + Entrée pour ajouter</span>
<button type="button"
class="btn-primary"
[disabled]="!newEntryContent.trim() || submittingEntry"
(click)="submitNewEntry()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter
</button>
</div>
</section>
<!-- Timeline -->
<section class="detail-section timeline-section">
<h2>Journal de session</h2>
<div class="empty-state" *ngIf="entries.length === 0">
<p>Aucune entrée pour le moment.</p>
<p class="hint" *ngIf="session.active">
Saisis une note, un évènement ou un jet ci-dessus pour commencer le journal.
</p>
</div>
<ul class="timeline" *ngIf="entries.length > 0">
<li class="timeline-entry"
*ngFor="let entry of entries"
[style.--type-color]="entryTypeMeta[entry.type].color">
<div class="entry-marker">
<lucide-icon [img]="typeIcons[entry.type]" [size]="14"></lucide-icon>
</div>
<div class="entry-body">
<!-- Mode lecture -->
<ng-container *ngIf="editingEntryId !== entry.id">
<div class="entry-header">
<span class="entry-type">{{ entryTypeMeta[entry.type].label }}</span>
<span class="entry-time">{{ entry.occurredAt | date:'HH:mm — dd/MM/yyyy' }}</span>
<div class="entry-actions">
<button type="button" class="btn-icon" (click)="startEditEntry(entry)" title="Modifier">
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
</button>
<button type="button" class="btn-icon btn-icon--danger" (click)="deleteEntry(entry)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
</div>
<p class="entry-content">{{ entry.content }}</p>
</ng-container>
<!-- Mode édition -->
<ng-container *ngIf="editingEntryId === entry.id">
<div class="type-selector type-selector--compact">
<button *ngFor="let type of entryTypes"
type="button"
class="type-chip"
[class.type-chip--active]="editEntryType === type"
[style.--type-color]="entryTypeMeta[type].color"
(click)="editEntryType = type">
<lucide-icon [img]="typeIcons[type]" [size]="12"></lucide-icon>
{{ entryTypeMeta[type].label }}
</button>
</div>
<textarea class="entry-input"
[(ngModel)]="editEntryContent"
name="editEntryContent"
rows="3"
(keydown.control.enter)="saveEditEntry(entry)"
(keydown.escape)="cancelEditEntry()"></textarea>
<div class="entry-input-footer">
<button type="button" class="btn-secondary btn-sm" (click)="cancelEditEntry()">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
Annuler
</button>
<button type="button"
class="btn-primary btn-sm"
[disabled]="!editEntryContent.trim()"
(click)="saveEditEntry(entry)">
<lucide-icon [img]="Check" [size]="12"></lucide-icon>
Sauvegarder
</button>
</div>
</ng-container>
</div>
</li>
</ul>
</section>
</div>
<!-- Colonne droite : panneau référence (Dés / Personnages / Scènes) -->
<aside class="play-aside">
<app-session-reference-panel
[campaignId]="session.campaignId"
[sessionId]="session.id"
[canAddToJournal]="session.active"
(rolled)="onDiceRolled($event)"
(aiReplyToJournal)="onAiReplyToJournal($event)">
</app-session-reference-panel>
</aside>
</div>
</div>

View File

@@ -0,0 +1,346 @@
.session-detail {
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1500px;
margin: 0 auto;
}
// ─────────────── Layout mode jeu (2 colonnes) ───────────────
.play-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 1.5rem;
align-items: start;
}
.play-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
/*
* Panneau latéral sticky pour garder dés + références visibles pendant
* que le MJ scroll dans le journal. Hauteur = viewport - padding pour ne
* pas déborder ; le panneau gère son propre scroll interne (.ref-content).
*/
.play-aside {
position: sticky;
top: 1rem;
height: calc(100vh - 3rem);
min-height: 0;
}
@media (max-width: 1024px) {
.play-grid {
grid-template-columns: 1fr;
}
.play-aside {
position: static;
height: auto;
}
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: #9ca3af;
font-size: 0.85rem;
text-decoration: none;
width: fit-content;
&:hover { color: #e5e7eb; }
}
// ─────────────── Header ───────────────
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
flex-wrap: wrap;
.header-texts { flex: 1; min-width: 0; }
h1 {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
font-weight: 700;
color: white;
margin: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
&.edit-mode input {
background: #0d1117;
border: 1px solid #374151;
color: white;
font-size: 1.25rem;
font-weight: 700;
padding: 0.4rem 0.75rem;
border-radius: 6px;
min-width: 280px;
}
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: #1f2937;
color: #9ca3af;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 999px;
}
.badge-active { background: #064e3b; color: #6ee7b7; }
.badge-muted { background: #1f2937; color: #9ca3af; }
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
// ─────────────── Boutons ───────────────
.btn-primary,
.btn-secondary,
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: none;
padding: 0.55rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
.btn-secondary{ background: #374151; color: #e5e7eb; &:hover:not(:disabled) { background: #4b5563; } }
.btn-danger { background: #7f1d1d; color: #fecaca; &:hover:not(:disabled) { background: #991b1b; } }
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.75rem; }
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
background: transparent;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.3rem 0.45rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.15s ease, color 0.15s ease;
&:hover:not(:disabled) { background: #1f2937; color: #e5e7eb; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
&--danger:hover:not(:disabled) { background: #7f1d1d; color: #fecaca; border-color: #7f1d1d; }
}
// ─────────────── Sections / cards ───────────────
.detail-section {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.5rem 1.75rem;
}
.timeline-section h2 {
margin: 0 0 1.25rem 0;
font-size: 1.1rem;
color: #e5e7eb;
}
.empty-state {
text-align: center;
padding: 1.5rem 0;
color: #6b7280;
p { margin: 0.25rem 0; }
.hint { font-size: 0.8rem; font-style: italic; }
}
// ─────────────── Form "Ajouter une entrée" ───────────────
.add-entry-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.type-selector {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
&--compact { margin-bottom: 0.5rem; }
}
/*
* Variable CSS --type-color injectée par le template depuis ENTRY_TYPE_META.
* Permet à chaque puce d'avoir sa propre teinte sans dupliquer la règle.
*/
.type-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.78rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover { border-color: var(--type-color, #6c63ff); color: #e5e7eb; }
&--active {
background: color-mix(in srgb, var(--type-color, #6c63ff) 18%, transparent);
border-color: var(--type-color, #6c63ff);
color: var(--type-color, #6c63ff);
font-weight: 600;
}
}
.entry-input {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
padding: 0.65rem 0.85rem;
border-radius: 8px;
resize: vertical;
min-height: 70px;
&:focus { outline: none; border-color: #6c63ff; }
}
.entry-input-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
.hint { color: #6b7280; font-size: 0.75rem; }
}
// ─────────────── Timeline ───────────────
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
// Ligne verticale qui relie les markers
&::before {
content: '';
position: absolute;
left: 14px;
top: 8px;
bottom: 8px;
width: 2px;
background: #1f2937;
}
}
.timeline-entry {
display: grid;
grid-template-columns: 30px 1fr;
gap: 0.85rem;
align-items: flex-start;
}
.entry-marker {
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: #0d1117;
border: 2px solid var(--type-color, #6c63ff);
color: var(--type-color, #6c63ff);
flex-shrink: 0;
z-index: 1;
}
.entry-body {
background: #111827;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.75rem 1rem;
min-width: 0;
}
.entry-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
.entry-type {
color: var(--type-color, #6c63ff);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.entry-time {
color: #6b7280;
font-size: 0.75rem;
}
.entry-actions {
margin-left: auto;
display: flex;
gap: 0.3rem;
}
}
.entry-content {
color: #e5e7eb;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}

View File

@@ -0,0 +1,276 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, LucideIconData,
Dices, ArrowLeft, Square, Trash2, Pencil, Check,
StickyNote, Sparkles, UserCheck, Plus, X
} from 'lucide-angular';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { SessionService } from '../../services/session.service';
import { Session } from '../../services/session.model';
import {
SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META
} from '../../services/session-entry.model';
import { SessionEntryService } from '../../services/session-entry.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
import { SessionReferencePanelComponent } from '../session-reference-panel/session-reference-panel.component';
import { DiceRollResult } from '../session-dice-panel/session-dice-panel.component';
/**
* Vue détail d'une Session avec journal horodaté.
* Form de saisie en haut, timeline en dessous (plus récent en premier).
* Le layout dédié "mode jeu" sera ajouté en Phase 4.
*/
@Component({
selector: 'app-session-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink, SessionReferencePanelComponent],
templateUrl: './session-detail.component.html',
styleUrls: ['./session-detail.component.scss']
})
export class SessionDetailComponent implements OnInit, OnDestroy {
readonly Dices = Dices;
readonly ArrowLeft = ArrowLeft;
readonly Square = Square;
readonly Trash2 = Trash2;
readonly Pencil = Pencil;
readonly Check = Check;
readonly Plus = Plus;
readonly X = X;
/** Mapping enum → composant Lucide pour le rendu des icônes par type. */
readonly typeIcons: Record<EntryType, LucideIconData> = {
NOTE: StickyNote,
EVENT: Sparkles,
DICE_ROLL: Dices,
PLAYER_ACTION: UserCheck,
};
readonly entryTypes: EntryType[] = ['NOTE', 'EVENT', 'DICE_ROLL', 'PLAYER_ACTION'];
readonly entryTypeMeta = ENTRY_TYPE_META;
session: Session | null = null;
/** Timeline triée du plus récent au plus ancien (DESC) pour l'UX en partie. */
entries: SessionEntry[] = [];
editingName = false;
editName = '';
/** State de la zone "Ajouter une entrée". */
newEntryType: EntryType = 'NOTE';
newEntryContent = '';
submittingEntry = false;
/** Id de l'entrée en cours d'édition (null si aucune). */
editingEntryId: string | null = null;
editEntryType: EntryType = 'NOTE';
editEntryContent = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private sessionService: SessionService,
private entryService: SessionEntryService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.layoutService.hide();
this.route.paramMap.pipe(
map(pm => pm.get('id')),
filter((id): id is string => !!id),
switchMap(id => this.sessionService.getSessionById(id).pipe(
catchError(() => of(null))
))
).subscribe(session => {
this.session = session;
if (session) {
this.pageTitleService.set(session.name);
this.loadEntries(session.id);
}
});
}
private loadEntries(sessionId: string): void {
this.entryService.getEntries(sessionId).pipe(
catchError(() => of([] as SessionEntry[]))
).subscribe(list => {
this.entries = list.slice().sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
});
}
// ─────────────── Renommage de la Session ───────────────
startRename(): void {
if (!this.session) return;
this.editName = this.session.name;
this.editingName = true;
}
cancelRename(): void {
this.editingName = false;
this.editName = '';
}
saveRename(): void {
if (!this.session || !this.editName.trim()) return;
this.sessionService.renameSession(this.session.id, this.editName.trim()).subscribe({
next: updated => {
this.session = updated;
this.editingName = false;
this.pageTitleService.set(updated.name);
},
error: () => console.error('Erreur lors du renommage de la session')
});
}
// ─────────────── Fin / suppression de Session ───────────────
endSession(): void {
if (!this.session || !this.session.active) return;
const session = this.session;
this.confirmDialog.confirm({
title: 'Terminer la session ?',
message: `Marquer la session "${session.name}" comme terminée ?`,
details: ['Tu pourras toujours consulter son contenu après.'],
confirmLabel: 'Terminer',
variant: 'warning'
}).then(ok => {
if (!ok) return;
this.sessionService.endSession(session.id).subscribe({
next: updated => this.session = updated,
error: () => console.error('Erreur lors de la fin de session')
});
});
}
deleteSession(): void {
if (!this.session) return;
const session = this.session;
const entryCount = this.entries.length;
const details = [
entryCount > 0
? `${entryCount} entrée${entryCount > 1 ? 's' : ''} de journal sera également supprimée.`
: 'Aucune entrée de journal pour cette session.',
'Cette action est irréversible.'
];
this.confirmDialog.confirm({
title: 'Supprimer la session ?',
message: `Supprimer définitivement la session "${session.name}" ?`,
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
const campaignId = session.campaignId;
this.sessionService.deleteSession(session.id).subscribe({
next: () => this.router.navigate(['/campaigns', campaignId]),
error: () => console.error('Erreur lors de la suppression de la session')
});
});
}
// ─────────────── Ajout d'entrée ───────────────
submitNewEntry(): void {
if (!this.session || this.submittingEntry) return;
const content = this.newEntryContent.trim();
if (!content) return;
this.submittingEntry = true;
const input: SessionEntryInput = { type: this.newEntryType, content };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => {
this.submittingEntry = false;
this.entries = [created, ...this.entries];
this.newEntryContent = '';
},
error: () => {
this.submittingEntry = false;
console.error('Erreur lors de l\'ajout de l\'entrée');
}
});
}
// ─────────────── Édition d'entrée ───────────────
startEditEntry(entry: SessionEntry): void {
this.editingEntryId = entry.id;
this.editEntryType = entry.type;
this.editEntryContent = entry.content;
}
cancelEditEntry(): void {
this.editingEntryId = null;
this.editEntryContent = '';
}
saveEditEntry(entry: SessionEntry): void {
if (!this.session) return;
const content = this.editEntryContent.trim();
if (!content) return;
const input: SessionEntryInput = { type: this.editEntryType, content };
this.entryService.updateEntry(this.session.id, entry.id, input).subscribe({
next: updated => {
this.entries = this.entries.map(e => e.id === updated.id ? updated : e);
this.editingEntryId = null;
},
error: () => console.error('Erreur lors de la mise à jour de l\'entrée')
});
}
/**
* Réception d'un jet de dés depuis le panneau latéral.
* On crée une entrée DICE_ROLL dans le journal avec le résumé formaté.
*/
onDiceRolled(result: DiceRollResult): void {
if (!this.session || !this.session.active) return;
const input: SessionEntryInput = { type: 'DICE_ROLL', content: result.summary };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout du jet au journal')
});
}
/**
* Réception d'une réponse IA à sauvegarder dans le journal.
* Type NOTE par défaut car c'est le MJ qui choisit de capter une suggestion
* comme repère — pas un évènement de partie en lui-même.
*/
onAiReplyToJournal(content: string): void {
if (!this.session || !this.session.active) return;
const trimmed = content.trim();
if (!trimmed) return;
const input: SessionEntryInput = { type: 'NOTE', content: '💡 ' + trimmed };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout de la suggestion IA au journal')
});
}
deleteEntry(entry: SessionEntry): void {
if (!this.session) return;
const session = this.session;
this.confirmDialog.confirm({
title: 'Supprimer cette entrée ?',
message: 'Cette entrée du journal sera définitivement supprimée.',
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.entryService.deleteEntry(session.id, entry.id).subscribe({
next: () => this.entries = this.entries.filter(e => e.id !== entry.id),
error: () => console.error('Erreur lors de la suppression de l\'entrée')
});
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}

View File

@@ -0,0 +1,61 @@
<div class="dice-panel">
<div class="dice-controls">
<div class="face-grid">
<button *ngFor="let f of faces"
type="button"
class="face-chip"
[class.face-chip--active]="selectedFace === f"
(click)="selectedFace = f">
d{{ f }}
</button>
</div>
<div class="dice-inputs">
<label class="input-group">
<span>Nombre</span>
<input type="number" min="1" max="20" [(ngModel)]="count" />
</label>
<label class="input-group">
<span>Modificateur</span>
<input type="number" [(ngModel)]="modifier" />
</label>
</div>
<button type="button" class="btn-primary btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
Lancer {{ count }}d{{ selectedFace }}{{ modifier === 0 ? '' : (modifier > 0 ? '+' + modifier : modifier) }}
</button>
</div>
<div class="dice-history" *ngIf="history.length > 0">
<div class="history-header">
<span>Derniers jets</span>
<button type="button" class="btn-link" (click)="clearHistory()" title="Vider l'historique local">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
<ul class="history-list">
<li *ngFor="let r of history" class="history-item">
<div class="history-text">
<span class="history-notation">{{ r.notation }}</span>
<span class="history-detail" *ngIf="r.rolls.length > 1">[{{ r.rolls.join(', ') }}]</span>
<span class="history-total">= {{ r.total }}</span>
</div>
<button type="button"
class="btn-icon"
[disabled]="!canAddToJournal"
[title]="canAddToJournal ? 'Ajouter au journal' : 'Session terminée'"
(click)="addToJournal(r)">
<lucide-icon [img]="BookmarkPlus" [size]="12"></lucide-icon>
</button>
</li>
</ul>
</div>
<p class="placeholder-hint" *ngIf="history.length === 0">
Choisis un dé et lance.
</p>
</div>

View File

@@ -0,0 +1,172 @@
.dice-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dice-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.face-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
}
.face-chip {
background: #111827;
border: 1px solid #1f2937;
color: #9ca3af;
font-size: 0.85rem;
font-weight: 600;
padding: 0.45rem 0.5rem;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
&:hover { border-color: #6c63ff; color: #e5e7eb; }
&--active {
background: rgba(108, 99, 255, 0.18);
border-color: #6c63ff;
color: #c4bdff;
}
}
.dice-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
span {
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.4px;
}
input {
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
padding: 0.4rem 0.55rem;
border-radius: 6px;
font-size: 0.85rem;
width: 100%;
&:focus { outline: none; border-color: #6c63ff; }
}
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
background: #6c63ff;
color: white;
border: none;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
.btn-roll { width: 100%; }
// ─────────────── Historique ───────────────
.dice-history {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
background: #111827;
border: 1px solid #1f2937;
border-radius: 6px;
padding: 0.4rem 0.55rem;
font-size: 0.8rem;
}
.history-text {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
flex-wrap: wrap;
.history-notation { color: #c4bdff; font-weight: 600; }
.history-detail { color: #6b7280; font-size: 0.72rem; }
.history-total { color: white; font-weight: 700; margin-left: auto; }
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.25rem 0.35rem;
border-radius: 5px;
cursor: pointer;
&:hover:not(:disabled) { background: #1f2937; color: #e5e7eb; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
.btn-link {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0;
&:hover { color: #e5e7eb; }
}
.placeholder-hint {
color: #6b7280;
font-size: 0.8rem;
font-style: italic;
text-align: center;
margin: 0;
}

View File

@@ -0,0 +1,92 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Dices, BookmarkPlus, Trash2 } from 'lucide-angular';
/** Faces de dés supportées par le roller. */
const DICE_FACES = [4, 6, 8, 10, 12, 20, 100] as const;
type DiceFace = typeof DICE_FACES[number];
/** Résultat d'un jet, exposé au parent pour ajout au journal. */
export interface DiceRollResult {
/** Notation lisible, ex: "2d6+3". */
notation: string;
/** Détail des dés individuels. */
rolls: number[];
modifier: number;
total: number;
/** Formatage textuel prêt à être écrit dans le journal. */
summary: string;
}
/**
* Panneau de jet de dés pour une session.
* Composant isolé : choix face/quantité/modificateur, jet, historique local court,
* et émission d'un événement vers le parent pour ajout au journal.
*/
@Component({
selector: 'app-session-dice-panel',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './session-dice-panel.component.html',
styleUrls: ['./session-dice-panel.component.scss']
})
export class SessionDicePanelComponent {
readonly Dices = Dices;
readonly BookmarkPlus = BookmarkPlus;
readonly Trash2 = Trash2;
readonly faces: readonly DiceFace[] = DICE_FACES;
/** Désactive le bouton "Ajouter au journal" si la session est terminée. */
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
selectedFace: DiceFace = 20;
count = 1;
modifier = 0;
/** Historique local (max 8 entrées) pour permettre de retrouver un jet récent. */
history: DiceRollResult[] = [];
roll(): void {
const safeCount = Math.max(1, Math.min(20, Math.floor(this.count)));
const rolls: number[] = [];
for (let i = 0; i < safeCount; i++) {
rolls.push(this.randomFace(this.selectedFace));
}
const sumRolls = rolls.reduce((s, n) => s + n, 0);
const total = sumRolls + this.modifier;
const modPart = this.modifier === 0 ? '' : (this.modifier > 0 ? `+${this.modifier}` : `${this.modifier}`);
const notation = `${safeCount}d${this.selectedFace}${modPart}`;
const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : '';
const summary = `🎲 ${notation}${detailsPart} = ${total}`;
const result: DiceRollResult = { notation, rolls, modifier: this.modifier, total, summary };
this.history = [result, ...this.history].slice(0, 8);
}
/** Émet vers le parent pour qu'il insère le jet comme entrée DICE_ROLL. */
addToJournal(result: DiceRollResult): void {
if (!this.canAddToJournal) return;
this.rolled.emit(result);
}
clearHistory(): void {
this.history = [];
}
/**
* crypto.getRandomValues si dispo, fallback Math.random sinon.
* Pas critique pour du JDR mais évite le biais Math.random sur les très petites distributions.
*/
private randomFace(face: DiceFace): number {
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return (buf[0] % face) + 1;
}
return Math.floor(Math.random() * face) + 1;
}
}

View File

@@ -0,0 +1,120 @@
<div class="reference-panel">
<nav class="ref-tabs">
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'ai'"
(click)="selectTab('ai')">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
IA
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'dice'"
(click)="selectTab('dice')">
<lucide-icon [img]="Dices" [size]="14"></lucide-icon>
Dés
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'characters'"
(click)="selectTab('characters')">
<lucide-icon [img]="User" [size]="14"></lucide-icon>
PJ/PNJ
</button>
<button type="button"
class="ref-tab"
[class.ref-tab--active]="activeTab === 'scenes'"
(click)="selectTab('scenes')">
<lucide-icon [img]="Swords" [size]="14"></lucide-icon>
Scènes
</button>
</nav>
<div class="ref-content" [class.ref-content--fill]="activeTab === 'ai'">
<!-- ====== IA ====== -->
<app-session-ai-chat-panel
*ngIf="activeTab === 'ai'"
[sessionId]="sessionId"
[canSaveToJournal]="canAddToJournal"
(saveToJournal)="onAiSaveToJournal($event)">
</app-session-ai-chat-panel>
<!-- ====== Dés ====== -->
<app-session-dice-panel
*ngIf="activeTab === 'dice'"
[canAddToJournal]="canAddToJournal"
(rolled)="onDiceRolled($event)">
</app-session-dice-panel>
<!-- ====== Personnages (PJ + PNJ) ====== -->
<div *ngIf="activeTab === 'characters'" class="ref-list">
<p class="loading-hint" *ngIf="loadingChars">Chargement…</p>
<div *ngIf="!loadingChars">
<div class="ref-group" *ngIf="characters.length > 0">
<h4>
<lucide-icon [img]="User" [size]="13"></lucide-icon>
Personnages joueurs
</h4>
<button *ngFor="let c of characters"
type="button"
class="ref-item"
(click)="openInNewTab(['campaigns', campaignId, 'characters', c.id!])">
<span class="ref-item-name">{{ c.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
</div>
<div class="ref-group" *ngIf="npcs.length > 0">
<h4>
<lucide-icon [img]="Drama" [size]="13"></lucide-icon>
Personnages non-joueurs
</h4>
<button *ngFor="let n of npcs"
type="button"
class="ref-item"
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])">
<span class="ref-item-name">{{ n.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
</div>
<p class="empty-hint" *ngIf="characters.length === 0 && npcs.length === 0">
Aucun personnage dans cette campagne.
</p>
</div>
</div>
<!-- ====== Scènes (arborescence aplatie) ====== -->
<div *ngIf="activeTab === 'scenes'" class="ref-list">
<p class="loading-hint" *ngIf="loadingTree">Chargement…</p>
<ng-container *ngIf="!loadingTree && treeData">
<p class="empty-hint" *ngIf="treeData.arcs.length === 0">
Aucun arc narratif. Construis le scénario de ta campagne pour le retrouver ici.
</p>
<div *ngFor="let arc of treeData.arcs" class="ref-group">
<h4>
<lucide-icon [img]="Swords" [size]="13"></lucide-icon>
{{ arc.name }}
</h4>
<div *ngFor="let chapter of chaptersOf(arc)" class="ref-subgroup">
<span class="ref-subgroup-title">{{ chapter.name }}</span>
<button *ngFor="let scene of scenesOf(chapter)"
type="button"
class="ref-item ref-item--nested"
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
<span class="ref-item-name">{{ scene.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
</div>
</div>
</ng-container>
</div>
</div>
</div>

View File

@@ -0,0 +1,143 @@
.reference-panel {
display: flex;
flex-direction: column;
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 12px;
overflow: hidden;
height: 100%;
min-height: 0;
}
// ─────────────── Tabs ───────────────
.ref-tabs {
display: flex;
background: #111827;
border-bottom: 1px solid #1f2937;
}
.ref-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
background: transparent;
color: #9ca3af;
border: none;
padding: 0.7rem 0.5rem;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover { color: #e5e7eb; background: rgba(108, 99, 255, 0.06); }
&--active {
color: #c4bdff;
border-bottom-color: #6c63ff;
background: rgba(108, 99, 255, 0.1);
}
}
.ref-content {
padding: 1rem;
overflow-y: auto;
flex: 1;
min-height: 0;
}
/*
* Onglet IA : on retire l'overflow du conteneur (le panneau de chat gère
* son propre scroll interne pour les messages) et on force display flex
* pour que l'enfant prenne toute la hauteur.
*/
.ref-content--fill {
display: flex;
overflow: hidden;
> app-session-ai-chat-panel {
flex: 1;
min-height: 0;
display: flex;
}
}
// ─────────────── Listes (PJ/PNJ/Scènes) ───────────────
.ref-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.ref-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
h4 {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.4px;
margin: 0 0 0.25rem 0;
}
}
.ref-subgroup {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-left: 0.25rem;
margin-top: 0.35rem;
.ref-subgroup-title {
color: #6b7280;
font-size: 0.72rem;
margin-bottom: 0.15rem;
}
}
.ref-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
background: #111827;
border: 1px solid #1f2937;
color: #e5e7eb;
font-size: 0.82rem;
text-align: left;
padding: 0.45rem 0.65rem;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover { border-color: #6c63ff; background: #131c2e; }
&--nested { padding-left: 0.9rem; }
.ref-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-item-icon {
color: #6b7280;
flex-shrink: 0;
}
}
.loading-hint,
.empty-hint {
color: #6b7280;
font-size: 0.8rem;
font-style: italic;
text-align: center;
margin: 0.5rem 0;
}

View File

@@ -0,0 +1,138 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { NpcService } from '../../services/npc.service';
import { Character } from '../../services/character.model';
import { Npc } from '../../services/npc.model';
import { Arc, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper';
import {
SessionDicePanelComponent, DiceRollResult
} from '../session-dice-panel/session-dice-panel.component';
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
type TabId = 'dice' | 'characters' | 'scenes' | 'ai';
/**
* Panneau latéral du mode jeu : référence rapide en lecture seule.
*
* <p>Charge à la volée les PJ/PNJ et l'arbre de scènes de la campagne associée
* à la session. La navigation vers les fiches s'ouvre dans un nouvel onglet
* pour ne pas casser le flux de la session en cours.</p>
*
* <p>Le sous-composant {@link SessionDicePanelComponent} émet un événement
* de jet qui remonte ici puis vers le parent via {@link rolled}.</p>
*/
@Component({
selector: 'app-session-reference-panel',
standalone: true,
imports: [CommonModule, LucideAngularModule, SessionDicePanelComponent, SessionAiChatPanelComponent],
templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss']
})
export class SessionReferencePanelComponent implements OnChanges {
readonly User = User;
readonly Drama = Drama;
readonly Swords = Swords;
readonly Dices = Dices;
readonly ExternalLink = ExternalLink;
readonly Sparkles = Sparkles;
@Input() campaignId!: string;
@Input() sessionId!: string;
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
@Output() aiReplyToJournal = new EventEmitter<string>();
activeTab: TabId = 'dice';
characters: Character[] = [];
npcs: Npc[] = [];
treeData: CampaignTreeData | null = null;
loadingChars = false;
loadingTree = false;
/** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
private charsLoaded = false;
private treeLoaded = false;
constructor(
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['campaignId']) {
this.charsLoaded = false;
this.treeLoaded = false;
this.characters = [];
this.npcs = [];
this.treeData = null;
}
}
selectTab(tab: TabId): void {
this.activeTab = tab;
if (tab === 'characters') this.ensureCharactersLoaded();
if (tab === 'scenes') this.ensureTreeLoaded();
}
private ensureCharactersLoaded(): void {
if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
this.loadingChars = true;
this.characterService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Character[])))
.subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
.subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });
}
private tryFinishCharsLoad(): void {
// On considère que le chargement est fini quand au moins une des deux listes
// a été assignée (vide ou pleine). Le double subscribe ci-dessus garantit
// qu'on tombe ici deux fois ; idempotent.
this.loadingChars = false;
this.charsLoaded = true;
}
private ensureTreeLoaded(): void {
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
this.loadingTree = true;
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
).subscribe(data => {
this.treeData = data;
this.loadingTree = false;
this.treeLoaded = true;
});
}
/**
* Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
* Le MJ peut consulter sans perdre son journal ni son historique de dés.
*/
openInNewTab(path: (string | number)[]): void {
const url = path.map(p => String(p)).join('/');
window.open('/' + url, '_blank', 'noopener');
}
/** Helpers de typage pour le template (Angular n'infère pas bien sans). */
chaptersOf(arc: Arc): Chapter[] {
return this.treeData?.chaptersByArc[arc.id!] ?? [];
}
scenesOf(chapter: Chapter): Scene[] {
return this.treeData?.scenesByChapter[chapter.id!] ?? [];
}
onDiceRolled(result: DiceRollResult): void {
this.rolled.emit(result);
}
onAiSaveToJournal(content: string): void {
this.aiReplyToJournal.emit(content);
}
}

View File

@@ -252,22 +252,12 @@
</div> </div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn"> <div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon> <lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible pour certaines images — voir details ci-dessous.</span> <span>Verification impossible (baseline absente ou registry injoignable).</span>
</div> </div>
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint"> <div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div> </div>
<ul class="update-images" *ngIf="updateStatus?.images?.length">
<li *ngFor="let img of updateStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
</li>
</ul>
<div class="form-row" *ngIf="updateStatus?.updateAvailable"> <div class="form-row" *ngIf="updateStatus?.updateAvailable">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying"> <button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon> <lucide-icon [img]="Download" [size]="16"></lucide-icon>
@@ -333,7 +323,7 @@
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'"> <ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success"> <div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon> <lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif.</span> <span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
</div> </div>
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn"> <div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
@@ -354,7 +344,7 @@
</div> </div>
<ul class="license-info"> <ul class="license-info">
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ licenseStatus.tierId }}</li> <li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
<li *ngIf="licenseStatus.expiresAt"> <li *ngIf="licenseStatus.expiresAt">
<strong>Validite :</strong> <strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }} jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
@@ -400,23 +390,68 @@
Indisponible : {{ betaStatus.disabledReason }} Indisponible : {{ betaStatus.disabledReason }}
</div> </div>
<div *ngIf="!betaChecking && betaStatus?.enabled"> <div *ngIf="!betaChecking && betaStatus?.enabled">
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success"> <div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
<code>docker compose pull &amp;&amp; docker compose up -d</code>.</span>
</div>
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon> <lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>Verification beta impossible pour certaines images.</span> <span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
</div> </div>
<ul class="update-images" *ngIf="betaStatus?.images?.length"> </div>
<li *ngFor="let img of betaStatus?.images"> </div>
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span> <!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span> <div class="channel-switch" *ngIf="channelStatus">
</li> <div class="channel-current">
</ul> <span class="channel-label">Canal actuel :</span>
<span class="channel-badge"
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
</span>
</div>
<!-- Sidecar dispo : boutons d'action -->
<ng-container *ngIf="channelStatus.switcherAvailable">
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
<button *ngIf="channelStatus.currentChannel === 'stable'"
type="button" class="btn-primary"
[disabled]="switchInFlight"
(click)="requestChannelSwitch('beta')">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
</button>
<!-- On beta -> proposer retour stable -->
<button *ngIf="channelStatus.currentChannel === 'beta'"
type="button" class="btn-secondary"
[disabled]="switchInFlight"
(click)="requestChannelSwitch('stable')">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
</button>
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
<div *ngIf="switchInFlight" class="alert alert-warn">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
</div>
<!-- Erreur eventuelle remontee par le sidecar -->
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ switchError }}</span>
</div>
</ng-container>
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
et fais <code>docker compose pull &amp;&amp; docker compose up -d</code> une
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
<code>igmlcreation/loremind-beta-</code> pour beta).
</span>
</div> </div>
</div> </div>
</ng-container> </ng-container>

View File

@@ -322,32 +322,42 @@
accent-color: #6c63ff; accent-color: #6c63ff;
} }
.update-images { .channel-switch {
list-style: none; margin-top: 1rem;
padding: 0; padding: 1rem;
margin: 0.75rem 0; background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.75rem;
} }
.update-images li { .channel-current {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.5rem;
padding: 0.4rem 0.6rem; font-size: 0.9rem;
background: rgba(255, 255, 255, 0.03);
.channel-label { color: #9ca3af; }
}
.channel-badge {
padding: 0.2rem 0.65rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.875rem; font-size: 0.8rem;
}
.badge-update {
margin-left: auto;
background: #6c63ff;
color: white;
font-size: 0.7rem;
font-weight: 700; font-weight: 700;
padding: 0.15rem 0.5rem; text-transform: uppercase;
border-radius: 3px; letter-spacing: 0.5px;
&.channel-stable {
background: rgba(76, 175, 80, 0.2);
color: #81c784;
}
&.channel-beta {
background: rgba(108, 99, 255, 0.2);
color: #a39bff;
}
} }
.badge-ok { .badge-ok {
margin-left: auto; margin-left: auto;
background: rgba(76, 175, 80, 0.2); background: rgba(76, 175, 80, 0.2);

View File

@@ -1,13 +1,14 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { interval, switchMap, Subscription } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service'; import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service'; import { ConfigService } from '../services/config.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service'; import { LayoutService } from '../services/layout.service';
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service';
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
/** /**
@@ -28,7 +29,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se
templateUrl: './settings.component.html', templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'] styleUrls: ['./settings.component.scss']
}) })
export class SettingsComponent implements OnInit { export class SettingsComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft; readonly ArrowLeft = ArrowLeft;
readonly RefreshCw = RefreshCw; readonly RefreshCw = RefreshCw;
@@ -53,6 +54,17 @@ export class SettingsComponent implements OnInit {
betaStatus: BetaStatusDTO | null = null; betaStatus: BetaStatusDTO | null = null;
betaChecking = false; betaChecking = false;
// --- Bascule de canal stable <-> beta via sidecar switcher ---
channelStatus: ChannelStatusDTO | null = null;
/** True pendant le polling apres clic. Bloque les boutons. */
switchInFlight = false;
/** ID de la commande de switch en cours, pour ignorer les vieux resultats. */
private switchCommandId: string | null = null;
/** Subscription du polling pour pouvoir l'arreter. */
private switchPollSub: Subscription | null = null;
/** Erreur affichee si le switch a echoue. */
switchError = '';
// --- Pull / delete de modeles Ollama --- // --- Pull / delete de modeles Ollama ---
/** Dialog d'ajout de modele ouvert/ferme. */ /** Dialog d'ajout de modele ouvert/ferme. */
pullDialogOpen = false; pullDialogOpen = false;
@@ -122,15 +134,20 @@ export class SettingsComponent implements OnInit {
private updatesService: UpdatesService, private updatesService: UpdatesService,
public config: ConfigService, public config: ConfigService,
private licenseService: LicenseService, private licenseService: LicenseService,
private confirmDialog: ConfirmDialogService private confirmDialog: ConfirmDialogService,
private layoutService: LayoutService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
// Page racine : on s'assure de ne pas heriter de la sidebar d'une
// section precedente (cf. fix CampaignsComponent / LoreComponent).
this.layoutService.hide();
this.loadSettings(); this.loadSettings();
if (this.config.updateCheckEnabled) { if (this.config.updateCheckEnabled) {
this.checkUpdates(); this.checkUpdates();
} }
this.loadLicense(); this.loadLicense();
this.loadChannelStatus();
} }
// --- Licence Patreon --------------------------------------------------- // --- Licence Patreon ---------------------------------------------------
@@ -237,6 +254,126 @@ export class SettingsComponent implements OnInit {
}); });
} }
// --- Bascule de canal stable <-> beta --------------------------------------
loadChannelStatus(): void {
this.licenseService.getChannelStatus().subscribe({
next: (s) => {
this.channelStatus = s;
// Si on revient sur l'ecran apres un reload (post-switch reussi),
// on affiche le dernier resultat eventuel jusqu'a interaction utilisateur.
},
error: () => { this.channelStatus = null; }
});
}
/**
* Declenche un switch de canal. La sequence cote UI :
* 1. Confirm modal (action destructrice : recreate des containers)
* 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande
* 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS
* 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche
* "Recharge la page dans quelques secondes" et on essaie de poll quand
* meme — au retour de Core, on detectera SUCCESS et on rechargera auto.
* 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons.
*/
requestChannelSwitch(target: ChannelName): void {
const confirmMessage = target === 'beta'
? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.'
: 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.';
this.confirmDialog.confirm({
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
message: confirmMessage,
details: [
'Les donnees (DB, images) sont preservees.',
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
],
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
variant: 'warning'
}).then(ok => {
if (!ok) return;
this.doChannelSwitch(target);
});
}
private doChannelSwitch(target: ChannelName): void {
this.switchInFlight = true;
this.switchError = '';
this.licenseService.switchChannel(target).subscribe((res) => {
if ('error' in res) {
this.switchError = res.error;
this.switchInFlight = false;
return;
}
this.switchCommandId = res.id;
this.startSwitchPolling();
});
}
/**
* Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un
* resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour
* a la fin de son traitement).
*/
private startSwitchPolling(): void {
this.stopSwitchPolling();
this.switchPollSub = interval(2000).pipe(
switchMap(() => this.licenseService.getChannelStatus())
).subscribe((status) => {
if (!status) return;
this.channelStatus = status;
const last = status.lastSwitch;
if (!last || last.id !== this.switchCommandId) return;
if (last.status === 'SUCCESS') {
// La page va se rafraichir auto via l'update-banner qui detecte le
// restart de Core. On laisse switchInFlight a true pour bloquer
// toute autre action en attendant.
this.stopSwitchPolling();
this.switchInFlight = false;
} else if (last.status === 'ERROR') {
this.switchError = last.message || 'Echec du switch';
this.stopSwitchPolling();
this.switchInFlight = false;
}
// IN_PROGRESS : on continue a poll.
});
}
private stopSwitchPolling(): void {
if (this.switchPollSub) {
this.switchPollSub.unsubscribe();
this.switchPollSub = null;
}
}
ngOnDestroy(): void {
this.stopSwitchPolling();
if (this.pullSubscription) {
this.pullSubscription.unsubscribe();
}
}
/**
* Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard
* Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on
* affiche l'ID brut pour rester debuggable.
*
* Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie.
* (Pas besoin de toucher au backend — c'est juste un libelle d'UI.)
*/
private static readonly TIER_LABELS: Record<string, string> = {
'28448887': 'Compagnon',
// '0000000': 'Aventurier',
// '0000000': 'Heros',
};
/** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */
tierLabel(tierId: string | null | undefined): string {
if (!tierId) return '';
return SettingsComponent.TIER_LABELS[tierId] ?? tierId;
}
/** Format human-readable des dates renvoyees par le backend. */ /** Format human-readable des dates renvoyees par le backend. */
formatDate(iso: string | null | undefined): string { formatDate(iso: string | null | undefined): string {
if (!iso) return ''; if (!iso) return '';