Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
This commit is contained in:
2026-06-07 09:52:15 +02:00
parent 5eb15dc449
commit edc4434298
113 changed files with 6347 additions and 662 deletions

1
.gitignore vendored
View File

@@ -108,3 +108,4 @@ docker-compose.override.yml
# ============================================================================ # ============================================================================
relay/ relay/
scripts/bump-version.mjs scripts/bump-version.mjs
brain/data/notebooks/5.json

View File

@@ -53,3 +53,27 @@ def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]:
for i in range(0, len(tokens), target_tokens): for i in range(0, len(tokens), target_tokens):
out.append(enc.decode(tokens[i : i + target_tokens])) out.append(enc.decode(tokens[i : i + target_tokens]))
return out return out
def split_in_half(text: str) -> tuple[str, str]:
"""Coupe `text` en deux moitiés ~égales, de préférence sur un saut de ligne
proche du milieu (pour ne pas trancher en plein mot/phrase).
Sert au repli anti-troncature des imports : quand la SORTIE d'un morceau est
coupée (le modèle ne peut pas tout réécrire en une réponse), on retraite ce
morceau en deux moitiés. Renvoie ('', '') si le texte est trop court pour
être découpé utilement (garde-fou anti-récursion infinie).
"""
text = text.strip()
if len(text) < 400:
return "", ""
mid = len(text) // 2
# Cherche un saut de ligne juste avant le milieu, sinon juste après.
cut = text.rfind("\n", 0, mid)
if cut < len(text) // 4:
nxt = text.find("\n", mid)
cut = nxt if nxt != -1 else mid
left, right = text[:cut].strip(), text[cut:].strip()
if not left or not right:
return "", ""
return left, right

View File

@@ -0,0 +1,20 @@
"""Port d'embeddings (RAG des notebooks).
Abstraction du calcul de vecteurs : un texte → une liste de floats. Les adapters
concrets (Ollama local, Mistral cloud) la satisfont par duck typing, comme pour
les LLMProvider. Le RAG n'en dépend que via cette interface.
"""
from __future__ import annotations
from typing import Protocol
class EmbeddingError(Exception):
"""Échec du calcul d'embeddings (modèle indisponible, réseau, quota…)."""
class EmbeddingProvider(Protocol):
"""Calcule les vecteurs d'une liste de textes (ordre préservé)."""
async def embed(self, texts: list[str]) -> list[list[float]]:
...

View File

@@ -12,9 +12,14 @@ from __future__ import annotations
import logging import logging
from app.application.chunking import chunk_text from app.application.chunking import chunk_text, split_in_half
from app.application.llm_json import load_json_object from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
# 2 moitiés. Borné en profondeur (3 niveaux => jusqu'à 8 sous-blocs).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import ( from app.domain.models import (
ArcProposal, ArcProposal,
CampaignImportResult, CampaignImportResult,
@@ -22,7 +27,7 @@ from app.domain.models import (
RoomProposal, RoomProposal,
SceneProposal, SceneProposal,
) )
from app.domain.ports import LLMProvider, PdfTextExtractor from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -229,8 +234,30 @@ class ImportCampaignUseCase:
} }
merger = _TreeMerger() merger = _TreeMerger()
skipped = 0
last_error: str | None = None
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=total)) # RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
# si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : keep-alive pendant l'appel LLM pour ne jamais laisser le
# flux SSE silencieux (sinon le Core coupe sur timeout d'inactivité).
try:
arcs_payload: list[dict] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total)
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
else:
arcs_payload = payload
merger.add(arcs_payload or [])
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
arcs, chapters, scenes = merger.counts() arcs, chapters, scenes = merger.counts()
yield { yield {
"type": "progress", "type": "progress",
@@ -239,42 +266,74 @@ class ImportCampaignUseCase:
"arc_count": arcs, "arc_count": arcs,
"chapter_count": chapters, "chapter_count": chapters,
"scene_count": scenes, "scene_count": scenes,
"skipped": skipped,
} }
if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
yield { yield {
"type": "done", "type": "done",
"arcs": _serialize_arcs(merger.result()), "arcs": _serialize_arcs(merger.result()),
"page_count": doc.page_count, "page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count, "ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
} }
# --- MAP : un morceau → sous-arbre --------------------------------------- # --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]: async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]:
return await self._extract_arcs(chunk, index=index, total=total, depth=0)
async def _extract_arcs(
self, text: str, *, index: int, total: int, depth: int
) -> list[dict]:
"""Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par
nom (un arc/chapitre coupé entre les moitiés est recollé)."""
prompt = ( prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence." "Renvoie maintenant le JSON de l'arborescence."
) )
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
return self._parse_arcs(raw, index=index) arcs, truncated = self._parse_arcs(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_arcs(left, index=index, total=total, depth=depth + 1)
b = await self._extract_arcs(right, index=index, total=total, depth=depth + 1)
return a + b
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return arcs
@staticmethod @staticmethod
def _parse_arcs(raw: str, *, index: int) -> list[dict]: def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué.""" """Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw) parsed, recovered = load_json_object(raw)
if parsed is None: if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index) truncated = looks_like_truncated_json(raw)
return [] if not truncated:
if recovered:
logger.warning( logger.warning(
"Morceau %s : sortie tronquée — récupération des éléments complets " "Morceau %s : aucun objet JSON exploitable, ignoré. "
"(envisagez des morceaux plus petits).", index) "Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return [], truncated
if isinstance(parsed, dict): if isinstance(parsed, dict):
arcs = parsed.get("arcs", []) arcs = parsed.get("arcs", [])
return arcs if isinstance(arcs, list) else [] return (arcs if isinstance(arcs, list) else []), recovered
return [] return [], recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]: def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:

View File

@@ -14,9 +14,16 @@ from __future__ import annotations
import logging import logging
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.llm_json import load_json_object from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut
# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
# profondeur pour éviter une récursion infinie (3 niveaux => jusqu'à 8 sous-blocs ;
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult from app.domain.models import RulesImportResult
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
@@ -95,6 +102,24 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()} return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
Titres insensibles à la casse : un même titre présent des deux côtés (une section
coupée par le re-découpage) voit ses contenus concaténés au lieu d'être écrasés.
"""
out = dict(a)
by_lower = {k.lower(): k for k in out}
for title, content in b.items():
key = by_lower.get(title.lower())
if key is not None:
out[key] = f"{out[key]}\n\n{content}".strip()
else:
out[title] = content
by_lower[title.lower()] = title
return out
class ImportRulesUseCase: class ImportRulesUseCase:
"""Transforme un PDF de règles en proposition de sections markdown.""" """Transforme un PDF de règles en proposition de sections markdown."""
@@ -152,48 +177,103 @@ class ImportRulesUseCase:
} }
merger = _SectionMerger() merger = _SectionMerger()
skipped = 0
last_error: str | None = None
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
new_titles = merger.add(await self._map_chunk(chunk, index=i, total=total)) # RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
new_titles: list[str] = []
try:
sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total)
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
else:
sections = payload
new_titles = merger.add(sections or {})
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
yield { yield {
"type": "progress", "type": "progress",
"current": i + 1, "current": i + 1,
"total": total, "total": total,
"new_sections": new_titles, "new_sections": new_titles,
"skipped": skipped,
} }
if total > 0 and skipped == total:
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
yield { yield {
"type": "done", "type": "done",
"sections": merger.result(), "sections": merger.result(),
"page_count": doc.page_count, "page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count, "ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
} }
# --- MAP : un morceau → sections ----------------------------------------- # --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]: async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
return await self._extract_sections(chunk, index=index, total=total, depth=0)
async def _extract_sections(
self, text: str, *, index: int, total: int, depth: int
) -> dict[str, str]:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
prompt = ( prompt = (
_MAP_SYSTEM.format( _MAP_SYSTEM.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
) )
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections." "Renvoie maintenant le JSON des sections."
) )
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
return self._parse_sections(raw, index=index) sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b)
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections
@staticmethod @staticmethod
def _parse_sections(raw: str, *, index: int) -> dict[str, str]: def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué.""" """Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw) parsed, recovered = load_json_object(raw)
if parsed is None: if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index) # Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute
return {} # structure complète (→ on signalera 'tronqué' pour re-découper).
if recovered: truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning( logger.warning(
"Morceau %s : sortie tronquée — récupération des sections complètes " "Morceau %s : aucun objet JSON exploitable, ignoré. "
"(envisagez des morceaux plus petits).", index) "Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {} return {}, False
return {str(k): str(v) for k, v in parsed.items()} return {str(k): str(v) for k, v in parsed.items()}, recovered

View File

@@ -13,6 +13,16 @@ et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé
from __future__ import annotations from __future__ import annotations
import json import json
import re
# Blocs de "réflexion" des modèles raisonneurs (Nemotron, DeepSeek-R1, QwQ…).
# Leur contenu est de la prose truffée d'accolades qui piège le détecteur de JSON
# (et n'est jamais la réponse) → on le retire avant toute analyse.
_REASONING_RE = re.compile(r"<think(?:ing)?>.*?</think(?:ing)?>", re.DOTALL | re.IGNORECASE)
def _strip_reasoning(raw: str) -> str:
return _REASONING_RE.sub("", raw)
def load_json_object(raw: str) -> tuple[object | None, bool]: def load_json_object(raw: str) -> tuple[object | None, bool]:
@@ -24,6 +34,7 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
auquel cas le second élément vaut True. auquel cas le second élément vaut True.
(None, False) si rien d'exploitable. (None, False) si rien d'exploitable.
""" """
raw = _strip_reasoning(raw)
obj = extract_json_object(raw) obj = extract_json_object(raw)
if obj is not None: if obj is not None:
try: try:
@@ -39,6 +50,18 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
return None, False return None, False
def looks_like_truncated_json(raw: str) -> bool:
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
sous-structure complète). On exige un contenu substantiel pour éviter les
faux positifs sur une courte réponse non-JSON."""
s = (raw or "").strip()
if "{" not in s or len(s) < 100:
return False
return s.count("{") > s.count("}") or s.count("[") > s.count("]")
def extract_json_object(raw: str) -> str | None: def extract_json_object(raw: str) -> str | None:
if not raw: if not raw:
return None return None

View File

@@ -18,7 +18,10 @@ from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ATTEMPTS = 4 # 3 tentatives : assez pour absorber un hoquet transitoire, sans s'acharner des
# minutes sur un modèle durablement lent/saturé (les heartbeats gardent le flux
# vivant, mais inutile de faire patienter l'utilisateur 15 min pour rien).
_ATTEMPTS = 3
_BASE_DELAY_SECONDS = 3.0 _BASE_DELAY_SECONDS = 3.0
# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus # Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus
# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min). # longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min).

View File

@@ -0,0 +1,90 @@
"""Use case : chat ANCRÉ sur les sources d'un notebook (RAG).
À chaque message, on retrouve les passages pertinents des sources (via le RAG) et
on les injecte dans le prompt système, en plus du contexte de campagne. Le modèle
répond donc en s'appuyant sur la/les source(s) — pas sur ses connaissances générales.
"""
from __future__ import annotations
from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider
_SYSTEM_PROMPT = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur.
Tu disposes de DEUX connaissances, toutes deux ci-dessous :
1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ;
2) LA SOURCE (extraits pertinents du PDF).
Règles :
- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE.
- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE.
- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante.
- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le.
- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »).
{context_block}
--- EXTRAITS PERTINENTS DE LA SOURCE ---
{sources_block}
--- FIN DES EXTRAITS ---
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action.
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si
c'est pertinent et explicitement souhaité. Formats acceptés :
```loremind-action
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}}
```
```loremind-action
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}}
```
```loremind-action
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}}
```
```loremind-action
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}}
```
```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
```
Réponds en français, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
class NotebookChatUseCase:
def __init__(self, rag: NotebookRagUseCase, llm: LLMChatProvider) -> None:
self._rag = rag
self._llm = llm
async def stream(
self,
source_ids: list[str],
messages: list[ChatMessage],
context: str = "",
top_k: int = 6,
) -> AsyncIterator[str]:
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
passages = await self._rag.retrieve(source_ids, last_user, top_k=top_k)
sources_block = (
"\n\n".join(self._format_passage(p) for p in passages)
if passages else "(aucun passage pertinent trouvé dans les sources)"
)
context_block = (
f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
)
system_prompt = _SYSTEM_PROMPT.format(
context_block=context_block, sources_block=sources_block)
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
yield token
@staticmethod
def _format_passage(p: dict) -> str:
page = p.get("page")
prefix = f"(p. {page}) " if page else ""
return f"{prefix}{p['text'].strip()}"

View File

