Compare commits

..

6 Commits

Author SHA1 Message Date
edc4434298 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
2026-06-07 09:52:15 +02:00
5eb15dc449 passage 0.10.4
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
2026-06-05 16:54:41 +02:00
da5b602f20 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
9ea43a1889 Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
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
2026-06-05 00:23:19 +02:00
211e26dae1 changement de version
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
2026-06-04 14:14:19 +02:00
53065c952b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
120 changed files with 6791 additions and 705 deletions

1
.gitignore vendored
View File

@@ -108,3 +108,4 @@ docker-compose.override.yml
# ============================================================================
relay/
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):
out.append(enc.decode(tokens[i : i + target_tokens]))
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
from app.application.chunking import chunk_text
from app.application.llm_json import load_json_object
from app.application.chunking import chunk_text, split_in_half
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.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 (
ArcProposal,
CampaignImportResult,
@@ -22,11 +27,13 @@ from app.domain.models import (
RoomProposal,
SceneProposal,
)
from app.domain.ports import LLMProvider, PdfTextExtractor
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
_TEMPERATURE = 0.2
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale"
@@ -227,8 +234,30 @@ class ImportCampaignUseCase:
}
merger = _TreeMerger()
skipped = 0
last_error: str | None = None
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()
yield {
"type": "progress",
@@ -237,42 +266,74 @@ class ImportCampaignUseCase:
"arc_count": arcs,
"chapter_count": chapters,
"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 {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- MAP : un morceau → sous-arbre ---------------------------------------
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 = (
_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."
)
raw = await generate_with_retry(
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
def _parse_arcs(raw: str, *, index: int) -> list[dict]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]:
"""Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
return []
if recovered:
logger.warning(
"Morceau %s : sortie tronquée — récupération des éléments complets "
"(envisagez des morceaux plus petits).", index)
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"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):
arcs = parsed.get("arcs", [])
return arcs if isinstance(arcs, list) else []
return []
return (arcs if isinstance(arcs, list) else []), recovered
return [], recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:

View File

@@ -14,16 +14,25 @@ from __future__ import annotations
import logging
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text
from app.application.llm_json import load_json_object
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
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.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.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Température basse : tâche de tri/réécriture fidèle, pas de créativité.
_TEMPERATURE = 0.2
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
@@ -93,6 +102,24 @@ class _SectionMerger:
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:
"""Transforme un PDF de règles en proposition de sections markdown."""
@@ -150,48 +177,103 @@ class ImportRulesUseCase:
}
merger = _SectionMerger()
skipped = 0
last_error: str | None = None
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 {
"type": "progress",
"current": i + 1,
"total": total,
"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 {
"type": "done",
"sections": merger.result(),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- MAP : un morceau → sections -----------------------------------------
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 = (
_MAP_SYSTEM.format(
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."
)
raw = await generate_with_retry(
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
def _parse_sections(raw: str, *, index: int) -> dict[str, str]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
return {}
if recovered:
logger.warning(
"Morceau %s : sortie tronquée — récupération des sections complètes "
"(envisagez des morceaux plus petits).", index)
# Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute
# structure complète (→ on signalera 'tronqué' pour re-découper).
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"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):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}
return {str(k): str(v) for k, v in parsed.items()}
return {}, False
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
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]:
@@ -24,6 +34,7 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
auquel cas le second élément vaut True.
(None, False) si rien d'exploitable.
"""
raw = _strip_reasoning(raw)
obj = extract_json_object(raw)
if obj is not None:
try:
@@ -39,6 +50,18 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
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:
if not raw:
return None

View File

@@ -12,13 +12,48 @@ from __future__ import annotations
import asyncio
import logging
import re
from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__)
# 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 = 2.0
_BASE_DELAY_SECONDS = 3.0
# 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).
_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0]
def _is_rate_limit(exc: LLMProviderError) -> bool:
msg = str(exc).lower()
return "429" in msg or "rate" in msg or "too many requests" in msg
def _is_daily_quota(exc: LLMProviderError) -> bool:
"""Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère
qu'au reset quotidien. OpenRouter le précise dans le corps du 429."""
msg = str(exc).lower()
return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg
# OpenRouter renvoie souvent le délai conseillé (saturation amont) :
# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que
# d'attendre une durée fixe arbitraire.
_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE)
def _suggested_retry_after(exc: LLMProviderError) -> float | None:
match = _RETRY_AFTER_RE.search(str(exc))
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
async def generate_with_retry(
@@ -28,7 +63,12 @@ async def generate_with_retry(
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2)."""
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se
libérer. Nombre de tentatives borné : si le quota est durablement épuisé
(ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin.
"""
delay = _BASE_DELAY_SECONDS
last_error: LLMProviderError | None = None
for attempt in range(_ATTEMPTS):
@@ -36,12 +76,27 @@ async def generate_with_retry(
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMProviderError as exc:
last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
# (sinon on enchaîne des attentes longues pour rien, et on spamme l'API).
if _is_daily_quota(exc):
logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc)
raise
if attempt < _ATTEMPTS - 1:
if _is_rate_limit(exc):
suggested = _suggested_retry_after(exc)
if suggested is not None:
# Indication serveur (saturation amont) + petite marge, plafonnée.
wait = min(suggested + 2.0, 60.0)
else:
wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)]
else:
wait = delay
delay *= 2
logger.warning(
"Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, exc, delay,
"Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
exc, wait,
)
await asyncio.sleep(delay)
delay *= 2
await asyncio.sleep(wait)
assert last_error is not None
raise last_error

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",
)
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
llm_provider: Literal["ollama", "onemin"] = "ollama"
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ;
# "openrouter" = OpenRouter ; "mistral" = Mistral ; "gemini" = Google Gemini.
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] = "ollama"
ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b"
@@ -47,6 +48,41 @@ class Settings(BaseSettings):
onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini"
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
# Pour un modele precis gratuit : id finissant par `:free`.
openrouter_api_key: str = ""
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).
# 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.

