Compare commits
8 Commits
v0.8.4-bet
...
694f687fec
| Author | SHA1 | Date | |
|---|---|---|---|
| 694f687fec | |||
| 87865338a0 | |||
| 586ddceff6 | |||
| 4b9b7f0995 | |||
| 3d73b1e6a7 | |||
| 759e47fc1f | |||
| f71bf3fcad | |||
| 0cd99dfb32 |
@@ -85,3 +85,57 @@ jobs:
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Job separe pour le sidecar `switcher`.
|
||||
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
|
||||
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
|
||||
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
|
||||
# donc uniquement sur les releases stables — pas la peine de re-publier
|
||||
# une variante beta du switcher, c'est une infrastructure neutre.
|
||||
build-switcher:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Detect channel
|
||||
id: meta
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [[ "${VERSION}" == *-beta* ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
|
||||
- name: Login to GHCR
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build & push switcher (stable only)
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./switcher
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
|
||||
|
||||
@@ -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.4-beta",
|
||||
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,
|
||||
)
|
||||
|
||||
11
core/pom.xml
11
core/pom.xml
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.4-beta</version>
|
||||
<version>0.9.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
@@ -96,6 +96,15 @@
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78.1</version>
|
||||
</dependency>
|
||||
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
|
||||
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
|
||||
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
|
||||
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
|
||||
<dependency>
|
||||
<groupId>com.google.crypto.tink</groupId>
|
||||
<artifactId>tink</artifactId>
|
||||
<version>1.14.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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,135 @@
|
||||
package com.loremind.application.licensing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
|
||||
*
|
||||
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||
* dans un volume partage. Quand on depose une commande, il :
|
||||
* <ol>
|
||||
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||
* <li>Lance docker compose pull + up -d</li>
|
||||
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
|
||||
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
|
||||
* sur l'hote. Le sidecar valide strictement le contenu de la commande
|
||||
* (channel ∈ {stable, beta} uniquement).
|
||||
*
|
||||
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
|
||||
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
|
||||
* presence de "loremind-beta-" => canal beta, sinon stable.
|
||||
*/
|
||||
@Service
|
||||
public class ChannelSwitcherService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
|
||||
|
||||
public enum Channel { STABLE, BETA }
|
||||
|
||||
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
|
||||
|
||||
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record SwitchResult(
|
||||
String id,
|
||||
SwitchStatus status,
|
||||
Channel channel,
|
||||
String message,
|
||||
Instant completedAt) {}
|
||||
|
||||
private final Path switcherDataPath;
|
||||
private final String imageNamespace;
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
public ChannelSwitcherService(
|
||||
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
|
||||
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
|
||||
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
|
||||
// qui peut etre absente dans les .env legacy).
|
||||
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
|
||||
this.switcherDataPath = Path.of(switcherDataPath);
|
||||
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
|
||||
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
|
||||
switcherDataPath, this.imageNamespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
|
||||
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
|
||||
*/
|
||||
public Channel getCurrentChannel() {
|
||||
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si le sidecar est disponible (volume partage accessible).
|
||||
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
|
||||
* avec instructions manuelles).
|
||||
*/
|
||||
public boolean isSwitcherAvailable() {
|
||||
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depose une commande de switch dans le volume partage. Renvoie l'ID
|
||||
* de la commande, que le client peut utiliser pour poller le status.
|
||||
*
|
||||
* @throws IllegalStateException si le sidecar n'est pas disponible
|
||||
* @throws IOException si l'ecriture du fichier echoue
|
||||
*/
|
||||
public String requestSwitch(Channel target) throws IOException {
|
||||
if (!isSwitcherAvailable()) {
|
||||
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
|
||||
}
|
||||
String id = UUID.randomUUID().toString();
|
||||
Map<String, Object> command = new LinkedHashMap<>();
|
||||
command.put("id", id);
|
||||
command.put("channel", target.name().toLowerCase());
|
||||
command.put("requestedAt", Instant.now().toString());
|
||||
|
||||
Path commandFile = switcherDataPath.resolve("command.json");
|
||||
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
|
||||
try {
|
||||
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
|
||||
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
|
||||
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally {
|
||||
// Cleanup au cas ou move aurait echoue avant le rename.
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
log.info("Switch command written: id={} channel={}", id, target);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
|
||||
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
|
||||
*/
|
||||
public SwitchResult getLastResult() {
|
||||
Path resultFile = switcherDataPath.resolve("result.json");
|
||||
if (!Files.exists(resultFile)) return null;
|
||||
try {
|
||||
return json.readValue(resultFile.toFile(), SwitchResult.class);
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.loremind.infrastructure.web;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Intercepteur global d'exceptions pour TOUS les @RestController.
|
||||
*
|
||||
* <p>Role :
|
||||
* <ul>
|
||||
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
|
||||
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
|
||||
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
|
||||
* par defaut de Spring — utile pour debug cote frontend (visible directement
|
||||
* dans la DevTools reseau).</li>
|
||||
* <li>Mapper les exceptions courantes vers des status HTTP appropries
|
||||
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
|
||||
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
|
||||
* Ce handler n'attrape QUE ce qui a echappe au catch local.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/**
|
||||
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
|
||||
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
|
||||
*/
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
|
||||
}
|
||||
|
||||
/** Entite JPA introuvable -> 404. */
|
||||
@ExceptionHandler(EntityNotFoundException.class)
|
||||
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
|
||||
}
|
||||
|
||||
/** JSON malforme dans le body de la requete -> 400. */
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
|
||||
}
|
||||
|
||||
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
ex.getBindingResult().getFieldErrors().forEach(e ->
|
||||
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Validation failed",
|
||||
"fields", fields
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
|
||||
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
|
||||
* cote client. C'est LE filet de securite.
|
||||
*
|
||||
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
|
||||
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
|
||||
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
|
||||
*/
|
||||
@ExceptionHandler(Throwable.class)
|
||||
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
|
||||
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("error", "Internal server error");
|
||||
body.put("type", ex.getClass().getSimpleName());
|
||||
String msg = safeMessage(ex);
|
||||
if (!msg.isEmpty()) body.put("message", msg);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||
}
|
||||
|
||||
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
|
||||
private static String safeMessage(Throwable ex) {
|
||||
return ex.getMessage() != null ? ex.getMessage() : "";
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -72,12 +71,6 @@ public class GameSystemController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
||||
}
|
||||
|
||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||
return new GameSystemService.GameSystemData(
|
||||
dto.getName(),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||
import com.loremind.application.licensing.LicenseService;
|
||||
import com.loremind.application.licensing.LicenseService.InstallException;
|
||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
|
||||
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -26,9 +30,11 @@ import java.util.Map;
|
||||
public class LicenseController {
|
||||
|
||||
private final LicenseService licenseService;
|
||||
private final ChannelSwitcherService channelSwitcher;
|
||||
|
||||
public LicenseController(LicenseService licenseService) {
|
||||
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
|
||||
this.licenseService = licenseService;
|
||||
this.channelSwitcher = channelSwitcher;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -82,6 +88,68 @@ public class LicenseController {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
|
||||
//
|
||||
// Le flux :
|
||||
// 1. UI POST /api/license/channel/switch { channel: "beta" }
|
||||
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
|
||||
// 3. Core depose une commande dans le volume partage
|
||||
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
|
||||
// 5. UI poll GET /api/license/channel pour suivre le status
|
||||
|
||||
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
|
||||
@GetMapping("/channel")
|
||||
public ChannelStatusDTO getChannel() {
|
||||
return ChannelStatusDTO.from(channelSwitcher);
|
||||
}
|
||||
|
||||
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
|
||||
@PostMapping("/channel/switch")
|
||||
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
|
||||
if (request == null || request.channel() == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
|
||||
}
|
||||
|
||||
ChannelSwitcherService.Channel target;
|
||||
try {
|
||||
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "invalid channel (allowed: stable, beta)"));
|
||||
}
|
||||
|
||||
// Garde : pas de switch vers beta sans licence Patreon valide.
|
||||
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
|
||||
// format), donc c'est ici qu'on doit refuser cote metier.
|
||||
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
|
||||
if (target == ChannelSwitcherService.Channel.BETA) {
|
||||
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
|
||||
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|
||||
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
|
||||
if (!allowed) {
|
||||
return ResponseEntity.status(403).body(Map.of(
|
||||
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!channelSwitcher.isSwitcherAvailable()) {
|
||||
return ResponseEntity.status(503).body(Map.of(
|
||||
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
|
||||
}
|
||||
|
||||
try {
|
||||
String id = channelSwitcher.requestSwitch(target);
|
||||
return ResponseEntity.accepted().body(Map.of(
|
||||
"id", id,
|
||||
"channel", target.name().toLowerCase(Locale.ROOT)));
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.status(500).body(Map.of(
|
||||
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
public record InstallRequest(String jwt) {}
|
||||
public record BetaChannelRequest(boolean enabled) {}
|
||||
public record ChannelSwitchRequest(String channel) {}
|
||||
}
|
||||
|
||||
@@ -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,30 @@
|
||||
package com.loremind.infrastructure.web.dto.licensing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||
|
||||
/**
|
||||
* Etat du canal courant + dernier resultat de switch.
|
||||
*
|
||||
* <p>{@code currentChannel} : detecte au demarrage de Core a partir du prefixe
|
||||
* d'image. {@code switcherAvailable} : indique si le sidecar de switch est
|
||||
* monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
|
||||
* par les instructions manuelles.
|
||||
*
|
||||
* <p>{@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
|
||||
* instance. Sinon, contient le resultat du dernier appel (en cours / succes /
|
||||
* erreur), utilise par l'UI pour suivre la progression apres clic.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ChannelStatusDTO(
|
||||
String currentChannel,
|
||||
boolean switcherAvailable,
|
||||
ChannelSwitcherService.SwitchResult lastSwitch) {
|
||||
|
||||
public static ChannelStatusDTO from(ChannelSwitcherService service) {
|
||||
return new ChannelStatusDTO(
|
||||
service.getCurrentChannel().name().toLowerCase(),
|
||||
service.isSwitcherAvailable(),
|
||||
service.getLastResult());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,18 @@ services:
|
||||
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
||||
# Chemin du docker config.json partage avec Watchtower
|
||||
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
||||
# Chemin du repertoire partage avec le switcher (commande + resultat).
|
||||
# Doit matcher le volume `switcher-data` monte ci-dessous.
|
||||
SWITCHER_DATA_PATH: /shared/switcher
|
||||
volumes:
|
||||
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
||||
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
||||
# privees du canal beta. Pas de creds = no-op.
|
||||
- docker-config:/shared/docker
|
||||
# Volume partage avec le switcher : Core ecrit une commande de switch
|
||||
# de canal ici (command.json), le switcher la traite et y depose son
|
||||
# resultat (result.json). Cf. service `switcher` ci-dessous.
|
||||
- switcher-data:/shared/switcher
|
||||
restart: unless-stopped
|
||||
|
||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||
@@ -167,6 +174,51 @@ services:
|
||||
- "${WEB_PORT:-8081}:80"
|
||||
restart: unless-stopped
|
||||
|
||||
# Sidecar de bascule de canal (stable <-> beta).
|
||||
#
|
||||
# Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
|
||||
# loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
|
||||
# des images, pas leur reference. Ce sidecar fait le `sed .env` + le
|
||||
# `docker compose pull/up -d` quand le Core depose une commande JSON.
|
||||
#
|
||||
# Securite : pas de port expose. La commande arrive via volume partage
|
||||
# (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
|
||||
# le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
|
||||
# compromission du Core.
|
||||
#
|
||||
# L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
|
||||
# `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
|
||||
# se tuerait lui-meme pendant le `docker compose up -d` (race condition).
|
||||
switcher:
|
||||
image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
|
||||
container_name: loremind-switcher
|
||||
# PAS de label watchtower : la maj du switcher se fait via le canal
|
||||
# stable uniquement, et hors du flow d'auto-update.
|
||||
volumes:
|
||||
# Socket Docker du host : permet de lancer docker compose pull/up.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Repertoire compose du host (docker-compose.yml + .env) — RW pour
|
||||
# pouvoir sed la ligne IMAGE_NAMESPACE.
|
||||
- ${COMPOSE_PROJECT_DIR:-./}:/compose
|
||||
# Volume partage avec le Core pour la commande + le resultat.
|
||||
- switcher-data:/data
|
||||
# Volume partage avec le Core + Watchtower : contient config.json avec
|
||||
# les creds GHCR (ecrits par le Core a partir du token Patreon).
|
||||
# Indispensable pour pull les images privees du canal beta.
|
||||
- docker-config:/shared/docker
|
||||
environment:
|
||||
# Repertoire interne ou trouver docker-compose.yml et .env. Bind au
|
||||
# volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
|
||||
COMPOSE_DIR: /compose
|
||||
# Nom de projet docker compose : fixe ici pour que le switcher cible
|
||||
# le MEME stack que celui qui tourne (sinon il creerait un duplicate).
|
||||
# Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
|
||||
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
|
||||
# Indique au CLI Docker du switcher ou trouver config.json (auth GHCR
|
||||
# pour les images privees beta). Meme mecanisme que sur Watchtower.
|
||||
DOCKER_CONFIG: /shared/docker
|
||||
restart: unless-stopped
|
||||
|
||||
# Mises a jour automatiques des images core/brain/web.
|
||||
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
||||
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
||||
@@ -214,3 +266,5 @@ volumes:
|
||||
# Volume partage Core <-> Watchtower : config.json Docker pour
|
||||
# l'authentification au registry prive GHCR (canal beta Patreon).
|
||||
docker-config:
|
||||
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
|
||||
switcher-data:
|
||||
|
||||
26
switcher/Dockerfile
Normal file
26
switcher/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
|
||||
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
|
||||
# dans le volume partage par le Core.
|
||||
#
|
||||
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
|
||||
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
|
||||
FROM alpine:3.20
|
||||
|
||||
# docker-cli : pour parler au socket Docker du host
|
||||
# docker-cli-compose : pour `docker compose pull/up`
|
||||
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
|
||||
# jq : parsing JSON de la commande
|
||||
# coreutils : pour `date -u --iso-8601=seconds`
|
||||
RUN apk add --no-cache \
|
||||
docker-cli \
|
||||
docker-cli-compose \
|
||||
bash \
|
||||
jq \
|
||||
coreutils
|
||||
|
||||
WORKDIR /switcher
|
||||
COPY watch.sh switch.sh ./
|
||||
RUN chmod +x watch.sh switch.sh
|
||||
|
||||
# Tourne en permanence en mode polling.
|
||||
ENTRYPOINT ["/switcher/watch.sh"]
|
||||
66
switcher/README.md
Normal file
66
switcher/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# LoreMind channel switcher
|
||||
|
||||
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
|
||||
sans manipulation manuelle du `.env` ni de docker-compose.
|
||||
|
||||
## Principe
|
||||
|
||||
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
|
||||
|
||||
1. Watch un fichier `command.json` dans un volume partagé avec le Core
|
||||
2. Quand une commande arrive :
|
||||
- Valide le canal cible (`stable` | `beta`)
|
||||
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
|
||||
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
|
||||
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
|
||||
|
||||
## Sécurité
|
||||
|
||||
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
|
||||
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
|
||||
un RCE sur l'hôte :
|
||||
|
||||
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
|
||||
fichier, point.
|
||||
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
|
||||
`stable` ou `beta` (case statement, pas de regex laxiste).
|
||||
- Aucun port n'est exposé. La communication se fait uniquement via volume
|
||||
partagé.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ User clique │ │ Core │ │ switcher │ │ Docker │
|
||||
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
|
||||
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ result.json │ ◄── Core poll
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Upgrade pour les installs existantes
|
||||
|
||||
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
|
||||
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
|
||||
**dernière** manipulation :
|
||||
|
||||
1. Récupérer le nouveau `docker-compose.yml` du repo
|
||||
2. Lancer `docker compose pull && docker compose up -d`
|
||||
|
||||
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
|
||||
|
||||
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
|
||||
|
||||
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
|
||||
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
|
||||
switcher exécute `docker compose up -d`. Si son propre image faisait partie
|
||||
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
|
||||
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
|
||||
condition fatale.
|
||||
|
||||
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
|
||||
explicitement `core brain web --no-deps` — jamais le switcher lui-même.
|
||||
123
switcher/switch.sh
Normal file
123
switcher/switch.sh
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
# switch.sh — execute le switch de canal pour LoreMind.
|
||||
#
|
||||
# Usage interne (appele par watch.sh) :
|
||||
# ./switch.sh stable
|
||||
# ./switch.sh beta
|
||||
#
|
||||
# Ce que ca fait, dans l'ordre :
|
||||
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
|
||||
# contre command injection si le Core etait compromis)
|
||||
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
|
||||
# 3. docker compose pull (recupere les nouvelles images du canal cible)
|
||||
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
|
||||
#
|
||||
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
|
||||
# sans interruption (cf. docker-compose.yml).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CHANNEL="${1:-}"
|
||||
|
||||
# --- Validation stricte -----------------------------------------------------
|
||||
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
|
||||
# C'est le filet de securite si le JSON depose dans /data/command.json
|
||||
# contenait un payload exotique (Core compromis = on ne laisse PAS
|
||||
# executer du code arbitraire sur l'hote).
|
||||
case "${CHANNEL}" in
|
||||
stable|beta) ;;
|
||||
*)
|
||||
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Configuration ---------------------------------------------------------
|
||||
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
|
||||
ENV_FILE="${COMPOSE_DIR}/.env"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
|
||||
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- Detection du nom de projet compose ------------------------------------
|
||||
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
|
||||
# ne correspond pas au nom du projet sous lequel les containers tournent),
|
||||
# on lit le label compose du container core en cours d'execution.
|
||||
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
|
||||
# la source de verite.
|
||||
PROJECT_NAME=$(docker inspect loremind-core \
|
||||
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
|
||||
2>/dev/null || echo "")
|
||||
if [[ -z "${PROJECT_NAME}" ]]; then
|
||||
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
|
||||
# (loremind-core tourne forcement quand l'UI declenche un switch).
|
||||
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
|
||||
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
|
||||
fi
|
||||
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
|
||||
echo "→ Projet compose cible: ${PROJECT_NAME}"
|
||||
|
||||
# --- Mapping canal -> (namespace, tag) -------------------------------------
|
||||
# Le slash final du namespace est important : concatene avec le suffixe image
|
||||
# (core/brain/web) dans le docker-compose.yml.
|
||||
# Cote tag : le workflow CI pousse :latest pour le canal stable, :beta pour
|
||||
# le canal beta. Le switcher doit donc forcer ces deux variables ensemble.
|
||||
case "${CHANNEL}" in
|
||||
stable)
|
||||
NAMESPACE="igmlcreation/loremind-"
|
||||
TAG="latest"
|
||||
;;
|
||||
beta)
|
||||
NAMESPACE="igmlcreation/loremind-beta-"
|
||||
TAG="beta"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Helper : met a jour (ou ajoute) une variable key=value dans le .env.
|
||||
update_env_var() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if grep -q "^${key}=" "${ENV_FILE}"; then
|
||||
# Sur Alpine, sed -i sans backup. Le pattern '/' dans la valeur impose
|
||||
# un delimiter alternatif (|).
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
|
||||
else
|
||||
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
|
||||
{
|
||||
echo ""
|
||||
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||
echo "${key}=${value}"
|
||||
} >> "${ENV_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||
echo "→ Mise a jour de IMAGE_NAMESPACE + TAG dans .env (canal: ${CHANNEL})"
|
||||
update_env_var "IMAGE_NAMESPACE" "${NAMESPACE}"
|
||||
update_env_var "TAG" "${TAG}"
|
||||
|
||||
# --- Etape 2 : docker compose pull -----------------------------------------
|
||||
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
|
||||
# --no-deps inutile ici : pull n'a pas de notion de deps.
|
||||
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
|
||||
# pour avoir la derniere version disponible — c'est le but du switch.
|
||||
cd "${COMPOSE_DIR}"
|
||||
docker compose pull core brain web
|
||||
|
||||
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
|
||||
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
|
||||
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
|
||||
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
|
||||
echo "→ Recreation des containers avec les nouvelles images"
|
||||
docker compose up -d --no-deps core brain web
|
||||
|
||||
echo ""
|
||||
echo "Switch vers le canal ${CHANNEL} termine avec succes."
|
||||
echo "Containers core/brain/web recrees avec ${NAMESPACE}*:${TAG}."
|
||||
88
switcher/watch.sh
Normal file
88
switcher/watch.sh
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# watch.sh — boucle principale du switcher.
|
||||
#
|
||||
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
|
||||
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
|
||||
# d'idempotence : on ne traite pas deux fois la meme requete.
|
||||
#
|
||||
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
|
||||
# remonter a l'UI via son endpoint de status.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATA_DIR="/data"
|
||||
COMMAND_FILE="${DATA_DIR}/command.json"
|
||||
RESULT_FILE="${DATA_DIR}/result.json"
|
||||
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
|
||||
|
||||
mkdir -p "${DATA_DIR}"
|
||||
|
||||
log() {
|
||||
echo "[$(date -u --iso-8601=seconds)] $*"
|
||||
}
|
||||
|
||||
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
|
||||
write_result() {
|
||||
local status="$1" # "in-progress" | "success" | "error"
|
||||
local channel="$2" # "stable" | "beta" | ""
|
||||
local message="$3"
|
||||
local id="$4"
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
|
||||
cat > "${tmp}" <<EOF
|
||||
{
|
||||
"id": "${id}",
|
||||
"status": "${status}",
|
||||
"channel": "${channel}",
|
||||
"message": $(printf '%s' "${message}" | jq -Rs .),
|
||||
"completedAt": "$(date -u --iso-8601=seconds)"
|
||||
}
|
||||
EOF
|
||||
mv "${tmp}" "${RESULT_FILE}"
|
||||
}
|
||||
|
||||
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
|
||||
|
||||
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
|
||||
# (un test de fichier) et l'utilisateur attend une reaction rapide.
|
||||
while true; do
|
||||
if [[ -f "${COMMAND_FILE}" ]]; then
|
||||
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
|
||||
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
# Idempotence : skip si on a deja traite cet ID.
|
||||
last_id=""
|
||||
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
|
||||
if [[ "${id}" == "${last_id}" ]]; then
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
|
||||
|
||||
log "New command received: id=${id} channel=${channel}"
|
||||
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
|
||||
|
||||
# Lance le switch. On capture stdout+stderr et le code de sortie.
|
||||
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
|
||||
log "Switch SUCCESS for id=${id} channel=${channel}"
|
||||
# Log la sortie sur plusieurs lignes pour faciliter le debug
|
||||
# (ce qu'on voit en docker logs).
|
||||
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
|
||||
write_result "success" "${channel}" "${output}" "${id}"
|
||||
else
|
||||
rc=$?
|
||||
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
|
||||
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
|
||||
write_result "error" "${channel}" "${output}" "${id}"
|
||||
fi
|
||||
|
||||
# Marque l'ID comme traite — empeche les replays.
|
||||
echo "${id}" > "${LAST_PROCESSED_FILE}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
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.4-beta",
|
||||
"version": "0.9.0-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.4-beta",
|
||||
"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.4-beta",
|
||||
"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();
|
||||
|
||||
@@ -19,6 +19,24 @@ export interface LicenseStatusDTO {
|
||||
betaChannelEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Etat du canal courant + dernier resultat de bascule (cf. ChannelStatusDTO cote backend).
|
||||
*/
|
||||
export type ChannelName = 'stable' | 'beta';
|
||||
export type SwitchStatus = 'IN_PROGRESS' | 'SUCCESS' | 'ERROR';
|
||||
|
||||
export interface ChannelStatusDTO {
|
||||
currentChannel: ChannelName;
|
||||
switcherAvailable: boolean;
|
||||
lastSwitch: {
|
||||
id: string;
|
||||
status: SwitchStatus;
|
||||
channel: ChannelName;
|
||||
message: string;
|
||||
completedAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflet de UpdateCheckService.BetaStatus.
|
||||
*/
|
||||
@@ -91,4 +109,23 @@ export class LicenseService {
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/** Etat du canal courant et dernier resultat de switch (pour polling UI). */
|
||||
getChannelStatus(): Observable<ChannelStatusDTO | null> {
|
||||
return this.http.get<ChannelStatusDTO>(`${this.apiUrl}/channel`, this.authOptions).pipe(
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declenche un switch de canal. 202 + { id, channel } si accepte,
|
||||
* sinon erreur (403 = pas de licence, 503 = sidecar indispo, etc.).
|
||||
*/
|
||||
switchChannel(channel: ChannelName): Observable<{ id: string; channel: ChannelName } | { error: string }> {
|
||||
return this.http.post<{ id: string; channel: ChannelName }>(
|
||||
`${this.apiUrl}/channel/switch`, { channel }, this.authOptions
|
||||
).pipe(
|
||||
catchError((err) => of({ error: err?.error?.error ?? 'Echec du switch de canal' }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -252,22 +252,12 @@
|
||||
</div>
|
||||
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
|
||||
<span>Verification impossible (baseline absente ou registry injoignable).</span>
|
||||
</div>
|
||||
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||
</div>
|
||||
|
||||
<ul class="update-images" *ngIf="updateStatus?.images?.length">
|
||||
<li *ngFor="let img of updateStatus?.images">
|
||||
<strong>{{ img.image }}</strong>
|
||||
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
|
||||
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
|
||||
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
|
||||
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
||||
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
@@ -333,7 +323,7 @@
|
||||
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
||||
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif.</span>
|
||||
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
@@ -354,7 +344,7 @@
|
||||
</div>
|
||||
|
||||
<ul class="license-info">
|
||||
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ licenseStatus.tierId }}</li>
|
||||
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
|
||||
<li *ngIf="licenseStatus.expiresAt">
|
||||
<strong>Validite :</strong>
|
||||
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
||||
@@ -400,23 +390,68 @@
|
||||
Indisponible : {{ betaStatus.disabledReason }}
|
||||
</div>
|
||||
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
||||
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
|
||||
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
|
||||
<code>docker compose pull && docker compose up -d</code>.</span>
|
||||
</div>
|
||||
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
|
||||
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>Verification beta impossible pour certaines images.</span>
|
||||
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
|
||||
</div>
|
||||
<ul class="update-images" *ngIf="betaStatus?.images?.length">
|
||||
<li *ngFor="let img of betaStatus?.images">
|
||||
<strong>{{ img.image }}</strong>
|
||||
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span>
|
||||
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
|
||||
<div class="channel-switch" *ngIf="channelStatus">
|
||||
<div class="channel-current">
|
||||
<span class="channel-label">Canal actuel :</span>
|
||||
<span class="channel-badge"
|
||||
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar dispo : boutons d'action -->
|
||||
<ng-container *ngIf="channelStatus.switcherAvailable">
|
||||
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'stable'"
|
||||
type="button" class="btn-primary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('beta')">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- On beta -> proposer retour stable -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'beta'"
|
||||
type="button" class="btn-secondary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('stable')">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
|
||||
<div *ngIf="switchInFlight" class="alert alert-warn">
|
||||
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
|
||||
</div>
|
||||
|
||||
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ switchError }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
|
||||
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
|
||||
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
|
||||
et fais <code>docker compose pull && docker compose up -d</code> une
|
||||
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
|
||||
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
|
||||
<code>igmlcreation/loremind-beta-</code> pour beta).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -322,32 +322,42 @@
|
||||
accent-color: #6c63ff;
|
||||
}
|
||||
|
||||
.update-images {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.75rem 0;
|
||||
.channel-switch {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.update-images li {
|
||||
.channel-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.channel-label { color: #9ca3af; }
|
||||
}
|
||||
.channel-badge {
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.badge-update {
|
||||
margin-left: auto;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.channel-stable {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #81c784;
|
||||
}
|
||||
&.channel-beta {
|
||||
background: rgba(108, 99, 255, 0.2);
|
||||
color: #a39bff;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
margin-left: auto;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { interval, switchMap, Subscription } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||
import { ConfigService } from '../services/config.service';
|
||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service';
|
||||
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss']
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
export class SettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
@@ -53,6 +54,17 @@ export class SettingsComponent implements OnInit {
|
||||
betaStatus: BetaStatusDTO | null = null;
|
||||
betaChecking = false;
|
||||
|
||||
// --- Bascule de canal stable <-> beta via sidecar switcher ---
|
||||
channelStatus: ChannelStatusDTO | null = null;
|
||||
/** True pendant le polling apres clic. Bloque les boutons. */
|
||||
switchInFlight = false;
|
||||
/** ID de la commande de switch en cours, pour ignorer les vieux resultats. */
|
||||
private switchCommandId: string | null = null;
|
||||
/** Subscription du polling pour pouvoir l'arreter. */
|
||||
private switchPollSub: Subscription | null = null;
|
||||
/** Erreur affichee si le switch a echoue. */
|
||||
switchError = '';
|
||||
|
||||
// --- Pull / delete de modeles Ollama ---
|
||||
/** Dialog d'ajout de modele ouvert/ferme. */
|
||||
pullDialogOpen = false;
|
||||
@@ -122,15 +134,20 @@ export class SettingsComponent implements OnInit {
|
||||
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();
|
||||
}
|
||||
this.loadLicense();
|
||||
this.loadChannelStatus();
|
||||
}
|
||||
|
||||
// --- Licence Patreon ---------------------------------------------------
|
||||
@@ -237,6 +254,126 @@ export class SettingsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bascule de canal stable <-> beta --------------------------------------
|
||||
|
||||
loadChannelStatus(): void {
|
||||
this.licenseService.getChannelStatus().subscribe({
|
||||
next: (s) => {
|
||||
this.channelStatus = s;
|
||||
// Si on revient sur l'ecran apres un reload (post-switch reussi),
|
||||
// on affiche le dernier resultat eventuel jusqu'a interaction utilisateur.
|
||||
},
|
||||
error: () => { this.channelStatus = null; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declenche un switch de canal. La sequence cote UI :
|
||||
* 1. Confirm modal (action destructrice : recreate des containers)
|
||||
* 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande
|
||||
* 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS
|
||||
* 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche
|
||||
* "Recharge la page dans quelques secondes" et on essaie de poll quand
|
||||
* meme — au retour de Core, on detectera SUCCESS et on rechargera auto.
|
||||
* 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons.
|
||||
*/
|
||||
requestChannelSwitch(target: ChannelName): void {
|
||||
const confirmMessage = target === 'beta'
|
||||
? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.'
|
||||
: 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.';
|
||||
|
||||
this.confirmDialog.confirm({
|
||||
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
|
||||
message: confirmMessage,
|
||||
details: [
|
||||
'Les donnees (DB, images) sont preservees.',
|
||||
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
|
||||
],
|
||||
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.doChannelSwitch(target);
|
||||
});
|
||||
}
|
||||
|
||||
private doChannelSwitch(target: ChannelName): void {
|
||||
this.switchInFlight = true;
|
||||
this.switchError = '';
|
||||
this.licenseService.switchChannel(target).subscribe((res) => {
|
||||
if ('error' in res) {
|
||||
this.switchError = res.error;
|
||||
this.switchInFlight = false;
|
||||
return;
|
||||
}
|
||||
this.switchCommandId = res.id;
|
||||
this.startSwitchPolling();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un
|
||||
* resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour
|
||||
* a la fin de son traitement).
|
||||
*/
|
||||
private startSwitchPolling(): void {
|
||||
this.stopSwitchPolling();
|
||||
this.switchPollSub = interval(2000).pipe(
|
||||
switchMap(() => this.licenseService.getChannelStatus())
|
||||
).subscribe((status) => {
|
||||
if (!status) return;
|
||||
this.channelStatus = status;
|
||||
const last = status.lastSwitch;
|
||||
if (!last || last.id !== this.switchCommandId) return;
|
||||
if (last.status === 'SUCCESS') {
|
||||
// La page va se rafraichir auto via l'update-banner qui detecte le
|
||||
// restart de Core. On laisse switchInFlight a true pour bloquer
|
||||
// toute autre action en attendant.
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
} else if (last.status === 'ERROR') {
|
||||
this.switchError = last.message || 'Echec du switch';
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
}
|
||||
// IN_PROGRESS : on continue a poll.
|
||||
});
|
||||
}
|
||||
|
||||
private stopSwitchPolling(): void {
|
||||
if (this.switchPollSub) {
|
||||
this.switchPollSub.unsubscribe();
|
||||
this.switchPollSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopSwitchPolling();
|
||||
if (this.pullSubscription) {
|
||||
this.pullSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard
|
||||
* Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on
|
||||
* affiche l'ID brut pour rester debuggable.
|
||||
*
|
||||
* Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie.
|
||||
* (Pas besoin de toucher au backend — c'est juste un libelle d'UI.)
|
||||
*/
|
||||
private static readonly TIER_LABELS: Record<string, string> = {
|
||||
'28448887': 'Compagnon',
|
||||
// '0000000': 'Aventurier',
|
||||
// '0000000': 'Heros',
|
||||
};
|
||||
|
||||
/** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */
|
||||
tierLabel(tierId: string | null | undefined): string {
|
||||
if (!tierId) return '';
|
||||
return SettingsComponent.TIER_LABELS[tierId] ?? tierId;
|
||||
}
|
||||
|
||||
/** Format human-readable des dates renvoyees par le backend. */
|
||||
formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
|
||||
Reference in New Issue
Block a user