@@ -0,0 +1,127 @@
"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document.
Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit
l'INTÉGRALITÉ des sources par lots :
- MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question
(ou « RAS » si rien) ;
- REDUCE : il synthétise toutes les notes en une réponse finale (streamée).
→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le
modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par
`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de
lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
"""
from __future__ import annotations
import logging
from typing import AsyncIterator
import tiktoken
from app.application.llm_retry import generate_with_retry
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_NO_MATCH = "RAS"
_MAP_TEMPERATURE = 0.2
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
QUESTION : {question}
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Informations pertinentes (ou « {no_match} ») :"""
_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de
l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait).
Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »),
et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta
réponse à sa structure / ses PNJ pour des adaptations cohérentes.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
{notes_block}
--- FIN DES NOTES ---
Réponds en français."""
class NotebookDeepUseCase:
def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None:
self._llm = llm
self._batch_tokens = max(2000, batch_tokens)
async def stream(
self,
source_ids: list[str],
question: str,
context: str = "",
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)"""
chunks: list[dict] = []
for sid in source_ids:
chunks.extend(vector_store.all_chunks(sid))
if not chunks:
yield {"type": "token", "token": "Aucune source indexée à analyser."}
yield {"type": "done"}
return
batches = self._group(chunks)
total = len(batches)
notes: list[str] = []
for i, batch in enumerate(batches):
yield {"type": "progress", "current": i, "total": total}
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
try:
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
except LLMProviderError as exc:
logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc)
continue
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
notes.append(answer)
yield {"type": "progress", "current": total, "total": total}
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
context_block = (
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else ""
)
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
async for token in llm_chat.stream_chat(
[ChatMessage(role="user", content=question)], system_prompt=system_prompt
):
yield {"type": "token", "token": token}
yield {"type": "done"}
def _group(self, chunks: list[dict]) -> list[list[dict]]:
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
enc = tiktoken.get_encoding("cl100k_base")
batches: list[list[dict]] = []
current: list[dict] = []
current_tokens = 0
for c in chunks:
t = len(enc.encode(c.get("text", "")))
if current and current_tokens + t > self._batch_tokens:
batches.append(current)
current, current_tokens = [], 0
current.append(c)
current_tokens += t
if current:
batches.append(current)
return batches

View File

@@ -0,0 +1,79 @@
"""Use case RAG des notebooks : indexer une source PDF et retrouver les passages
pertinents pour une question.
Chaîne d'indexation : PDF → extraction texte (+OCR) → découpage en extraits courts
→ embeddings → stockage vectoriel (fichier). À la requête : on embed la question
et on récupère les extraits les plus proches (cosinus) pour ancrer le chat.
Extraits PLUS COURTS que pour l'import (recopie) : ici on veut une granularité fine
pour que la recherche pointe un passage précis, pas un demi-chapitre.
"""
from __future__ import annotations
import logging
from app.application.chunking import chunk_text
from app.application.embeddings import EmbeddingProvider
from app.domain.ports import PdfTextExtractor
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_RAG_CHUNK_TOKENS = 600
# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro
# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
_MIN_LETTERS = 15
def _has_enough_text(piece: str) -> bool:
return sum(c.isalpha() for c in piece) >= _MIN_LETTERS
class NotebookRagUseCase:
def __init__(
self,
extractor: PdfTextExtractor,
embedder: EmbeddingProvider,
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
) -> None:
self._extractor = extractor
self._embedder = embedder
self._chunk_target_tokens = chunk_target_tokens
async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict:
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
et stocke une source. Renvoie un récap."""
doc = self._extractor.extract(pdf_bytes)
chunks: list[str] = []
pages: list[int] = []
for page in doc.pages:
for piece in chunk_text(page.text, self._chunk_target_tokens):
if not _has_enough_text(piece):
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
chunks.append(piece)
pages.append(page.index + 1) # n° de page 1-based pour l'affichage
logger.info(
"Indexation notebook source=%s : %s page(s) (%s OCR), %s extrait(s).",
source_id, doc.page_count, doc.ocr_page_count, len(chunks),
)
if not chunks:
vector_store.save(source_id, [], [])
return {"chunks": 0, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count}
vectors = await self._embedder.embed(chunks)
count = vector_store.save(source_id, chunks, vectors, pages)
return {
"chunks": count,
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
}
async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]:
"""Passages les plus pertinents (toutes sources) pour `query`."""
ids = [s for s in source_ids if vector_store.exists(s)]
if not ids or not query.strip():
return []
query_vectors = await self._embedder.embed([query])
if not query_vectors:
return []
return vector_store.search(ids, query_vectors[0], top_k)

View File

@@ -0,0 +1,45 @@
"""Heartbeats pour garder un flux SSE 'vivant' pendant une coroutine longue.
Problème résolu : pendant un appel LLM lent (import sur provider gratuit), le
Brain ne produit AUCUN évènement SSE. Le Core (WebClient) ne 'voit aucun item'
et coupe la connexion sur timeout d'inactivité :
ReactiveException: Did not observe any item or terminal signal within Nms
C'est le piège classique du SSE long. La parade standard = envoyer un keep-alive
périodique. `with_heartbeat` exécute une coroutine en émettant un évènement
'heartbeat' toutes les `interval` secondes tant qu'elle tourne, puis son résultat
('result', valeur). Le Core remet son chrono à zéro sur n'importe quel évènement
reçu (même inconnu) → plus de coupure, quelle que soit la lenteur du modèle.
"""
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Awaitable
# Bien sous le timeout d'inactivité du Core (600s) ET de tout proxy (nginx ~60s).
HEARTBEAT_INTERVAL_SECONDS = 15.0
async def with_heartbeat(
coro: Awaitable[Any],
*,
interval: float = HEARTBEAT_INTERVAL_SECONDS,
) -> AsyncIterator[tuple[str, Any]]:
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
qu'elle n'est pas terminée, puis ('result', valeur).
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
(client déconnecté), la tâche sous-jacente est annulée.
"""
task: asyncio.Task = asyncio.ensure_future(coro)
try:
while not task.done():
done, _ = await asyncio.wait({task}, timeout=interval)
if not done:
yield ("heartbeat", None)
yield ("result", task.result())
finally:
if not task.done():
task.cancel()

View File

@@ -25,8 +25,9 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter. # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ;
llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama" # "openrouter" = OpenRouter ; "mistral" = Mistral ; "gemini" = Google Gemini.
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] = "ollama"
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b" llm_model: str = "gemma4:26b"
@@ -53,6 +54,35 @@ class Settings(BaseSettings):
openrouter_api_key: str = "" openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free" openrouter_model: str = "openrouter/free"
# Mistral (La Plateforme, OpenAI-compatible). Cle + modele modifiables depuis
# l'UI. Tier gratuit « Experiment » sur console.mistral.ai (sans CB). Defaut =
# mistral-large-latest (128k contexte, bon en francais et en JSON fidele).
mistral_api_key: str = ""
mistral_model: str = "mistral-large-latest"
# Google Gemini (endpoint OpenAI-compatible). Cle gratuite sur
# aistudio.google.com (sans CB). Defaut = gemini-2.0-flash : ~1M de contexte
# (un livre tient en 1-2 appels), rapide, fidele, quota gratuit genereux.
gemini_api_key: str = ""
gemini_model: str = "gemini-2.0-flash"
# Embeddings (RAG des notebooks/ateliers). Modele SEPARE du chat.
# "ollama" = local (gratuit, illimite, ideal pour indexer un livre = bcp
# d'appels) ; "mistral" = cloud EU (mistral-embed, soumis au rate limit).
embedding_provider: Literal["ollama", "mistral"] = "ollama"
ollama_embedding_model: str = "nomic-embed-text"
mistral_embedding_model: str = "mistral-embed"
# Au démarrage, si le provider d'embeddings est Ollama et que le modèle n'est
# pas présent, le Brain le télécharge automatiquement (en arrière-plan) → le RAG
# marche "out of the box" pour un nouvel utilisateur. Désactivable (connexion
# limitée, gestion manuelle des modèles).
auto_pull_embedding_model: bool = True
# Nombre d'extraits récupérés par question dans le chat des ateliers (RAG).
# Plus haut = plus de couverture pour les questions larges (« liste les… »),
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
rag_top_k: int = 8
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.

View File

@@ -31,6 +31,15 @@ _ALLOWED_KEYS = frozenset({
"onemin_model", "onemin_model",
"openrouter_api_key", "openrouter_api_key",
"openrouter_model", "openrouter_model",
"mistral_api_key",
"mistral_model",
"gemini_api_key",
"gemini_model",
"embedding_provider",
"ollama_embedding_model",
"mistral_embedding_model",
"auto_pull_embedding_model",
"rag_top_k",
"import_chunk_tokens", "import_chunk_tokens",
}) })

View File

@@ -0,0 +1,178 @@
"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
Gemini expose un endpoint COMPATIBLE OpenAI
(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client
"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral.
Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour
l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais
atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit
# rien). Gemini répond vite ; 120s est large.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class GeminiLLMProvider:
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.gemini_api_key:
raise LLMProviderError(
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
"(clé gratuite sur aistudio.google.com)."
)
self._api_key = settings.gemini_api_key
self._model = settings.gemini_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage), avec garde-fous au temps écoulé."""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec deux garde-fous : 1er token borné (échec rapide
si rien ne sort) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur Gemini : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
"votre quota gratuit."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import ou augmentez le timeout."
) from exc
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur Gemini (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur Gemini : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,188 @@
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_URL = "https://api.mistral.ai/v1/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file
# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit
# peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class MistralLLMProvider:
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise LLMProviderError(
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.mistral_api_key
self._model = settings.mistral_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx :
si le provider envoyait des keep-alive sans contenu, l'appel pendrait à
l'infini. Ici on coupe net après `self._timeout` secondes.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné
(file d'attente → échec rapide) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur Mistral : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
"en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
"choisissez un modèle plus disponible."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail de Mistral (modèle inconnu, clé
# invalide 401, quota 429…), sinon on n'a que le code HTTP nu.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur Mistral (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, clé invalide 401, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur Mistral : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur Mistral ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,58 @@
"""Adapter d'embeddings Mistral (cloud, EU) — POST /v1/embeddings.
Soumis au rate limit du tier gratuit : pour indexer un gros document on envoie
les textes par lots (et l'appelant peut espacer les appels si besoin).
"""
from __future__ import annotations
import httpx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
_API_URL = "https://api.mistral.ai/v1/embeddings"
# Lot raisonnable pour ne pas envoyer un payload géant d'un coup.
_BATCH_SIZE = 64
class MistralEmbeddingProvider:
"""Implémente EmbeddingProvider via l'API Mistral embeddings."""
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise EmbeddingError(
"Clé API Mistral manquante (requise pour les embeddings Mistral). "
"Configure-la dans les Paramètres ou choisis Ollama pour les embeddings."
)
self._api_key = settings.mistral_api_key
self._model = settings.mistral_embedding_model
self._timeout = settings.llm_timeout_seconds
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
out: list[list[float]] = []
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=self._timeout) as client:
for start in range(0, len(texts), _BATCH_SIZE):
batch = texts[start:start + _BATCH_SIZE]
try:
response = await client.post(
_API_URL, headers=headers, json={"model": self._model, "input": batch})
if response.status_code >= 400:
raise EmbeddingError(
f"Mistral embeddings HTTP {response.status_code} : "
f"{response.text.strip()[:300]}")
data = response.json()
except httpx.HTTPError as exc:
raise EmbeddingError(f"Erreur Mistral embeddings : {exc}") from exc
items = data.get("data")
if not isinstance(items, list) or len(items) != len(batch):
raise EmbeddingError("Réponse d'embeddings Mistral inattendue (taille incohérente).")
for item in items:
out.append([float(x) for x in item.get("embedding", [])])
return out

View File

@@ -0,0 +1,43 @@
"""Adapter d'embeddings Ollama (local) — endpoint /api/embed.
Gratuit et illimité (tourne sur la machine). Nécessite d'avoir pullé le modèle
d'embedding (ex. `ollama pull nomic-embed-text`).
"""
from __future__ import annotations
import httpx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
class OllamaEmbeddingProvider:
"""Implémente EmbeddingProvider via Ollama /api/embed (batch)."""
def __init__(self, settings: Settings) -> None:
self._base_url = settings.ollama_base_url
self._model = settings.ollama_embedding_model
self._timeout = settings.llm_timeout_seconds
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
url = f"{self._base_url}/api/embed"
payload = {"model": self._model, "input": texts}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
if response.status_code >= 400:
body = response.text
raise EmbeddingError(
f"Ollama embeddings HTTP {response.status_code} : {body.strip()[:300]}. "
f"Le modèle '{self._model}' est-il installé ? (ollama pull {self._model})"
)
data = response.json()
except httpx.HTTPError as exc:
raise EmbeddingError(f"Erreur Ollama embeddings : {exc}") from exc
vectors = data.get("embeddings")
if not isinstance(vectors, list) or len(vectors) != len(texts):
raise EmbeddingError("Réponse d'embeddings Ollama inattendue (taille incohérente).")
return [[float(x) for x in v] for v in vectors]

View File

@@ -11,17 +11,26 @@ qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging
from typing import AsyncIterator from typing import AsyncIterator
import httpx import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
from app.domain.ports import LLMProviderError from app.domain.ports import LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions" _API_URL = "https://openrouter.ai/api/v1/chat/completions"
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class OpenRouterLLMProvider: class OpenRouterLLMProvider:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
@@ -51,12 +60,64 @@ class OpenRouterLLMProvider:
output_format: str | None = None, output_format: str | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.""" """One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un
modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER
PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout
(des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après
`self._timeout` secondes, quoi qu'il arrive.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter"
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
provider: str,
) -> str:
"""Collecte le stream avec DEUX garde-fous au temps écoulé :
- 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué
en file d'attente (que des keep-alive, aucun contenu) → échec rapide ;
- ceiling global (`self._timeout`) : génération qui ne se termine jamais.
Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des
octets' et empêchent son read-timeout de se déclencher.
"""
async def _collect() -> str:
chunks: list[str] = [] chunks: list[str] = []
async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature): agen = self._stream(messages, None, temperature, output_format)
try:
while True:
# Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite
# on laisse générer (le ceiling global couvre le reste).
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur {provider} : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
"probablement en file d'attente / saturé. Réessayez plus tard ou "
"choisissez un autre modèle (1min.ai, ou payant)."
)
chunks.append(token) chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks) return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
async def stream_chat( async def stream_chat(
self, self,
messages: list[ChatMessage], messages: list[ChatMessage],
@@ -72,6 +133,7 @@ class OpenRouterLLMProvider:
messages: list[ChatMessage], messages: list[ChatMessage],
system_prompt: str | None, system_prompt: str | None,
temperature: float | None, temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = [] payload_messages: list[dict[str, str]] = []
if system_prompt: if system_prompt:
@@ -86,6 +148,10 @@ class OpenRouterLLMProvider:
} }
if temperature is not None: if temperature is not None:
body["temperature"] = temperature body["temperature"] = temperature
# NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/
# providers GRATUITS ne le supportent pas et renvoient une réponse VIDE.
# On laisse le modèle répondre librement ; l'extraction JSON en aval
# (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:

View File

@@ -0,0 +1,109 @@
"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde.
Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]}
À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en
Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle.
"""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
_STORE_DIR = Path("data/notebooks")
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
def _path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.json"
def save(
source_id: str,
chunks: list[str],
vectors: list[list[float]],
pages: list[int] | None = None,
) -> int:
"""Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits."""
if len(chunks) != len(vectors):
raise ValueError("chunks et vectors de tailles différentes")
if pages is not None and len(pages) != len(chunks):
raise ValueError("pages et chunks de tailles différentes")
_STORE_DIR.mkdir(parents=True, exist_ok=True)
items = []
for i, (c, v) in enumerate(zip(chunks, vectors)):
item = {"text": c, "vector": v}
if pages is not None:
item["page"] = pages[i]
items.append(item)
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
_path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return len(chunks)
def exists(source_id: str) -> bool:
return _path(source_id).exists()
def delete(source_id: str) -> None:
_path(source_id).unlink(missing_ok=True)
def _load(source_id: str) -> list[dict]:
p = _path(source_id)
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return data.get("chunks", []) if isinstance(data, dict) else []
def all_chunks(source_id: str) -> list[dict]:
"""Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode
« analyse approfondie » (map-reduce sur tout le document)."""
return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)]
def _cosine(a: list[float], b: list[float]) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = 0.0
na = 0.0
nb = 0.0
for x, y in zip(a, b):
dot += x * y
na += x * x
nb += y * y
if na == 0.0 or nb == 0.0:
return 0.0
return dot / (math.sqrt(na) * math.sqrt(nb))
def search(
source_ids: list[str],
query_vector: list[float],
top_k: int = 6,
) -> list[dict]:
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
Chaque résultat : {"text": str, "score": float, "source_id": str}.
"""
scored: list[dict] = []
for sid in source_ids:
for chunk in _load(sid):
vector = chunk.get("vector") or []
score = _cosine(query_vector, vector)
scored.append({
"text": chunk.get("text", ""),
"score": score,
"source_id": sid,
"page": chunk.get("page"),
})
scored.sort(key=lambda c: c["score"], reverse=True)
return scored[:top_k]

View File

@@ -4,7 +4,9 @@ Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
au domaine via injection de dépendance (ports + use cases), et transforme les au domaine via injection de dépendance (ports + use cases), et transforme les
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici. erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
""" """
import asyncio
import json import json
import logging
from typing import Annotated, AsyncIterator, Literal from typing import Annotated, AsyncIterator, Literal
import hmac import hmac
@@ -14,11 +16,22 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import re
from app.application.adapt_campaign import AdaptCampaignUseCase from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.chat import ChatUseCase from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase from app.application.generate_page import GeneratePageUseCase
from app.application.import_campaign import ImportCampaignUseCase from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase from app.application.import_rules import ImportRulesUseCase
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.application.notebook_rag import NotebookRagUseCase
from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase
from app.application.embeddings import EmbeddingError
from app.infrastructure import vector_store
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides from app.core.settings_store import save_overrides
from app.domain.models import ( from app.domain.models import (
@@ -46,14 +59,18 @@ from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.10.3-beta", version="0.11.0-beta",
) )
logger = logging.getLogger(__name__)
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup # Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer # à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
@@ -357,6 +374,10 @@ def get_llm_provider(
return OneMinAiLLMProvider(settings) return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter": if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings) return OpenRouterLLMProvider(settings)
if settings.llm_provider == "mistral":
return MistralLLMProvider(settings)
if settings.llm_provider == "gemini":
return GeminiLLMProvider(settings)
return OllamaLLMProvider(settings) return OllamaLLMProvider(settings)
except LLMProviderError as exc: except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
@@ -416,6 +437,38 @@ def get_adapt_campaign_use_case(
llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens) llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens)
def get_embedding_provider(
settings: Annotated[Settings, Depends(get_settings)],
):
"""Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`."""
try:
if settings.embedding_provider == "mistral":
return MistralEmbeddingProvider(settings)
return OllamaEmbeddingProvider(settings)
except EmbeddingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_notebook_rag_use_case(
embedder: Annotated[object, Depends(get_embedding_provider)],
) -> NotebookRagUseCase:
return NotebookRagUseCase(extractor=_PDF_EXTRACTOR, embedder=embedder) # type: ignore[arg-type]
def get_notebook_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
) -> NotebookChatUseCase:
return NotebookChatUseCase(rag=rag, llm=llm) # type: ignore[arg-type]
def get_notebook_deep_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookDeepUseCase:
return NotebookDeepUseCase(llm=llm, batch_tokens=settings.import_chunk_tokens)
# --- Endpoints --- # --- Endpoints ---
@@ -425,6 +478,54 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "brain"} return {"status": "ok", "service": "brain"}
@app.on_event("startup")
async def _auto_install_embedding_model() -> None:
"""Au démarrage : si le provider d'embeddings est Ollama et que le modèle n'est
pas installé, on le télécharge EN ARRIÈRE-PLAN → le RAG marche d'emblée pour un
nouvel utilisateur, sans bloquer le démarrage du Brain. Best-effort (Ollama peut
être absent / la connexion limitée) ; désactivable via `auto_pull_embedding_model`.
"""
settings = get_settings()
if not settings.auto_pull_embedding_model or settings.embedding_provider != "ollama":
return
asyncio.create_task(_ensure_ollama_embedding_model(settings.ollama_base_url, settings.ollama_embedding_model))
async def _ensure_ollama_embedding_model(base_url: str, model: str) -> None:
# Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis
# vérifie la présence du modèle avant de le tirer.
for attempt in range(10):
try:
async with httpx.AsyncClient(timeout=10) as client:
tags = await client.get(f"{base_url}/api/tags")
tags.raise_for_status()
names = [m.get("name", "") for m in tags.json().get("models", [])]
if any(n == model or n.startswith(model + ":") for n in names):
logger.info("Modèle d'embedding '%s' déjà présent.", model)
return
break # Ollama joignable, modèle absent → on tire (ci-dessous)
except httpx.HTTPError:
await asyncio.sleep(min(5 * (attempt + 1), 30))
else:
logger.warning(
"Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé "
"(il sera tirable manuellement : ollama pull %s).", model, model)
return
logger.info("Téléchargement automatique du modèle d'embedding '%s'", model)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp:
resp.raise_for_status()
async for _line in resp.aiter_lines():
pass # on draine la progression NDJSON jusqu'à la fin
logger.info("Modèle d'embedding '%s' prêt.", model)
except httpx.HTTPError as exc:
logger.warning(
"Auto-installation du modèle d'embedding '%s' échouée : %s "
"(tirage manuel possible : ollama pull %s).", model, exc, model)
@app.post("/generate", response_model=GenerateResponse) @app.post("/generate", response_model=GenerateResponse)
async def generate( async def generate(
body: GenerateRequest, body: GenerateRequest,
@@ -555,6 +656,11 @@ async def import_rules_stream(
yield _sse("error", {"message": str(exc)}) yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc: except LLMProviderError as exc:
yield _sse("error", {"message": str(exc)}) yield _sse("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit
# PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique
# sans détail). On la transforme en évènement `error` propre + log avec trace.
logger.exception("Import règles : erreur inattendue dans le flux.")
yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
@@ -590,6 +696,10 @@ async def import_campaign_stream(
yield _sse("error", {"message": str(exc)}) yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc: except LLMProviderError as exc:
yield _sse("error", {"message": str(exc)}) yield _sse("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas
# une erreur inattendue casser le flux sans détail.
logger.exception("Import campagne : erreur inattendue dans le flux.")
yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream") return StreamingResponse(event_stream(), media_type="text/event-stream")
@@ -773,6 +883,245 @@ async def summarize_conversation_title(
return SummarizeTitleResponseDTO(title=title) return SummarizeTitleResponseDTO(title=title)
# --- Tables aléatoires : génération IA + improvisation -----------------------
_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$")
def _dice_total_range(formula: str) -> tuple[int, int] | None:
"""(min, max) des totaux possibles d'une formule NdM, ou None si invalide."""
match = _DICE_FORMULA_RE.match(formula or "")
if not match:
return None
count = int(match.group(1)) if match.group(1) else 1
faces = int(match.group(2))
if count < 1 or count > 100 or faces < 2 or faces > 10000:
return None
return count, count * faces
class GenerateTableRequestDTO(BaseModel):
description: str
dice_formula: str = Field(default="1d20")
# Contexte libre assemblé par le Core (nom de campagne, système, ambiance…).
context: str = Field(default="")
class GeneratedTableEntryDTO(BaseModel):
min_roll: int
max_roll: int
label: str
detail: str = ""
class GenerateTableResponseDTO(BaseModel):
name: str
description: str = ""
entries: list[GeneratedTableEntryDTO]
@app.post("/generate/random-table", response_model=GenerateTableResponseDTO)
async def generate_random_table(
body: GenerateTableRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateTableResponseDTO:
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
rng = _dice_total_range(body.dice_formula)
if rng is None:
raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).")
lo, hi = rng
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else ""
prompt = (
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n"
f"Sujet : {body.description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "entries": '
'[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n'
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
"sans trou ni chevauchement, dans l'ordre croissant.\n"
"- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
"Renvoie maintenant le JSON."
)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de table exploitable.")
entries: list[GeneratedTableEntryDTO] = []
for e in parsed.get("entries", []) or []:
if not isinstance(e, dict):
continue
try:
mn = int(e["min_roll"])
mx = int(e["max_roll"])
except (KeyError, TypeError, ValueError):
continue
label = str(e.get("label") or "").strip()
if not label:
continue
entries.append(GeneratedTableEntryDTO(
min_roll=mn, max_roll=max(mn, mx), label=label[:200],
detail=str(e.get("detail") or "").strip(),
))
if not entries:
raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.")
name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée"
return GenerateTableResponseDTO(
name=name,
description=str(parsed.get("description") or "").strip(),
entries=entries,
)
class ImproviseRollRequestDTO(BaseModel):
table_name: str
result_label: str
result_detail: str = Field(default="")
context: str = Field(default="")
class ImproviseRollResponseDTO(BaseModel):
narration: str
@app.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO)
async def improvise_table_roll(
body: ImproviseRollRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> ImproviseRollResponseDTO:
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else ""
context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else ""
prompt = (
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}."
f"{context_block}\n\n"
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
"Pas de méta, pas d'options : juste la narration, en français."
)
try:
raw = await llm.generate(prompt, temperature=0.8)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ImproviseRollResponseDTO(narration=raw.strip())
# --- Notebooks (atelier RAG) : indexation des sources + chat ancré ----------
class IndexSourceResponseDTO(BaseModel):
chunks: int
page_count: int
ocr_page_count: int
@app.post("/index/notebook-source", response_model=IndexSourceResponseDTO)
async def index_notebook_source(
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
source_id: str = Form(...),
file: UploadFile = File(...),
) -> IndexSourceResponseDTO:
"""Indexe une source PDF (extraction + embeddings + stockage vectoriel)."""
content = await file.read()
if not content:
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
if len(content) > _MAX_PDF_BYTES:
raise HTTPException(
status_code=413, detail=f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo).")
try:
recap = await rag.index_source(source_id, content)
except PdfExtractionError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except EmbeddingError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return IndexSourceResponseDTO(**recap)
@app.delete("/index/notebook-source/{source_id}")
def delete_notebook_source(source_id: str) -> dict[str, str]:
"""Supprime les vecteurs d'une source (au DELETE d'une source/notebook)."""
vector_store.delete(source_id)
return {"status": "deleted", "source_id": source_id}
class NotebookChatMessageDTO(BaseModel):
role: str
content: str
class NotebookChatRequestDTO(BaseModel):
source_ids: list[str] = Field(default_factory=list)
messages: list[NotebookChatMessageDTO] = Field(default_factory=list)
context: str = Field(default="")
@app.post("/chat/notebook/stream")
async def chat_notebook_stream(
body: NotebookChatRequestDTO,
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis
streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}."""
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
top_k = max(1, min(settings.rag_top_k, 200))
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
try:
async for token in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k):
if token:
yield _sse("token", {"token": token})
yield _sse("done", {})
except (LLMProviderError, EmbeddingError) as exc:
yield _sse("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux.
logger.exception("Chat notebook : erreur inattendue.")
yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/chat/notebook/deep/stream")
async def chat_notebook_deep_stream(
body: NotebookChatRequestDTO,
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
question = next((m.content for m in reversed(body.messages) if m.role == "user"), "")
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not question.strip():
yield _sse("error", {"message": "Question vide."})
return
try:
async for ev in use_case.stream(body.source_ids, question, context=body.context):
ev_type = ev.pop("type")
yield _sse(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc:
yield _sse("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale.
logger.exception("Analyse approfondie : erreur inattendue.")
yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
# --- Mapping DTO → domaine (frontière HTTP) --------------------------------- # --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
@@ -890,7 +1239,7 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture. Les secrets (onemin_api_key) sont masques en lecture.
""" """
llm_provider: Literal["ollama", "onemin", "openrouter"] llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"]
ollama_base_url: str ollama_base_url: str
llm_model: str llm_model: str
onemin_model: str onemin_model: str
@@ -899,6 +1248,18 @@ class SettingsDTO(BaseModel):
openrouter_model: str openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee). # True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
openrouter_api_key_set: bool openrouter_api_key_set: bool
mistral_model: str
# True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee).
mistral_api_key_set: bool
gemini_model: str
# True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee).
gemini_api_key_set: bool
# Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama.
embedding_provider: Literal["ollama", "mistral"]
ollama_embedding_model: str
mistral_embedding_model: str
auto_pull_embedding_model: bool
rag_top_k: int
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI. # aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int llm_num_ctx: int
@@ -911,7 +1272,7 @@ class SettingsDTO(BaseModel):
class SettingsUpdateDTO(BaseModel): class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels.""" """Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None
ollama_base_url: str | None = None ollama_base_url: str | None = None
llm_model: str | None = None llm_model: str | None = None
onemin_model: str | None = None onemin_model: str | None = None
@@ -919,6 +1280,15 @@ class SettingsUpdateDTO(BaseModel):
onemin_api_key: str | None = None onemin_api_key: str | None = None
openrouter_model: str | None = None openrouter_model: str | None = None
openrouter_api_key: str | None = None openrouter_api_key: str | None = None
mistral_model: str | None = None
mistral_api_key: str | None = None
gemini_model: str | None = None
gemini_api_key: str | None = None
embedding_provider: Literal["ollama", "mistral"] | None = None
ollama_embedding_model: str | None = None
mistral_embedding_model: str | None = None
auto_pull_embedding_model: bool | None = None
rag_top_k: int | None = None
llm_num_ctx: int | None = None llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None llm_timeout_seconds: int | None = None
@@ -933,6 +1303,15 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
onemin_api_key_set=bool(s.onemin_api_key), onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model, openrouter_model=s.openrouter_model,
openrouter_api_key_set=bool(s.openrouter_api_key), openrouter_api_key_set=bool(s.openrouter_api_key),
mistral_model=s.mistral_model,
mistral_api_key_set=bool(s.mistral_api_key),
gemini_model=s.gemini_model,
gemini_api_key_set=bool(s.gemini_api_key),
embedding_provider=s.embedding_provider,
ollama_embedding_model=s.ollama_embedding_model,
mistral_embedding_model=s.mistral_embedding_model,
auto_pull_embedding_model=s.auto_pull_embedding_model,
rag_top_k=s.rag_top_k,
llm_num_ctx=s.llm_num_ctx, llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens, import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds, llm_timeout_seconds=s.llm_timeout_seconds,
@@ -1138,6 +1517,99 @@ async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
return {"models": models} return {"models": models}
# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est
# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee
# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`.
_MISTRAL_FALLBACK_MODELS = [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
"ministral-8b-latest",
"ministral-3b-latest",
"magistral-medium-latest",
"magistral-small-latest",
"pixtral-large-latest",
"codestral-latest",
]
@app.get("/models/mistral")
async def list_mistral_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[dict[str, object]]]:
"""Catalogue des modeles Mistral. Dynamique si une cle est configuree
(GET /v1/models, qui requiert l'auth), sinon repli statique.
Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment)."""
key = settings.mistral_api_key
if not key:
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
"https://api.mistral.ai/v1/models",
headers={"Authorization": f"Bearer {key}"},
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
# Cle invalide / API down : on ne casse pas l'UI, on propose le repli.
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")})
if not ids:
ids = _MISTRAL_FALLBACK_MODELS
return {"models": [{"id": i} for i in ids]}
# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand
# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux.
_GEMINI_FALLBACK_MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
"gemini-1.5-flash",
"gemini-1.5-pro",
]
@app.get("/models/gemini")
async def list_gemini_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[dict[str, object]]]:
"""Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint
OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}."""
key = settings.gemini_api_key
if not key:
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
"https://generativelanguage.googleapis.com/v1beta/openai/models",
headers={"Authorization": f"Bearer {key}"},
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
# Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur
# selectionnee soit directement utilisable dans l'appel chat. On garde les
# modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste.
ids: set[str] = set()
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if mid.startswith("models/"):
mid = mid[len("models/"):]
if mid.startswith("gemini-"):
ids.add(mid)
clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS
return {"models": [{"id": i} for i in clean]}
@app.get("/models/onemin") @app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]: def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur. """Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.10.3-beta</version> <version>0.11.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -1,46 +1,32 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
* Service applicatif : conseils d'adaptation d'un PDF à une campagne existante. * Service applicatif : conseils d'adaptation d'un PDF à une campagne existante.
* *
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) — la même * <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) via
* matière que le chat de campagne — et délègue la génération streamée au Brain via * {@link CampaignBriefBuilder} et délègue la génération streamée au Brain via
* {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.</p> * {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.</p>
*/ */
@Service @Service
public class CampaignAdaptService { public class CampaignAdaptService {
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder; private final CampaignBriefBuilder briefBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final CampaignPdfAdvisor advisor; private final CampaignPdfAdvisor advisor;
public CampaignAdaptService( public CampaignAdaptService(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder, CampaignBriefBuilder briefBuilder,
LoreStructuralContextBuilder loreContextBuilder,
CampaignPdfAdvisor advisor) { CampaignPdfAdvisor advisor) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder; this.briefBuilder = briefBuilder;
this.loreContextBuilder = loreContextBuilder;
this.advisor = advisor; this.advisor = advisor;
} }
@@ -55,64 +41,6 @@ public class CampaignAdaptService {
Campaign campaign = campaignRepository.findById(campaignId) Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId)); .orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId));
advisor.adviseStreaming( advisor.adviseStreaming(
pdfBytes, filename, buildBrief(campaign), messagesJson, onToken, onComplete, onError); pdfBytes, filename, briefBuilder.build(campaign), messagesJson, onToken, onComplete, onError);
}
/** Construit un résumé markdown de la campagne (structure + PNJ + lore). */
private String buildBrief(Campaign campaign) {
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
StringBuilder sb = new StringBuilder();
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
sb.append("\n");
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
sb.append("### ").append(entry.getKey()).append("\n");
for (PageSummary page : entry.getValue()) {
sb.append("- ").append(page.title()).append("\n");
}
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
} }
} }

