Compare commits
4 Commits
v0.8.5
...
v0.9.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 694f687fec | |||
| 87865338a0 | |||
| 586ddceff6 | |||
| 4b9b7f0995 |
@@ -27,6 +27,7 @@ from app.domain.models import (
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
PageSummary,
|
||||
SessionContext,
|
||||
)
|
||||
from app.domain.ports import LLMChatProvider
|
||||
|
||||
@@ -67,6 +68,7 @@ class ChatUseCase:
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""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.
|
||||
"""
|
||||
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(
|
||||
messages,
|
||||
@@ -92,12 +94,13 @@ class ChatUseCase:
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> str:
|
||||
"""Version publique — utilisée par le controller HTTP pour compter
|
||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||
"""
|
||||
return self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||
)
|
||||
|
||||
# --- Construction du system prompt --------------------------------------
|
||||
@@ -109,6 +112,7 @@ class ChatUseCase:
|
||||
campaign: CampaignStructuralContext | None,
|
||||
narrative: NarrativeEntityContext | None,
|
||||
game_system: GameSystemContext | None = None,
|
||||
session: SessionContext | None = None,
|
||||
) -> str:
|
||||
sections = [_BASE_SYSTEM]
|
||||
if lore is not None:
|
||||
@@ -121,6 +125,8 @@ class ChatUseCase:
|
||||
sections.append(self._format_page(page))
|
||||
if narrative is not None:
|
||||
sections.append(self._format_narrative_entity(narrative))
|
||||
if session is not None:
|
||||
sections.append(self._format_session(session))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
# --- Blocs Lore ---------------------------------------------------------
|
||||
@@ -342,6 +348,53 @@ class ChatUseCase:
|
||||
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
|
||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||
|
||||
@@ -229,3 +229,30 @@ class GameSystemContext:
|
||||
system_name: str
|
||||
system_description: str | None
|
||||
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]
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.domain.models import (
|
||||
NpcSummary,
|
||||
ChatMessage,
|
||||
GameSystemContext,
|
||||
JournalEntrySummary,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
@@ -33,6 +34,7 @@ from app.domain.models import (
|
||||
PageSummary,
|
||||
SceneBranchHint,
|
||||
SceneSummary,
|
||||
SessionContext,
|
||||
)
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||
@@ -41,7 +43,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.8.5",
|
||||
version="0.9.0-beta",
|
||||
)
|
||||
|
||||
|
||||
@@ -243,13 +245,34 @@ class GameSystemContextDTO(BaseModel):
|
||||
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):
|
||||
"""Requête de chat streamé : historique + contextes structurels.
|
||||
|
||||
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
|
||||
mais au moins l'un des deux "niveaux haut" (lore_context ou
|
||||
campaign_context) doit être fourni. Le validateur `check_scope` applique
|
||||
cette règle à la frontière HTTP.
|
||||
Les contextes (lore, page, campaign, narrative_entity, session) sont
|
||||
optionnels, mais au moins l'un des contextes "racines" (lore_context,
|
||||
campaign_context ou session_context) doit être fourni. Le validateur
|
||||
`check_scope` applique cette règle à la frontière HTTP.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessageDTO] = Field(min_length=1)
|
||||
@@ -258,10 +281,15 @@ class ChatStreamRequestDTO(BaseModel):
|
||||
campaign_context: CampaignContextDTO | None = None
|
||||
narrative_entity: NarrativeEntityDTO | None = None
|
||||
game_system_context: GameSystemContextDTO | None = None
|
||||
session_context: SessionContextDTO | None = None
|
||||
|
||||
def has_scope(self) -> bool:
|
||||
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
|
||||
return self.lore_context is not None or self.campaign_context is not None
|
||||
"""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
|
||||
or self.session_context is not None
|
||||
)
|
||||
|
||||
|
||||
# --- Factories d'injection de dépendance ---
|
||||
@@ -385,6 +413,7 @@ async def chat_stream(
|
||||
campaign_context = _to_campaign_context(body.campaign_context)
|
||||
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
||||
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 ---
|
||||
# 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,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
session_context=session_context,
|
||||
)
|
||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||
current_msg = messages[-1] if messages else None
|
||||
@@ -421,6 +451,7 @@ async def chat_stream(
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
session_context=session_context,
|
||||
):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.5</version>
|
||||
<version>0.9.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@ public record ChatRequest(
|
||||
PageContext pageContext,
|
||||
CampaignStructuralContext campaignContext,
|
||||
NarrativeEntityContext narrativeEntity,
|
||||
GameSystemContext gameSystemContext) {
|
||||
GameSystemContext gameSystemContext,
|
||||
SessionContext sessionContext) {
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
@@ -50,6 +51,7 @@ public record ChatRequest(
|
||||
private CampaignStructuralContext campaignContext;
|
||||
private NarrativeEntityContext narrativeEntity;
|
||||
private GameSystemContext gameSystemContext;
|
||||
private SessionContext sessionContext;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
@@ -83,9 +85,14 @@ public record ChatRequest(
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder sessionContext(SessionContext sessionContext) {
|
||||
this.sessionContext = sessionContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChatRequest build() {
|
||||
return new ChatRequest(messages, loreContext, pageContext,
|
||||
campaignContext, narrativeEntity, gameSystemContext);
|
||||
campaignContext, narrativeEntity, gameSystemContext, sessionContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
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 java.util.LinkedHashMap;
|
||||
@@ -58,9 +60,35 @@ public class BrainChatPayloadBuilder {
|
||||
if (request.gameSystemContext() != null) {
|
||||
root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
|
||||
}
|
||||
if (request.sessionContext() != null) {
|
||||
root.put("session_context", sessionContextToMap(request.sessionContext()));
|
||||
}
|
||||
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) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.systemName());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
|
||||
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
||||
import com.loremind.application.generationcontext.StreamChatForSessionUseCase;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
|
||||
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.core.task.TaskExecutor;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -42,14 +44,17 @@ public class AiChatController {
|
||||
|
||||
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
||||
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
|
||||
private final StreamChatForSessionUseCase streamChatForSessionUseCase;
|
||||
private final TaskExecutor taskExecutor;
|
||||
|
||||
public AiChatController(
|
||||
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
||||
StreamChatForCampaignUseCase streamChatForCampaignUseCase,
|
||||
StreamChatForSessionUseCase streamChatForSessionUseCase,
|
||||
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
|
||||
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
||||
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
|
||||
this.streamChatForSessionUseCase = streamChatForSessionUseCase;
|
||||
this.taskExecutor = taskExecutor;
|
||||
}
|
||||
|
||||
@@ -74,6 +79,19 @@ public class AiChatController {
|
||||
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é ------------------------
|
||||
|
||||
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) --------
|
||||
|
||||
private void sendUsage(SseEmitter emitter, ChatUsage usage) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -64,33 +64,44 @@ fi
|
||||
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
|
||||
echo "→ Projet compose cible: ${PROJECT_NAME}"
|
||||
|
||||
# --- Mapping canal -> namespace --------------------------------------------
|
||||
# Le slash final est important : il est concatene avec le suffixe image
|
||||
# --- 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-" ;;
|
||||
beta) NAMESPACE="igmlcreation/loremind-beta-" ;;
|
||||
stable)
|
||||
NAMESPACE="igmlcreation/loremind-"
|
||||
TAG="latest"
|
||||
;;
|
||||
beta)
|
||||
NAMESPACE="igmlcreation/loremind-beta-"
|
||||
TAG="beta"
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||
# On veut REMPLACER une ligne existante IMAGE_NAMESPACE=... ou AJOUTER
|
||||
# si absente. Cas typique : .env utilisateur peut avoir cette ligne ou non.
|
||||
#
|
||||
# Sed -i avec un pattern qui matche la ligne entiere. Si pas de match,
|
||||
# on append.
|
||||
echo "→ Mise a jour de IMAGE_NAMESPACE dans .env (canal: ${CHANNEL})"
|
||||
if grep -q '^IMAGE_NAMESPACE=' "${ENV_FILE}"; then
|
||||
# Sur Alpine, sed -i sans backup. Le pattern d'echappement '/' dans
|
||||
# le namespace impose un delimiter alternatif (|).
|
||||
sed -i "s|^IMAGE_NAMESPACE=.*|IMAGE_NAMESPACE=${NAMESPACE}|" "${ENV_FILE}"
|
||||
# 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 avec un commentaire.
|
||||
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
|
||||
{
|
||||
echo ""
|
||||
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||
echo "IMAGE_NAMESPACE=${NAMESPACE}"
|
||||
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}*)"
|
||||
@@ -109,4 +120,4 @@ 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}*."
|
||||
echo "Containers core/brain/web recrees avec ${NAMESPACE}*:${TAG}."
|
||||
|
||||
77
web/e2e/tests/secondary-sidebar-isolation.spec.ts
Normal file
77
web/e2e/tests/secondary-sidebar-isolation.spec.ts
Normal 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
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.5",
|
||||
"version": "0.9.0-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.5",
|
||||
"version": "0.9.0-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.5",
|
||||
"version": "0.9.0-beta",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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/: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: '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/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) },
|
||||
|
||||
@@ -196,4 +196,60 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -382,3 +382,57 @@
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, 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 { forkJoin, of } from 'rxjs';
|
||||
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 { CharacterService } from '../../../services/character.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 { Npc } from '../../../services/npc.model';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
@@ -38,6 +40,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Dices = Dices;
|
||||
readonly Drama = Drama;
|
||||
readonly Check = Check;
|
||||
readonly Play = Play;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
@@ -55,6 +58,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
characters: Character[] = [];
|
||||
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
|
||||
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. */
|
||||
editing = false;
|
||||
@@ -78,6 +91,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private gameSystemService: GameSystemService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private sessionService: SessionService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
@@ -104,6 +118,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.loadSessions(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
@@ -138,6 +153,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
this.loadLinkedGameSystem(campaign);
|
||||
this.loadCharacters(campaign.id!);
|
||||
this.loadNpcs(campaign.id!);
|
||||
this.loadSessions(campaign.id!);
|
||||
this.arcs = treeData.arcs;
|
||||
this.chapterCountByArc = this.computeChapterCounts(treeData);
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
@@ -184,6 +200,55 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
).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 {
|
||||
if (!this.campaign) return;
|
||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { Campaign } from '../services/campaign.model';
|
||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign/campaign-create/campaign-create.component';
|
||||
|
||||
@@ -22,10 +23,15 @@ export class CampaignsComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private campaignService: CampaignService
|
||||
private campaignService: CampaignService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { GameSystemService } from '../services/game-system.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { GameSystem } from '../services/game-system.model';
|
||||
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -24,10 +25,14 @@ export class GameSystemsComponent implements OnInit {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private gameSystemService: GameSystemService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
|
||||
import { LoreService } from '../services/lore.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { Lore } from '../services/lore.model';
|
||||
import { LoreCreateComponent } from './lore-create/lore-create.component';
|
||||
|
||||
@@ -26,10 +27,15 @@ export class LoreComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private loreService: LoreService,
|
||||
private layoutService: LayoutService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n
|
||||
export class AiChatService {
|
||||
private readonly loreEndpoint = '/api/ai/chat/stream';
|
||||
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).
|
||||
@@ -89,7 +90,16 @@ export class AiChatService {
|
||||
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> {
|
||||
return new Observable<ChatStreamEvent>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
35
web/src/app/services/session-entry.model.ts
Normal file
35
web/src/app/services/session-entry.model.ts
Normal 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' },
|
||||
};
|
||||
35
web/src/app/services/session-entry.service.ts
Normal file
35
web/src/app/services/session-entry.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
15
web/src/app/services/session.model.ts
Normal file
15
web/src/app/services/session.model.ts
Normal 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;
|
||||
}
|
||||
51
web/src/app/services/session.service.ts
Normal file
51
web/src/app/services/session.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 ? 'L’IA 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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
276
web/src/app/sessions/session-detail/session-detail.component.ts
Normal file
276
web/src/app/sessions/session-detail/session-detail.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Do
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||
import { ConfigService } from '../services/config.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';
|
||||
|
||||
@@ -133,10 +134,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private updatesService: UpdatesService,
|
||||
public config: ConfigService,
|
||||
private licenseService: LicenseService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
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();
|
||||
if (this.config.updateCheckEnabled) {
|
||||
this.checkUpdates();
|
||||
|
||||
Reference in New Issue
Block a user