View File

@@ -29,6 +29,17 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx",
"onemin_api_key",
"onemin_model",
"openrouter_api_key",
"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",
})

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

@@ -0,0 +1,205 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
"""
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
logger = logging.getLogger(__name__)
from app.domain.ports import LLMProviderError
_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:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
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 : 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] = []
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)
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 {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(
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
# 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:
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 d'OpenRouter (ex. le 429 précise
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
# code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur OpenRouter (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 commentaires 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, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur OpenRouter : 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 OpenRouter ({exc.__class__.__name__}) : {detail}"

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
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
"""
import asyncio
import json
import logging
from typing import Annotated, AsyncIterator, Literal
import hmac
@@ -14,11 +16,22 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
import re
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase
from app.application.import_campaign import ImportCampaignUseCase
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.settings_store import save_overrides
from app.domain.models import (
@@ -45,14 +58,19 @@ from app.domain.models import (
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
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
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.10.0-beta",
version="0.11.0-beta",
)
logger = logging.getLogger(__name__)
# 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
@@ -354,6 +372,12 @@ def get_llm_provider(
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings)
if settings.llm_provider == "mistral":
return MistralLLMProvider(settings)
if settings.llm_provider == "gemini":
return GeminiLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
@@ -413,6 +437,38 @@ def get_adapt_campaign_use_case(
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 ---
@@ -422,6 +478,54 @@ def health() -> dict[str, str]:
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)
async def generate(
body: GenerateRequest,
@@ -552,6 +656,11 @@ async def import_rules_stream(
yield _sse("error", {"message": str(exc)})
except LLMProviderError as 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")
@@ -587,6 +696,10 @@ async def import_campaign_stream(
yield _sse("error", {"message": str(exc)})
except LLMProviderError as 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")
@@ -688,7 +801,9 @@ async def chat_stream(
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx,
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
}
async def event_stream() -> AsyncIterator[str]:
@@ -768,6 +883,245 @@ async def summarize_conversation_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) ---------------------------------
@@ -885,12 +1239,27 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin"]
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
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
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
@@ -903,12 +1272,23 @@ class SettingsDTO(BaseModel):
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin"] | None = None
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
openrouter_model: 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
import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None
@@ -921,6 +1301,17 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model,
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,
import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds,
@@ -1081,6 +1472,144 @@ async def delete_ollama_model(
return {"status": "deleted", "name": name}
@app.get("/models/openrouter")
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
"""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
def _is_zero(value: object) -> bool:
try:
return float(value) == 0.0 # type: ignore[arg-type]
except (TypeError, ValueError):
return False
models: list[dict[str, object]] = []
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if not mid:
continue
pricing = m.get("pricing") or {}
is_free = mid.endswith(":free") or (
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
)
try:
ctx = int(m.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
models.append({
"id": mid,
"name": str(m.get("name") or mid),
"context_length": ctx,
"free": is_free,
})
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
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")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

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

View File

@@ -1,46 +1,32 @@
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.ports.CampaignPdfAdvisor;
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 java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* 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
* matière que le chat de campagne — et délègue la génération streamée au Brain via
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) 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>
*/
@Service
public class CampaignAdaptService {
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final CampaignBriefBuilder briefBuilder;
private final CampaignPdfAdvisor advisor;
public CampaignAdaptService(
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
CampaignBriefBuilder briefBuilder,
CampaignPdfAdvisor advisor) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.briefBuilder = briefBuilder;
this.advisor = advisor;
}
@@ -55,64 +41,6 @@ public class CampaignAdaptService {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId));
advisor.adviseStreaming(
pdfBytes, filename, buildBrief(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();
pdfBytes, filename, briefBuilder.build(campaign), messagesJson, onToken, onComplete, onError);
}
}

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

@@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder {
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
if (!anyHub) {
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
@@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder {
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
if (arc.getType() != ArcType.HUB) continue;
boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());

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) {
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(
"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) {
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(
"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

@@ -140,6 +140,21 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null);
}
@GetMapping("/models/openrouter")
public ResponseEntity<Map<String, Object>> listOpenRouterModels() {
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
* 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();
}
}

View File

@@ -5,7 +5,9 @@ server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 10M;
# Upload max : aligné sur la limite multipart du Core (64 Mo). Couvre les
# imports/adaptations de PDF (livres de règles/campagne illustrés) et les images.
client_max_body_size 64M;
location /api/ {
proxy_pass http://core:8080/api/;
@@ -15,9 +17,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Streaming SSE (chat, import, adaptation) : pas de buffering, et timeouts
# longs car une génération sur gros PDF peut tarder avant le 1er octet.
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# index.html : toujours revalide. Empeche un navigateur qui a precedemment

4
web/package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.10.0-beta",
"version": "0.11.0-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"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/: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/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/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) },
@@ -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/: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/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/: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) },

View File

@@ -7,6 +7,7 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -40,6 +41,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -59,7 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
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 }) => {
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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -77,6 +78,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -116,7 +118,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
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(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -59,6 +60,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -82,7 +84,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
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(
switchMap(data => {
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 { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service';
import { RandomTableService } from '../services/random-table.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.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)
@@ -22,13 +25,17 @@ export interface CampaignTreeData {
scenesByChapter: Record<string, Scene[]>;
characters: Character[];
npcs: Npc[];
randomTables: RandomTable[];
}
export function loadCampaignTreeData(
service: CampaignService,
campaignId: string,
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> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent
@@ -36,11 +43,14 @@ export function loadCampaignTreeData(
return forkJoin({
arcs: service.getArcs(campaignId),
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(
switchMap(({ arcs, characters, npcs }) => {
switchMap(({ arcs, characters, npcs, randomTables }) => {
if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs });
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables });
}
const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -55,7 +65,7 @@ export function loadCampaignTreeData(
});
if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs });
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables });
}
const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -64,7 +74,7 @@ export function loadCampaignTreeData(
map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters, npcs };
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables };
})
);
})
@@ -127,6 +137,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
id: `chapter-${ch.id}`,
label: ch.name,
iconKey: ch.icon ?? undefined,
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{
@@ -155,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">
<h2>Arcs narratifs</h2>
<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()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc

View File

@@ -12,6 +12,7 @@ import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model';
@@ -94,6 +95,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private gameSystemService: GameSystemService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private sessionService: SessionService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
@@ -111,8 +113,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}))
@@ -148,8 +150,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
@@ -246,18 +248,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
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 {
if (!this.campaign || !arc.id) return;
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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
@@ -91,6 +92,7 @@ export class CampaignImportComponent implements OnInit {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
@@ -102,7 +104,7 @@ export class CampaignImportComponent implements OnInit {
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// 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)))
.subscribe(data => this.existingData = data);
}

View File

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

View File

@@ -26,13 +26,17 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<div class="hub-section">
<h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
</h2>
<small class="field-hint">
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
que la quête soit débloquée dans une Partie. La progression en cours et l'état
des faits se gèrent dans l'écran de la Partie, pas ici.
Définition du scénario : toutes les conditions doivent être remplies (ET) pour que
{{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des
faits se gèrent dans l'écran de la Partie, pas ici.
</small>
<app-prerequisite-editor
[prerequisites]="prerequisites"

View File

@@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -92,6 +93,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -128,7 +130,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
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(
switchMap(data => {
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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService, GlobalItem } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../../services/campaign.model';
@@ -70,6 +71,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -89,7 +91,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(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 }) => {
this.chapter = chapter;
this.scenes = scenes;

View File

@@ -6,7 +6,14 @@
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
<p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0"
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel
</span>
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
@@ -25,8 +32,9 @@
</div>
</header>
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
OU dès qu'un chapitre linéaire porte des conditions. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
@@ -34,8 +42,9 @@
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
<small class="view-section-empty">
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie.
</small>
</ng-container>

View File

@@ -1,5 +1,21 @@
// Styles partagés via styles/_view.scss
// Badge "Conditionnel" : chapitre porteur de conditions de déblocage.
.cond-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
color: #f0c674;
background: rgba(240, 198, 116, 0.12);
border: 1px solid rgba(240, 198, 116, 0.35);
}
.quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04);

View File

@@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -32,6 +33,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
readonly Lock = Lock;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';
@@ -50,6 +52,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
@@ -77,7 +80,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
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(
switchMap(data => {
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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { SessionService } from '../../../services/session.service';
import { LayoutService } from '../../../services/layout.service';
@@ -55,6 +56,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private playthroughService: PlaythroughService,
private sessionService: SessionService,
private layoutService: LayoutService,
@@ -78,7 +80,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
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),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -37,6 +38,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -58,7 +60,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
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)
}).subscribe(({ campaign, allCampaigns, treeData, 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 { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -41,6 +42,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
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 }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? '';

View File

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

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