View File

@@ -0,0 +1,93 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Construit un résumé markdown d'une campagne (structure arcs→chapitres→scènes +
* PNJ + univers/lore). Partagé par les fonctions IA qui doivent « voir » la
* campagne : conseils d'adaptation PDF (CampaignAdaptService) et ateliers RAG
* (NotebookService). Centralisé ici pour une seule source de vérité.
*/
@Service
public class CampaignBriefBuilder {
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
public CampaignBriefBuilder(
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder) {
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
}
public String build(Campaign campaign) {
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
StringBuilder sb = new StringBuilder();
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
sb.append("\n");
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
sb.append("### ").append(entry.getKey()).append("\n");
for (PageSummary page : entry.getValue()) {
sb.append("- ").append(page.title()).append("\n");
}
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}

View File

@@ -0,0 +1,132 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Service d'application des notebooks (atelier RAG) : CRUD, indexation des sources
* (déléguée au Brain), conversation persistée, et assemblage du contexte campagne.
*/
@Service
public class NotebookService {
private final NotebookRepository repository;
private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder;
public NotebookService(
NotebookRepository repository,
NotebookIndexer indexer,
CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) {
this.repository = repository;
this.indexer = indexer;
this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder;
}
// --- Notebooks ---
public Notebook createNotebook(String campaignId, String name) {
String safeName = (name == null || name.isBlank()) ? "Nouvel atelier" : name.trim();
return repository.save(Notebook.builder().campaignId(campaignId).name(safeName).build());
}
public java.util.Optional<Notebook> getNotebook(String id) {
return repository.findById(id);
}
public List<Notebook> getNotebooksByCampaign(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public Notebook renameNotebook(String id, String name) {
Notebook nb = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Notebook introuvable: " + id));
nb.setName((name == null || name.isBlank()) ? nb.getName() : name.trim());
return repository.save(nb);
}
public void deleteNotebook(String id) {
// Supprime les vecteurs de chaque source côté Brain (best-effort) avant la BDD.
for (NotebookSource s : repository.findSourcesByNotebookId(id)) {
indexer.delete(s.getId());
}
repository.deleteById(id);
}
// --- Sources ---
public List<NotebookSource> getSources(String notebookId) {
return repository.findSourcesByNotebookId(notebookId);
}
/**
* Ajoute une source : crée la ligne (INDEXING), lance l'indexation Brain, puis
* met à jour (READY + compteurs) ou (FAILED) en cas d'échec — et relaie l'erreur.
*/
public NotebookSource addSource(String notebookId, String filename, byte[] pdfBytes) {
if (!repository.existsById(notebookId)) {
throw new IllegalArgumentException("Notebook introuvable: " + notebookId);
}
NotebookSource source = repository.saveSource(NotebookSource.builder()
.notebookId(notebookId)
.filename(filename != null && !filename.isBlank() ? filename : "source.pdf")
.status("INDEXING")
.build());
try {
NotebookIndexer.IndexResult result = indexer.index(source.getId(), pdfBytes, filename);
source.setStatus("READY");
source.setChunkCount(result.chunks());
source.setPageCount(result.pageCount());
return repository.saveSource(source);
} catch (RuntimeException e) {
source.setStatus("FAILED");
repository.saveSource(source);
throw e;
}
}
public void deleteSource(String sourceId) {
repository.findSourceById(sourceId).ifPresent(s -> indexer.delete(s.getId()));
repository.deleteSourceById(sourceId);
}
public List<String> readySourceIds(String notebookId) {
return repository.findSourcesByNotebookId(notebookId).stream()
.filter(s -> "READY".equals(s.getStatus()))
.map(NotebookSource::getId)
.toList();
}
// --- Conversation ---
public List<NotebookMessage> getMessages(String notebookId) {
return repository.findMessagesByNotebookId(notebookId);
}
public NotebookMessage addMessage(String notebookId, String role, String content) {
return repository.saveMessage(NotebookMessage.builder()
.notebookId(notebookId).role(role).content(content).build());
}
// --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
* l'IA « voit » la campagne, pas seulement son nom. */
public String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
return briefBuilder.build(campaign);
}
}

View File

@@ -0,0 +1,133 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Service d'application pour les tables aléatoires (campagne).
*/
@Service
public class RandomTableService {
private final RandomTableRepository repository;
private final RandomTableGenerator generator;
private final CampaignRepository campaignRepository;
private final GameSystemRepository gameSystemRepository;
public RandomTableService(
RandomTableRepository repository,
RandomTableGenerator generator,
CampaignRepository campaignRepository,
GameSystemRepository gameSystemRepository) {
this.repository = repository;
this.generator = generator;
this.campaignRepository = campaignRepository;
this.gameSystemRepository = gameSystemRepository;
}
public record TableData(
String name,
String description,
String diceFormula,
String icon,
List<RandomTableEntry> entries,
String campaignId,
Integer order
) {}
public RandomTable createTable(TableData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
RandomTable table = RandomTable.builder()
.name(data.name())
.description(data.description())
.diceFormula(data.diceFormula())
.icon(data.icon())
.entries(copyEntries(data.entries()))
.campaignId(data.campaignId())
.order(order)
.build();
return repository.save(table);
}
public Optional<RandomTable> getTableById(String id) {
return repository.findById(id);
}
public List<RandomTable> getTablesByCampaignId(String campaignId) {
return repository.findByCampaignId(campaignId);
}
public RandomTable updateTable(String id, TableData data) {
RandomTable existing = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Table aléatoire introuvable: " + id));
existing.setName(data.name());
existing.setDescription(data.description());
existing.setDiceFormula(data.diceFormula());
existing.setIcon(data.icon());
existing.setEntries(copyEntries(data.entries()));
if (data.order() != null) {
existing.setOrder(data.order());
}
return repository.save(existing);
}
public void deleteTable(String id) {
repository.deleteById(id);
}
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId));
return RandomTable.builder()
.name(g.name())
.description(g.description())
.diceFormula(formula)
.campaignId(campaignId)
.entries(g.entries() != null ? new ArrayList<>(g.entries()) : new ArrayList<>())
.build();
}
/** Brode un court récit IA sur un résultat tiré (pour la partie). */
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId));
}
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
private String buildContext(String campaignId) {
if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return "";
StringBuilder sb = new StringBuilder();
sb.append("Campagne : ").append(campaign.getName());
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
sb.append("").append(campaign.getDescription().trim());
}
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
gameSystemRepository.findById(campaign.getGameSystemId())
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
}
return sb.toString();
}
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {
return entries != null ? new ArrayList<>(entries) : new ArrayList<>();
}
private int nextOrderFor(String campaignId) {
return repository.findByCampaignId(campaignId).stream()
.mapToInt(RandomTable::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Atelier d'adaptation (« notebook ») d'une campagne : une ou plusieurs sources
* PDF indexées (RAG) + une conversation, persistés pour y revenir.
* <p>
* Les SOURCES ({@link NotebookSource}) et les MESSAGES ({@link NotebookMessage})
* sont gérés comme entités liées par {@code notebookId} (chargées séparément).
*/
@Data
@Builder
public class Notebook {
private String id;
private String name;
private String campaignId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Un message de la conversation d'un {@link Notebook}. {@code role} = "user" ou
* "assistant". Persisté pour recharger l'historique de l'atelier.
*/
@Data
@Builder
public class NotebookMessage {
private String id;
private String notebookId;
private String role;
private String content;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,24 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Une source (PDF) d'un {@link Notebook}. Son {@code id} sert de clé d'indexation
* vectorielle côté Brain (les vecteurs vivent sur le volume du Brain).
* <p>
* {@code status} : INDEXING (en cours), READY (interrogeable), FAILED (échec).
*/
@Data
@Builder
public class NotebookSource {
private String id;
private String notebookId;
private String filename;
private String status;
private int chunkCount;
private int pageCount;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,50 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Table aléatoire d'une campagne : on jette un dé ({@code diceFormula}) et la
* valeur tombée désigne une {@link RandomTableEntry} (par sa plage) → un résultat.
* <p>
* Outil MJ classique (rencontres, butin, complications, noms…). Le JET lui-même
* est effectué côté client (instantané, comme le panneau de dés) ; le domaine ne
* fait que stocker la table et ses entrées. Scope campagne (cross-aggregate via ID).
*/
@Data
@Builder
public class RandomTable {
private String id;
private String name;
/** Description libre (à quoi sert la table). Nullable. */
private String description;
/** Formule du dé à lancer : "1d20", "2d6", "d100"… */
private String diceFormula;
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
private String icon;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste des tables de la campagne. */
private int order;
/** Entrées ordonnées (par plage de jet). Jamais null après construction. */
private List<RandomTableEntry> entries;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public List<RandomTableEntry> getEntries() {
if (entries == null) entries = new ArrayList<>();
return entries;
}
}

View File

@@ -0,0 +1,29 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
/**
* Une entrée d'une {@link RandomTable} : une PLAGE de jet (minRoll..maxRoll, bornes
* incluses) qui mappe vers un résultat. Les plages permettent les tables PONDÉRÉES
* (un résultat couvrant 110 est plus probable qu'un couvrant 1112).
* <p>
* Value object possédé par la table (pas d'identité propre côté domaine) : à chaque
* mise à jour, les entrées sont remplacées en bloc.
*/
@Data
@Builder
public class RandomTableEntry {
/** Borne basse du jet (incluse). */
private int minRoll;
/** Borne haute du jet (incluse). Pour une entrée unitaire, min == max. */
private int maxRoll;
/** Résultat court affiché (ex. "Embuscade de gobelins"). */
private String label;
/** Détail markdown : « ce que c'est » (effet, description). Nullable. */
private String detail;
}

View File

@@ -0,0 +1,36 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.List;
import java.util.function.Consumer;
/**
* Port de sortie : chat ANCRÉ (RAG) sur les sources d'un notebook, streamé.
* Le Brain récupère les passages pertinents puis streame la réponse token par token.
*/
public interface NotebookChatStreamer {
/** Un message de la conversation transmis au Brain. */
record Msg(String role, String content) {}
/** Avancement de l'analyse approfondie (lecture du document par lots). */
record Progress(int current, int total) {}
/**
* Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil
* de l'eau : {@code onToken} par fragment, {@code onProgress} (mode approfondi
* uniquement) pendant la lecture du document, {@code onDone} à la fin,
* {@code onError} en cas d'échec.
*
* @param deep true = analyse approfondie (map-reduce sur tout le document) ;
* false = chat RAG (top-k).
*/
void stream(
List<String> sourceIds,
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle…).
* Mappée en HTTP 502 par le contrôleur.
*/
public class NotebookException extends RuntimeException {
public NotebookException(String message) {
super(message);
}
public NotebookException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Port de sortie : indexation RAG d'une source de notebook (déléguée au Brain).
* Les vecteurs vivent côté Brain, keyés par {@code sourceId}.
*/
public interface NotebookIndexer {
/** Récapitulatif d'indexation renvoyé par le Brain. */
record IndexResult(int chunks, int pageCount, int ocrPageCount) {}
/** Indexe une source (extraction + embeddings + stockage vectoriel). */
IndexResult index(String sourceId, byte[] pdfBytes, String filename);
/** Supprime les vecteurs d'une source (au DELETE d'une source/notebook). */
void delete(String sourceId);
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des notebooks (atelier), de leurs sources
* et de leur conversation. Port unique (3 agrégats liés) pour rester compact.
*/
public interface NotebookRepository {
// --- Notebook ---
Notebook save(Notebook notebook);
Optional<Notebook> findById(String id);
List<Notebook> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
// --- Sources ---
NotebookSource saveSource(NotebookSource source);
Optional<NotebookSource> findSourceById(String id);
List<NotebookSource> findSourcesByNotebookId(String notebookId);
void deleteSourceById(String id);
// --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message);
List<NotebookMessage> findMessagesByNotebookId(String notebookId);
}

View File

@@ -0,0 +1,15 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du
* modèle, réponse inexploitable…). Mappée en HTTP 502 par le contrôleur.
*/
public class RandomTableGenerationException extends RuntimeException {
public RandomTableGenerationException(String message) {
super(message);
}
public RandomTableGenerationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import java.util.List;
/**
* Port de sortie : génération IA d'une table aléatoire et improvisation narrative
* sur un résultat tiré. Implémenté par un client du Brain (service IA Python).
*/
public interface RandomTableGenerator {
/** Table proposée (non persistée) à partir d'une description + formule de dé. */
record GeneratedTable(String name, String description, List<RandomTableEntry> entries) {}
/**
* Génère une proposition de table couvrant la formule de dé, sur le sujet
* donné, en s'appuyant sur le contexte (campagne, système…) s'il est fourni.
*/
GeneratedTable generate(String description, String diceFormula, String context);
/**
* Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.
*/
String improvise(String tableName, String resultLabel, String resultDetail, String context);
}

View File

@@ -0,0 +1,22 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.RandomTable;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des {@link RandomTable}.
*/
public interface RandomTableRepository {
RandomTable save(RandomTable table);
Optional<RandomTable> findById(String id);
List<RandomTable> findByCampaignId(String campaignId);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -90,8 +90,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
} }
} catch (Exception e) { } catch (Exception e) {
if (!terminated[0]) { if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic
// est impossible (timeout WebClient, connexion coupée, réponse non-2xx…).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain.", e)); "Erreur lors du streaming d'import depuis le Brain : " + cause, e));
} }
} }
} }

View File

@@ -0,0 +1,139 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
* Pattern identique aux imports streamés (cf. BrainRulesImportClient).
*/
@Component
public class BrainNotebookChatClient implements NotebookChatStreamer {
private static final String PATH = "/chat/notebook/stream";
private static final String DEEP_PATH = "/chat/notebook/deep/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long timeoutSeconds;
public BrainNotebookChatClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds;
}
@Override
public void stream(
List<String> sourceIds,
List<Msg> messages,
String context,
boolean deep,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("source_ids", sourceIds);
payload.put("messages", messages.stream()
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
.collect(Collectors.toList()));
payload.put("context", context == null ? "" : context);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload)
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onDone.run(); // flux terminé sans event done explicite
}
} catch (Exception e) {
if (!terminated[0]) {
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new NotebookException(
"Erreur lors du streaming du chat depuis le Brain : " + cause, e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onToken,
Consumer<Progress> onProgress,
Runnable onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
} else if ("progress".equals(event)) {
onProgress.accept(new Progress(readInt(data, "current"), readInt(data, "total")));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.run();
} else if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new NotebookException("Le Brain a signalé une erreur : " + readMessage(data)));
}
}
private int readInt(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asInt() : 0;
} catch (Exception e) {
return 0;
}
}
private String readField(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asText() : null;
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
String msg = readField(data, "message");
return msg != null ? msg : data;
}
}

View File

@@ -0,0 +1,100 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
/**
* Adapter de sortie : indexe une source de notebook via le Brain (multipart,
* one-shot bloquant — l'indexation d'un livre peut prendre du temps, d'où le
* RestTemplate à timeout long {@code brainImportRestTemplate}).
*/
@Component
public class BrainNotebookIndexClient implements NotebookIndexer {
private static final Logger LOG = LoggerFactory.getLogger(BrainNotebookIndexClient.class);
private static final String INDEX_PATH = "/index/notebook-source";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainNotebookIndexClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public IndexResult index(String sourceId, byte[] pdfBytes, String filename) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("source_id", sourceId);
body.add("file", filePart(pdfBytes, filename));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
IndexResponse resp = restTemplate.postForObject(
baseUrl + INDEX_PATH, entity, IndexResponse.class);
if (resp == null) {
throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation.");
}
return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount);
} catch (ResourceAccessException e) {
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new NotebookException(
"Le Brain a répondu HTTP " + e.getStatusCode().value()
+ " : " + e.getResponseBodyAsString(), e);
} catch (NotebookException e) {
throw e;
} catch (Exception e) {
throw new NotebookException("Erreur inattendue lors de l'indexation via le Brain.", e);
}
}
@Override
public void delete(String sourceId) {
// Best-effort : si le Brain est down, on ne bloque pas la suppression côté Core
// (les vecteurs orphelins seront simplement ignorés / nettoyables plus tard).
try {
restTemplate.delete(baseUrl + INDEX_PATH + "/" + sourceId);
} catch (Exception e) {
LOG.warn("Suppression des vecteurs de la source {} échouée (ignorée) : {}", sourceId, e.getMessage());
}
}
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "source.pdf" : filename;
}
};
}
/** Réponse JSON du Brain (snake_case). */
private static class IndexResponse {
public int chunks;
@JsonProperty("page_count")
public int pageCount;
@JsonProperty("ocr_page_count")
public int ocrPageCount;
}
}

View File

@@ -0,0 +1,120 @@
package com.loremind.infrastructure.ai;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Adapter de sortie : implémente {@link RandomTableGenerator} en appelant le Brain
* (POST one-shot, RestTemplate). Le secret inter-service est injecté par l'intercepteur
* du bean {@code brainRestTemplate}.
*/
@Component
public class BrainRandomTableClient implements RandomTableGenerator {
private static final String GENERATE_PATH = "/generate/random-table";
private static final String IMPROVISE_PATH = "/improvise/table-roll";
private final RestTemplate restTemplate;
private final String baseUrl;
public BrainRandomTableClient(
RestTemplate restTemplate,
@Value("${brain.base-url}") String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
@Override
public GeneratedTable generate(String description, String diceFormula, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("description", description == null ? "" : description);
req.put("dice_formula", diceFormula == null ? "1d20" : diceFormula);
req.put("context", context == null ? "" : context);
Map<String, Object> resp = post(GENERATE_PATH, req);
if (resp == null) {
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
}
List<RandomTableEntry> entries = new ArrayList<>();
Object rawEntries = resp.get("entries");
if (rawEntries instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
Integer min = asInt(m.get("min_roll"));
Integer max = asInt(m.get("max_roll"));
String label = asString(m.get("label"));
if (min == null || max == null || label == null || label.isBlank()) continue;
entries.add(RandomTableEntry.builder()
.minRoll(min)
.maxRoll(Math.max(min, max))
.label(label)
.detail(asString(m.get("detail")))
.build());
}
}
if (entries.isEmpty()) {
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
}
String name = asString(resp.get("name"));
return new GeneratedTable(
name != null && !name.isBlank() ? name : description,
asString(resp.get("description")),
entries);
}
@Override
public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("table_name", tableName == null ? "" : tableName);
req.put("result_label", resultLabel == null ? "" : resultLabel);
req.put("result_detail", resultDetail == null ? "" : resultDetail);
req.put("context", context == null ? "" : context);
Map<String, Object> resp = post(IMPROVISE_PATH, req);
String narration = resp != null ? asString(resp.get("narration")) : null;
return narration != null ? narration : "";
}
@SuppressWarnings("unchecked")
private Map<String, Object> post(String path, Map<String, Object> body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
return restTemplate.postForObject(baseUrl + path, entity, Map.class);
} catch (ResourceAccessException e) {
throw new RandomTableGenerationException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) {
throw new RandomTableGenerationException(
"Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e);
} catch (Exception e) {
throw new RandomTableGenerationException("Erreur inattendue lors de l'appel au Brain.", e);
}
}
private static Integer asInt(Object o) {
if (o instanceof Number n) return n.intValue();
if (o instanceof String s) {
try { return Integer.parseInt(s.trim()); } catch (NumberFormatException ignored) { return null; }
}
return null;
}
private static String asString(Object o) {
return o != null ? o.toString() : null;
}
}

View File

@@ -148,8 +148,13 @@ public class BrainRulesImportClient implements RulesPdfImporter {
} }
} catch (Exception e) { } catch (Exception e) {
if (!terminated[0]) { if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un
// message générique et le diagnostic est impossible (timeout WebClient,
// connexion coupée, réponse non-2xx du Brain, etc.).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new RulesImportException( onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain.", e)); "Erreur lors du streaming d'import depuis le Brain : " + cause, e));
} }
} }
} }

View File

@@ -0,0 +1,47 @@
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;
@Entity
@Table(name = "notebooks", indexes = {
@Index(name = "idx_notebooks_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,41 @@
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;
@Entity
@Table(name = "notebook_messages", indexes = {
@Index(name = "idx_notebook_messages_notebook_id", columnList = "notebook_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookMessageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "notebook_id", nullable = false)
private Long notebookId;
@Column(nullable = false, length = 16)
private String role;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,47 @@
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;
@Entity
@Table(name = "notebook_sources", indexes = {
@Index(name = "idx_notebook_sources_notebook_id", columnList = "notebook_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotebookSourceJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "notebook_id", nullable = false)
private Long notebookId;
@Column(nullable = false)
private String filename;
@Column(nullable = false, length = 16)
private String status;
@Column(name = "chunk_count", nullable = false)
private int chunkCount;
@Column(name = "page_count", nullable = false)
private int pageCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,51 @@
package com.loremind.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* Entité JPA d'une entrée de table aléatoire (enfant de {@link RandomTableJpaEntity}).
* Ordonnée par {@code position}. La référence parente est exclue de toString/equals
* pour éviter les récursions infinies (relation bidirectionnelle).
*/
@Entity
@Table(name = "random_table_entries", indexes = {
@Index(name = "idx_random_table_entries_table_id", columnList = "random_table_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RandomTableEntryJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "min_roll", nullable = false)
private int minRoll;
@Column(name = "max_roll", nullable = false)
private int maxRoll;
@Column(nullable = false)
private String label;
@Column(columnDefinition = "TEXT")
private String detail;
/** Position d'affichage dans la table (ordre des entrées). */
@Column(nullable = false)
private int position;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "random_table_id", nullable = false)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private RandomTableJpaEntity randomTable;
}

View File

@@ -0,0 +1,73 @@
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;
import java.util.ArrayList;
import java.util.List;
/**
* Entité JPA d'une table aléatoire (parent) avec ses entrées ordonnées (enfants).
* Tables créées automatiquement par Hibernate (ddl-auto=update).
*/
@Entity
@Table(name = "random_tables")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RandomTableJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "dice_formula", nullable = false, length = 32)
private String diceFormula;
@Column(length = 64)
private String icon;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@OneToMany(
mappedBy = "randomTable",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@OrderBy("position ASC")
@Builder.Default
private List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,12 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookJpaRepository extends JpaRepository<NotebookJpaEntity, Long> {
List<NotebookJpaEntity> findByCampaignIdOrderByUpdatedAtDesc(Long campaignId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotebookSourceJpaRepository extends JpaRepository<NotebookSourceJpaEntity, Long> {
List<NotebookSourceJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId);
}

View File

@@ -0,0 +1,13 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
}

View File

@@ -0,0 +1,155 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookMessage;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NotebookJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NotebookMessageJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NotebookSourceJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresNotebookRepository implements NotebookRepository {
private final NotebookJpaRepository notebookJpa;
private final NotebookSourceJpaRepository sourceJpa;
private final NotebookMessageJpaRepository messageJpa;
public PostgresNotebookRepository(
NotebookJpaRepository notebookJpa,
NotebookSourceJpaRepository sourceJpa,
NotebookMessageJpaRepository messageJpa) {
this.notebookJpa = notebookJpa;
this.sourceJpa = sourceJpa;
this.messageJpa = messageJpa;
}
// --- Notebook ---
@Override
public Notebook save(Notebook notebook) {
NotebookJpaEntity entity = notebook.getId() != null
? notebookJpa.findById(Long.parseLong(notebook.getId())).orElseGet(NotebookJpaEntity::new)
: new NotebookJpaEntity();
entity.setName(notebook.getName());
entity.setCampaignId(Long.parseLong(notebook.getCampaignId()));
return toNotebook(notebookJpa.save(entity));
}
@Override
public Optional<Notebook> findById(String id) {
return notebookJpa.findById(Long.parseLong(id)).map(this::toNotebook);
}
@Override
public List<Notebook> findByCampaignId(String campaignId) {
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
.map(this::toNotebook).collect(Collectors.toList());
}
@Override
@Transactional
public void deleteById(String id) {
Long nid = Long.parseLong(id);
messageJpa.deleteByNotebookId(nid);
sourceJpa.deleteByNotebookId(nid);
notebookJpa.deleteById(nid);
}
@Override
public boolean existsById(String id) {
return notebookJpa.existsById(Long.parseLong(id));
}
// --- Sources ---
@Override
public NotebookSource saveSource(NotebookSource source) {
NotebookSourceJpaEntity entity = source.getId() != null
? sourceJpa.findById(Long.parseLong(source.getId())).orElseGet(NotebookSourceJpaEntity::new)
: new NotebookSourceJpaEntity();
entity.setNotebookId(Long.parseLong(source.getNotebookId()));
entity.setFilename(source.getFilename());
entity.setStatus(source.getStatus());
entity.setChunkCount(source.getChunkCount());
entity.setPageCount(source.getPageCount());
return toSource(sourceJpa.save(entity));
}
@Override
public Optional<NotebookSource> findSourceById(String id) {
return sourceJpa.findById(Long.parseLong(id)).map(this::toSource);
}
@Override
public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toSource).collect(Collectors.toList());
}
@Override
public void deleteSourceById(String id) {
sourceJpa.deleteById(Long.parseLong(id));
}
// --- Messages ---
@Override
public NotebookMessage saveMessage(NotebookMessage message) {
NotebookMessageJpaEntity entity = NotebookMessageJpaEntity.builder()
.notebookId(Long.parseLong(message.getNotebookId()))
.role(message.getRole())
.content(message.getContent())
.build();
return toMessage(messageJpa.save(entity));
}
@Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
// --- Mapping ---
private Notebook toNotebook(NotebookJpaEntity e) {
return Notebook.builder()
.id(e.getId().toString())
.name(e.getName())
.campaignId(e.getCampaignId().toString())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private NotebookSource toSource(NotebookSourceJpaEntity e) {
return NotebookSource.builder()
.id(e.getId().toString())
.notebookId(e.getNotebookId().toString())
.filename(e.getFilename())
.status(e.getStatus())
.chunkCount(e.getChunkCount())
.pageCount(e.getPageCount())
.createdAt(e.getCreatedAt())
.build();
}
private NotebookMessage toMessage(NotebookMessageJpaEntity e) {
return NotebookMessage.builder()
.id(e.getId().toString())
.notebookId(e.getNotebookId().toString())
.role(e.getRole())
.content(e.getContent())
.createdAt(e.getCreatedAt())
.build();
}
}

View File

@@ -0,0 +1,106 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
import com.loremind.infrastructure.persistence.entity.RandomTableEntryJpaEntity;
import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity;
import com.loremind.infrastructure.persistence.jpa.RandomTableJpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresRandomTableRepository implements RandomTableRepository {
private final RandomTableJpaRepository jpaRepository;
public PostgresRandomTableRepository(RandomTableJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
@Transactional
public RandomTable save(RandomTable table) {
// Création OU mise à jour : on charge l'entité gérée si elle existe afin que
// le remplacement des entrées (clear + add) déclenche bien orphanRemoval.
RandomTableJpaEntity entity = (table.getId() != null)
? jpaRepository.findById(Long.parseLong(table.getId())).orElseGet(RandomTableJpaEntity::new)
: new RandomTableJpaEntity();
entity.setName(table.getName());
entity.setDescription(table.getDescription());
entity.setDiceFormula(table.getDiceFormula());
entity.setIcon(table.getIcon());
entity.setCampaignId(Long.parseLong(table.getCampaignId()));
entity.setOrder(table.getOrder());
// Remplacement en bloc des entrées (les anciennes sont supprimées via orphanRemoval).
entity.getEntries().clear();
int position = 0;
for (RandomTableEntry e : table.getEntries()) {
entity.getEntries().add(RandomTableEntryJpaEntity.builder()
.minRoll(e.getMinRoll())
.maxRoll(e.getMaxRoll())
.label(e.getLabel())
.detail(e.getDetail())
.position(position++)
.randomTable(entity)
.build());
}
RandomTableJpaEntity saved = jpaRepository.save(entity);
return toDomainEntity(saved);
}
@Override
@Transactional(readOnly = true)
public Optional<RandomTable> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
@Transactional(readOnly = true)
public List<RandomTable> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public boolean existsById(String id) {
return jpaRepository.existsById(Long.parseLong(id));
}
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder()
.minRoll(c.getMinRoll())
.maxRoll(c.getMaxRoll())
.label(c.getLabel())
.detail(c.getDetail())
.build())
.collect(Collectors.toCollection(ArrayList::new));
return RandomTable.builder()
.id(e.getId().toString())
.name(e.getName())
.description(e.getDescription())
.diceFormula(e.getDiceFormula())
.icon(e.getIcon())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.entries(entries)
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,206 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.NotebookService;
import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* REST Controller des notebooks (atelier RAG). CRUD + upload/indexation de sources
* + chat ancré streamé (SSE) qui persiste la conversation.
*/
@RestController
@RequestMapping("/api/notebooks")
public class NotebookController {
private static final long SSE_TIMEOUT_MS = 10 * 60 * 1000L;
private final NotebookService service;
private final NotebookChatStreamer chatStreamer;
private final TaskExecutor taskExecutor;
public NotebookController(
NotebookService service,
NotebookChatStreamer chatStreamer,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
this.service = service;
this.chatStreamer = chatStreamer;
this.taskExecutor = taskExecutor;
}
// --- Notebooks ---
@PostMapping
public ResponseEntity<Notebook> create(@RequestBody CreateRequest req) {
return ResponseEntity.ok(service.createNotebook(req.campaignId(), req.name()));
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<Notebook>> listByCampaign(@PathVariable String campaignId) {
return ResponseEntity.ok(service.getNotebooksByCampaign(campaignId));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> get(@PathVariable String id) {
Notebook nb = service.getNotebook(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"));
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", nb.getId());
out.put("name", nb.getName());
out.put("campaignId", nb.getCampaignId());
out.put("sources", service.getSources(id));
out.put("messages", service.getMessages(id));
return ResponseEntity.ok(out);
}
@PutMapping("/{id}")
public ResponseEntity<Notebook> rename(@PathVariable String id, @RequestBody RenameRequest req) {
return ResponseEntity.ok(service.renameNotebook(id, req.name()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteNotebook(id);
return ResponseEntity.noContent().build();
}
// --- Sources ---
@PostMapping("/{id}/sources")
public ResponseEntity<NotebookSource> addSource(
@PathVariable String id,
@RequestParam("file") MultipartFile file) {
try {
byte[] bytes = file.getBytes();
NotebookSource source = service.addSource(id, file.getOriginalFilename(), bytes);
return ResponseEntity.ok(source);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier illisible", e);
} catch (NotebookException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
@DeleteMapping("/sources/{sourceId}")
public ResponseEntity<Void> deleteSource(@PathVariable String sourceId) {
service.deleteSource(sourceId);
return ResponseEntity.noContent().build();
}
// --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@PathVariable String id, @RequestBody ChatRequest req) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
Notebook nb = service.getNotebook(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"));
String userMessage = req.message() == null ? "" : req.message().trim();
if (userMessage.isEmpty()) {
fail(emitter, new IllegalArgumentException("Message vide."));
return emitter;
}
// Persiste le message utilisateur AVANT le stream (l'historique inclura ce tour).
service.addMessage(id, "user", userMessage);
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList();
List<String> sourceIds = service.readySourceIds(id);
String context = service.buildContext(nb.getCampaignId());
boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> {
StringBuilder assistant = new StringBuilder();
chatStreamer.stream(
sourceIds, history, context, deep,
token -> { assistant.append(token); sendToken(emitter, token); },
progress -> sendProgress(emitter, progress),
() -> {
// Persiste la réponse de l'assistant à la fin du stream.
if (assistant.length() > 0) {
service.addMessage(id, "assistant", assistant.toString());
}
complete(emitter);
},
error -> fail(emitter, error));
});
return emitter;
}
// --- Helpers SSE (mêmes conventions que AiChatController) ---
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void sendProgress(SseEmitter emitter, NotebookChatStreamer.Progress p) {
try {
emitter.send(SseEmitter.event().name("progress")
.data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void complete(SseEmitter emitter) {
try {
emitter.send(SseEmitter.event().name("done").data("{}"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void fail(SseEmitter emitter, Throwable error) {
try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
}
}
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2).append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.append('"').toString();
}
public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {}
}

View File

@@ -0,0 +1,100 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.RandomTableService;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import com.loremind.infrastructure.web.mapper.RandomTableMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/random-tables")
public class RandomTableController {
private final RandomTableService service;
private final RandomTableMapper mapper;
public RandomTableController(RandomTableService service, RandomTableMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<RandomTableDTO> create(@RequestBody RandomTableDTO dto) {
RandomTable created = service.createTable(toData(dto, null));
return ResponseEntity.ok(mapper.toDTO(created));
}
@GetMapping("/{id}")
public ResponseEntity<RandomTableDTO> getById(@PathVariable String id) {
return service.getTableById(id)
.map(t -> ResponseEntity.ok(mapper.toDTO(t)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<RandomTableDTO>> getByCampaign(@PathVariable String campaignId) {
List<RandomTableDTO> dtos = service.getTablesByCampaignId(campaignId).stream()
.map(mapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}")
public ResponseEntity<RandomTableDTO> update(@PathVariable String id, @RequestBody RandomTableDTO dto) {
RandomTable updated = service.updateTable(id, toData(dto, dto.getOrder()));
return ResponseEntity.ok(mapper.toDTO(updated));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.deleteTable(id);
return ResponseEntity.noContent().build();
}
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {
try {
RandomTable proposal = service.generateProposal(req.campaignId(), req.description(), req.diceFormula());
return ResponseEntity.ok(mapper.toDTO(proposal));
} catch (RandomTableGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
/** Improvisation IA d'un court récit sur un résultat tiré (utilisé en partie). */
@PostMapping("/improvise")
public ResponseEntity<Map<String, String>> improvise(@RequestBody ImproviseRequest req) {
try {
String narration = service.improviseRoll(
req.campaignId(), req.tableName(), req.resultLabel(), req.resultDetail());
return ResponseEntity.ok(Map.of("narration", narration));
} catch (RandomTableGenerationException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e);
}
}
public record GenerateRequest(String campaignId, String description, String diceFormula) {}
public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {}
private RandomTableService.TableData toData(RandomTableDTO dto, Integer order) {
return new RandomTableService.TableData(
dto.getName(),
dto.getDescription(),
dto.getDiceFormula(),
dto.getIcon(),
mapper.toDomainEntries(dto.getEntries()),
dto.getCampaignId(),
order
);
}
}

View File

@@ -145,6 +145,16 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/openrouter", null); return forward(HttpMethod.GET, "/models/openrouter", null);
} }
@GetMapping("/models/mistral")
public ResponseEntity<Map<String, Object>> listMistralModels() {
return forward(HttpMethod.GET, "/models/mistral", null);
}
@GetMapping("/models/gemini")
public ResponseEntity<Map<String, Object>> listGeminiModels() {
return forward(HttpMethod.GET, "/models/gemini", null);
}
/** /**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque * Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des * appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des

View File

@@ -0,0 +1,21 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* DTO d'une table aléatoire (avec ses entrées).
*/
@Data
public class RandomTableDTO {
private String id;
private String name;
private String description;
private String diceFormula;
private String icon;
private String campaignId;
private int order;
private List<RandomTableEntryDTO> entries = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data;
/**
* DTO d'une entrée de table aléatoire (plage de jet → résultat).
*/
@Data
public class RandomTableEntryDTO {
private int minRoll;
private int maxRoll;
private String label;
private String detail;
}

View File

@@ -0,0 +1,51 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO;
import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableEntryDTO;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class RandomTableMapper {
public RandomTableDTO toDTO(RandomTable t) {
if (t == null) return null;
RandomTableDTO dto = new RandomTableDTO();
dto.setId(t.getId());
dto.setName(t.getName());
dto.setDescription(t.getDescription());
dto.setDiceFormula(t.getDiceFormula());
dto.setIcon(t.getIcon());
dto.setCampaignId(t.getCampaignId());
dto.setOrder(t.getOrder());
dto.setEntries(t.getEntries().stream().map(this::toEntryDTO).collect(Collectors.toList()));
return dto;
}
public List<RandomTableEntry> toDomainEntries(List<RandomTableEntryDTO> dtos) {
if (dtos == null) return List.of();
return dtos.stream().map(this::toDomainEntry).collect(Collectors.toList());
}
private RandomTableEntryDTO toEntryDTO(RandomTableEntry e) {
RandomTableEntryDTO dto = new RandomTableEntryDTO();
dto.setMinRoll(e.getMinRoll());
dto.setMaxRoll(e.getMaxRoll());
dto.setLabel(e.getLabel());
dto.setDetail(e.getDetail());
return dto;
}
private RandomTableEntry toDomainEntry(RandomTableEntryDTO dto) {
return RandomTableEntry.builder()
.minRoll(dto.getMinRoll())
.maxRoll(dto.getMaxRoll())
.label(dto.getLabel())
.detail(dto.getDetail())
.build();
}
}

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -17,7 +17,6 @@ export const routes: Routes = [
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) }, { path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) },
{ path: 'campaigns/:campaignId/adapt', loadComponent: () => import('./campaigns/campaign/campaign-adapt/campaign-adapt.component').then(m => m.CampaignAdaptComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
@@ -26,6 +25,11 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) },
{ path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) },
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -40,6 +41,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -59,7 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length; this.existingArcCount = treeData.arcs.length;

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -77,6 +78,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -116,7 +118,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -59,6 +60,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -82,7 +84,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -3,10 +3,13 @@ import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service'; import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service'; import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service'; import { NpcService } from '../services/npc.service';
import { RandomTableService } from '../services/random-table.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model'; import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model'; import { Npc } from '../services/npc.model';
import { RandomTable } from '../services/random-table.model';
import { catchError } from 'rxjs/operators';
/** /**
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes) * Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
@@ -22,13 +25,17 @@ export interface CampaignTreeData {
scenesByChapter: Record<string, Scene[]>; scenesByChapter: Record<string, Scene[]>;
characters: Character[]; characters: Character[];
npcs: Npc[]; npcs: Npc[];
randomTables: RandomTable[];
} }
export function loadCampaignTreeData( export function loadCampaignTreeData(
service: CampaignService, service: CampaignService,
campaignId: string, campaignId: string,
characterService: CharacterService, characterService: CharacterService,
npcService: NpcService npcService: NpcService,
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
// tables aléatoires sont chargées et apparaissent dans la sidebar.
randomTableService?: RandomTableService
): Observable<CampaignTreeData> { ): Observable<CampaignTreeData> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie, // Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent // pas à la campagne — on ne les charge plus ici (les vues qui les affichent
@@ -36,11 +43,14 @@ export function loadCampaignTreeData(
return forkJoin({ return forkJoin({
arcs: service.getArcs(campaignId), arcs: service.getArcs(campaignId),
characters: of([] as Character[]), characters: of([] as Character[]),
npcs: npcService.getByCampaign(campaignId) npcs: npcService.getByCampaign(campaignId),
randomTables: randomTableService
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
: of([] as RandomTable[])
}).pipe( }).pipe(
switchMap(({ arcs, characters, npcs }) => { switchMap(({ arcs, characters, npcs, randomTables }) => {
if (arcs.length === 0) { if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs }); return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables });
} }
const chapterCalls = arcs.map(a => const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -55,7 +65,7 @@ export function loadCampaignTreeData(
}); });
if (allChapters.length === 0) { if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs }); return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables });
} }
const sceneCalls = allChapters.map(c => const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -64,7 +74,7 @@ export function loadCampaignTreeData(
map(sceneResults => { map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {}; const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters, npcs }; return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables };
}) })
); );
}) })
@@ -157,7 +167,46 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
}; };
}); });
return [...arcNodes, npcsNode]; const sortedTables = [...(data.randomTables ?? [])].sort(byName);
const tableItems: TreeItem[] = sortedTables.map(t => ({
id: `random-table-${t.id}`,
label: t.name,
iconKey: t.icon ?? 'dice',
route: `/campaigns/${campaignId}/random-tables/${t.id}`
}));
const tablesNode: TreeItem = {
id: 'random-tables-root',
label: 'Tables aléatoires',
iconKey: 'dice',
children: tableItems,
meta: tableItems.length ? String(tableItems.length) : undefined,
sectionHeaderBefore: 'Outils',
createActions: [{
id: 'new-random-table',
label: 'Nouvelle table',
route: `/campaigns/${campaignId}/random-tables/create`,
actionIcon: 'plus'
}]
};
// Lien simple vers les ateliers (la liste se charge sur sa page — pas de fetch ici).
const notebooksNode: TreeItem = {
id: 'notebooks-root',
label: 'Ateliers (IA + PDF)',
iconKey: 'book-open',
route: `/campaigns/${campaignId}/notebooks`
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = {
id: 'import-pdf-root',
label: 'Importer un PDF',
iconKey: 'file-up',
route: `/campaigns/${campaignId}/import`
};
return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode];
} }
/** /**

View File

@@ -1,61 +0,0 @@
<div class="adapt-page">
<div class="page-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<h1>Adapter un PDF à cette campagne</h1>
<p class="subtitle">
L'IA connaît votre campagne (structure, PNJ, univers) et lit le PDF, puis discute avec vous
pour l'intégrer et l'adapter. Ce sont des <strong>conseils</strong> : rien n'est créé,
vous appliquez ce qui vous plaît — et vous pouvez lui répondre pour qu'elle ajuste.
</p>
</div>
<!-- Choix / changement du PDF -->
<section class="upload-bar">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary" [disabled]="streaming" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="15"></lucide-icon>
{{ hasConversation ? 'Changer de PDF' : 'Choisir un PDF à adapter' }}
</button>
<span class="file-name" *ngIf="fileName">{{ fileName }}</span>
</section>
<p class="adapt-error" *ngIf="error">{{ error }}</p>
<!-- Conversation -->
<section class="chat" *ngIf="hasConversation">
<div class="msg" *ngFor="let m of messages; let i = index"
[class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-role">{{ m.role === 'user' ? 'Vous' : 'IA' }}</div>
<div class="msg-body" *ngIf="m.role === 'user'">{{ m.content }}</div>
<div class="msg-body markdown-body" *ngIf="m.role === 'assistant'" [innerHTML]="m.content | markdown"></div>
<div class="msg-actions" *ngIf="m.role === 'assistant' && m.content && !(streaming && i === messages.length - 1)">
<button type="button" class="btn-copy" (click)="copy(i)">
<lucide-icon [img]="copiedIndex === i ? Check : Copy" [size]="12"></lucide-icon>
{{ copiedIndex === i ? 'Copié' : 'Copier' }}
</button>
</div>
<span class="streaming-cursor" *ngIf="streaming && i === messages.length - 1 && m.role === 'assistant'"></span>
</div>
</section>
<!-- Saisie d'un feedback -->
<section class="composer" *ngIf="hasConversation">
<textarea
[(ngModel)]="input"
[disabled]="streaming"
rows="2"
placeholder="Répondez à l'IA : corrigez, demandez une alternative, un autre lieu à intégrer…"
(keydown.enter)="$event.preventDefault(); sendCurrent()"></textarea>
<button type="button" class="btn-send" [disabled]="streaming || !input.trim()" (click)="sendCurrent()">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</section>
</div>

View File

@@ -1,184 +0,0 @@
.adapt-page {
padding: 2rem 2.5rem;
max-width: 900px;
}
.page-header {
margin-bottom: 1.25rem;
h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; }
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
&:hover { color: #c4b5fd; }
}
.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; strong { color: #d1d5db; } }
// --- Barre PDF --------------------------------------------------------------
.upload-bar {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.6; cursor: progress; }
}
.file-name { color: #9ca3af; font-size: 0.82rem; }
.adapt-error {
margin: 0 0 1rem;
padding: 0.55rem 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.85rem;
}
// --- Conversation -----------------------------------------------------------
.chat {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-bottom: 1rem;
}
.msg {
border-radius: 12px;
padding: 0.7rem 0.9rem;
border: 1px solid #1f2937;
&--user {
background: rgba(108, 99, 255, 0.1);
border-color: rgba(108, 99, 255, 0.3);
align-self: flex-end;
max-width: 85%;
}
&--assistant {
background: #0b1220;
}
}
.msg-role {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.35rem;
}
.msg-body {
color: #d1d5db;
font-size: 0.92rem;
line-height: 1.6;
white-space: pre-wrap;
&.markdown-body { white-space: normal; }
::ng-deep {
h1, h2, h3 { color: #f3f4f6; margin: 0.9rem 0 0.4rem; }
h2 { font-size: 1.08rem; }
h3 { font-size: 1rem; }
ul, ol { padding-left: 1.3rem; }
li { margin: 0.2rem 0; }
strong { color: #fff; }
code { background: #1f2937; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
a { color: #a78bfa; }
p { margin: 0.4rem 0; }
}
}
.msg-actions { margin-top: 0.5rem; }
.btn-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px solid #374151;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.streaming-cursor {
display: inline-block;
color: #6c63ff;
animation: blink 1s step-start infinite;
}
@keyframes blink { 50% { opacity: 0; } }
// --- Composer ---------------------------------------------------------------
.composer {
display: flex;
gap: 0.5rem;
align-items: flex-end;
position: sticky;
bottom: 0;
padding-top: 0.5rem;
background: linear-gradient(to top, var(--color-bg, #0a0f1a) 70%, transparent);
textarea {
flex: 1;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #f3f4f6;
padding: 0.55rem 0.7rem;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
&:focus { outline: none; border-color: #6c63ff; }
&:disabled { opacity: 0.6; }
}
}
.btn-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 8px;
border: none;
background: #6c63ff;
color: white;
cursor: pointer;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.5; cursor: default; }
}

View File

@@ -1,130 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, ArrowLeft, Upload, Copy, Check, Send } from 'lucide-angular';
import { CampaignAdaptService, AdaptMessage } from '../../../services/campaign-adapt.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { MarkdownPipe } from '../../../shared/markdown.pipe';
const FIRST_PROMPT = 'Propose-moi comment intégrer et adapter ce PDF à ma campagne.';
/**
* Page « Adapter un PDF » — CONVERSATIONNELLE. L'IA connaît la campagne (structure,
* PNJ, univers) + lit le PDF, propose une 1re adaptation, puis l'utilisateur peut
* répondre (corriger, demander des alternatives…) et l'IA rebondit.
* Route : /campaigns/:campaignId/adapt — rien n'est créé, conseils à appliquer à la main.
*/
@Component({
selector: 'app-campaign-adapt',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe],
templateUrl: './campaign-adapt.component.html',
styleUrls: ['./campaign-adapt.component.scss']
})
export class CampaignAdaptComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Copy = Copy;
readonly Check = Check;
readonly Send = Send;
campaignId = '';
/** PDF choisi, conservé pour les tours de conversation suivants. */
private file: File | null = null;
fileName = '';
/** Conversation affichée (user + assistant). */
messages: AdaptMessage[] = [];
streaming = false;
error: string | null = null;
/** Saisie du message en cours. */
input = '';
copiedIndex: number | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CampaignAdaptService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.pageTitle.set('Adapter un PDF');
this.campaignSidebar.show(this.campaignId);
}
get hasConversation(): boolean { return this.messages.length > 0; }
// --- Choix du PDF (démarre / réinitialise la conversation) ---------------
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
this.file = file;
this.fileName = file.name;
this.messages = [];
this.error = null;
this.send(FIRST_PROMPT);
}
// --- Envoi d'un message (1er tour ou feedback) ---------------------------
sendCurrent(): void {
const text = this.input.trim();
if (!text || this.streaming) return;
this.input = '';
this.send(text);
}
private send(text: string): void {
if (!this.file || this.streaming) return;
this.error = null;
this.messages.push({ role: 'user', content: text });
// Historique envoyé = tout jusqu'au message user inclus (sans la bulle vide).
const payload: AdaptMessage[] = this.messages.map(m => ({ role: m.role, content: m.content }));
const assistant: AdaptMessage = { role: 'assistant', content: '' };
this.messages.push(assistant);
this.streaming = true;
this.service.adviseStream(this.campaignId, this.file, payload).subscribe({
next: (ev) => {
if (ev.type === 'token') {
assistant.content += ev.value;
} else if (ev.type === 'done') {
this.streaming = false;
}
},
error: (err: Error) => {
this.streaming = false;
// Bulle assistant restée vide → on la retire pour ne pas afficher de vide.
if (!assistant.content) {
this.messages = this.messages.filter(m => m !== assistant);
}
this.error = err?.message ? `Échec : ${err.message}` : "Échec de l'adaptation.";
}
});
}
copy(index: number): void {
const msg = this.messages[index];
if (!msg) return;
navigator.clipboard?.writeText(msg.content).then(() => {
this.copiedIndex = index;
setTimeout(() => (this.copiedIndex = null), 2000);
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -144,14 +144,6 @@
<div class="section-header"> <div class="section-header">
<h2>Arcs narratifs</h2> <h2>Arcs narratifs</h2>
<div class="section-header-actions"> <div class="section-header-actions">
<button class="btn-add btn-add--secondary" (click)="adaptCampaign()" title="Conseils IA pour adapter un PDF à cette campagne">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Adapter un PDF
</button>
<button class="btn-add btn-add--secondary" (click)="importCampaign()" title="Générer l'arborescence depuis un PDF de campagne">
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
Importer un PDF
</button>
<button class="btn-add" (click)="createArc()"> <button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc Nouvel arc

View File

@@ -12,6 +12,7 @@ import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model'; import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { SessionService } from '../../../services/session.service'; import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service'; import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model'; import { Playthrough } from '../../../services/campaign.model';
@@ -94,6 +95,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private gameSystemService: GameSystemService, private gameSystemService: GameSystemService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private sessionService: SessionService, private sessionService: SessionService,
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private layoutService: LayoutService, private layoutService: LayoutService,
@@ -111,8 +113,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({ switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id), campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
), ),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
})) }))
@@ -148,8 +150,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(id), campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe( treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
), ),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => { }).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
@@ -246,18 +248,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
} }
/** Ouvre la page d'import d'un PDF de campagne (proposition d'arbre à réviser). */
importCampaign(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'import']);
}
/** Ouvre la page de conseils d'adaptation d'un PDF à cette campagne. */
adaptCampaign(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'adapt']);
}
openArc(arc: Arc): void { openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return; if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);

View File

@@ -10,6 +10,7 @@ import { CampaignImportService } from '../../../services/campaign-import.service
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model'; import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
@@ -91,6 +92,7 @@ export class CampaignImportComponent implements OnInit {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService private pageTitle: PageTitleService
) {} ) {}
@@ -102,7 +104,7 @@ export class CampaignImportComponent implements OnInit {
// Pré-chargement de l'arborescence existante (pour fusionner à la revue). // Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// En cas d'échec on dégrade : tout sera considéré comme nouveau. // En cas d'échec on dégrade : tout sera considéré comme nouveau.
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
.pipe(catchError(() => of(null))) .pipe(catchError(() => of(null)))
.subscribe(data => this.existingData = data); .subscribe(data => this.existingData = data);
} }

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -42,6 +43,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId); const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? ''; this.arcName = currentArc?.name ?? '';

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -128,7 +130,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -6,6 +6,7 @@ import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service'; import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../../services/campaign.model'; import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
@@ -70,6 +71,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService private pageTitleService: PageTitleService
) {} ) {}
@@ -89,7 +91,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId), scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { }).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter; this.chapter = chapter;
this.scenes = scenes; this.scenes = scenes;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -51,6 +52,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -78,7 +80,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -0,0 +1,41 @@
<div class="nac" [class.created]="status === 'created'">
<div class="nac-head">
<lucide-icon [img]="typeIcon" [size]="15"></lucide-icon>
<span class="nac-type">{{ typeLabel }}</span>
<span class="nac-name">{{ action.name }}</span>
</div>
<p class="nac-desc" *ngIf="action.description">{{ action.description }}</p>
<!-- Cibles -->
<div class="nac-targets" *ngIf="status !== 'created' && needsArc">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option>
</select>
</label>
<label *ngIf="needsChapter">
Chapitre
<select [(ngModel)]="selectedChapterId">
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option>
</select>
</label>
</div>
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
<div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span>
</div>
</div>

View File

@@ -0,0 +1,52 @@
.nac {
margin-top: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 9px;
border: 1px solid rgba(168, 130, 255, 0.3);
background: rgba(168, 130, 255, 0.08);
&.created { border-color: rgba(107, 208, 138, 0.4); background: rgba(107, 208, 138, 0.08); }
}
.nac-head {
display: flex; align-items: center; gap: 0.4rem;
color: #c4a8ff;
.nac-type {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(168,130,255,0.18);
}
.nac-name { font-weight: 600; color: inherit; }
}
.nac-desc {
margin: 0.4rem 0 0; font-size: 0.85rem; color: var(--color-text-muted, #cfd3da);
white-space: pre-wrap;
}
.nac-targets {
display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.5rem;
label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
select {
padding: 0.3rem 0.45rem; border-radius: 6px; font: inherit;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit;
}
}
.nac-warn { margin: 0.4rem 0 0; font-size: 0.78rem; color: #e0a458; }
.nac-foot { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.55rem; }
.nac-create, .nac-open {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; border-radius: 7px; cursor: pointer; font-size: 0.82rem; font-weight: 600;
border: none;
}
.nac-create {
background: #8a6dff; color: #fff;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.5; cursor: default; }
}
.nac-open { background: rgba(107,208,138,0.2); color: #6bd08a; }
.nac-open:hover { background: rgba(107,208,138,0.3); }
.nac-error { color: #e88; font-size: 0.8rem; }

View File

@@ -0,0 +1,185 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { Arc, Chapter } from '../../../services/campaign.model';
import { NotebookAction } from '../../../services/notebook-action.model';
/**
* Carte « Créer dans la campagne » issue d'une proposition de l'IA. Gère la cible
* (chapitre pour une scène, arc pour un chapitre) et appelle les services existants.
*/
@Component({
selector: 'app-notebook-action-card',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './notebook-action-card.component.html',
styleUrls: ['./notebook-action-card.component.scss']
})
export class NotebookActionCardComponent implements OnInit {
readonly Plus = Plus;
readonly Check = Check;
readonly ExternalLink = ExternalLink;
@Input() action!: NotebookAction;
@Input() campaignId!: string;
@Input() arcs: Arc[] = [];
@Input() chaptersByArc: Record<string, Chapter[]> = {};
/** Émis après une création réussie → l'atelier rafraîchit la sidebar. */
@Output() created = new EventEmitter<void>();
selectedArcId = '';
selectedChapterId = '';
status: 'idle' | 'creating' | 'created' | 'error' = 'idle';
errorMsg = '';
createdRoute: string[] | null = null;
constructor(
private campaignService: CampaignService,
private npcService: NpcService,
private tableService: RandomTableService,
private router: Router
) {}
ngOnInit(): void {
if (this.arcs.length) this.selectedArcId = this.arcs[0].id!;
this.syncChapter();
}
get typeLabel(): string {
switch (this.action.type) {
case 'npc': return 'PNJ';
case 'scene': return 'Scène';
case 'chapter': return 'Chapitre';
case 'arc': return 'Arc';
case 'table': return 'Table aléatoire';
default: return this.action.type;
}
}
get typeIcon() {
switch (this.action.type) {
case 'npc': return Drama;
case 'scene': return Clapperboard;
case 'chapter': return BookText;
case 'arc': return GitBranch;
case 'table': return Dices;
default: return Plus;
}
}
get needsArc(): boolean { return this.action.type === 'chapter' || this.action.type === 'scene'; }
get needsChapter(): boolean { return this.action.type === 'scene'; }
get targetChapters(): Chapter[] { return this.chaptersByArc[this.selectedArcId] ?? []; }
syncChapter(): void {
const chs = this.targetChapters;
this.selectedChapterId = chs.length ? chs[0].id! : '';
}
get canCreate(): boolean {
if (this.status === 'creating' || this.status === 'created') return false;
if (this.needsArc && !this.selectedArcId) return false;
if (this.needsChapter && !this.selectedChapterId) return false;
return true;
}
create(): void {
if (!this.canCreate) return;
this.status = 'creating';
this.errorMsg = '';
switch (this.action.type) {
case 'npc': return this.createNpc();
case 'arc': return this.createArc();
case 'chapter': return this.createChapter();
case 'scene': return this.createScene();
case 'table': return this.createTable();
}
}
private createNpc(): void {
this.npcService.create({
name: this.action.name,
campaignId: this.campaignId,
values: this.action.description ? { Description: this.action.description } : {}
}).subscribe({
next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]),
error: (e) => this.fail(e)
});
}
private createArc(): void {
this.campaignService.createArc({
name: this.action.name,
description: this.action.description,
campaignId: this.campaignId,
order: this.arcs.length,
type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR'
}).subscribe({
next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]),
error: (e) => this.fail(e)
});
}
private createChapter(): void {
const order = (this.chaptersByArc[this.selectedArcId] ?? []).length;
this.campaignService.createChapter({
name: this.action.name,
description: this.action.description,
arcId: this.selectedArcId,
order
}).subscribe({
next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]),
error: (e) => this.fail(e)
});
}
private createScene(): void {
this.campaignService.createScene({
name: this.action.name,
description: this.action.description,
playerNarration: this.action.content,
chapterId: this.selectedChapterId,
order: 0
}).subscribe({
next: (s) => this.done(
['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]),
error: (e) => this.fail(e)
});
}
private createTable(): void {
this.tableService.create({
name: this.action.name,
diceFormula: this.action.diceFormula || '1d20',
campaignId: this.campaignId,
entries: (this.action.entries ?? []).map(e => ({
minRoll: e.minRoll, maxRoll: e.maxRoll, label: e.label, detail: e.detail
}))
}).subscribe({
next: (t) => this.done(['/campaigns', this.campaignId, 'random-tables', t.id!]),
error: (e) => this.fail(e)
});
}
private done(route: string[]): void {
this.status = 'created';
this.createdRoute = route;
this.created.emit();
}
private fail(err: unknown): void {
this.status = 'error';
this.errorMsg = (err as { error?: { message?: string } })?.error?.message || 'Échec de la création.';
}
openCreated(): void {
if (this.createdRoute) this.router.navigate(this.createdRoute);
}
}

View File

@@ -0,0 +1,98 @@
<div class="nbd-page" *ngIf="detail">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p>
<div class="nbd-source" *ngFor="let s of sources">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span>
</div>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading">
Ajoute un PDF source pour commencer à discuter avec.
</p>
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
<p class="nbd-empty" *ngIf="messages.length === 0">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
</p>
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon>
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
<ng-container *ngIf="m.role === 'assistant'; else userContent">
<ng-container *ngIf="parsedOf(m) as p">
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
<div class="nbd-msg-content">{{ p.text }}<span class="cursor" *ngIf="sending && !p.text && !p.actions.length && !deepProgress"></span></div>
<app-notebook-action-card
*ngFor="let a of p.actions; trackBy: trackAction"
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
</ng-container>
</ng-container>
<ng-template #userContent>
<div class="nbd-msg-content">{{ m.content }}</div>
</ng-template>
</div>
</div>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,106 @@
.nbd-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 2rem; height: calc(100vh - 60px); display: flex; flex-direction: column; }
.nbd-toolbar {
display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.nbd-title {
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 1.15rem; font-weight: 600;
border: 1px solid transparent; background: transparent; color: inherit;
&:hover, &:focus { border-color: rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); outline: none; }
}
}
.nbd-grid {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 280px 1fr; gap: 1rem;
}
/* Sources */
.nbd-sources {
display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 0.75rem;
}
.nbd-sources-head {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
h3 { display: flex; align-items: center; gap: 0.4rem; margin: 0; font-size: 0.95rem; }
}
.btn-upload {
display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer;
padding: 0.3rem 0.55rem; border-radius: 6px; font-size: 0.78rem;
border: 1px solid rgba(102,126,234,0.4); color: #8ea2ff; background: rgba(102,126,234,0.1);
&:hover { background: rgba(102,126,234,0.2); }
&.disabled { opacity: 0.6; cursor: default; }
}
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
.nbd-source {
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
border-radius: 7px; background: rgba(255,255,255,0.03);
lucide-icon.ok { color: #6bd08a; }
lucide-icon.fail { color: #e88; }
lucide-icon.busy { color: #e0c074; }
.nbd-source-info { flex: 1; min-width: 0; }
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; }
.nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
.nbd-source-del {
border: none; background: none; color: #e88; cursor: pointer; padding: 0.15rem;
border-radius: 4px; &:hover { background: rgba(224,90,90,0.15); }
}
}
/* Chat */
.nbd-chat {
display: flex; flex-direction: column; min-height: 0;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
}
.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.nbd-msg {
max-width: 88%;
.nbd-msg-role {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem;
}
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; }
&.user { align-self: flex-end; text-align: right;
.nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; }
}
&.assistant { align-self: flex-start;
.nbd-msg-content { background: rgba(255,255,255,0.04); padding: 0.5rem 0.75rem; border-radius: 10px; }
}
}
.cursor { opacity: 0.6; }
.nbd-input {
display: flex; gap: 0.5rem; padding: 0.6rem; border-top: 1px solid rgba(255,255,255,0.08);
textarea {
flex: 1; resize: none; padding: 0.55rem 0.7rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit;
}
.btn-send {
display: inline-flex; align-items: center; justify-content: center; width: 44px;
border: none; border-radius: 8px; background: #667eea; color: #fff; cursor: pointer;
&:hover:not(:disabled) { background: #5568d3; }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-deep {
display: inline-flex; align-items: center; justify-content: center; width: 44px;
border: 1px solid rgba(168,130,255,0.4); border-radius: 8px;
background: rgba(168,130,255,0.12); color: #c4a8ff; cursor: pointer;
&:hover:not(:disabled) { background: rgba(168,130,255,0.22); }
&:disabled { opacity: 0.5; cursor: default; }
}
}
.nbd-deep-progress {
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: #c4a8ff; font-style: italic;
}

View File

@@ -0,0 +1,187 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model';
import { Arc, Chapter } from '../../../services/campaign.model';
import { loadCampaignTreeData } from '../../campaign-tree.helper';
import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component';
/**
* Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
* Route : /campaigns/:campaignId/notebooks/:notebookId
*/
@Component({
selector: 'app-notebook-detail',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent],
templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss']
})
export class NotebookDetailComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Trash2 = Trash2;
readonly Send = Send;
readonly FileText = FileText;
readonly Loader = Loader;
readonly CheckCircle2 = CheckCircle2;
readonly AlertCircle = AlertCircle;
readonly Sparkles = Sparkles;
readonly Layers = Layers;
campaignId = '';
notebookId = '';
detail: NotebookDetail | null = null;
sources: NotebookSource[] = [];
messages: NotebookMessage[] = [];
uploading = false;
uploadError = '';
sending = false;
draft = '';
/** Avancement de l'analyse approfondie (lecture du doc par lots). */
deepProgress: { current: number; total: number } | null = null;
// Arbre de la campagne — sert aux cartes d'action (cibles arc/chapitre).
arcs: Arc[] = [];
chaptersByArc: Record<string, Chapter[]> = {};
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NotebookService,
private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
this.notebookId = this.route.snapshot.paramMap.get('notebookId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.loadTree();
}
this.load();
}
private loadTree(): void {
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
.subscribe({
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
error: () => { /* cibles indisponibles : les cartes le signaleront */ }
});
}
/** Après création depuis une carte : rafraîchit la sidebar (l'élément apparaît)
* et l'arbre des cibles (pour les cartes suivantes). */
onActionCreated(): void {
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.loadTree();
}
}
/** Sépare le texte affiché des blocs d'action — MÉMORISÉ par message : renvoie
* une référence STABLE tant que le contenu n'a pas changé. Indispensable :
* appeler le parseur directement dans le template recréait le DOM à chaque
* détection de changement (au mousedown) → les clics sur les cartes étaient
* perdus. */
parsedOf(m: NotebookMessage): { text: string; actions: NotebookAction[] } {
const cache = m as unknown as {
_parsedFor?: string;
_parsed?: { text: string; actions: NotebookAction[] };
};
if (cache._parsedFor !== m.content || !cache._parsed) {
cache._parsed = parseNotebookActions(m.content);
cache._parsedFor = m.content;
}
return cache._parsed;
}
/** trackBy stable pour les cartes d'action (évite toute recréation parasite). */
trackAction(index: number): number { return index; }
load(): void {
this.service.get(this.notebookId).subscribe({
next: (d) => {
this.detail = d;
this.sources = d.sources ?? [];
this.messages = d.messages ?? [];
},
error: () => this.back()
});
}
reloadSources(): void {
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] });
}
// --- Sources ---
onFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
this.uploading = true;
this.uploadError = '';
this.service.addSource(this.notebookId, file).subscribe({
next: () => { this.uploading = false; this.reloadSources(); },
error: (err) => {
this.uploading = false;
this.uploadError = err?.error?.message || 'Échec de l\'indexation. Réessaie ou vérifie le modèle d\'embedding.';
this.reloadSources();
}
});
}
removeSource(s: NotebookSource): void {
this.service.deleteSource(s.id).subscribe(() => this.reloadSources());
}
// --- Chat ---
send(deep = false): void {
const text = this.draft.trim();
if (!text || this.sending) return;
this.draft = '';
this.deepProgress = null;
this.messages.push({ role: 'user', content: text });
const assistant: NotebookMessage = { role: 'assistant', content: '' };
this.messages.push(assistant);
this.sending = true;
this.service.streamChat(this.notebookId, text, deep).subscribe({
next: (ev) => {
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
else if (ev.type === 'progress') this.deepProgress = { current: ev.current, total: ev.total };
else if (ev.type === 'error') assistant.content += (assistant.content ? '\n\n' : '') + `⚠️ ${ev.message}`;
},
complete: () => { this.sending = false; this.deepProgress = null; },
error: () => { this.sending = false; this.deepProgress = null; }
});
}
rename(): void {
if (!this.detail || !this.detail.name.trim()) return;
this.service.rename(this.notebookId, this.detail.name.trim()).subscribe();
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId, 'notebooks']);
}
hasReadySource(): boolean {
return this.sources.some(s => s.status === 'READY');
}
}

View File

@@ -0,0 +1,35 @@
<div class="nbl-page">
<div class="nbl-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Retour
</button>
</div>
<header class="nbl-header">
<h1><lucide-icon [img]="BookOpen" [size]="22"></lucide-icon> Ateliers d'adaptation</h1>
<p class="nbl-hint">
Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter
son contenu à ta campagne. La source et la conversation sont conservées.
</p>
</header>
<div class="nbl-create">
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
(keyup.enter)="create()">
<button class="btn-create" (click)="create()" [disabled]="creating">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ creating ? 'Création…' : 'Nouvel atelier' }}
</button>
</div>
<div class="nbl-list">
<p class="empty" *ngIf="notebooks.length === 0">Aucun atelier pour l'instant.</p>
<button class="nbl-item" *ngFor="let nb of notebooks" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,49 @@
.nbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; }
.nbl-toolbar { margin-bottom: 1rem; }
.btn-back {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.7rem; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer; font-size: 0.85rem;
&:hover { background: rgba(255,255,255,0.09); }
}
.nbl-header {
margin-bottom: 1.25rem;
h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; }
.nbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; }
}
.nbl-create {
display: flex; gap: 0.5rem; margin-bottom: 1.5rem;
input {
flex: 1; padding: 0.55rem 0.75rem; border-radius: 7px;
border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04);
color: inherit; font: inherit;
}
.btn-create {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.55rem 1rem; border: none; border-radius: 7px;
background: #667eea; color: #fff; font-weight: 600; cursor: pointer;
&:hover:not(:disabled) { background: #5568d3; }
&:disabled { opacity: 0.55; cursor: default; }
}
}
.nbl-list { display: flex; flex-direction: column; gap: 0.4rem; }
.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.nbl-item {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 0.85rem; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.07); }
.nbl-item-name { flex: 1; font-weight: 500; }
.nbl-del {
display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88;
&:hover { background: rgba(224,90,90,0.15); }
}
}

View File

@@ -0,0 +1,85 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { Notebook } from '../../../services/notebook.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
* Liste des ateliers (notebooks) d'une campagne + création.
* Route : /campaigns/:campaignId/notebooks
*/
@Component({
selector: 'app-notebook-list',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './notebook-list.component.html',
styleUrls: ['./notebook-list.component.scss']
})
export class NotebookListComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly BookOpen = BookOpen;
campaignId = '';
notebooks: Notebook[] = [];
newName = '';
creating = false;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NotebookService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? '';
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
this.load();
}
}
load(): void {
this.service.listByCampaign(this.campaignId).subscribe({
next: (list) => this.notebooks = list,
error: () => this.notebooks = []
});
}
create(): void {
if (this.creating) return;
this.creating = true;
this.service.create(this.campaignId, this.newName.trim() || 'Nouvel atelier').subscribe({
next: (nb) => this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]),
error: () => this.creating = false
});
}
open(nb: Notebook): void {
this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]);
}
remove(nb: Notebook, ev: Event): void {
ev.stopPropagation();
this.confirmDialog.confirm({
title: 'Supprimer l\'atelier',
message: `Supprimer « ${nb.name} » et ses sources indexées ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.delete(nb.id).subscribe(() => this.load());
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil, Plus
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PlaythroughService } from '../../../services/playthrough.service'; import { PlaythroughService } from '../../../services/playthrough.service';
import { SessionService } from '../../../services/session.service'; import { SessionService } from '../../../services/session.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
@@ -55,6 +56,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private sessionService: SessionService, private sessionService: SessionService,
private layoutService: LayoutService, private layoutService: LayoutService,
@@ -78,7 +80,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService), treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService),
playthrough: this.playthroughService.getById(this.playthroughId), playthrough: this.playthroughService.getById(this.playthroughId),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),

View File

@@ -6,6 +6,7 @@ import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PlaythroughService } from '../../../services/playthrough.service'; import { PlaythroughService } from '../../../services/playthrough.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -37,6 +38,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private playthroughService: PlaythroughService, private playthroughService: PlaythroughService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService private pageTitleService: PageTitleService
@@ -58,7 +60,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService), treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService),
playthrough: this.playthroughService.getById(this.playthroughId) playthrough: this.playthroughService.getById(this.playthroughId)
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
this.playthrough = playthrough; this.playthrough = playthrough;

View File

@@ -0,0 +1,85 @@
<div class="rte-page">
<div class="rte-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-save" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</div>
<h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1>
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
<div class="form-row">
<label for="rt-name">Nom *</label>
<input id="rt-name" type="text" [(ngModel)]="name" placeholder="Ex: Rencontres en forêt">
</div>
<div class="form-row">
<label for="rt-desc">Description</label>
<textarea id="rt-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert cette table ?"></textarea>
</div>
<div class="form-row">
<label for="rt-formula">Formule du dé *</label>
<input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…"
[class.invalid]="diceFormula && !formulaValid">
<small class="hint" [class.bad]="diceFormula && !formulaValid">
{{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }}
</small>
</div>
<!-- Génération IA -->
<div class="ai-box">
<div class="ai-title">
<lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA
</div>
<p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
<textarea rows="2" [(ngModel)]="aiPrompt"
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
<div class="ai-actions">
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
{{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }}
</button>
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
</div>
</div>
<div class="entries-head">
<h2>Entrées</h2>
<div class="entries-actions">
<button class="btn-mini" (click)="autoRanges()" title="Répartir les plages sur la formule">
<lucide-icon [img]="Wand2" [size]="13"></lucide-icon> Auto-plages
</button>
<button class="btn-mini" (click)="addEntry()">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
</button>
</div>
</div>
<div class="entry-row head">
<span class="c-range">Min</span>
<span class="c-range">Max</span>
<span class="c-label">Résultat</span>
<span class="c-detail">Détail</span>
<span class="c-del"></span>
</div>
<div class="entry-row" *ngFor="let e of entries; let i = index">
<input class="c-range" type="number" [(ngModel)]="e.minRoll">
<input class="c-range" type="number" [(ngModel)]="e.maxRoll">
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<p class="empty-hint" *ngIf="entries.length === 0">Aucune entrée — clique « Ajouter ».</p>
</div>

View File

@@ -0,0 +1,172 @@
.rte-page {
max-width: 900px;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
}
.rte-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
font-size: 0.85rem;
&:hover { background: rgba(255, 255, 255, 0.09); }
&:disabled { opacity: 0.5; cursor: default; }
}
.btn-save { background: #667eea; border-color: #667eea; color: #fff; }
.btn-save:hover:not(:disabled) { background: #5568d3; }
}
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 0; }
.error {
padding: 0.6rem 0.9rem;
border-radius: 6px;
background: rgba(224, 90, 90, 0.12);
border: 1px solid rgba(224, 90, 90, 0.4);
color: #e88;
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 1rem;
label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); }
input, textarea {
padding: 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font: inherit;
&.invalid { border-color: rgba(224, 90, 90, 0.6); }
}
.hint { font-size: 0.75rem; color: var(--color-text-muted, #9aa0aa); }
.hint.bad { color: #e88; }
}
.entries-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1.5rem 0 0.5rem;
.entries-actions { display: flex; gap: 0.4rem; }
.btn-mini {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
font-size: 0.8rem;
&:hover { background: rgba(255, 255, 255, 0.09); }
}
}
.entry-row {
display: grid;
grid-template-columns: 70px 70px 1.2fr 1.6fr 36px;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.4rem;
&.head {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa);
margin-bottom: 0.3rem;
}
input {
padding: 0.4rem 0.55rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font: inherit;
width: 100%;
}
.btn-del {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
border-radius: 6px;
border: 1px solid rgba(224, 90, 90, 0.3);
background: rgba(224, 90, 90, 0.08);
color: #e88;
cursor: pointer;
&:hover { background: rgba(224, 90, 90, 0.18); }
}
}
.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
.ai-box {
margin: 1.25rem 0 0.5rem;
padding: 0.85rem 1rem;
border-radius: 10px;
background: rgba(168, 130, 255, 0.08);
border: 1px solid rgba(168, 130, 255, 0.28);
.ai-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: #c4a8ff;
}
.ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); }
textarea {
width: 100%;
padding: 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font: inherit;
}
.ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; }
.btn-ai {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
border: none;
border-radius: 7px;
background: #8a6dff;
color: #fff;
font-weight: 600;
cursor: pointer;
&:hover:not(:disabled) { background: #7a5cf0; }
&:disabled { opacity: 0.55; cursor: default; }
}
.ai-error { color: #e88; font-size: 0.82rem; }
}

View File

@@ -0,0 +1,185 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { RandomTable, RandomTableEntry, RandomTableCreate } from '../../../services/random-table.model';
import { DiceUtils } from '../../../shared/dice.utils';
/**
* Création/édition d'une table aléatoire (formule de dé + entrées par plage).
* Routes : /campaigns/:campaignId/random-tables/create
* /campaigns/:campaignId/random-tables/:tableId/edit
*/
@Component({
selector: 'app-random-table-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './random-table-edit.component.html',
styleUrls: ['./random-table-edit.component.scss']
})
export class RandomTableEditComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Save = Save;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Wand2 = Wand2;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
tableId: string | null = null;
name = '';
description = '';
diceFormula = '1d20';
entries: RandomTableEntry[] = [];
saving = false;
errorMessage = '';
// --- Génération IA ---
aiPrompt = '';
generating = false;
aiError = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private service: RandomTableService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.tableId = params.get('tableId');
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
if (this.tableId) {
this.service.getById(this.tableId).subscribe({
next: (t: RandomTable) => {
this.name = t.name;
this.description = t.description ?? '';
this.diceFormula = t.diceFormula;
this.entries = t.entries.map(e => ({ ...e }));
},
error: () => this.back()
});
} else {
// Démarre avec une entrée vide pour guider.
this.addEntry();
}
}
get formulaValid(): boolean {
return DiceUtils.parse(this.diceFormula) !== null;
}
addEntry(): void {
const range = DiceUtils.totalRange(this.diceFormula);
const next = this.entries.length ? this.entries[this.entries.length - 1].maxRoll + 1 : (range?.min ?? 1);
this.entries.push({ minRoll: next, maxRoll: next, label: '', detail: '' });
}
removeEntry(i: number): void {
this.entries.splice(i, 1);
}
/** Répartit équitablement la plage du dé sur toutes les entrées. */
autoRanges(): void {
const range = DiceUtils.totalRange(this.diceFormula);
if (!range || this.entries.length === 0) return;
const span = range.max - range.min + 1;
const n = this.entries.length;
const base = Math.floor(span / n);
let cursor = range.min;
this.entries.forEach((e, idx) => {
const size = idx === n - 1 ? (range.max - cursor + 1) : Math.max(1, base);
e.minRoll = cursor;
e.maxRoll = Math.min(range.max, cursor + size - 1);
cursor = e.maxRoll + 1;
});
}
/** Génère la table via l'IA et préremplit le formulaire (l'utilisateur révise avant d'enregistrer). */
generateWithAI(): void {
if (!this.campaignId) return;
if (!this.aiPrompt.trim()) { this.aiError = 'Décris la table à générer.'; return; }
if (!this.formulaValid) { this.aiError = 'Choisis d\'abord une formule de dé valide.'; return; }
this.generating = true;
this.aiError = '';
this.service.generate(this.campaignId, this.aiPrompt.trim(), this.diceFormula.trim()).subscribe({
next: (t) => {
this.generating = false;
if (t.name && !this.name.trim()) this.name = t.name;
if (t.description) this.description = t.description;
this.entries = (t.entries ?? []).map(e => ({ ...e }));
},
error: (err) => {
this.generating = false;
this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.';
}
});
}
save(): void {
if (!this.campaignId) return;
if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; }
if (!this.formulaValid) { this.errorMessage = 'Formule de dé invalide (ex. 1d20, 2d6, d100).'; return; }
this.saving = true;
this.errorMessage = '';
const cleanEntries = this.entries
.filter(e => e.label.trim())
.map(e => ({
minRoll: e.minRoll,
maxRoll: Math.max(e.minRoll, e.maxRoll),
label: e.label.trim(),
detail: e.detail?.trim() || undefined
}));
if (this.tableId) {
const payload: RandomTable = {
id: this.tableId,
name: this.name.trim(),
description: this.description.trim() || undefined,
diceFormula: this.diceFormula.trim(),
campaignId: this.campaignId,
entries: cleanEntries
};
this.service.update(this.tableId, payload).subscribe({
next: () => this.goToView(this.tableId!),
error: (e) => this.fail(e)
});
} else {
const payload: RandomTableCreate = {
name: this.name.trim(),
description: this.description.trim() || undefined,
diceFormula: this.diceFormula.trim(),
campaignId: this.campaignId,
entries: cleanEntries
};
this.service.create(payload).subscribe({
next: (t) => this.goToView(t.id!),
error: (e) => this.fail(e)
});
}
}
private goToView(id: string): void {
this.saving = false;
this.router.navigate(['/campaigns', this.campaignId, 'random-tables', id]);
}
private fail(err: unknown): void {
this.saving = false;
this.errorMessage = 'Échec de l\'enregistrement.';
console.error('RandomTable save failed', err);
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -0,0 +1,57 @@
<div class="rt-page" *ngIf="table">
<div class="rt-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Éditer
</button>
</div>
<header class="rt-header">
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
<p class="rt-desc" *ngIf="table.description">{{ table.description }}</p>
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
</header>
<!-- Zone de jet -->
<section class="rt-roll">
<button class="btn-roll" (click)="roll()">
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Lancer {{ table.diceFormula }}
</button>
<div class="rt-result" *ngIf="lastRoll">
<span class="rt-total">{{ lastRoll.total }}</span>
<span class="rt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span>
<span class="rt-arrow"></span>
<span class="rt-matched" *ngIf="matched">{{ matched.label }}</span>
<span class="rt-nomatch" *ngIf="!matched">Aucune entrée pour ce résultat</span>
</div>
</section>
<div class="rt-detail" *ngIf="matched?.detail">{{ matched?.detail }}</div>
<!-- Liste des entrées -->
<section class="rt-entries">
<div class="rt-empty" *ngIf="table.entries.length === 0">
Aucune entrée — édite la table pour en ajouter.
</div>
<table *ngIf="table.entries.length > 0">
<thead>
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
</thead>
<tbody>
<tr *ngFor="let e of table.entries" [class.matched]="isMatched(e)">
<td class="col-range">{{ rangeLabel(e) }}</td>
<td>
<div class="entry-label">{{ e.label }}</div>
<div class="entry-detail" *ngIf="e.detail">{{ e.detail }}</div>
</td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@@ -0,0 +1,133 @@
.rt-page {
max-width: 860px;
margin: 0 auto;
padding: 1rem 1.5rem 3rem;
}
.rt-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
.spacer { flex: 1; }
button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
font-size: 0.85rem;
&:hover { background: rgba(255, 255, 255, 0.09); }
}
}
.rt-header {
margin-bottom: 1.25rem;
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.35rem;
font-size: 1.5rem;
}
.rt-desc { margin: 0 0 0.4rem; color: var(--color-text-muted, #9aa0aa); }
.rt-formula code {
background: rgba(255, 255, 255, 0.08);
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
}
.rt-roll {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem;
border-radius: 10px;
background: rgba(102, 126, 234, 0.08);
border: 1px solid rgba(102, 126, 234, 0.25);
margin-bottom: 1rem;
.btn-roll {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.1rem;
border: none;
border-radius: 8px;
background: #667eea;
color: #fff;
font-weight: 600;
cursor: pointer;
&:hover { background: #5568d3; }
}
.rt-result {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 1.05rem;
}
.rt-total { font-weight: 700; font-size: 1.3rem; color: #8ea2ff; }
.rt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; }
.rt-matched { font-weight: 600; }
.rt-nomatch { color: #e0a458; font-style: italic; }
}
.rt-detail {
white-space: pre-wrap;
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border-left: 3px solid #667eea;
}
.rt-entries {
.rt-empty {
color: var(--color-text-muted, #9aa0aa);
font-style: italic;
padding: 1rem 0;
}
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
vertical-align: top;
}
th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa);
}
.col-range { width: 84px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.entry-label { font-weight: 500; }
.entry-detail {
margin-top: 0.2rem;
font-size: 0.85rem;
color: var(--color-text-muted, #9aa0aa);
white-space: pre-wrap;
}
tr.matched {
background: rgba(102, 126, 234, 0.16);
td { border-bottom-color: rgba(102, 126, 234, 0.3); }
}
}

View File

@@ -0,0 +1,85 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { RandomTable, RandomTableEntry } from '../../../services/random-table.model';
import { DiceUtils, DiceRoll } from '../../../shared/dice.utils';
/**
* Vue d'une table aléatoire : affiche les entrées et permet de LANCER le dé
* (jet côté client) → surligne l'entrée tombée + montre son détail.
* Route : /campaigns/:campaignId/random-tables/:tableId
*/
@Component({
selector: 'app-random-table-view',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './random-table-view.component.html',
styleUrls: ['./random-table-view.component.scss']
})
export class RandomTableViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Dices = Dices;
campaignId: string | null = null;
tableId: string | null = null;
table: RandomTable | null = null;
lastRoll: DiceRoll | null = null;
matched: RandomTableEntry | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: RandomTableService,
private campaignSidebar: CampaignSidebarService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.tableId = params.get('tableId');
if (this.tableId) {
this.service.getById(this.tableId).subscribe({
next: t => this.table = t,
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignSidebar.show(this.campaignId);
}
}
roll(): void {
if (!this.table) return;
const result = DiceUtils.roll(this.table.diceFormula);
if (!result) { this.lastRoll = null; this.matched = null; return; }
this.lastRoll = result;
this.matched = this.table.entries.find(
e => result.total >= e.minRoll && result.total <= e.maxRoll
) ?? null;
}
isMatched(entry: RandomTableEntry): boolean {
return this.matched === entry;
}
rangeLabel(entry: RandomTableEntry): string {
return entry.minRoll === entry.maxRoll
? String(entry.minRoll)
: `${entry.minRoll}${entry.maxRoll}`;
}
edit(): void {
if (this.campaignId && this.tableId) {
this.router.navigate(['/campaigns', this.campaignId, 'random-tables', this.tableId, 'edit']);
}
}
back(): void {
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
}
}

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -41,6 +42,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId); const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? ''; this.chapterName = currentChapter?.name ?? '';

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -80,6 +81,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -132,7 +134,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId), scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId), chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -48,6 +49,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -78,7 +80,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId), scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -2,7 +2,7 @@ import {
Folder, Folder,
Users, Swords, MapPin, Shield, Crown, Skull, Gem, Users, Swords, MapPin, Shield, Crown, Skull, Gem,
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain, BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, LucideIconData
} from 'lucide-angular'; } from 'lucide-angular';
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons'; import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
@@ -42,6 +42,8 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
{ key: 'key', icon: Key }, { key: 'key', icon: Key },
{ key: 'globe', icon: Globe }, { key: 'globe', icon: Globe },
{ key: 'compass', icon: Compass }, { key: 'compass', icon: Compass },
{ key: 'dice', icon: Dices },
{ key: 'file-up', icon: FileUp },
]; ];
/** Icône par défaut pour un dossier sans icône. */ /** Icône par défaut pour un dossier sans icône. */

View File

@@ -1,108 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
/** Évènements du flux SSE de conseils d'adaptation. */
export type AdaptStreamEvent =
| { type: 'token'; value: string }
| { type: 'done' }
| { type: 'error'; message: string };
/** Message de la conversation d'adaptation. */
export interface AdaptMessage {
role: 'user' | 'assistant';
content: string;
}
/**
* Service : adaptation conversationnelle d'un PDF à une campagne (streamée).
* fetch() + SSE (POST multipart impossible avec EventSource), décodage ligne à ligne.
*/
@Injectable({ providedIn: 'root' })
export class CampaignAdaptService {
adviseStream(campaignId: string, file: File, messages: AdaptMessage[]): Observable<AdaptStreamEvent> {
return new Observable<AdaptStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
form.append('messages', JSON.stringify(messages ?? []));
fetch(`/api/campaigns/${campaignId}/adapt-pdf/stream`, {
method: 'POST',
headers: { 'Accept': 'text/event-stream' },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consume(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
private async consume(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: AdaptStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = "Échec de l'adaptation.";
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
subscriber.error(new Error(message));
} else if (name === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else if (name === 'token') {
try {
const tok = (JSON.parse(currentData) as { token?: string }).token;
if (tok) subscriber.next({ type: 'token', value: tok });
} catch { /* fragment ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
}

View File

@@ -3,6 +3,7 @@ import { forkJoin, Subscription } from 'rxjs';
import { CampaignService } from './campaign.service'; import { CampaignService } from './campaign.service';
import { CharacterService } from './character.service'; import { CharacterService } from './character.service';
import { NpcService } from './npc.service'; import { NpcService } from './npc.service';
import { RandomTableService } from './random-table.service';
import { LayoutService } from './layout.service'; import { LayoutService } from './layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
@@ -26,6 +27,7 @@ export class CampaignSidebarService {
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService private layoutService: LayoutService
) {} ) {}
@@ -42,7 +44,8 @@ export class CampaignSidebarService {
this.campaignService, this.campaignService,
campaignId, campaignId,
this.characterService, this.characterService,
this.npcService this.npcService,
this.randomTableService
) )
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId)); this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));

View File

@@ -0,0 +1,49 @@
/**
* Protocole d'« actions » proposées par l'IA dans le chat d'un atelier : des blocs
* ```loremind-action {json} ``` que le front transforme en cartes « Créer dans la
* campagne ». Ce module définit les types + le parseur (extraction + texte nettoyé).
*/
export interface NotebookActionEntry {
minRoll: number;
maxRoll: number;
label: string;
detail?: string;
}
export interface NotebookAction {
type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table';
name: string;
description?: string;
content?: string;
arcType?: 'LINEAR' | 'HUB';
diceFormula?: string;
entries?: NotebookActionEntry[];
}
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
/**
* Extrait les blocs d'action COMPLETS d'un message et renvoie le texte sans ces
* blocs. Les blocs encore en cours de streaming (clôture absente) ne matchent pas
* → ils restent affichés tels quels brièvement, puis deviennent une carte.
*/
export function parseNotebookActions(content: string): { text: string; actions: NotebookAction[] } {
const actions: NotebookAction[] = [];
if (!content) return { text: '', actions };
let match: RegExpExecArray | null;
ACTION_RE.lastIndex = 0;
while ((match = ACTION_RE.exec(content)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (obj && typeof obj.type === 'string' && VALID_TYPES.has(obj.type) && typeof obj.name === 'string') {
actions.push(obj as NotebookAction);
}
} catch {
/* bloc JSON invalide : ignoré */
}
}
const text = content.replace(ACTION_RE, '').trim();
return { text, actions };
}

Some files were not shown because too many files have changed in this diff Show More