Mise à jour avec la possibilité de mettre des images
This commit is contained in:
@@ -1,16 +1,30 @@
|
|||||||
"""Use case : chat conversationnel LoreMind avec Structural Context.
|
"""Use case : chat conversationnel LoreMind avec Structural Context.
|
||||||
|
|
||||||
Construit un system prompt riche à partir du contexte structurel du Lore
|
Construit un system prompt riche à partir de 4 contextes possibles
|
||||||
(noms des dossiers, titres des pages, templates, tags) puis délègue au port
|
(Lore, Page focalisée, Campagne, entité narrative focalisée) puis délègue
|
||||||
`LLMChatProvider` pour le streaming token par token.
|
au port `LLMChatProvider` pour le streaming token par token.
|
||||||
|
|
||||||
Ne charge PAS les valeurs des pages — l'IA doit être au courant de ce qui
|
Ne charge PAS le contenu détaillé des pages — l'IA doit savoir ce qui
|
||||||
existe, pas être noyée sous le contenu. Pattern "Structural Context", plus
|
existe, pas être noyée sous le texte. Pattern "Structural Context", plus
|
||||||
simple que le RAG sémantique tant que le Lore reste de taille humaine.
|
simple que le RAG sémantique tant que les univers restent de taille humaine.
|
||||||
|
|
||||||
|
Combinaisons supportées :
|
||||||
|
- lore seul → chat Lore (page-edit / page-create)
|
||||||
|
- lore + page_context → chat Lore focalisé page
|
||||||
|
- campaign (+lore si liée) + optional narrative_entity → chat Campagne
|
||||||
"""
|
"""
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
from app.domain.models import ChatMessage, LoreStructuralContext, PageContext
|
from app.domain.models import (
|
||||||
|
ArcSummary,
|
||||||
|
CampaignStructuralContext,
|
||||||
|
ChatMessage,
|
||||||
|
ChapterSummary,
|
||||||
|
LoreStructuralContext,
|
||||||
|
NarrativeEntityContext,
|
||||||
|
PageContext,
|
||||||
|
PageSummary,
|
||||||
|
)
|
||||||
from app.domain.ports import LLMChatProvider
|
from app.domain.ports import LLMChatProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -21,22 +35,22 @@ _DEFAULT_TEMPERATURE = 0.7
|
|||||||
|
|
||||||
|
|
||||||
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
|
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
|
||||||
Tu dialogues avec le MJ pour l'aider à enrichir son univers.
|
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
|
||||||
|
|
||||||
Règles de ton :
|
Règles de ton :
|
||||||
- Réponds en français, ton chaleureux et créatif.
|
- Réponds en français, ton chaleureux et créatif.
|
||||||
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
|
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
|
||||||
- Propose des idées qui s'intègrent dans l'univers existant ci-dessous.
|
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
|
||||||
|
|
||||||
Règles de cohérence (IMPORTANT) :
|
Règles de cohérence (IMPORTANT) :
|
||||||
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures) — c'est ton rôle d'assistant créatif.
|
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
|
||||||
- Tu ne peux PAS faire référence à un élément du Lore du MJ comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans la section "Organisation" ci-dessous.
|
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
|
||||||
- Si l'utilisateur mentionne un nom que tu ne vois pas dans l'organisation, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans ton univers actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
|
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
|
||||||
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
|
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
|
||||||
|
|
||||||
|
|
||||||
class ChatUseCase:
|
class ChatUseCase:
|
||||||
"""Orchestre un tour de conversation avec le LLM + contexte Lore."""
|
"""Orchestre un tour de conversation avec le LLM + contextes structurels."""
|
||||||
|
|
||||||
def __init__(self, llm: LLMChatProvider) -> None:
|
def __init__(self, llm: LLMChatProvider) -> None:
|
||||||
self._llm = llm
|
self._llm = llm
|
||||||
@@ -44,17 +58,22 @@ class ChatUseCase:
|
|||||||
async def stream(
|
async def stream(
|
||||||
self,
|
self,
|
||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
context: LoreStructuralContext,
|
*,
|
||||||
|
lore_context: LoreStructuralContext | None = None,
|
||||||
page_context: PageContext | None = None,
|
page_context: PageContext | None = None,
|
||||||
|
campaign_context: CampaignStructuralContext | None = None,
|
||||||
|
narrative_entity: NarrativeEntityContext | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
||||||
|
|
||||||
Si `page_context` est fourni, le system prompt gagne une section
|
Les 4 contextes sont tous optionnels, mais au moins l'un des deux
|
||||||
"PAGE EN COURS" qui oriente l'IA vers cette page précise (titre,
|
"niveaux haut" (lore_context ou campaign_context) doit être fourni
|
||||||
template, champs, valeurs actuelles). Sans ce contexte, le chat
|
pour que le prompt ait du sens. Le controller (main.py) applique
|
||||||
reste générique au Lore (comportement avant b8).
|
cette règle à la frontière HTTP.
|
||||||
"""
|
"""
|
||||||
system_prompt = self._build_system_prompt(context, page_context)
|
system_prompt = self._build_system_prompt(
|
||||||
|
lore_context, page_context, campaign_context, narrative_entity
|
||||||
|
)
|
||||||
async for token in self._llm.stream_chat(
|
async for token in self._llm.stream_chat(
|
||||||
messages,
|
messages,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
@@ -62,28 +81,70 @@ class ChatUseCase:
|
|||||||
):
|
):
|
||||||
yield token
|
yield token
|
||||||
|
|
||||||
|
# --- Construction du system prompt --------------------------------------
|
||||||
|
|
||||||
def _build_system_prompt(
|
def _build_system_prompt(
|
||||||
self,
|
self,
|
||||||
ctx: LoreStructuralContext,
|
lore: LoreStructuralContext | None,
|
||||||
page_ctx: PageContext | None,
|
page: PageContext | None,
|
||||||
|
campaign: CampaignStructuralContext | None,
|
||||||
|
narrative: NarrativeEntityContext | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else ""
|
sections = [_BASE_SYSTEM]
|
||||||
folders_block = self._format_folders(ctx.folders)
|
if lore is not None:
|
||||||
tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)"
|
sections.append(self._format_lore(lore))
|
||||||
|
if campaign is not None:
|
||||||
|
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
|
||||||
|
if page is not None:
|
||||||
|
sections.append(self._format_page(page))
|
||||||
|
if narrative is not None:
|
||||||
|
sections.append(self._format_narrative_entity(narrative))
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
prompt = (
|
# --- Blocs Lore ---------------------------------------------------------
|
||||||
f"{_BASE_SYSTEM}\n\n"
|
|
||||||
f"--- UNIVERS COURANT ---\n"
|
@staticmethod
|
||||||
|
def _format_lore(ctx: LoreStructuralContext) -> str:
|
||||||
|
desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else ""
|
||||||
|
folders_block = ChatUseCase._format_folders(ctx.folders)
|
||||||
|
tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)"
|
||||||
|
return (
|
||||||
|
"--- UNIVERS (Lore) ---\n"
|
||||||
f"Nom : {ctx.lore_name}{desc}\n\n"
|
f"Nom : {ctx.lore_name}{desc}\n\n"
|
||||||
f"Organisation :\n{folders_block}\n\n"
|
f"Organisation :\n{folders_block}\n\n"
|
||||||
f"Tags déjà utilisés : {tags_line}"
|
f"Tags déjà utilisés : {tags_line}"
|
||||||
)
|
)
|
||||||
if page_ctx is not None:
|
|
||||||
prompt += "\n\n" + self._format_page_context(page_ctx)
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_page_context(pc: PageContext) -> str:
|
def _format_folders(folders: dict[str, list[PageSummary]]) -> str:
|
||||||
|
"""Rend chaque page avec son contenu exploitable par le LLM.
|
||||||
|
|
||||||
|
Depuis b9 : affiche en plus des champs values/tags/pages liées sous
|
||||||
|
forme d'une fiche indentée par page, et seulement si l'info existe
|
||||||
|
(prompt compact quand une page est vierge).
|
||||||
|
"""
|
||||||
|
if not folders:
|
||||||
|
return "(Lore vide pour l'instant)"
|
||||||
|
lines: list[str] = []
|
||||||
|
for folder_name, pages in folders.items():
|
||||||
|
lines.append(f"- {folder_name} (dossier)")
|
||||||
|
if not pages:
|
||||||
|
lines.append(" (vide)")
|
||||||
|
continue
|
||||||
|
for ps in pages:
|
||||||
|
lines.append(f" - {ps.title} [template: {ps.template_name}]")
|
||||||
|
for field_name, value in ps.values.items():
|
||||||
|
lines.append(f" · {field_name} : {value}")
|
||||||
|
if ps.tags:
|
||||||
|
lines.append(f" · tags : {', '.join(ps.tags)}")
|
||||||
|
if ps.related_page_titles:
|
||||||
|
lines.append(
|
||||||
|
" · liée à : " + ", ".join(ps.related_page_titles)
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_page(pc: PageContext) -> str:
|
||||||
"""Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée."""
|
"""Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée."""
|
||||||
if pc.template_fields:
|
if pc.template_fields:
|
||||||
fields_block = "\n".join(
|
fields_block = "\n".join(
|
||||||
@@ -92,29 +153,99 @@ class ChatUseCase:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
fields_block = "(aucun champ défini dans ce template)"
|
fields_block = "(aucun champ défini dans ce template)"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"--- PAGE EN COURS D'ÉDITION ---\n"
|
"--- PAGE EN COURS D'ÉDITION ---\n"
|
||||||
f"Titre : {pc.title}\n"
|
f"Titre : {pc.title}\n"
|
||||||
f"Template : {pc.template_name}\n"
|
f"Template : {pc.template_name}\n"
|
||||||
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
|
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
|
||||||
f"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. "
|
"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. "
|
||||||
f"Si l'utilisateur te demande de proposer des idées, elles doivent "
|
"Si l'utilisateur te demande de proposer des idées, elles doivent "
|
||||||
f"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas "
|
"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas "
|
||||||
f"vers d'autres pages ou d'autres templates du Lore, même si ça te "
|
"vers d'autres pages ou d'autres templates du Lore, même si ça te "
|
||||||
f"semblerait pertinent."
|
"semblerait pertinent."
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Blocs Campagne -----------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_campaign(ctx: CampaignStructuralContext, *, lore_present: bool) -> str:
|
||||||
|
desc = f"\nDescription : {ctx.campaign_description}" if ctx.campaign_description else ""
|
||||||
|
arcs_block = ChatUseCase._format_arcs(ctx.arcs)
|
||||||
|
lore_note = (
|
||||||
|
"\n(Cette campagne est liée à l'univers ci-dessus : tu peux t'appuyer dessus.)"
|
||||||
|
if lore_present
|
||||||
|
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"--- CAMPAGNE COURANTE ---\n"
|
||||||
|
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
|
||||||
|
f"Structure narrative :\n{arcs_block}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_folders(folders: dict[str, list[tuple[str, str]]]) -> str:
|
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||||
if not folders:
|
if not arcs:
|
||||||
return "(Lore vide pour l'instant)"
|
return "(Aucun arc créé pour l'instant.)"
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for folder_name, pages in folders.items():
|
for arc in arcs:
|
||||||
lines.append(f"- {folder_name} (dossier)")
|
lines.append(f"- {arc.name} (arc){ChatUseCase._illustration_hint(arc.illustration_count)}")
|
||||||
if not pages:
|
if arc.description:
|
||||||
lines.append(" (vide)")
|
lines.append(f" Synopsis : {arc.description}")
|
||||||
else:
|
if not arc.chapters:
|
||||||
for title, template in pages:
|
lines.append(" (aucun chapitre)")
|
||||||
lines.append(f" - {title} [template: {template}]")
|
continue
|
||||||
|
for chapter in arc.chapters:
|
||||||
|
lines.extend(ChatUseCase._format_chapter_block(chapter))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_chapter_block(chapter: ChapterSummary) -> list[str]:
|
||||||
|
hint = ChatUseCase._illustration_hint(chapter.illustration_count)
|
||||||
|
block = [f" - {chapter.name} (chapitre){hint}"]
|
||||||
|
if chapter.description:
|
||||||
|
block.append(f" Synopsis : {chapter.description}")
|
||||||
|
if not chapter.scenes:
|
||||||
|
block.append(" (aucune scène)")
|
||||||
|
else:
|
||||||
|
for scene in chapter.scenes:
|
||||||
|
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
|
||||||
|
block.append(f" - {scene.name} (scène){sc_hint}")
|
||||||
|
if scene.description:
|
||||||
|
block.append(f" Description : {scene.description}")
|
||||||
|
return block
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _illustration_hint(count: int) -> str:
|
||||||
|
"""Rend " [N illustrations]" si count > 0, sinon chaine vide.
|
||||||
|
|
||||||
|
Informe l'IA que l'entite a deja un support visuel. Permet de prioriser
|
||||||
|
les suggestions ecrites qui collent a l'existant visuel plutot que de
|
||||||
|
diverger.
|
||||||
|
"""
|
||||||
|
if count <= 0:
|
||||||
|
return ""
|
||||||
|
noun = "illustration" if count == 1 else "illustrations"
|
||||||
|
return f" [{count} {noun}]"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||||
|
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||||
|
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
|
||||||
|
ne.entity_type.lower(), ne.entity_type.upper()
|
||||||
|
)
|
||||||
|
if ne.fields:
|
||||||
|
fields_block = "\n".join(
|
||||||
|
f'- "{key}" : {value or "(vide)"}'
|
||||||
|
for key, value in ne.fields.items()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fields_block = "(aucun champ renseigné)"
|
||||||
|
return (
|
||||||
|
f"--- {type_label} EN COURS D'ÉDITION ---\n"
|
||||||
|
f"Titre : {ne.title}\n"
|
||||||
|
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
|
||||||
|
"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette entité narrative. "
|
||||||
|
"Tes suggestions doivent enrichir UNIQUEMENT les champs listés ci-dessus. "
|
||||||
|
"Ne déborde pas vers d'autres arcs, chapitres ou scènes de la campagne, "
|
||||||
|
"même si ça te semblerait pertinent."
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
ollama_base_url: str = "http://localhost:11434"
|
ollama_base_url: str = "http://localhost:11434"
|
||||||
llm_model: str = "gemma4:e2b"
|
llm_model: str = "gemma4:26b"
|
||||||
llm_timeout_seconds: int = 120
|
llm_timeout_seconds: int = 120
|
||||||
|
|
||||||
|
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
|
||||||
|
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
|
||||||
|
# à 16384 pour tenir ~100 pages enrichies. Coût VRAM : ~600 MB de KV cache
|
||||||
|
# supplémentaire (vs 2048) pour le modèle gemma 2B. Surchargeable via
|
||||||
|
# LLM_NUM_CTX dans .env si besoin (ex: VRAM limitée → 8192).
|
||||||
|
llm_num_ctx: int = 16384
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -53,20 +53,40 @@ class ChatMessage:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LoreStructuralContext:
|
class PageSummary:
|
||||||
"""Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer.
|
"""Résumé enrichi d'une page du Lore, projeté pour alimenter le prompt.
|
||||||
|
|
||||||
Pas de contenu des pages — uniquement noms, dossiers, templates, tags.
|
Depuis b9 : on ne se contente plus du nom + template, on embarque aussi
|
||||||
Suffit pour que l'IA propose des suggestions cohérentes avec l'existant.
|
les valeurs des champs dynamiques (tronquées côté Core Java à 500 car.),
|
||||||
|
les tags, et les titres des pages liées (les IDs techniques sont déjà
|
||||||
|
résolus en titres lisibles côté Java — voir LoreStructuralContextBuilder).
|
||||||
|
|
||||||
|
Les notes privées du MJ restent volontairement absentes ici (confinées
|
||||||
|
à leur page d'édition via PageContext quand l'utilisateur y travaille).
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
template_name: str
|
||||||
|
values: dict[str, str]
|
||||||
|
tags: list[str]
|
||||||
|
related_page_titles: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LoreStructuralContext:
|
||||||
|
"""Carte structurelle enrichie d'un Lore pour nourrir l'IA.
|
||||||
|
|
||||||
|
Depuis b9 : chaque page expose son contenu (values, tags, liens) via
|
||||||
|
PageSummary. Le prompt n'est plus qu'une table des matières — c'est
|
||||||
|
une encyclopédie condensée que le LLM peut directement citer.
|
||||||
|
|
||||||
Le dict `folders` est indexé par nom de dossier et mappe vers la liste
|
Le dict `folders` est indexé par nom de dossier et mappe vers la liste
|
||||||
des pages qu'il contient, chaque page étant représentée par le tuple
|
des pages qu'il contient (PageSummary).
|
||||||
(page_title, template_name).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lore_name: str
|
lore_name: str
|
||||||
lore_description: str | None
|
lore_description: str | None
|
||||||
folders: dict[str, list[tuple[str, str]]]
|
folders: dict[str, list[PageSummary]]
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
@@ -87,3 +107,65 @@ class PageContext:
|
|||||||
template_name: str
|
template_name: str
|
||||||
template_fields: list[str]
|
template_fields: list[str]
|
||||||
values: dict[str, str]
|
values: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SceneSummary:
|
||||||
|
"""Résumé d'une scène : nom + description courte + nb illustrations."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
# Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations
|
||||||
|
# attachees. 0 par defaut pour retrocompat si le Core n'envoie rien.
|
||||||
|
illustration_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChapterSummary:
|
||||||
|
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
scenes: list[SceneSummary]
|
||||||
|
illustration_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArcSummary:
|
||||||
|
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
chapters: list[ChapterSummary]
|
||||||
|
illustration_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CampaignStructuralContext:
|
||||||
|
"""Carte narrative enrichie d'une Campagne pour nourrir l'IA.
|
||||||
|
|
||||||
|
Jumeau de LoreStructuralContext côté Campaign. On décrit l'arbre
|
||||||
|
arcs → chapitres → scènes en donnant le NOM + une DESCRIPTION courte
|
||||||
|
(synopsis) à chaque niveau. Les champs longs (notes MJ, narration
|
||||||
|
joueur, combat) restent réservés à l'entité focus via
|
||||||
|
NarrativeEntityContext. Ordre narratif préservé dans la liste `arcs`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
campaign_name: str
|
||||||
|
campaign_description: str | None
|
||||||
|
arcs: list[ArcSummary]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NarrativeEntityContext:
|
||||||
|
"""Contexte d'une entité narrative précise en cours d'édition.
|
||||||
|
|
||||||
|
Équivalent de PageContext côté Campaign. Focalise l'IA sur un Arc,
|
||||||
|
Chapter ou Scene en particulier. `entity_type` ∈ {"arc","chapter","scene"}.
|
||||||
|
Les `fields` sont une map ordonnée nomChamp → valeurActuelle (chaîne
|
||||||
|
vide si non renseigné).
|
||||||
|
"""
|
||||||
|
|
||||||
|
entity_type: str
|
||||||
|
title: str
|
||||||
|
fields: dict[str, str]
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ class OllamaLLMProvider:
|
|||||||
self._base_url = settings.ollama_base_url
|
self._base_url = settings.ollama_base_url
|
||||||
self._model = settings.llm_model
|
self._model = settings.llm_model
|
||||||
self._timeout = settings.llm_timeout_seconds
|
self._timeout = settings.llm_timeout_seconds
|
||||||
|
self._num_ctx = settings.llm_num_ctx
|
||||||
|
|
||||||
|
def _build_options(self, temperature: float | None) -> dict[str, object]:
|
||||||
|
"""Construit le dict `options` attendu par Ollama (hyperparamètres).
|
||||||
|
|
||||||
|
`num_ctx` est TOUJOURS envoyé — sinon Ollama retombe sur son défaut
|
||||||
|
2048 et tronque silencieusement les gros prompts (Structural Context
|
||||||
|
du Lore enrichi depuis b9). `temperature` n'est ajoutée que si
|
||||||
|
fournie par le use case (sinon Ollama utilise son défaut).
|
||||||
|
"""
|
||||||
|
options: dict[str, object] = {"num_ctx": self._num_ctx}
|
||||||
|
if temperature is not None:
|
||||||
|
options["temperature"] = temperature
|
||||||
|
return options
|
||||||
|
|
||||||
async def generate(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
@@ -39,12 +53,10 @@ class OllamaLLMProvider:
|
|||||||
"model": self._model,
|
"model": self._model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
|
"options": self._build_options(temperature),
|
||||||
}
|
}
|
||||||
if output_format is not None:
|
if output_format is not None:
|
||||||
payload["format"] = output_format
|
payload["format"] = output_format
|
||||||
if temperature is not None:
|
|
||||||
# Ollama attend les hyperparamètres sous la clé "options".
|
|
||||||
payload["options"] = {"temperature": temperature}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
@@ -87,9 +99,8 @@ class OllamaLLMProvider:
|
|||||||
"model": self._model,
|
"model": self._model,
|
||||||
"messages": payload_messages,
|
"messages": payload_messages,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
|
"options": self._build_options(temperature),
|
||||||
}
|
}
|
||||||
if temperature is not None:
|
|
||||||
payload["options"] = {"temperature": temperature}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,7 +14,18 @@ from pydantic import BaseModel, Field
|
|||||||
from app.application.chat import ChatUseCase
|
from app.application.chat import ChatUseCase
|
||||||
from app.application.generate_page import GeneratePageUseCase
|
from app.application.generate_page import GeneratePageUseCase
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
from app.domain.models import ChatMessage, LoreStructuralContext, PageContext, PageGenerationContext
|
from app.domain.models import (
|
||||||
|
ArcSummary,
|
||||||
|
CampaignStructuralContext,
|
||||||
|
ChapterSummary,
|
||||||
|
ChatMessage,
|
||||||
|
LoreStructuralContext,
|
||||||
|
NarrativeEntityContext,
|
||||||
|
PageContext,
|
||||||
|
PageGenerationContext,
|
||||||
|
PageSummary,
|
||||||
|
SceneSummary,
|
||||||
|
)
|
||||||
from app.domain.ports import LLMProvider, LLMProviderError
|
from app.domain.ports import LLMProvider, LLMProviderError
|
||||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||||
|
|
||||||
@@ -61,19 +72,27 @@ class ChatMessageDTO(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class FolderPageDTO(BaseModel):
|
class PageSummaryDTO(BaseModel):
|
||||||
"""Résumé d'une page dans un dossier (titre + nom de template)."""
|
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
|
||||||
|
|
||||||
|
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
|
||||||
|
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
|
||||||
|
pour un Lore avec beaucoup de pages vierges).
|
||||||
|
"""
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
template_name: str
|
template_name: str
|
||||||
|
values: dict[str, str] = Field(default_factory=dict)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
related_page_titles: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class LoreContextDTO(BaseModel):
|
class LoreContextDTO(BaseModel):
|
||||||
"""Carte structurelle du Lore : on envoie des noms, pas des contenus."""
|
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
|
||||||
|
|
||||||
lore_name: str
|
lore_name: str
|
||||||
lore_description: str | None = None
|
lore_description: str | None = None
|
||||||
folders: dict[str, list[FolderPageDTO]] = Field(default_factory=dict)
|
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
|
||||||
tags: list[str] = Field(default_factory=list)
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,12 +105,68 @@ class PageContextDTO(BaseModel):
|
|||||||
values: dict[str, str] = Field(default_factory=dict)
|
values: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneSummaryDTO(BaseModel):
|
||||||
|
"""Résumé d'une scène : nom + description courte (synopsis)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
|
||||||
|
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
|
||||||
|
illustration_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterSummaryDTO(BaseModel):
|
||||||
|
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
scenes: list[SceneSummaryDTO] = Field(default_factory=list)
|
||||||
|
illustration_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ArcSummaryDTO(BaseModel):
|
||||||
|
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
chapters: list[ChapterSummaryDTO] = Field(default_factory=list)
|
||||||
|
illustration_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignContextDTO(BaseModel):
|
||||||
|
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||||
|
|
||||||
|
campaign_name: str
|
||||||
|
campaign_description: str | None = None
|
||||||
|
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class NarrativeEntityDTO(BaseModel):
|
||||||
|
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel."""
|
||||||
|
|
||||||
|
entity_type: str = Field(pattern="^(arc|chapter|scene)$")
|
||||||
|
title: str
|
||||||
|
fields: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ChatStreamRequestDTO(BaseModel):
|
class ChatStreamRequestDTO(BaseModel):
|
||||||
"""Requête de chat streamé : historique + contexte Lore (+ page éditée)."""
|
"""Requête de chat streamé : historique + contextes structurels.
|
||||||
|
|
||||||
|
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
|
||||||
|
mais au moins l'un des deux "niveaux haut" (lore_context ou
|
||||||
|
campaign_context) doit être fourni. Le validateur `check_scope` applique
|
||||||
|
cette règle à la frontière HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
messages: list[ChatMessageDTO] = Field(min_length=1)
|
messages: list[ChatMessageDTO] = Field(min_length=1)
|
||||||
lore_context: LoreContextDTO
|
lore_context: LoreContextDTO | None = None
|
||||||
page_context: PageContextDTO | None = None
|
page_context: PageContextDTO | None = None
|
||||||
|
campaign_context: CampaignContextDTO | None = None
|
||||||
|
narrative_entity: NarrativeEntityDTO | None = None
|
||||||
|
|
||||||
|
def has_scope(self) -> bool:
|
||||||
|
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
|
||||||
|
return self.lore_context is not None or self.campaign_context is not None
|
||||||
|
|
||||||
|
|
||||||
# --- Factories d'injection de dépendance ---
|
# --- Factories d'injection de dépendance ---
|
||||||
@@ -185,38 +260,38 @@ async def chat_stream(
|
|||||||
body: ChatStreamRequestDTO,
|
body: ChatStreamRequestDTO,
|
||||||
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Chat streamé (Server-Sent Events) avec Structural Context du Lore.
|
"""Chat streamé (Server-Sent Events) avec Structural Context.
|
||||||
|
|
||||||
|
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
|
||||||
|
entité narrative focalisée). Au moins un contexte racine (Lore ou
|
||||||
|
Campagne) est requis pour que la requête ait du sens.
|
||||||
|
|
||||||
Format de flux :
|
Format de flux :
|
||||||
- Chaque token : `data: {"token": "..."}\\n\\n`
|
- Chaque token : `data: {"token": "..."}\\n\\n`
|
||||||
- Fin normale : `event: done\\ndata: {}\\n\\n`
|
- Fin normale : `event: done\\ndata: {}\\n\\n`
|
||||||
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
|
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
|
||||||
|
|
||||||
Le media_type `text/event-stream` déclenche le comportement SSE côté
|
|
||||||
navigateur (objet EventSource) et la désactivation automatique du buffer.
|
|
||||||
"""
|
"""
|
||||||
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
if not body.has_scope():
|
||||||
context = LoreStructuralContext(
|
raise HTTPException(
|
||||||
lore_name=body.lore_context.lore_name,
|
status_code=422,
|
||||||
lore_description=body.lore_context.lore_description,
|
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
|
||||||
folders={
|
|
||||||
folder: [(p.title, p.template_name) for p in pages]
|
|
||||||
for folder, pages in body.lore_context.folders.items()
|
|
||||||
},
|
|
||||||
tags=body.lore_context.tags,
|
|
||||||
)
|
|
||||||
page_context: PageContext | None = None
|
|
||||||
if body.page_context is not None:
|
|
||||||
page_context = PageContext(
|
|
||||||
title=body.page_context.title,
|
|
||||||
template_name=body.page_context.template_name,
|
|
||||||
template_fields=body.page_context.template_fields,
|
|
||||||
values=body.page_context.values,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
||||||
|
lore_context = _to_lore_context(body.lore_context)
|
||||||
|
page_context = _to_page_context(body.page_context)
|
||||||
|
campaign_context = _to_campaign_context(body.campaign_context)
|
||||||
|
narrative_entity = _to_narrative_entity(body.narrative_entity)
|
||||||
|
|
||||||
async def event_stream() -> AsyncIterator[str]:
|
async def event_stream() -> AsyncIterator[str]:
|
||||||
try:
|
try:
|
||||||
async for token in use_case.stream(messages, context, page_context):
|
async for token in use_case.stream(
|
||||||
|
messages,
|
||||||
|
lore_context=lore_context,
|
||||||
|
page_context=page_context,
|
||||||
|
campaign_context=campaign_context,
|
||||||
|
narrative_entity=narrative_entity,
|
||||||
|
):
|
||||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||||
yield "event: done\ndata: {}\n\n"
|
yield "event: done\ndata: {}\n\n"
|
||||||
@@ -224,3 +299,85 @@ async def chat_stream(
|
|||||||
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
|
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None:
|
||||||
|
if dto is None:
|
||||||
|
return None
|
||||||
|
return LoreStructuralContext(
|
||||||
|
lore_name=dto.lore_name,
|
||||||
|
lore_description=dto.lore_description,
|
||||||
|
folders={
|
||||||
|
folder: [_to_page_summary(p) for p in pages]
|
||||||
|
for folder, pages in dto.folders.items()
|
||||||
|
},
|
||||||
|
tags=dto.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_page_summary(dto: PageSummaryDTO) -> PageSummary:
|
||||||
|
return PageSummary(
|
||||||
|
title=dto.title,
|
||||||
|
template_name=dto.template_name,
|
||||||
|
values=dict(dto.values),
|
||||||
|
tags=list(dto.tags),
|
||||||
|
related_page_titles=list(dto.related_page_titles),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_page_context(dto: PageContextDTO | None) -> PageContext | None:
|
||||||
|
if dto is None:
|
||||||
|
return None
|
||||||
|
return PageContext(
|
||||||
|
title=dto.title,
|
||||||
|
template_name=dto.template_name,
|
||||||
|
template_fields=dto.template_fields,
|
||||||
|
values=dto.values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None:
|
||||||
|
if dto is None:
|
||||||
|
return None
|
||||||
|
arcs = [
|
||||||
|
ArcSummary(
|
||||||
|
name=arc.name,
|
||||||
|
description=arc.description,
|
||||||
|
illustration_count=arc.illustration_count,
|
||||||
|
chapters=[
|
||||||
|
ChapterSummary(
|
||||||
|
name=ch.name,
|
||||||
|
description=ch.description,
|
||||||
|
illustration_count=ch.illustration_count,
|
||||||
|
scenes=[
|
||||||
|
SceneSummary(
|
||||||
|
name=sc.name,
|
||||||
|
description=sc.description,
|
||||||
|
illustration_count=sc.illustration_count,
|
||||||
|
)
|
||||||
|
for sc in ch.scenes
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for ch in arc.chapters
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for arc in dto.arcs
|
||||||
|
]
|
||||||
|
return CampaignStructuralContext(
|
||||||
|
campaign_name=dto.campaign_name,
|
||||||
|
campaign_description=dto.campaign_description,
|
||||||
|
arcs=arcs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None:
|
||||||
|
if dto is None:
|
||||||
|
return None
|
||||||
|
return NarrativeEntityContext(
|
||||||
|
entity_type=dto.entity_type,
|
||||||
|
title=dto.title,
|
||||||
|
fields=dict(dto.fields),
|
||||||
|
)
|
||||||
|
|||||||
@@ -69,6 +69,13 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images). -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>8.5.11</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public class ArcService {
|
|||||||
arc.setRewards(updated.getRewards());
|
arc.setRewards(updated.getRewards());
|
||||||
arc.setResolution(updated.getResolution());
|
arc.setResolution(updated.getResolution());
|
||||||
arc.setRelatedPageIds(updated.getRelatedPageIds());
|
arc.setRelatedPageIds(updated.getRelatedPageIds());
|
||||||
|
arc.setIllustrationImageIds(updated.getIllustrationImageIds());
|
||||||
return arcRepository.save(arc);
|
return arcRepository.save(arc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public class ChapterService {
|
|||||||
chapter.setPlayerObjectives(updated.getPlayerObjectives());
|
chapter.setPlayerObjectives(updated.getPlayerObjectives());
|
||||||
chapter.setNarrativeStakes(updated.getNarrativeStakes());
|
chapter.setNarrativeStakes(updated.getNarrativeStakes());
|
||||||
chapter.setRelatedPageIds(updated.getRelatedPageIds());
|
chapter.setRelatedPageIds(updated.getRelatedPageIds());
|
||||||
|
chapter.setIllustrationImageIds(updated.getIllustrationImageIds());
|
||||||
return chapterRepository.save(chapter);
|
return chapterRepository.save(chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public class SceneService {
|
|||||||
scene.setCombatDifficulty(updated.getCombatDifficulty());
|
scene.setCombatDifficulty(updated.getCombatDifficulty());
|
||||||
scene.setEnemies(updated.getEnemies());
|
scene.setEnemies(updated.getEnemies());
|
||||||
scene.setRelatedPageIds(updated.getRelatedPageIds());
|
scene.setRelatedPageIds(updated.getRelatedPageIds());
|
||||||
|
scene.setIllustrationImageIds(updated.getIllustrationImageIds());
|
||||||
return sceneRepository.save(scene);
|
return sceneRepository.save(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
|
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.SceneSummary;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service applicatif qui construit un {@link CampaignStructuralContext}
|
||||||
|
* depuis le Campaign Context (projection Campaign → GenerationContext).
|
||||||
|
*
|
||||||
|
* Traverse l'arbre arcs → chapitres → scènes en respectant l'ordre narratif
|
||||||
|
* (tri sur le champ `order` de chaque entité). Charge le NOM + le SYNOPSIS
|
||||||
|
* (description courte) de chaque niveau : l'IA sait donc de quoi parle
|
||||||
|
* chaque scène/chapitre/arc sans qu'on lui passe les notes MJ ou la
|
||||||
|
* narration détaillée — celles-ci restent réservées à l'entité focus via
|
||||||
|
* NarrativeEntityContext.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CampaignStructuralContextBuilder {
|
||||||
|
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
|
public CampaignStructuralContextBuilder(
|
||||||
|
CampaignRepository campaignRepository,
|
||||||
|
ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository) {
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
|
||||||
|
* nom + description courte à chaque niveau).
|
||||||
|
* @throws IllegalArgumentException si la Campagne est introuvable
|
||||||
|
*/
|
||||||
|
public CampaignStructuralContext build(String campaignId) {
|
||||||
|
Campaign campaign = campaignRepository.findById(campaignId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||||
|
|
||||||
|
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.sorted(Comparator.comparingInt(Arc::getOrder))
|
||||||
|
.map(this::toArcSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return CampaignStructuralContext.builder()
|
||||||
|
.campaignName(campaign.getName())
|
||||||
|
.campaignDescription(campaign.getDescription())
|
||||||
|
.arcs(arcs)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArcSummary toArcSummary(Arc arc) {
|
||||||
|
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||||
|
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||||
|
.map(this::toChapterSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ArcSummary.builder()
|
||||||
|
.name(arc.getName())
|
||||||
|
.description(arc.getDescription())
|
||||||
|
.illustrationCount(countImages(arc.getIllustrationImageIds()))
|
||||||
|
.chapters(chapters)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||||
|
List<SceneSummary> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||||
|
.sorted(Comparator.comparingInt(Scene::getOrder))
|
||||||
|
.map(this::toSceneSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ChapterSummary.builder()
|
||||||
|
.name(chapter.getName())
|
||||||
|
.description(chapter.getDescription())
|
||||||
|
.illustrationCount(countImages(chapter.getIllustrationImageIds()))
|
||||||
|
.scenes(scenes)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SceneSummary toSceneSummary(Scene scene) {
|
||||||
|
return SceneSummary.builder()
|
||||||
|
.name(scene.getName())
|
||||||
|
.description(scene.getDescription())
|
||||||
|
.illustrationCount(countImages(scene.getIllustrationImageIds()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||||
|
private static int countImages(List<String> ids) {
|
||||||
|
return ids == null ? 0 : ids.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,9 @@ public class GeneratePageValuesUseCase {
|
|||||||
.loreDescription(lore.getDescription())
|
.loreDescription(lore.getDescription())
|
||||||
.folderName(folder.getName())
|
.folderName(folder.getName())
|
||||||
.templateName(template.getName())
|
.templateName(template.getName())
|
||||||
.templateFields(template.getFields())
|
// Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
|
||||||
|
// necessitent un workflow different (pas de generation LLM texte).
|
||||||
|
.templateFields(template.textFieldNames())
|
||||||
.pageTitle(page.getTitle())
|
.pageTitle(page.getTitle())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -114,10 +116,12 @@ public class GeneratePageValuesUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void requireNonEmptyFields(Template template) {
|
private void requireNonEmptyFields(Template template) {
|
||||||
if (template.getFields() == null || template.getFields().isEmpty()) {
|
// On exige au moins un champ TEXT : les champs IMAGE ne sont pas genereables
|
||||||
|
// par l'IA (pas de text-to-image pour l'instant).
|
||||||
|
if (template.textFieldNames().isEmpty()) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Le template '" + template.getName()
|
"Le template '" + template.getName()
|
||||||
+ "' n'a aucun champ à générer.");
|
+ "' n'a aucun champ texte à générer.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||||
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
|
import com.loremind.domain.lorecontext.Template;
|
||||||
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service applicatif qui construit un {@link LoreStructuralContext}
|
||||||
|
* depuis le Lore Context (Single Responsibility : projection LoreContext → GenerationContext).
|
||||||
|
*
|
||||||
|
* Partagé entre {@link StreamChatForLoreUseCase} (Lore) et
|
||||||
|
* {@link StreamChatForCampaignUseCase} (Campagne liée à un Lore) pour
|
||||||
|
* respecter DRY — la carte structurelle d'un Lore se calcule de la même
|
||||||
|
* manière des deux côtés.
|
||||||
|
*
|
||||||
|
* Depuis b9 : chaque PageSummary embarque values/tags/relatedPageTitles
|
||||||
|
* (résolus en titres), avec troncature à {@value #MAX_VALUE_LENGTH} caractères
|
||||||
|
* par valeur pour garder le prompt sous contrôle.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LoreStructuralContextBuilder {
|
||||||
|
|
||||||
|
/** Garde-fou : évite qu'un champ énorme (ex: "Histoire" de 5000 car.) ne sature le prompt. */
|
||||||
|
private static final int MAX_VALUE_LENGTH = 500;
|
||||||
|
|
||||||
|
private final LoreRepository loreRepository;
|
||||||
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
|
private final PageRepository pageRepository;
|
||||||
|
private final TemplateRepository templateRepository;
|
||||||
|
|
||||||
|
public LoreStructuralContextBuilder(
|
||||||
|
LoreRepository loreRepository,
|
||||||
|
LoreNodeRepository loreNodeRepository,
|
||||||
|
PageRepository pageRepository,
|
||||||
|
TemplateRepository templateRepository) {
|
||||||
|
this.loreRepository = loreRepository;
|
||||||
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
|
this.pageRepository = pageRepository;
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la carte structurelle pour un Lore obligatoire.
|
||||||
|
* @throws IllegalArgumentException si le Lore est introuvable
|
||||||
|
*/
|
||||||
|
public LoreStructuralContext build(String loreId) {
|
||||||
|
return buildOptional(loreId).orElseThrow(() ->
|
||||||
|
new IllegalArgumentException("Lore non trouvé avec l'ID: " + loreId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante non-strict : renvoie Optional.empty() si le Lore a été supprimé
|
||||||
|
* (cas d'une Campagne dont le loreId pointe sur un Lore effacé entre-temps).
|
||||||
|
*/
|
||||||
|
public Optional<LoreStructuralContext> buildOptional(String loreId) {
|
||||||
|
return loreRepository.findById(loreId).map(this::buildFromLore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoreStructuralContext buildFromLore(Lore lore) {
|
||||||
|
List<LoreNode> nodes = loreNodeRepository.findByLoreId(lore.getId());
|
||||||
|
List<Page> pages = pageRepository.findByLoreId(lore.getId());
|
||||||
|
List<Template> templates = templateRepository.findByLoreId(lore.getId());
|
||||||
|
|
||||||
|
// Maps de résolution construites une seule fois — évite les N² en aval.
|
||||||
|
Map<String, String> templateNameById = templates.stream()
|
||||||
|
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
|
||||||
|
Map<String, String> pageTitleById = pages.stream()
|
||||||
|
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
|
||||||
|
|
||||||
|
return LoreStructuralContext.builder()
|
||||||
|
.loreName(lore.getName())
|
||||||
|
.loreDescription(lore.getDescription())
|
||||||
|
.folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
|
||||||
|
.tags(extractUniqueTags(pages))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, List<PageSummary>> buildFoldersMap(
|
||||||
|
List<LoreNode> nodes,
|
||||||
|
List<Page> pages,
|
||||||
|
Map<String, String> templateNameById,
|
||||||
|
Map<String, String> pageTitleById) {
|
||||||
|
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
|
||||||
|
Map<String, List<PageSummary>> folders = new LinkedHashMap<>();
|
||||||
|
for (LoreNode node : nodes) {
|
||||||
|
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById, pageTitleById));
|
||||||
|
}
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PageSummary> pagesInFolder(
|
||||||
|
String nodeId,
|
||||||
|
List<Page> allPages,
|
||||||
|
Map<String, String> templateNameById,
|
||||||
|
Map<String, String> pageTitleById) {
|
||||||
|
return allPages.stream()
|
||||||
|
.filter(p -> nodeId.equals(p.getNodeId()))
|
||||||
|
.map(p -> toPageSummary(p, templateNameById, pageTitleById))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PageSummary toPageSummary(
|
||||||
|
Page page,
|
||||||
|
Map<String, String> templateNameById,
|
||||||
|
Map<String, String> pageTitleById) {
|
||||||
|
return PageSummary.builder()
|
||||||
|
.title(page.getTitle())
|
||||||
|
.templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
|
||||||
|
.values(truncatedValues(page.getValues()))
|
||||||
|
.tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
|
||||||
|
.relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copie défensive des values avec troncature par valeur.
|
||||||
|
* Les entrées vides/nulles sont filtrées pour alléger le prompt.
|
||||||
|
*/
|
||||||
|
private Map<String, String> truncatedValues(Map<String, String> source) {
|
||||||
|
if (source == null || source.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
Map<String, String> out = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, String> e : source.entrySet()) {
|
||||||
|
String v = e.getValue();
|
||||||
|
if (v == null || v.isBlank()) continue;
|
||||||
|
out.put(e.getKey(), truncate(v));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String value) {
|
||||||
|
if (value.length() <= MAX_VALUE_LENGTH) return value;
|
||||||
|
return value.substring(0, MAX_VALUE_LENGTH) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout les IDs de pages liées en titres. Un ID qui ne matche rien
|
||||||
|
* (page supprimée entre-temps) est silencieusement ignoré — pas de "?"
|
||||||
|
* qui polluerait le prompt.
|
||||||
|
*/
|
||||||
|
private List<String> resolveRelatedTitles(
|
||||||
|
List<String> relatedIds, Map<String, String> pageTitleById) {
|
||||||
|
if (relatedIds == null || relatedIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return relatedIds.stream()
|
||||||
|
.map(pageTitleById::get)
|
||||||
|
.filter(title -> title != null && !title.isBlank())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractUniqueTags(List<Page> pages) {
|
||||||
|
return pages.stream()
|
||||||
|
.filter(p -> p.getTags() != null)
|
||||||
|
.flatMap(p -> p.getTags().stream())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service applicatif qui construit un {@link NarrativeEntityContext}
|
||||||
|
* depuis une entité Arc / Chapter / Scene du Campaign Context.
|
||||||
|
*
|
||||||
|
* Responsabilité unique : mapper les champs textuels spécifiques de chaque
|
||||||
|
* type vers la map uniforme `fields` du VO. Utilise LinkedHashMap pour
|
||||||
|
* préserver l'ordre des champs dans le prompt (lisibilité).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class NarrativeEntityContextBuilder {
|
||||||
|
|
||||||
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
|
public NarrativeEntityContextBuilder(
|
||||||
|
ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository) {
|
||||||
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||||
|
*
|
||||||
|
* @param entityType "arc", "chapter" ou "scene" (insensible à la casse)
|
||||||
|
* @param entityId l'ID de l'entité
|
||||||
|
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||||
|
*/
|
||||||
|
public NarrativeEntityContext build(String entityType, String entityId) {
|
||||||
|
String normalized = entityType == null ? "" : entityType.trim().toLowerCase();
|
||||||
|
switch (normalized) {
|
||||||
|
case "arc": return fromArc(loadArc(entityId));
|
||||||
|
case "chapter": return fromChapter(loadChapter(entityId));
|
||||||
|
case "scene": return fromScene(loadScene(entityId));
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chargement ---------------------------------------------------------
|
||||||
|
|
||||||
|
private Arc loadArc(String id) {
|
||||||
|
return arcRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Arc non trouvé: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Chapter loadChapter(String id) {
|
||||||
|
return chapterRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Chapitre non trouvé: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Scene loadScene(String id) {
|
||||||
|
return sceneRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Scène non trouvée: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping entité → VO ------------------------------------------------
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromArc(Arc a) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "description (synopsis)", a.getDescription());
|
||||||
|
putField(fields, "themes", a.getThemes());
|
||||||
|
putField(fields, "stakes", a.getStakes());
|
||||||
|
putField(fields, "rewards", a.getRewards());
|
||||||
|
putField(fields, "resolution", a.getResolution());
|
||||||
|
putField(fields, "gmNotes", a.getGmNotes());
|
||||||
|
return NarrativeEntityContext.builder()
|
||||||
|
.entityType("arc")
|
||||||
|
.title(a.getName())
|
||||||
|
.fields(fields)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromChapter(Chapter c) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "description (synopsis)", c.getDescription());
|
||||||
|
putField(fields, "playerObjectives", c.getPlayerObjectives());
|
||||||
|
putField(fields, "narrativeStakes", c.getNarrativeStakes());
|
||||||
|
putField(fields, "gmNotes", c.getGmNotes());
|
||||||
|
return NarrativeEntityContext.builder()
|
||||||
|
.entityType("chapter")
|
||||||
|
.title(c.getName())
|
||||||
|
.fields(fields)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromScene(Scene s) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
putField(fields, "description", s.getDescription());
|
||||||
|
putField(fields, "location", s.getLocation());
|
||||||
|
putField(fields, "timing", s.getTiming());
|
||||||
|
putField(fields, "atmosphere", s.getAtmosphere());
|
||||||
|
putField(fields, "playerNarration", s.getPlayerNarration());
|
||||||
|
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
||||||
|
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||||
|
putField(fields, "enemies", s.getEnemies());
|
||||||
|
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||||
|
return NarrativeEntityContext.builder()
|
||||||
|
.entityType("scene")
|
||||||
|
.title(s.getName())
|
||||||
|
.fields(fields)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||||
|
private static void putField(Map<String, String> target, String key, String value) {
|
||||||
|
target.put(key, value == null ? "" : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.loremind.application.generationcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case applicatif : chat conversationnel pour une Campagne avec Structural Context.
|
||||||
|
*
|
||||||
|
* Orchestre :
|
||||||
|
* 1. Chargement de la carte narrative de la Campagne (arcs → chapitres → scènes).
|
||||||
|
* 2. Si la Campagne est liée à un Lore (`loreId`), chargement également de
|
||||||
|
* la carte du Lore associé (asymétrie métier : Campagne voit son Lore).
|
||||||
|
* 3. Si une entité narrative précise est ciblée (arc/chapter/scene en cours
|
||||||
|
* d'édition), focalisation via `NarrativeEntityContext`.
|
||||||
|
* 4. Délégation au port `AiChatProvider` pour le streaming token par token.
|
||||||
|
*
|
||||||
|
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StreamChatForCampaignUseCase {
|
||||||
|
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||||
|
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||||
|
private final NarrativeEntityContextBuilder narrativeEntityContextBuilder;
|
||||||
|
private final AiChatProvider aiChatProvider;
|
||||||
|
|
||||||
|
public StreamChatForCampaignUseCase(
|
||||||
|
CampaignRepository campaignRepository,
|
||||||
|
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||||
|
LoreStructuralContextBuilder loreContextBuilder,
|
||||||
|
NarrativeEntityContextBuilder narrativeEntityContextBuilder,
|
||||||
|
AiChatProvider aiChatProvider) {
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.campaignContextBuilder = campaignContextBuilder;
|
||||||
|
this.loreContextBuilder = loreContextBuilder;
|
||||||
|
this.narrativeEntityContextBuilder = narrativeEntityContextBuilder;
|
||||||
|
this.aiChatProvider = aiChatProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streame la réponse du LLM pour la Campagne donnée.
|
||||||
|
*
|
||||||
|
* Méthode bloquante : retourne une fois le stream terminé (onComplete ou onError).
|
||||||
|
* L'appelant (controller SSE) doit l'exécuter dans un thread dédié.
|
||||||
|
*
|
||||||
|
* @param campaignId obligatoire — la campagne concernée
|
||||||
|
* @param entityType optionnel ("arc"|"chapter"|"scene") — si fourni avec entityId,
|
||||||
|
* focalise l'IA sur l'entité narrative en cours d'édition.
|
||||||
|
* @param entityId optionnel — ID de l'entité si `entityType` est fourni
|
||||||
|
* @throws IllegalArgumentException si la Campagne (ou l'entité ciblée) est introuvable
|
||||||
|
*/
|
||||||
|
public void execute(
|
||||||
|
String campaignId,
|
||||||
|
String entityType,
|
||||||
|
String entityId,
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
Consumer<String> onToken,
|
||||||
|
Runnable onComplete,
|
||||||
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
|
Campaign campaign = campaignRepository.findById(campaignId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||||
|
|
||||||
|
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaignId);
|
||||||
|
LoreStructuralContext loreContext = loadLinkedLoreContextOrNull(campaign);
|
||||||
|
NarrativeEntityContext narrativeEntity = buildNarrativeEntityOrNull(entityType, entityId);
|
||||||
|
|
||||||
|
ChatRequest request = ChatRequest.builder()
|
||||||
|
.messages(messages)
|
||||||
|
.loreContext(loreContext)
|
||||||
|
.campaignContext(campaignContext)
|
||||||
|
.narrativeEntity(narrativeEntity)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le LoreStructuralContext si la campagne est liée ET que le Lore
|
||||||
|
* existe encore (cas dégradé : loreId pointant sur un Lore supprimé →
|
||||||
|
* on continue sans contexte Lore plutôt que d'échouer).
|
||||||
|
*/
|
||||||
|
private LoreStructuralContext loadLinkedLoreContextOrNull(Campaign campaign) {
|
||||||
|
if (!campaign.isLinkedToLore()) return null;
|
||||||
|
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext buildNarrativeEntityOrNull(String entityType, String entityId) {
|
||||||
|
if (entityType == null || entityType.isBlank()) return null;
|
||||||
|
if (entityId == null || entityId.isBlank()) return null;
|
||||||
|
return narrativeEntityContextBuilder.build(entityType, entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,52 +3,43 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage;
|
|
||||||
import com.loremind.domain.generationcontext.PageContext;
|
import com.loremind.domain.generationcontext.PageContext;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
|
* Use case applicatif : chat conversationnel avec Structural Context d'un Lore.
|
||||||
*
|
*
|
||||||
* Orchestrateur — charge la carte structurelle (dossiers + pages + templates
|
* Orchestrateur fin — délègue la construction du LoreStructuralContext au
|
||||||
* + tags) depuis le LoreContext, la traduit vers le GenerationContext, puis
|
* {@link LoreStructuralContextBuilder} (service partagé avec
|
||||||
* délègue au port AiChatProvider pour le streaming.
|
* {@link StreamChatForCampaignUseCase}), charge le PageContext si demandé,
|
||||||
|
* puis délègue au port AiChatProvider pour le streaming.
|
||||||
*
|
*
|
||||||
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
|
* Zéro persistance : la conversation est éphémère (responsabilité du frontend).
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class StreamChatForLoreUseCase {
|
public class StreamChatForLoreUseCase {
|
||||||
|
|
||||||
private final LoreRepository loreRepository;
|
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
|
||||||
private final PageRepository pageRepository;
|
private final PageRepository pageRepository;
|
||||||
private final TemplateRepository templateRepository;
|
private final TemplateRepository templateRepository;
|
||||||
private final AiChatProvider aiChatProvider;
|
private final AiChatProvider aiChatProvider;
|
||||||
|
|
||||||
public StreamChatForLoreUseCase(
|
public StreamChatForLoreUseCase(
|
||||||
LoreRepository loreRepository,
|
LoreStructuralContextBuilder loreContextBuilder,
|
||||||
LoreNodeRepository loreNodeRepository,
|
|
||||||
PageRepository pageRepository,
|
PageRepository pageRepository,
|
||||||
TemplateRepository templateRepository,
|
TemplateRepository templateRepository,
|
||||||
AiChatProvider aiChatProvider) {
|
AiChatProvider aiChatProvider) {
|
||||||
this.loreRepository = loreRepository;
|
this.loreContextBuilder = loreContextBuilder;
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
|
||||||
this.pageRepository = pageRepository;
|
this.pageRepository = pageRepository;
|
||||||
this.templateRepository = templateRepository;
|
this.templateRepository = templateRepository;
|
||||||
this.aiChatProvider = aiChatProvider;
|
this.aiChatProvider = aiChatProvider;
|
||||||
@@ -73,7 +64,7 @@ public class StreamChatForLoreUseCase {
|
|||||||
Runnable onComplete,
|
Runnable onComplete,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
LoreStructuralContext loreContext = buildLoreContext(loreId);
|
LoreStructuralContext loreContext = loreContextBuilder.build(loreId);
|
||||||
PageContext pageContext = (pageId == null || pageId.isBlank())
|
PageContext pageContext = (pageId == null || pageId.isBlank())
|
||||||
? null
|
? null
|
||||||
: buildPageContext(pageId);
|
: buildPageContext(pageId);
|
||||||
@@ -87,8 +78,6 @@ public class StreamChatForLoreUseCase {
|
|||||||
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
aiChatProvider.streamChat(request, onToken, onComplete, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Construction du contexte d'une page précise ------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge la Page + son Template et construit un PageContext prêt à injecter.
|
* Charge la Page + son Template et construit un PageContext prêt à injecter.
|
||||||
* Si le template est absent (page orpheline), on renvoie un PageContext
|
* Si le template est absent (page orpheline), on renvoie un PageContext
|
||||||
@@ -106,9 +95,9 @@ public class StreamChatForLoreUseCase {
|
|||||||
Template template = templateRepository.findById(page.getTemplateId()).orElse(null);
|
Template template = templateRepository.findById(page.getTemplateId()).orElse(null);
|
||||||
if (template != null) {
|
if (template != null) {
|
||||||
templateName = template.getName();
|
templateName = template.getName();
|
||||||
templateFields = template.getFields() != null
|
// On expose uniquement les noms des champs TEXT a l'IA pour le chat.
|
||||||
? template.getFields()
|
// Les champs IMAGE ne sont pas pertinents pour une generation textuelle.
|
||||||
: Collections.emptyList();
|
templateFields = template.textFieldNames();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,59 +112,4 @@ public class StreamChatForLoreUseCase {
|
|||||||
.values(values)
|
.values(values)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Construction de la carte structurelle ------------------------------
|
|
||||||
|
|
||||||
private LoreStructuralContext buildLoreContext(String loreId) {
|
|
||||||
Lore lore = loreRepository.findById(loreId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException(
|
|
||||||
"Lore non trouvé avec l'ID: " + loreId));
|
|
||||||
|
|
||||||
List<LoreNode> nodes = loreNodeRepository.findByLoreId(loreId);
|
|
||||||
List<Page> pages = pageRepository.findByLoreId(loreId);
|
|
||||||
List<Template> templates = templateRepository.findByLoreId(loreId);
|
|
||||||
|
|
||||||
Map<String, List<FolderPage>> folders = buildFoldersMap(nodes, pages, templates);
|
|
||||||
List<String> tags = extractUniqueTags(pages);
|
|
||||||
|
|
||||||
return LoreStructuralContext.builder()
|
|
||||||
.loreName(lore.getName())
|
|
||||||
.loreDescription(lore.getDescription())
|
|
||||||
.folders(folders)
|
|
||||||
.tags(tags)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, List<FolderPage>> buildFoldersMap(
|
|
||||||
List<LoreNode> nodes, List<Page> pages, List<Template> templates) {
|
|
||||||
|
|
||||||
Map<String, String> templateNameById = templates.stream()
|
|
||||||
.collect(Collectors.toMap(Template::getId, Template::getName, (a, b) -> a));
|
|
||||||
|
|
||||||
// LinkedHashMap : préserve l'ordre d'insertion pour un prompt lisible.
|
|
||||||
Map<String, List<FolderPage>> folders = new LinkedHashMap<>();
|
|
||||||
for (LoreNode node : nodes) {
|
|
||||||
folders.put(node.getName(), pagesInFolder(node.getId(), pages, templateNameById));
|
|
||||||
}
|
|
||||||
return folders;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<FolderPage> pagesInFolder(
|
|
||||||
String nodeId, List<Page> allPages, Map<String, String> templateNameById) {
|
|
||||||
return allPages.stream()
|
|
||||||
.filter(p -> nodeId.equals(p.getNodeId()))
|
|
||||||
.map(p -> FolderPage.builder()
|
|
||||||
.title(p.getTitle())
|
|
||||||
.templateName(templateNameById.getOrDefault(p.getTemplateId(), "?"))
|
|
||||||
.build())
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> extractUniqueTags(List<Page> pages) {
|
|
||||||
return pages.stream()
|
|
||||||
.filter(p -> p.getTags() != null)
|
|
||||||
.flatMap(p -> p.getTags().stream())
|
|
||||||
.distinct()
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.loremind.application.images;
|
||||||
|
|
||||||
|
import com.loremind.domain.images.Image;
|
||||||
|
import com.loremind.domain.images.ports.ImageRepository;
|
||||||
|
import com.loremind.domain.images.ports.ImageStorage;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour le Shared Kernel images.
|
||||||
|
*
|
||||||
|
* Orchestre l'upload / download / delete en combinant les deux ports du
|
||||||
|
* domaine : ImageStorage (binaire) et ImageRepository (metadonnees).
|
||||||
|
*
|
||||||
|
* Couche Application de l'Architecture Hexagonale : pas de JPA, pas de HTTP,
|
||||||
|
* pas de MinIO ici. Juste de la logique metier pure.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ImageService {
|
||||||
|
|
||||||
|
/** MIME types autorises a l'upload. Evite les fichiers piegeux deguises en image. */
|
||||||
|
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Taille max coherente avec la config Spring (application.properties). */
|
||||||
|
private static final long MAX_SIZE_BYTES = 10L * 1024 * 1024; // 10 Mo
|
||||||
|
|
||||||
|
private final ImageRepository imageRepository;
|
||||||
|
private final ImageStorage imageStorage;
|
||||||
|
|
||||||
|
public ImageService(ImageRepository imageRepository, ImageStorage imageStorage) {
|
||||||
|
this.imageRepository = imageRepository;
|
||||||
|
this.imageStorage = imageStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case upload : valide -> envoie le binaire -> persiste les metadonnees.
|
||||||
|
*
|
||||||
|
* En cas d'echec de persistance DB apres un upload MinIO reussi, on tente
|
||||||
|
* une compensation (suppression du binaire orphelin) pour eviter de
|
||||||
|
* laisser trainer un fichier sans reference.
|
||||||
|
*/
|
||||||
|
public Image upload(String filename, String contentType, InputStream data, long sizeBytes) {
|
||||||
|
validateUpload(filename, contentType, sizeBytes);
|
||||||
|
|
||||||
|
String storageKey = imageStorage.upload(filename, contentType, data, sizeBytes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Image image = Image.builder()
|
||||||
|
.filename(filename)
|
||||||
|
.contentType(contentType)
|
||||||
|
.sizeBytes(sizeBytes)
|
||||||
|
.storageKey(storageKey)
|
||||||
|
.uploadedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
return imageRepository.save(image);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
// Compensation : on evite le binaire orphelin en MinIO si la DB a plante.
|
||||||
|
imageStorage.delete(storageKey);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Image> getById(String id) {
|
||||||
|
return imageRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere le flux binaire d'une image via son ID metier.
|
||||||
|
* Utilise par le controller pour servir GET /api/images/:id.
|
||||||
|
*/
|
||||||
|
public Optional<InputStream> downloadById(String id) {
|
||||||
|
return imageRepository.findById(id)
|
||||||
|
.map(img -> imageStorage.download(img.getStorageKey()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression symetrique : binaire d'abord, metadonnees ensuite. */
|
||||||
|
public void deleteById(String id) {
|
||||||
|
imageRepository.findById(id).ifPresent(img -> {
|
||||||
|
imageStorage.delete(img.getStorageKey());
|
||||||
|
imageRepository.deleteById(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation --------------------------------------------------------
|
||||||
|
|
||||||
|
private void validateUpload(String filename, String contentType, long sizeBytes) {
|
||||||
|
if (filename == null || filename.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Le nom du fichier est requis.");
|
||||||
|
}
|
||||||
|
if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType.toLowerCase())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Type de fichier non supporte. Types acceptes : " + List.copyOf(ALLOWED_MIME_TYPES));
|
||||||
|
}
|
||||||
|
if (sizeBytes <= 0) {
|
||||||
|
throw new IllegalArgumentException("Le fichier est vide.");
|
||||||
|
}
|
||||||
|
if (sizeBytes > MAX_SIZE_BYTES) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Fichier trop volumineux (max " + (MAX_SIZE_BYTES / 1024 / 1024) + " Mo).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,9 @@ public class PageService {
|
|||||||
existing.setValues(changes.getValues() != null
|
existing.setValues(changes.getValues() != null
|
||||||
? new HashMap<>(changes.getValues())
|
? new HashMap<>(changes.getValues())
|
||||||
: new HashMap<>());
|
: new HashMap<>());
|
||||||
|
existing.setImageValues(changes.getImageValues() != null
|
||||||
|
? new HashMap<>(changes.getImageValues())
|
||||||
|
: new HashMap<>());
|
||||||
existing.setNotes(changes.getNotes());
|
existing.setNotes(changes.getNotes());
|
||||||
existing.setTags(changes.getTags() != null
|
existing.setTags(changes.getTags() != null
|
||||||
? new ArrayList<>(changes.getTags())
|
? new ArrayList<>(changes.getTags())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ public class TemplateService {
|
|||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String defaultNodeId,
|
String defaultNodeId,
|
||||||
List<String> fields) {
|
List<TemplateField> fields) {
|
||||||
Template template = Template.builder()
|
Template template = Template.builder()
|
||||||
.loreId(loreId)
|
.loreId(loreId)
|
||||||
.name(name)
|
.name(name)
|
||||||
@@ -68,8 +69,8 @@ public class TemplateService {
|
|||||||
existing.setDescription(changes.getDescription());
|
existing.setDescription(changes.getDescription());
|
||||||
existing.setDefaultNodeId(changes.getDefaultNodeId());
|
existing.setDefaultNodeId(changes.getDefaultNodeId());
|
||||||
existing.setFields(changes.getFields() != null
|
existing.setFields(changes.getFields() != null
|
||||||
? new ArrayList<>(changes.getFields())
|
? new ArrayList<TemplateField>(changes.getFields())
|
||||||
: new ArrayList<>());
|
: new ArrayList<TemplateField>());
|
||||||
// loreId volontairement immuable : un template ne migre pas d'un Lore à l'autre.
|
// loreId volontairement immuable : un template ne migre pas d'un Lore à l'autre.
|
||||||
return templateRepository.save(existing);
|
return templateRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ public class Arc {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
|
||||||
|
* Galerie ordonnee : la 1ere image est l'illustration principale.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public class Chapter {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des images (Shared Kernel) illustrant ce chapitre.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ public class Scene {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des images (Shared Kernel) illustrant cette scene.
|
||||||
|
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Singular;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte narrative enrichie d'une Campagne pour nourrir l'IA.
|
||||||
|
*
|
||||||
|
* Ceci est un Value Object du Generation Context (Bounded Context IA).
|
||||||
|
* Jumeau de LoreStructuralContext côté Campaign : on décrit l'arbre
|
||||||
|
* arcs → chapitres → scènes avec le NOM + une DESCRIPTION courte à chaque
|
||||||
|
* niveau. Les champs longs (notes MJ, narration joueur, combat) restent
|
||||||
|
* exclus : l'IA les obtient uniquement via {@link NarrativeEntityContext}
|
||||||
|
* pour l'entité focus.
|
||||||
|
*
|
||||||
|
* Objectif : permettre à l'IA de répondre "c'est quoi la scène X ?" même
|
||||||
|
* quand X n'est pas l'entité en cours d'édition, sans exploser le prompt.
|
||||||
|
* Budget typique : ~30 tokens/scène × 100 scènes = 3k tokens (confortable).
|
||||||
|
*
|
||||||
|
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
|
||||||
|
* fait par le use case côté application layer).
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class CampaignStructuralContext {
|
||||||
|
|
||||||
|
String campaignName;
|
||||||
|
String campaignDescription;
|
||||||
|
@Singular List<ArcSummary> arcs;
|
||||||
|
|
||||||
|
/** Résumé d'un arc : nom + description courte + ses chapitres. */
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public static class ArcSummary {
|
||||||
|
String name;
|
||||||
|
String description;
|
||||||
|
/** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
|
||||||
|
int illustrationCount;
|
||||||
|
@Singular List<ChapterSummary> chapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public static class ChapterSummary {
|
||||||
|
String name;
|
||||||
|
String description;
|
||||||
|
int illustrationCount;
|
||||||
|
@Singular List<SceneSummary> scenes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résumé d'une scène : nom + description courte. */
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public static class SceneSummary {
|
||||||
|
String name;
|
||||||
|
String description;
|
||||||
|
int illustrationCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,35 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* Object de valeur encapsulant une requête de chat streamé.
|
* Object de valeur encapsulant une requête de chat streamé.
|
||||||
*
|
*
|
||||||
* Regroupe l'historique de la conversation et le contexte structurel du
|
* Ceci est un Value Object du Generation Context.
|
||||||
* Lore — les deux informations dont l'IA a besoin pour répondre.
|
* Regroupe l'historique de la conversation et les contextes structurels
|
||||||
|
* (Lore et/ou Campagne) dont l'IA a besoin pour répondre.
|
||||||
|
*
|
||||||
|
* Combinaisons supportées (asymétrie demandée par le métier) :
|
||||||
|
* - loreContext seul → chat Lore (page-edit / page-create)
|
||||||
|
* - loreContext + pageContext → chat Lore focalisé sur une page
|
||||||
|
* - campaignContext (+ loreContext si liée) → chat Campagne, voit son Lore associé
|
||||||
|
* - campaignContext + narrativeEntity → chat Campagne focalisé sur arc/chapter/scene
|
||||||
|
*
|
||||||
|
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
|
||||||
|
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
|
||||||
|
* pas l'inverse).
|
||||||
*/
|
*/
|
||||||
@Value
|
@Value
|
||||||
@Builder
|
@Builder
|
||||||
public class ChatRequest {
|
public class ChatRequest {
|
||||||
|
|
||||||
List<ChatMessage> messages;
|
List<ChatMessage> messages;
|
||||||
|
|
||||||
|
/** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
|
||||||
LoreStructuralContext loreContext;
|
LoreStructuralContext loreContext;
|
||||||
/** Optionnel : contexte d'une page précise en cours d'édition. Null = chat générique au Lore. */
|
|
||||||
|
/** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
|
||||||
PageContext pageContext;
|
PageContext pageContext;
|
||||||
|
|
||||||
|
/** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
|
||||||
|
CampaignStructuralContext campaignContext;
|
||||||
|
|
||||||
|
/** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
|
||||||
|
NarrativeEntityContext narrativeEntity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Carte structurelle d'un Lore pour nourrir l'IA sans tout lui envoyer.
|
* Carte structurelle enrichie d'un Lore pour nourrir l'IA.
|
||||||
*
|
*
|
||||||
* Équivalent Java du LoreStructuralContext Python. Pas de contenu des pages,
|
* Équivalent Java du LoreStructuralContext Python. Depuis l'étape b9,
|
||||||
* uniquement la structure (dossiers, titres, templates, tags). Suffit pour
|
* chaque page expose ses valeurs de champs, ses tags et ses pages liées
|
||||||
* que l'IA propose des suggestions cohérentes avec l'existant.
|
* (résolues en titres) — plus uniquement son nom et son template.
|
||||||
*
|
*
|
||||||
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
* La map `folders` est indexée par nom de dossier et mappe vers la liste
|
||||||
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
|
||||||
@@ -23,17 +23,30 @@ public class LoreStructuralContext {
|
|||||||
|
|
||||||
String loreName;
|
String loreName;
|
||||||
String loreDescription;
|
String loreDescription;
|
||||||
Map<String, List<FolderPage>> folders;
|
Map<String, List<PageSummary>> folders;
|
||||||
@Singular List<String> tags;
|
@Singular List<String> tags;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé minimaliste d'une page : juste son titre et son template.
|
* Résumé projeté d'une page pour l'IA.
|
||||||
* Pas de valeurs, pas de notes, pas de tags (pour garder le prompt léger).
|
*
|
||||||
|
* Contient le contenu utile au raisonnement LLM :
|
||||||
|
* - title + templateName : identification
|
||||||
|
* - values : contenu des champs dynamiques (tronqué côté builder)
|
||||||
|
* - tags : étiquettes métier
|
||||||
|
* - relatedPageTitles : pages liées DÉJÀ résolues en titres lisibles
|
||||||
|
* (les IDs techniques n'ont aucune utilité dans un prompt LLM).
|
||||||
|
*
|
||||||
|
* Les notes privées du MJ ne figurent PAS ici (choix b9 : exposer
|
||||||
|
* uniquement ce qui est partageable en narration — les secrets MJ
|
||||||
|
* restent confinés à leur page d'édition).
|
||||||
*/
|
*/
|
||||||
@Value
|
@Value
|
||||||
@Builder
|
@Builder
|
||||||
public static class FolderPage {
|
public static class PageSummary {
|
||||||
String title;
|
String title;
|
||||||
String templateName;
|
String templateName;
|
||||||
|
Map<String, String> values;
|
||||||
|
List<String> tags;
|
||||||
|
List<String> relatedPageTitles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.loremind.domain.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte d'une entité narrative précise en cours d'édition (Arc, Chapter, ou Scene).
|
||||||
|
*
|
||||||
|
* Ceci est un Value Object du Generation Context.
|
||||||
|
* Équivalent de PageContext côté Lore mais appliqué à la Campagne : injecté
|
||||||
|
* dans le system prompt pour orienter l'IA vers CETTE entité précise plutôt
|
||||||
|
* que vers l'arbre narratif global. Modèle uniforme pour les 3 types :
|
||||||
|
* un discriminator `entityType` + un titre + une map de champs textuels.
|
||||||
|
*
|
||||||
|
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
|
||||||
|
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
|
||||||
|
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class NarrativeEntityContext {
|
||||||
|
|
||||||
|
/** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
|
||||||
|
String entityType;
|
||||||
|
String title;
|
||||||
|
Map<String, String> fields;
|
||||||
|
}
|
||||||
56
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
56
core/src/main/java/com/loremind/domain/images/Image.java
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package com.loremind.domain.images;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entite de domaine representant une image uploadee par l'utilisateur.
|
||||||
|
*
|
||||||
|
* Shared Kernel : cette entite vit dans un package transverse (ni LoreContext
|
||||||
|
* ni CampaignContext) car une image peut etre referencee par n'importe quelle
|
||||||
|
* entite de ces deux contextes (Page, Scene, Chapter, Arc). Elle n'appartient
|
||||||
|
* a aucun context en particulier.
|
||||||
|
*
|
||||||
|
* Design :
|
||||||
|
* - Metadata en DB relationnelle (Postgres)
|
||||||
|
* - Binaire sur object storage (MinIO/S3) referencE par `storageKey`
|
||||||
|
* - Le domaine ne connait pas MinIO : il manipule juste une cle opaque.
|
||||||
|
*
|
||||||
|
* Architecture Hexagonale : entite pure, aucune dependance technique.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Image {
|
||||||
|
|
||||||
|
/** Identifiant stable (String pour rester agnostique vis-a-vis du stockage). */
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/** Nom original du fichier uploade (ex: "portrait-elfe.jpg"). */
|
||||||
|
private String filename;
|
||||||
|
|
||||||
|
/** Type MIME valide (ex: "image/jpeg", "image/png", "image/webp"). */
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
/** Taille en octets, utile pour quotas et affichage UI. */
|
||||||
|
private long sizeBytes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cle opaque dans l'object storage (ex: "images/abc123.jpg").
|
||||||
|
* Le domaine ne fait qu'acheminer cette cle ; seul l'adaptateur MinIO sait
|
||||||
|
* comment la transformer en bucket + path pour recuperer le binaire.
|
||||||
|
*/
|
||||||
|
private String storageKey;
|
||||||
|
|
||||||
|
/** Horodatage de l'upload initial (l'image est immuable apres creation). */
|
||||||
|
private LocalDateTime uploadedAt;
|
||||||
|
|
||||||
|
// --- Methodes metier ---------------------------------------------------
|
||||||
|
|
||||||
|
/** Une image est "sereement valide" si elle pointe bien vers un binaire. */
|
||||||
|
public boolean isValid() {
|
||||||
|
return storageKey != null && !storageKey.isBlank()
|
||||||
|
&& contentType != null && contentType.startsWith("image/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.loremind.domain.images.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.images.Image;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des metadonnees d'images.
|
||||||
|
*
|
||||||
|
* Architecture Hexagonale : ce port est defini dans le domaine ; il est
|
||||||
|
* implemente par un adaptateur d'infrastructure (PostgresImageRepository).
|
||||||
|
*
|
||||||
|
* Ne manipule QUE les metadonnees (filename, mimeType, storageKey...).
|
||||||
|
* Le binaire est gere par un autre port : ImageStorage.
|
||||||
|
* Cette separation suit le Single Responsibility Principle (SRP).
|
||||||
|
*/
|
||||||
|
public interface ImageRepository {
|
||||||
|
|
||||||
|
Image save(Image image);
|
||||||
|
|
||||||
|
Optional<Image> findById(String id);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
boolean existsById(String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.loremind.domain.images.ports;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour le stockage du BINAIRE des images.
|
||||||
|
*
|
||||||
|
* Separe de ImageRepository (metadonnees) pour respecter le SRP :
|
||||||
|
* - ImageRepository --> Postgres (metadonnees)
|
||||||
|
* - ImageStorage --> MinIO/S3 (fichiers binaires)
|
||||||
|
*
|
||||||
|
* Le domaine raisonne en termes de "cle opaque" (storageKey).
|
||||||
|
* Chaque implementation (MinIO, filesystem, S3...) traduit cette cle selon
|
||||||
|
* sa propre logique physique.
|
||||||
|
*/
|
||||||
|
public interface ImageStorage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un flux binaire et retourne la cle generee.
|
||||||
|
*
|
||||||
|
* @param filename nom d'origine (utilise pour extraire l'extension)
|
||||||
|
* @param contentType MIME type valide
|
||||||
|
* @param data flux binaire a stocker
|
||||||
|
* @param sizeBytes taille en octets (requis par certains backends comme S3)
|
||||||
|
* @return cle opaque utilisable ensuite pour retrouver le binaire
|
||||||
|
*/
|
||||||
|
String upload(String filename, String contentType, InputStream data, long sizeBytes);
|
||||||
|
|
||||||
|
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
|
||||||
|
InputStream download(String storageKey);
|
||||||
|
|
||||||
|
/** Supprime le binaire. No-op silencieux si la cle n'existe pas. */
|
||||||
|
void delete(String storageKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type d'un champ dynamique d'un Template.
|
||||||
|
*
|
||||||
|
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
|
||||||
|
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
|
||||||
|
* (stockee dans Page.imageValues : Map<String, List<String>>)
|
||||||
|
*
|
||||||
|
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
|
||||||
|
*/
|
||||||
|
public enum FieldType {
|
||||||
|
TEXT,
|
||||||
|
IMAGE
|
||||||
|
}
|
||||||
@@ -31,9 +31,16 @@ public class Page {
|
|||||||
private String templateId;
|
private String templateId;
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
/** Valeurs des champs dynamiques définis par le Template. */
|
/** Valeurs des champs dynamiques TEXT définis par le Template. */
|
||||||
private Map<String, String> values;
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE du
|
||||||
|
* template, la liste ordonnee des IDs d'images uploadees (Shared Kernel images).
|
||||||
|
* Structure separee de `values` pour garder des types homogenes par map.
|
||||||
|
*/
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
/** Notes privées du MJ (non exportées vers FoundryVTT). */
|
/** Notes privées du MJ (non exportées vers FoundryVTT). */
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
@@ -61,6 +68,22 @@ public class Page {
|
|||||||
return values == null ? null : values.get(fieldName);
|
return values == null ? null : values.get(fieldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remplace la liste d'IDs d'images pour un champ IMAGE donne. */
|
||||||
|
public void setImageFieldValue(String fieldName, List<String> imageIds) {
|
||||||
|
if (imageValues == null) {
|
||||||
|
imageValues = new HashMap<>();
|
||||||
|
}
|
||||||
|
imageValues.put(fieldName, imageIds != null ? new ArrayList<>(imageIds) : new ArrayList<>());
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste d'IDs d'images pour un champ IMAGE (ou liste vide si absent). */
|
||||||
|
public List<String> getImageFieldValue(String fieldName) {
|
||||||
|
if (imageValues == null) return new ArrayList<>();
|
||||||
|
List<String> ids = imageValues.get(fieldName);
|
||||||
|
return ids != null ? ids : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
public void addTag(String tag) {
|
public void addTag(String tag) {
|
||||||
if (tag == null || tag.isBlank()) return;
|
if (tag == null || tag.isBlank()) return;
|
||||||
if (tags == null) tags = new ArrayList<>();
|
if (tags == null) tags = new ArrayList<>();
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import java.util.List;
|
|||||||
* Un Template :
|
* Un Template :
|
||||||
* - appartient à un Lore (loreId)
|
* - appartient à un Lore (loreId)
|
||||||
* - définit le noeud par défaut où seront rangées les Pages créées (defaultNodeId)
|
* - définit le noeud par défaut où seront rangées les Pages créées (defaultNodeId)
|
||||||
* - porte une liste ordonnée de noms de champs dynamiques (fields)
|
* - porte une liste ordonnée de {@link TemplateField} (nom + type TEXT/IMAGE)
|
||||||
* qui seront instanciés sur chaque Page produite depuis ce gabarit.
|
* qui seront instanciés sur chaque Page produite depuis ce gabarit.
|
||||||
*
|
*
|
||||||
|
* Evolution : les `fields` etaient autrefois de simples `List<String>` (noms seuls).
|
||||||
|
* Depuis l'ajout du support des images, chaque champ a un type discriminant pour
|
||||||
|
* piloter le rendu UI et la logique IA.
|
||||||
|
*
|
||||||
* Entité pure du domaine : aucune dépendance technique (Spring, JPA, etc.).
|
* Entité pure du domaine : aucune dépendance technique (Spring, JPA, etc.).
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@@ -23,11 +27,11 @@ import java.util.List;
|
|||||||
public class Template {
|
public class Template {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String loreId; // Rattachement au Lore propriétaire
|
private String loreId; // Rattachement au Lore propriétaire
|
||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String defaultNodeId; // Noeud cible des Pages générées
|
private String defaultNodeId; // Noeud cible des Pages générées
|
||||||
private List<String> fields; // Noms des champs dynamiques (ordonnés)
|
private List<TemplateField> fields; // Champs dynamiques ordonnes (nom + type)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
@@ -38,23 +42,39 @@ public class Template {
|
|||||||
return fields == null ? 0 : fields.size();
|
return fields == null ? 0 : fields.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ajoute un champ à la fin de la liste (ignore les doublons et les blancs). */
|
/**
|
||||||
public void addField(String fieldName) {
|
* Retourne uniquement les noms des champs de type TEXT.
|
||||||
if (fieldName == null || fieldName.isBlank()) {
|
* Utilise par l'IA : seul le texte peut etre genere, pas les images.
|
||||||
|
*/
|
||||||
|
public List<String> textFieldNames() {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
return fields.stream()
|
||||||
|
.filter(f -> f.getType() == FieldType.TEXT)
|
||||||
|
.map(TemplateField::getName)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ajoute un champ a la fin de la liste (ignore les doublons par nom et les blancs). */
|
||||||
|
public void addField(TemplateField field) {
|
||||||
|
if (field == null || field.getName() == null || field.getName().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (fields == null) {
|
if (fields == null) {
|
||||||
fields = new ArrayList<>();
|
fields = new ArrayList<>();
|
||||||
}
|
}
|
||||||
if (!fields.contains(fieldName)) {
|
boolean alreadyPresent = fields.stream()
|
||||||
fields.add(fieldName);
|
.anyMatch(f -> f.getName().equals(field.getName()));
|
||||||
|
if (!alreadyPresent) {
|
||||||
|
fields.add(field);
|
||||||
this.updatedAt = LocalDateTime.now();
|
this.updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retire un champ s'il existe. */
|
/** Retire le champ dont le nom correspond (premiere occurrence). */
|
||||||
public void removeField(String fieldName) {
|
public void removeField(String fieldName) {
|
||||||
if (fields != null && fields.remove(fieldName)) {
|
if (fields == null || fieldName == null) return;
|
||||||
|
boolean removed = fields.removeIf(f -> fieldName.equals(f.getName()));
|
||||||
|
if (removed) {
|
||||||
this.updatedAt = LocalDateTime.now();
|
this.updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object d'un champ de Template.
|
||||||
|
*
|
||||||
|
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
|
||||||
|
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
||||||
|
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
||||||
|
*
|
||||||
|
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
|
||||||
|
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
|
||||||
|
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
|
||||||
|
* casser le contrat.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TemplateField {
|
||||||
|
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
|
||||||
|
private String name;
|
||||||
|
/** Type du champ, pilote le rendu et la generation IA. */
|
||||||
|
private FieldType type;
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||||
|
public static TemplateField text(String name) {
|
||||||
|
return new TemplateField(name, FieldType.TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ de type IMAGE. */
|
||||||
|
public static TemplateField image(String name) {
|
||||||
|
return new TemplateField(name, FieldType.IMAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.loremind.infrastructure.ai;
|
package com.loremind.infrastructure.ai;
|
||||||
|
|
||||||
|
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.SceneSummary;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext.FolderPage;
|
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||||
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import com.loremind.domain.generationcontext.PageContext;
|
import com.loremind.domain.generationcontext.PageContext;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
import com.loremind.domain.generationcontext.ports.AiProviderException;
|
||||||
@@ -23,11 +28,15 @@ import java.util.function.Consumer;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter de sortie : implémente AiChatProvider en appelant
|
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
|
||||||
* le Brain Python via WebClient + SSE (Server-Sent Events).
|
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
|
||||||
*
|
*
|
||||||
* Responsabilités :
|
* Responsabilités :
|
||||||
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
|
* 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
|
||||||
|
* Sérialise lore_context, page_context, campaign_context et
|
||||||
|
* narrative_entity de façon conditionnelle selon le scénario d'appel
|
||||||
|
* (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
|
||||||
|
* focalisé arc-chapter-scene).
|
||||||
* 2. Consommer le flux SSE token par token.
|
* 2. Consommer le flux SSE token par token.
|
||||||
* 3. Invoquer onToken / onComplete / onError au bon moment.
|
* 3. Invoquer onToken / onComplete / onError au bon moment.
|
||||||
* 4. Traduire toute erreur technique en AiProviderException.
|
* 4. Traduire toute erreur technique en AiProviderException.
|
||||||
@@ -123,29 +132,32 @@ public class BrainAiChatClient implements AiChatProvider {
|
|||||||
|
|
||||||
// --- Construction du payload JSON vers le Brain -------------------------
|
// --- Construction du payload JSON vers le Brain -------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload JSON. Chaque contexte optionnel est omis s'il est
|
||||||
|
* null, pour s'aligner sur le schéma Pydantic côté Brain (champs
|
||||||
|
* Optional qui restent absents du dict transmis au LLM).
|
||||||
|
*/
|
||||||
private Map<String, Object> toPayload(ChatRequest request) {
|
private Map<String, Object> toPayload(ChatRequest request) {
|
||||||
Map<String, Object> root = new LinkedHashMap<>();
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
root.put("messages", request.getMessages().stream()
|
root.put("messages", request.getMessages().stream()
|
||||||
.map(this::messageToMap)
|
.map(this::messageToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
|
||||||
// page_context est optionnel côté Brain (Pydantic l'accepte null).
|
if (request.getLoreContext() != null) {
|
||||||
// On ne l'ajoute au payload que s'il est effectivement fourni.
|
root.put("lore_context", loreContextToMap(request.getLoreContext()));
|
||||||
|
}
|
||||||
if (request.getPageContext() != null) {
|
if (request.getPageContext() != null) {
|
||||||
root.put("page_context", pageContextToMap(request.getPageContext()));
|
root.put("page_context", pageContextToMap(request.getPageContext()));
|
||||||
}
|
}
|
||||||
|
if (request.getCampaignContext() != null) {
|
||||||
|
root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
|
||||||
|
}
|
||||||
|
if (request.getNarrativeEntity() != null) {
|
||||||
|
root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
|
||||||
|
}
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
|
||||||
map.put("title", pc.getTitle());
|
|
||||||
map.put("template_name", pc.getTemplateName());
|
|
||||||
map.put("template_fields", pc.getTemplateFields());
|
|
||||||
map.put("values", pc.getValues());
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> messageToMap(ChatMessage m) {
|
private Map<String, Object> messageToMap(ChatMessage m) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("role", m.getRole());
|
map.put("role", m.getRole());
|
||||||
@@ -159,9 +171,9 @@ public class BrainAiChatClient implements AiChatProvider {
|
|||||||
map.put("lore_description", ctx.getLoreDescription());
|
map.put("lore_description", ctx.getLoreDescription());
|
||||||
|
|
||||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, List<FolderPage>> e : ctx.getFolders().entrySet()) {
|
for (Map.Entry<String, List<PageSummary>> e : ctx.getFolders().entrySet()) {
|
||||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||||
.map(this::folderPageToMap)
|
.map(this::pageSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
map.put("folders", foldersMap);
|
map.put("folders", foldersMap);
|
||||||
@@ -169,10 +181,86 @@ public class BrainAiChatClient implements AiChatProvider {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> folderPageToMap(FolderPage fp) {
|
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", fp.getTitle());
|
map.put("title", ps.getTitle());
|
||||||
map.put("template_name", fp.getTemplateName());
|
map.put("template_name", ps.getTemplateName());
|
||||||
|
// values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
|
||||||
|
// de l'info — payload réseau plus léger quand la page est vierge.
|
||||||
|
if (ps.getValues() != null && !ps.getValues().isEmpty()) {
|
||||||
|
map.put("values", ps.getValues());
|
||||||
|
}
|
||||||
|
if (ps.getTags() != null && !ps.getTags().isEmpty()) {
|
||||||
|
map.put("tags", ps.getTags());
|
||||||
|
}
|
||||||
|
if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
|
||||||
|
map.put("related_page_titles", ps.getRelatedPageTitles());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("title", pc.getTitle());
|
||||||
|
map.put("template_name", pc.getTemplateName());
|
||||||
|
map.put("template_fields", pc.getTemplateFields());
|
||||||
|
map.put("values", pc.getValues());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("campaign_name", ctx.getCampaignName());
|
||||||
|
map.put("campaign_description", ctx.getCampaignDescription());
|
||||||
|
map.put("arcs", ctx.getArcs().stream()
|
||||||
|
.map(this::arcSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("name", a.getName());
|
||||||
|
map.put("description", a.getDescription());
|
||||||
|
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
|
||||||
|
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
|
||||||
|
if (a.getIllustrationCount() > 0) {
|
||||||
|
map.put("illustration_count", a.getIllustrationCount());
|
||||||
|
}
|
||||||
|
map.put("chapters", a.getChapters().stream()
|
||||||
|
.map(this::chapterSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("name", c.getName());
|
||||||
|
map.put("description", c.getDescription());
|
||||||
|
if (c.getIllustrationCount() > 0) {
|
||||||
|
map.put("illustration_count", c.getIllustrationCount());
|
||||||
|
}
|
||||||
|
map.put("scenes", c.getScenes().stream()
|
||||||
|
.map(this::sceneSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("name", s.getName());
|
||||||
|
map.put("description", s.getDescription());
|
||||||
|
if (s.getIllustrationCount() > 0) {
|
||||||
|
map.put("illustration_count", s.getIllustrationCount());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("entity_type", ne.getEntityType());
|
||||||
|
map.put("title", ne.getTitle());
|
||||||
|
map.put("fields", ne.getFields());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une Map<String, List<String>> du domaine en chaine JSON et inversement.
|
||||||
|
*
|
||||||
|
* Utilise pour Page.imageValues : pour chaque champ IMAGE du template
|
||||||
|
* (ex: "Portrait"), la map stocke la liste ordonnee des IDs d'images uploadees.
|
||||||
|
*
|
||||||
|
* Exemple de JSON produit :
|
||||||
|
* {"Portrait": ["42","17"], "Carte": ["99"]}
|
||||||
|
*
|
||||||
|
* Adaptateur technique d'infrastructure : le domaine ne connait jamais ce converter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class StringListMapJsonConverter
|
||||||
|
implements AttributeConverter<Map<String, List<String>>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(Map<String, List<String>> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur serialisation Map<String, List<String>> -> JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, List<String>> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(dbData, new TypeReference<Map<String, List<String>>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur deserialisation JSON -> Map<String, List<String>>", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.loremind.domain.lorecontext.FieldType;
|
||||||
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertisseur JPA pour {@code List<TemplateField>}.
|
||||||
|
*
|
||||||
|
* <h3>Backward compatibility (CRITIQUE)</h3>
|
||||||
|
* Les templates crees avant l'introduction de {@link TemplateField} sont
|
||||||
|
* persistes au format legacy : {@code ["Nom", "Histoire", "Portrait"]}.
|
||||||
|
* Les nouveaux templates utilisent le format : {@code [{"name":"Nom","type":"TEXT"}, ...]}.
|
||||||
|
*
|
||||||
|
* Ce converter sait lire les DEUX formats en lecture (tolerant) mais ecrit
|
||||||
|
* toujours au nouveau format. Cela evite une migration de donnees risquee :
|
||||||
|
* la premiere ecriture d'un template legacy suffit a le convertir.
|
||||||
|
*
|
||||||
|
* <h3>Responsabilite</h3>
|
||||||
|
* Adaptateur technique pur : le domaine ne connait jamais ce converter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class TemplateFieldListJsonConverter
|
||||||
|
implements AttributeConverter<List<TemplateField>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(List<TemplateField> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur serialisation List<TemplateField> -> JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TemplateField> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = MAPPER.readTree(dbData);
|
||||||
|
if (!root.isArray()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<TemplateField> result = new ArrayList<>();
|
||||||
|
for (JsonNode item : root) {
|
||||||
|
if (item.isTextual()) {
|
||||||
|
// Format legacy : chaine simple, on suppose TEXT par defaut.
|
||||||
|
result.add(TemplateField.text(item.asText()));
|
||||||
|
} else if (item.isObject()) {
|
||||||
|
// Nouveau format : {name, type}
|
||||||
|
String name = item.path("name").asText(null);
|
||||||
|
String typeStr = item.path("type").asText("TEXT");
|
||||||
|
FieldType type;
|
||||||
|
try {
|
||||||
|
type = FieldType.valueOf(typeStr);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
// Type inconnu (ajoute par une version future) : fallback TEXT.
|
||||||
|
type = FieldType.TEXT;
|
||||||
|
}
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
result.add(new TemplateField(name, type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur deserialisation JSON -> List<TemplateField>", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utilitaire de test pour verifier le parsing d'une chaine brute. */
|
||||||
|
static List<TemplateField> parseForTests(String dbData) {
|
||||||
|
return new TemplateFieldListJsonConverter().convertToEntityAttribute(dbData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeRef garde pour reference future si on veut deserialiser directement.
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static final TypeReference<List<TemplateField>> TYPE_REF =
|
||||||
|
new TypeReference<>() {};
|
||||||
|
}
|
||||||
@@ -62,6 +62,12 @@ public class ArcJpaEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images (Shared Kernel) illustrant cet arc. JSON dans colonne TEXT. */
|
||||||
|
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ public class ChapterJpaEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entite JPA pour les metadonnees d'images en PostgreSQL.
|
||||||
|
* Le binaire est stocke cote MinIO (reference par storage_key).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "images")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ImageJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String filename;
|
||||||
|
|
||||||
|
@Column(name = "content_type", nullable = false)
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
@Column(name = "size_bytes", nullable = false)
|
||||||
|
private long sizeBytes;
|
||||||
|
|
||||||
|
/** Cle opaque dans MinIO, unique. */
|
||||||
|
@Column(name = "storage_key", nullable = false, unique = true)
|
||||||
|
private String storageKey;
|
||||||
|
|
||||||
|
@Column(name = "uploaded_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime uploadedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (uploadedAt == null) {
|
||||||
|
uploadedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@@ -46,6 +47,11 @@ public class PageJpaEntity {
|
|||||||
@Convert(converter = StringMapJsonConverter.class)
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
private Map<String, String> values;
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Stocke les IDs d'images par champ IMAGE du template. JSON dans colonne TEXT. */
|
||||||
|
@Column(name = "image_values_json", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ public class SceneJpaEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "illustration_image_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -14,7 +15,8 @@ import java.util.List;
|
|||||||
* Entité JPA pour la persistance des Templates en PostgreSQL.
|
* Entité JPA pour la persistance des Templates en PostgreSQL.
|
||||||
* - loreId et defaultNodeId : colonnes typées (FK logiques, pas de @ManyToOne
|
* - loreId et defaultNodeId : colonnes typées (FK logiques, pas de @ManyToOne
|
||||||
* pour respecter l'isolation des Bounded Contexts).
|
* pour respecter l'isolation des Bounded Contexts).
|
||||||
* - fields : stocké en JSON (TEXT) via StringListJsonConverter.
|
* - fields : stocké en JSON (TEXT) via TemplateFieldListJsonConverter.
|
||||||
|
* Les anciens templates (format legacy ["a","b"]) sont lus de maniere tolerante.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "templates")
|
@Table(name = "templates")
|
||||||
@@ -41,8 +43,8 @@ public class TemplateJpaEntity {
|
|||||||
private Long defaultNodeId;
|
private Long defaultNodeId;
|
||||||
|
|
||||||
@Column(name = "fields", columnDefinition = "TEXT")
|
@Column(name = "fields", columnDefinition = "TEXT")
|
||||||
@Convert(converter = StringListJsonConverter.class)
|
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||||
private List<String> fields;
|
private List<TemplateField> fields;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Spring Data JPA pour ImageJpaEntity.
|
||||||
|
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
|
||||||
|
}
|
||||||
@@ -80,6 +80,9 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(jpaEntity.getCreatedAt())
|
.createdAt(jpaEntity.getCreatedAt())
|
||||||
.updatedAt(jpaEntity.getUpdatedAt())
|
.updatedAt(jpaEntity.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -101,6 +104,9 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.relatedPageIds(arc.getRelatedPageIds() != null
|
.relatedPageIds(arc.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(arc.getRelatedPageIds())
|
? new ArrayList<>(arc.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(arc.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(arc.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(arc.getCreatedAt())
|
.createdAt(arc.getCreatedAt())
|
||||||
.updatedAt(arc.getUpdatedAt())
|
.updatedAt(arc.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(jpaEntity.getCreatedAt())
|
.createdAt(jpaEntity.getCreatedAt())
|
||||||
.updatedAt(jpaEntity.getUpdatedAt())
|
.updatedAt(jpaEntity.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -96,6 +99,9 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.relatedPageIds(chapter.getRelatedPageIds() != null
|
.relatedPageIds(chapter.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(chapter.getRelatedPageIds())
|
? new ArrayList<>(chapter.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(chapter.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(chapter.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(chapter.getCreatedAt())
|
.createdAt(chapter.getCreatedAt())
|
||||||
.updatedAt(chapter.getUpdatedAt())
|
.updatedAt(chapter.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.images.Image;
|
||||||
|
import com.loremind.domain.images.ports.ImageRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptateur de sortie : implemente le port ImageRepository du domaine.
|
||||||
|
* Fait la traduction Image (domaine) <-> ImageJpaEntity (JPA).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class PostgresImageRepository implements ImageRepository {
|
||||||
|
|
||||||
|
private final ImageJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresImageRepository(ImageJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Image save(Image image) {
|
||||||
|
ImageJpaEntity saved = jpaRepository.save(toJpa(image));
|
||||||
|
return toDomain(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Image> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsById(String id) {
|
||||||
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Conversions -------------------------------------------------------
|
||||||
|
|
||||||
|
private Image toDomain(ImageJpaEntity e) {
|
||||||
|
return Image.builder()
|
||||||
|
.id(e.getId().toString())
|
||||||
|
.filename(e.getFilename())
|
||||||
|
.contentType(e.getContentType())
|
||||||
|
.sizeBytes(e.getSizeBytes())
|
||||||
|
.storageKey(e.getStorageKey())
|
||||||
|
.uploadedAt(e.getUploadedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageJpaEntity toJpa(Image img) {
|
||||||
|
Long id = img.getId() != null ? Long.parseLong(img.getId()) : null;
|
||||||
|
return ImageJpaEntity.builder()
|
||||||
|
.id(id)
|
||||||
|
.filename(img.getFilename())
|
||||||
|
.contentType(img.getContentType())
|
||||||
|
.sizeBytes(img.getSizeBytes())
|
||||||
|
.storageKey(img.getStorageKey())
|
||||||
|
.uploadedAt(img.getUploadedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,7 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
|
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
|
||||||
.title(e.getTitle())
|
.title(e.getTitle())
|
||||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.notes(e.getNotes())
|
.notes(e.getNotes())
|
||||||
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
|
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
|
||||||
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
|
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
|
||||||
@@ -120,6 +121,7 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
? Long.parseLong(p.getTemplateId()) : null)
|
? Long.parseLong(p.getTemplateId()) : null)
|
||||||
.title(p.getTitle())
|
.title(p.getTitle())
|
||||||
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
||||||
.notes(p.getNotes())
|
.notes(p.getNotes())
|
||||||
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
|
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
|
||||||
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())
|
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
.relatedPageIds(jpaEntity.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
? new ArrayList<>(jpaEntity.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(jpaEntity.getCreatedAt())
|
.createdAt(jpaEntity.getCreatedAt())
|
||||||
.updatedAt(jpaEntity.getUpdatedAt())
|
.updatedAt(jpaEntity.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -106,6 +109,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.relatedPageIds(scene.getRelatedPageIds() != null
|
.relatedPageIds(scene.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(scene.getRelatedPageIds())
|
? new ArrayList<>(scene.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(scene.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(scene.getCreatedAt())
|
.createdAt(scene.getCreatedAt())
|
||||||
.updatedAt(scene.getUpdatedAt())
|
.updatedAt(scene.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.postgres;
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity;
|
import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity;
|
||||||
import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository;
|
||||||
@@ -76,7 +77,9 @@ public class PostgresTemplateRepository implements TemplateRepository {
|
|||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.description(e.getDescription())
|
.description(e.getDescription())
|
||||||
.defaultNodeId(e.getDefaultNodeId() != null ? e.getDefaultNodeId().toString() : null)
|
.defaultNodeId(e.getDefaultNodeId() != null ? e.getDefaultNodeId().toString() : null)
|
||||||
.fields(e.getFields() != null ? new ArrayList<>(e.getFields()) : new ArrayList<>())
|
.fields(e.getFields() != null
|
||||||
|
? new ArrayList<TemplateField>(e.getFields())
|
||||||
|
: new ArrayList<TemplateField>())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
.updatedAt(e.getUpdatedAt())
|
.updatedAt(e.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -89,7 +92,9 @@ public class PostgresTemplateRepository implements TemplateRepository {
|
|||||||
.name(t.getName())
|
.name(t.getName())
|
||||||
.description(t.getDescription())
|
.description(t.getDescription())
|
||||||
.defaultNodeId(t.getDefaultNodeId() != null ? Long.parseLong(t.getDefaultNodeId()) : null)
|
.defaultNodeId(t.getDefaultNodeId() != null ? Long.parseLong(t.getDefaultNodeId()) : null)
|
||||||
.fields(t.getFields() != null ? new ArrayList<>(t.getFields()) : new ArrayList<>())
|
.fields(t.getFields() != null
|
||||||
|
? new ArrayList<TemplateField>(t.getFields())
|
||||||
|
: new ArrayList<TemplateField>())
|
||||||
.createdAt(t.getCreatedAt())
|
.createdAt(t.getCreatedAt())
|
||||||
.updatedAt(t.getUpdatedAt())
|
.updatedAt(t.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.loremind.infrastructure.storage;
|
||||||
|
|
||||||
|
import io.minio.BucketExistsArgs;
|
||||||
|
import io.minio.MakeBucketArgs;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration Spring pour le client MinIO (S3-compatible).
|
||||||
|
*
|
||||||
|
* Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter.
|
||||||
|
* S'assure au demarrage que le bucket configure existe (filet de securite :
|
||||||
|
* normalement docker-compose/minio-init l'a deja cree).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MinioConfig {
|
||||||
|
|
||||||
|
@Value("${minio.endpoint}")
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Value("${minio.access-key}")
|
||||||
|
private String accessKey;
|
||||||
|
|
||||||
|
@Value("${minio.secret-key}")
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
@Value("${minio.bucket}")
|
||||||
|
private String bucket;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MinioClient minioClient() {
|
||||||
|
return MinioClient.builder()
|
||||||
|
.endpoint(endpoint)
|
||||||
|
.credentials(accessKey, secretKey)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garantit l'existence du bucket au demarrage. Si MinIO n'est pas joignable,
|
||||||
|
* on loggue juste l'erreur sans planter l'application : le developpeur
|
||||||
|
* recevra une erreur claire au premier upload plutot qu'au boot.
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void ensureBucketExists() {
|
||||||
|
try {
|
||||||
|
MinioClient client = minioClient();
|
||||||
|
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
|
||||||
|
if (!exists) {
|
||||||
|
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
|
||||||
|
System.out.println("[MinIO] Bucket '" + bucket + "' cree.");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("[MinIO] Initialisation impossible (endpoint=" + endpoint
|
||||||
|
+ "). Les uploads d'images echoueront tant que MinIO n'est pas joignable. "
|
||||||
|
+ "Cause : " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.loremind.infrastructure.storage;
|
||||||
|
|
||||||
|
import com.loremind.domain.images.ports.ImageStorage;
|
||||||
|
import io.minio.GetObjectArgs;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.PutObjectArgs;
|
||||||
|
import io.minio.RemoveObjectArgs;
|
||||||
|
import io.minio.errors.ErrorResponseException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptateur d'infrastructure : implemente le port ImageStorage en utilisant
|
||||||
|
* MinIO (compatible S3) comme backend de stockage d'objets.
|
||||||
|
*
|
||||||
|
* Le domaine ne sait rien de MinIO : il manipule juste des cles opaques.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MinioImageStorageAdapter implements ImageStorage {
|
||||||
|
|
||||||
|
private final MinioClient minioClient;
|
||||||
|
private final String bucket;
|
||||||
|
|
||||||
|
public MinioImageStorageAdapter(MinioClient minioClient,
|
||||||
|
@Value("${minio.bucket}") String bucket) {
|
||||||
|
this.minioClient = minioClient;
|
||||||
|
this.bucket = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String upload(String filename, String contentType, InputStream data, long sizeBytes) {
|
||||||
|
String storageKey = generateStorageKey(filename);
|
||||||
|
try {
|
||||||
|
minioClient.putObject(
|
||||||
|
PutObjectArgs.builder()
|
||||||
|
.bucket(bucket)
|
||||||
|
.object(storageKey)
|
||||||
|
.stream(data, sizeBytes, -1)
|
||||||
|
.contentType(contentType)
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
return storageKey;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Echec de l'upload de l'image vers MinIO : " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputStream download(String storageKey) {
|
||||||
|
try {
|
||||||
|
return minioClient.getObject(
|
||||||
|
GetObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
||||||
|
);
|
||||||
|
} catch (ErrorResponseException e) {
|
||||||
|
// Objet inexistant (cle orpheline) : on retourne null plutot que de propager.
|
||||||
|
if ("NoSuchKey".equals(e.errorResponse().code())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String storageKey) {
|
||||||
|
try {
|
||||||
|
minioClient.removeObject(
|
||||||
|
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Suppression idempotente : on loggue mais on ne propage pas.
|
||||||
|
System.err.println("[MinIO] Erreur suppression (non bloquante) : " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genere une cle unique tout en gardant l'extension d'origine (utile pour
|
||||||
|
* le Content-Disposition et les outils comme Foundry qui s'en servent).
|
||||||
|
*/
|
||||||
|
private String generateStorageKey(String originalFilename) {
|
||||||
|
String ext = extractExtension(originalFilename);
|
||||||
|
return "images/" + UUID.randomUUID() + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractExtension(String filename) {
|
||||||
|
if (filename == null) return "";
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == filename.length() - 1) return "";
|
||||||
|
String ext = filename.substring(dot).toLowerCase();
|
||||||
|
// On n'accepte que les extensions connues pour eviter les injections de path.
|
||||||
|
return ext.matches("\\.(jpg|jpeg|png|webp|gif)") ? ext : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.generationcontext.StreamChatForCampaignUseCase;
|
||||||
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
import com.loremind.application.generationcontext.StreamChatForLoreUseCase;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
|
||||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
||||||
import org.springframework.core.task.AsyncTaskExecutor;
|
import org.springframework.core.task.AsyncTaskExecutor;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -19,7 +21,10 @@ import java.util.stream.Collectors;
|
|||||||
/**
|
/**
|
||||||
* REST Controller pour le chat IA streamé (Server-Sent Events).
|
* REST Controller pour le chat IA streamé (Server-Sent Events).
|
||||||
*
|
*
|
||||||
* POST /api/ai/chat/stream → flux SSE de tokens
|
* Deux endpoints :
|
||||||
|
* - POST /api/ai/chat/stream → chat ancré sur un Lore
|
||||||
|
* - POST /api/ai/chat/stream-campaign → chat ancré sur une Campagne
|
||||||
|
* (qui tire automatiquement son Lore)
|
||||||
*
|
*
|
||||||
* Le streaming est lancé dans un thread séparé (AsyncTaskExecutor) pour
|
* Le streaming est lancé dans un thread séparé (AsyncTaskExecutor) pour
|
||||||
* ne pas bloquer le thread servlet pendant toute la durée de la génération.
|
* ne pas bloquer le thread servlet pendant toute la durée de la génération.
|
||||||
@@ -34,38 +39,49 @@ public class AiChatController {
|
|||||||
private static final long SSE_TIMEOUT_MS = 5 * 60 * 1000L;
|
private static final long SSE_TIMEOUT_MS = 5 * 60 * 1000L;
|
||||||
|
|
||||||
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
private final StreamChatForLoreUseCase streamChatForLoreUseCase;
|
||||||
|
private final StreamChatForCampaignUseCase streamChatForCampaignUseCase;
|
||||||
private final AsyncTaskExecutor taskExecutor;
|
private final AsyncTaskExecutor taskExecutor;
|
||||||
|
|
||||||
public AiChatController(
|
public AiChatController(
|
||||||
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
StreamChatForLoreUseCase streamChatForLoreUseCase,
|
||||||
|
StreamChatForCampaignUseCase streamChatForCampaignUseCase,
|
||||||
AsyncTaskExecutor taskExecutor) {
|
AsyncTaskExecutor taskExecutor) {
|
||||||
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
this.streamChatForLoreUseCase = streamChatForLoreUseCase;
|
||||||
|
this.streamChatForCampaignUseCase = streamChatForCampaignUseCase;
|
||||||
this.taskExecutor = taskExecutor;
|
this.taskExecutor = taskExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Endpoints ----------------------------------------------------------
|
||||||
|
|
||||||
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public SseEmitter chatStream(@RequestBody ChatStreamRequestDTO body) {
|
public SseEmitter chatStream(@RequestBody ChatStreamRequestDTO body) {
|
||||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||||
|
|
||||||
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
||||||
|
|
||||||
taskExecutor.execute(() -> runStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
|
taskExecutor.execute(() -> runLoreStreaming(emitter, body.getLoreId(), body.getPageId(), messages));
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/chat/stream-campaign", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter chatStreamCampaign(@RequestBody ChatStreamCampaignRequestDTO body) {
|
||||||
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||||
|
List<ChatMessage> messages = toDomainMessages(body.getMessages());
|
||||||
|
|
||||||
|
taskExecutor.execute(() -> runCampaignStreaming(
|
||||||
|
emitter, body.getCampaignId(), body.getEntityType(), body.getEntityId(), messages));
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Exécution du streaming dans un thread dédié ------------------------
|
// --- Exécution du streaming dans un thread dédié ------------------------
|
||||||
|
|
||||||
private void runStreaming(SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
|
private void runLoreStreaming(
|
||||||
|
SseEmitter emitter, String loreId, String pageId, List<ChatMessage> messages) {
|
||||||
try {
|
try {
|
||||||
streamChatForLoreUseCase.execute(
|
streamChatForLoreUseCase.execute(
|
||||||
loreId,
|
loreId, pageId, messages,
|
||||||
pageId,
|
|
||||||
messages,
|
|
||||||
token -> sendToken(emitter, token),
|
token -> sendToken(emitter, token),
|
||||||
() -> complete(emitter),
|
() -> complete(emitter),
|
||||||
error -> fail(emitter, error)
|
error -> fail(emitter, error));
|
||||||
);
|
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
// Lore ou Page introuvable : on envoie un event error puis on termine proprement.
|
// Lore ou Page introuvable : on envoie un event error puis on termine proprement.
|
||||||
fail(emitter, e);
|
fail(emitter, e);
|
||||||
@@ -74,6 +90,25 @@ public class AiChatController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void runCampaignStreaming(
|
||||||
|
SseEmitter emitter,
|
||||||
|
String campaignId,
|
||||||
|
String entityType,
|
||||||
|
String entityId,
|
||||||
|
List<ChatMessage> messages) {
|
||||||
|
try {
|
||||||
|
streamChatForCampaignUseCase.execute(
|
||||||
|
campaignId, entityType, entityId, messages,
|
||||||
|
token -> sendToken(emitter, token),
|
||||||
|
() -> complete(emitter),
|
||||||
|
error -> fail(emitter, error));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
fail(emitter, e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail(emitter, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
|
// --- Helpers SSE (un seul point d'écriture par type d'événement) --------
|
||||||
|
|
||||||
private void sendToken(SseEmitter emitter, String token) {
|
private void sendToken(SseEmitter emitter, String token) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.images.ImageService;
|
||||||
|
import com.loremind.domain.images.Image;
|
||||||
|
import com.loremind.infrastructure.web.dto.images.ImageDTO;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller pour le Shared Kernel images.
|
||||||
|
*
|
||||||
|
* Expose :
|
||||||
|
* - POST /api/images (multipart/form-data, champ "file")
|
||||||
|
* - GET /api/images/{id} (metadonnees JSON)
|
||||||
|
* - GET /api/images/{id}/content (binaire, pour <img src=...>)
|
||||||
|
* - DELETE /api/images/{id}
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/images")
|
||||||
|
public class ImageController {
|
||||||
|
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
public ImageController(ImageService imageService) {
|
||||||
|
this.imageService = imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ImageDTO> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
try (InputStream in = file.getInputStream()) {
|
||||||
|
Image saved = imageService.upload(
|
||||||
|
file.getOriginalFilename(),
|
||||||
|
file.getContentType(),
|
||||||
|
in,
|
||||||
|
file.getSize()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(toDTO(saved));
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
// Validation metier : MIME non autorise, fichier vide, taille excessive...
|
||||||
|
return ResponseEntity.badRequest().body(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ImageDTO> getMetadata(@PathVariable String id) {
|
||||||
|
return imageService.getById(id)
|
||||||
|
.map(img -> ResponseEntity.ok(toDTO(img)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming du binaire. Le navigateur pourra directement l'utiliser dans
|
||||||
|
* une balise <img src="/api/images/42/content">.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/content")
|
||||||
|
public ResponseEntity<InputStreamResource> getContent(@PathVariable String id) {
|
||||||
|
Optional<Image> metadata = imageService.getById(id);
|
||||||
|
if (metadata.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
Image img = metadata.get();
|
||||||
|
InputStream stream = imageService.downloadById(id).orElse(null);
|
||||||
|
if (stream == null) {
|
||||||
|
// Metadonnees presentes mais binaire perdu -> incoherence, on renvoie 404.
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(img.getContentType()))
|
||||||
|
.contentLength(img.getSizeBytes())
|
||||||
|
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||||
|
.body(new InputStreamResource(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable String id) {
|
||||||
|
imageService.deleteById(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping -----------------------------------------------------------
|
||||||
|
|
||||||
|
private ImageDTO toDTO(Image img) {
|
||||||
|
ImageDTO dto = new ImageDTO();
|
||||||
|
dto.setId(img.getId());
|
||||||
|
dto.setFilename(img.getFilename());
|
||||||
|
dto.setContentType(img.getContentType());
|
||||||
|
dto.setSizeBytes(img.getSizeBytes());
|
||||||
|
dto.setUrl("/api/images/" + img.getId() + "/content");
|
||||||
|
dto.setUploadedAt(img.getUploadedAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.lorecontext.TemplateService;
|
import com.loremind.application.lorecontext.TemplateService;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -21,20 +23,27 @@ public class TemplateController {
|
|||||||
|
|
||||||
private final TemplateService templateService;
|
private final TemplateService templateService;
|
||||||
private final TemplateMapper templateMapper;
|
private final TemplateMapper templateMapper;
|
||||||
|
private final TemplateFieldMapper fieldMapper;
|
||||||
|
|
||||||
public TemplateController(TemplateService templateService, TemplateMapper templateMapper) {
|
public TemplateController(TemplateService templateService,
|
||||||
|
TemplateMapper templateMapper,
|
||||||
|
TemplateFieldMapper fieldMapper) {
|
||||||
this.templateService = templateService;
|
this.templateService = templateService;
|
||||||
this.templateMapper = templateMapper;
|
this.templateMapper = templateMapper;
|
||||||
|
this.fieldMapper = fieldMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<TemplateDTO> createTemplate(@RequestBody TemplateDTO dto) {
|
public ResponseEntity<TemplateDTO> createTemplate(@RequestBody TemplateDTO dto) {
|
||||||
|
List<TemplateField> fields = dto.getFields() == null
|
||||||
|
? List.of()
|
||||||
|
: dto.getFields().stream().map(fieldMapper::toDomain).toList();
|
||||||
Template created = templateService.createTemplate(
|
Template created = templateService.createTemplate(
|
||||||
dto.getLoreId(),
|
dto.getLoreId(),
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
dto.getDescription(),
|
dto.getDescription(),
|
||||||
dto.getDefaultNodeId(),
|
dto.getDefaultNodeId(),
|
||||||
dto.getFields()
|
fields
|
||||||
);
|
);
|
||||||
return ResponseEntity.ok(templateMapper.toDTO(created));
|
return ResponseEntity.ok(templateMapper.toDTO(created));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,7 @@ public class ArcDTO {
|
|||||||
|
|
||||||
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
|
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images (Shared Kernel) illustrant cet arc. */
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ public class ChapterDTO {
|
|||||||
|
|
||||||
/** IDs des pages du Lore liées (weak cross-context references). */
|
/** IDs des pages du Lore liées (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images (Shared Kernel) illustrant ce chapitre. */
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,7 @@ public class SceneDTO {
|
|||||||
|
|
||||||
/** IDs des pages du Lore liées (weak cross-context references). */
|
/** IDs des pages du Lore liées (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images (Shared Kernel) illustrant cette scene. */
|
||||||
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.generationcontext;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO HTTP de requête pour POST /api/ai/chat/stream-campaign.
|
||||||
|
*
|
||||||
|
* Le Core charge lui-même :
|
||||||
|
* - la carte narrative à partir de {campaignId} ;
|
||||||
|
* - la carte du Lore associé si la campagne a un `loreId` (asymétrie :
|
||||||
|
* une Campagne voit son Lore, l'inverse n'est pas vrai) ;
|
||||||
|
* - l'entité narrative focalisée si {entityType}+{entityId} sont fournis.
|
||||||
|
*
|
||||||
|
* Le frontend n'a qu'à envoyer l'historique + les IDs.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ChatStreamCampaignRequestDTO {
|
||||||
|
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
/** Optionnel : "arc", "chapter" ou "scene". Si fourni, doit être accompagné d'entityId. */
|
||||||
|
private String entityType;
|
||||||
|
/** Optionnel : ID de l'entité narrative en cours d'édition. */
|
||||||
|
private String entityId;
|
||||||
|
|
||||||
|
private List<ChatMessageDTO> messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.images;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de retour pour les metadonnees d'une image.
|
||||||
|
* Ne contient PAS le binaire : celui-ci est servi separement via
|
||||||
|
* GET /api/images/{id}/content.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ImageDTO {
|
||||||
|
private String id;
|
||||||
|
private String filename;
|
||||||
|
private String contentType;
|
||||||
|
private long sizeBytes;
|
||||||
|
/**
|
||||||
|
* URL relative pour telecharger le binaire.
|
||||||
|
* Le front construit l'URL absolue en prefixant le baseUrl de l'API.
|
||||||
|
*/
|
||||||
|
private String url;
|
||||||
|
private LocalDateTime uploadedAt;
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ public class PageDTO {
|
|||||||
private String templateId;
|
private String templateId;
|
||||||
private String title;
|
private String title;
|
||||||
private Map<String, String> values;
|
private Map<String, String> values;
|
||||||
|
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
private String notes;
|
private String notes;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
private List<String> relatedPageIds;
|
private List<String> relatedPageIds;
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ public class TemplateDTO {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String defaultNodeId;
|
private String defaultNodeId;
|
||||||
private List<String> fields;
|
private List<TemplateFieldDTO> fields;
|
||||||
private int fieldCount;
|
private int fieldCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour un champ de Template.
|
||||||
|
*
|
||||||
|
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
||||||
|
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TemplateFieldDTO {
|
||||||
|
private String name;
|
||||||
|
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
|
||||||
|
private String type;
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ public class ArcMapper {
|
|||||||
dto.setRelatedPageIds(arc.getRelatedPageIds() != null
|
dto.setRelatedPageIds(arc.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(arc.getRelatedPageIds())
|
? new ArrayList<>(arc.getRelatedPageIds())
|
||||||
: new ArrayList<>());
|
: new ArrayList<>());
|
||||||
|
dto.setIllustrationImageIds(arc.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(arc.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,9 @@ public class ArcMapper {
|
|||||||
.relatedPageIds(dto.getRelatedPageIds() != null
|
.relatedPageIds(dto.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(dto.getRelatedPageIds())
|
? new ArrayList<>(dto.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(dto.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(dto.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public class ChapterMapper {
|
|||||||
dto.setRelatedPageIds(chapter.getRelatedPageIds() != null
|
dto.setRelatedPageIds(chapter.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(chapter.getRelatedPageIds())
|
? new ArrayList<>(chapter.getRelatedPageIds())
|
||||||
: new ArrayList<>());
|
: new ArrayList<>());
|
||||||
|
dto.setIllustrationImageIds(chapter.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(chapter.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +52,9 @@ public class ChapterMapper {
|
|||||||
.relatedPageIds(dto.getRelatedPageIds() != null
|
.relatedPageIds(dto.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(dto.getRelatedPageIds())
|
? new ArrayList<>(dto.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(dto.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(dto.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class PageMapper {
|
|||||||
dto.setTemplateId(page.getTemplateId());
|
dto.setTemplateId(page.getTemplateId());
|
||||||
dto.setTitle(page.getTitle());
|
dto.setTitle(page.getTitle());
|
||||||
dto.setValues(page.getValues() != null ? new HashMap<>(page.getValues()) : new HashMap<>());
|
dto.setValues(page.getValues() != null ? new HashMap<>(page.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(page.getImageValues() != null ? new HashMap<>(page.getImageValues()) : new HashMap<>());
|
||||||
dto.setNotes(page.getNotes());
|
dto.setNotes(page.getNotes());
|
||||||
dto.setTags(page.getTags() != null ? new ArrayList<>(page.getTags()) : new ArrayList<>());
|
dto.setTags(page.getTags() != null ? new ArrayList<>(page.getTags()) : new ArrayList<>());
|
||||||
dto.setRelatedPageIds(page.getRelatedPageIds() != null ? new ArrayList<>(page.getRelatedPageIds()) : new ArrayList<>());
|
dto.setRelatedPageIds(page.getRelatedPageIds() != null ? new ArrayList<>(page.getRelatedPageIds()) : new ArrayList<>());
|
||||||
@@ -41,6 +42,7 @@ public class PageMapper {
|
|||||||
.templateId(dto.getTemplateId())
|
.templateId(dto.getTemplateId())
|
||||||
.title(dto.getTitle())
|
.title(dto.getTitle())
|
||||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
.notes(dto.getNotes())
|
.notes(dto.getNotes())
|
||||||
.tags(dto.getTags() != null ? new ArrayList<>(dto.getTags()) : new ArrayList<>())
|
.tags(dto.getTags() != null ? new ArrayList<>(dto.getTags()) : new ArrayList<>())
|
||||||
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())
|
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ public class SceneMapper {
|
|||||||
dto.setRelatedPageIds(scene.getRelatedPageIds() != null
|
dto.setRelatedPageIds(scene.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(scene.getRelatedPageIds())
|
? new ArrayList<>(scene.getRelatedPageIds())
|
||||||
: new ArrayList<>());
|
: new ArrayList<>());
|
||||||
|
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,9 @@ public class SceneMapper {
|
|||||||
.relatedPageIds(dto.getRelatedPageIds() != null
|
.relatedPageIds(dto.getRelatedPageIds() != null
|
||||||
? new ArrayList<>(dto.getRelatedPageIds())
|
? new ArrayList<>(dto.getRelatedPageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.illustrationImageIds(dto.getIllustrationImageIds() != null
|
||||||
|
? new ArrayList<>(dto.getIllustrationImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
|
import com.loremind.domain.lorecontext.FieldType;
|
||||||
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
||||||
|
* {@link TemplateFieldDTO} (wire).
|
||||||
|
*
|
||||||
|
* Tolerance : un type inconnu recu du client est interprete comme TEXT
|
||||||
|
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TemplateFieldMapper {
|
||||||
|
|
||||||
|
public TemplateFieldDTO toDTO(TemplateField field) {
|
||||||
|
if (field == null) return null;
|
||||||
|
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
|
||||||
|
return new TemplateFieldDTO(field.getName(), typeStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||||
|
if (dto == null) return null;
|
||||||
|
FieldType type;
|
||||||
|
try {
|
||||||
|
type = dto.getType() != null ? FieldType.valueOf(dto.getType()) : FieldType.TEXT;
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
type = FieldType.TEXT;
|
||||||
|
}
|
||||||
|
return new TemplateField(dto.getName(), type);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapper pour convertir entre Template (entité de domaine) et TemplateDTO.
|
* Mapper pour convertir entre Template (entité de domaine) et TemplateDTO.
|
||||||
|
* Delegue la conversion de chaque champ a {@link TemplateFieldMapper}.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TemplateMapper {
|
public class TemplateMapper {
|
||||||
|
|
||||||
|
private final TemplateFieldMapper fieldMapper;
|
||||||
|
|
||||||
|
public TemplateMapper(TemplateFieldMapper fieldMapper) {
|
||||||
|
this.fieldMapper = fieldMapper;
|
||||||
|
}
|
||||||
|
|
||||||
public TemplateDTO toDTO(Template template) {
|
public TemplateDTO toDTO(Template template) {
|
||||||
if (template == null) {
|
if (template == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -22,9 +32,7 @@ public class TemplateMapper {
|
|||||||
dto.setName(template.getName());
|
dto.setName(template.getName());
|
||||||
dto.setDescription(template.getDescription());
|
dto.setDescription(template.getDescription());
|
||||||
dto.setDefaultNodeId(template.getDefaultNodeId());
|
dto.setDefaultNodeId(template.getDefaultNodeId());
|
||||||
dto.setFields(template.getFields() != null
|
dto.setFields(mapFieldsToDto(template.getFields()));
|
||||||
? new ArrayList<>(template.getFields())
|
|
||||||
: new ArrayList<>());
|
|
||||||
dto.setFieldCount(template.fieldCount());
|
dto.setFieldCount(template.fieldCount());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -39,9 +47,21 @@ public class TemplateMapper {
|
|||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.defaultNodeId(dto.getDefaultNodeId())
|
.defaultNodeId(dto.getDefaultNodeId())
|
||||||
.fields(dto.getFields() != null
|
.fields(mapFieldsToDomain(dto.getFields()))
|
||||||
? new ArrayList<>(dto.getFields())
|
|
||||||
: new ArrayList<>())
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TemplateFieldDTO> mapFieldsToDto(List<TemplateField> fields) {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
List<TemplateFieldDTO> result = new ArrayList<>(fields.size());
|
||||||
|
for (TemplateField f : fields) result.add(fieldMapper.toDTO(f));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateField> mapFieldsToDomain(List<TemplateFieldDTO> dtos) {
|
||||||
|
if (dtos == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> result = new ArrayList<>(dtos.size());
|
||||||
|
for (TemplateFieldDTO d : dtos) result.add(fieldMapper.toDomain(d));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,14 @@ spring.web.cors.allow-credentials=true
|
|||||||
# Configuration du Brain (service IA Python)
|
# Configuration du Brain (service IA Python)
|
||||||
brain.base-url=http://localhost:8000
|
brain.base-url=http://localhost:8000
|
||||||
brain.timeout-seconds=120
|
brain.timeout-seconds=120
|
||||||
|
|
||||||
|
# Configuration MinIO (Shared Kernel images - Object Storage)
|
||||||
|
# Le bucket est cree automatiquement par le service minio-init (docker-compose up -d).
|
||||||
|
minio.endpoint=http://localhost:9000
|
||||||
|
minio.access-key=minioadmin
|
||||||
|
minio.secret-key=minioadmin
|
||||||
|
minio.bucket=loremind-images
|
||||||
|
|
||||||
|
# Limites d'upload d'images (MB)
|
||||||
|
spring.servlet.multipart.max-file-size=10MB
|
||||||
|
spring.servlet.multipart.max-request-size=10MB
|
||||||
|
|||||||
51
docker-compose.yml
Normal file
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# ==========================================================================
|
||||||
|
# LoreMind — services d'infrastructure locaux
|
||||||
|
# ==========================================================================
|
||||||
|
# Pour l'instant, seul MinIO est géré ici. Postgres, Backend Core, Brain
|
||||||
|
# Python et Frontend Angular sont lancés manuellement en dev (IDE).
|
||||||
|
#
|
||||||
|
# Démarrage :
|
||||||
|
# docker-compose up -d minio
|
||||||
|
# Console web MinIO : http://localhost:9001 (identifiants : minioadmin / minioadmin)
|
||||||
|
# API S3 compatible : http://localhost:9000
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: loremind-minio
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API S3 (utilisée par le backend Java)
|
||||||
|
- "9001:9001" # Console web d'administration
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Création automatique du bucket "loremind-images" au démarrage.
|
||||||
|
# Sans ça, le backend Java planterait au premier upload.
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
container_name: loremind-minio-init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 minioadmin minioadmin &&
|
||||||
|
mc mb --ignore-existing local/loremind-images &&
|
||||||
|
mc anonymous set download local/loremind-images &&
|
||||||
|
echo 'Bucket loremind-images prêt.'
|
||||||
|
"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio-data:
|
||||||
|
driver: local
|
||||||
176
docs/plan.md
176
docs/plan.md
@@ -332,9 +332,52 @@ Stack retenue : **FastAPI + Ollama local + Architecture Hexagonale** (Ports/Adap
|
|||||||
- [x] Section "Le savais-tu ?" sur la nature debug-friendly du format SSE (`curl -N` suffit).
|
- [x] Section "Le savais-tu ?" sur la nature debug-friendly du format SSE (`curl -N` suffit).
|
||||||
- [x] Quiz 5 QCM.
|
- [x] Quiz 5 QCM.
|
||||||
|
|
||||||
###### b5.7 — À faire plus tard (étendre au reste de l'app)
|
###### b5.7 — Intégration dans la Campagne (arc / chapter / scene) ✅ (20 avril 2026, après-midi)
|
||||||
- [ ] Intégration du drawer dans `arc-edit`, `chapter-edit`, `scene-edit` (Campagne). Nécessite un nouveau port `AiChatProvider.streamChatForCampaign(campaignId, messages)` qui charge Campagne courante + Lore associé (asymétrie demandée : une Campagne voit son Lore, un Lore ne voit PAS ses campagnes).
|
|
||||||
- [ ] Persistance optionnelle de la conversation (entité `Conversation` côté Java, historique reprenable).
|
> ✅ **Drawer IA disponible sur les 3 écrans de Campagne.** Un MJ peut dialoguer avec l'IA depuis l'arc, le chapitre ou la scène en cours. Le prompt système reçoit automatiquement l'arbre narratif (noms seulement — pas de contenu), les champs de l'entité focus, et — si la campagne est liée à un Lore — les templates + l'arbre des pages de ce Lore. **Asymétrie respectée** : un Lore ne voit PAS ses campagnes (sens unique Campagne → Lore).
|
||||||
|
|
||||||
|
###### b5.7.1 — Value Objects narratifs (core/domain) ✅
|
||||||
|
- [x] `CampaignStructuralContext` : arbre `campaignName + campaignDescription + List<ArcSummary>` avec `ArcSummary(name + chapters)` et `ChapterSummary(name + sceneNames)`. Lombok `@Value @Builder @Singular`.
|
||||||
|
- [x] `NarrativeEntityContext` : VO "focus" avec `entityType ∈ {arc, chapter, scene}` + `title` + `Map<String,String> fields` (description, themes, stakes, playerObjectives, atmosphere…).
|
||||||
|
- [x] `ChatRequest` étendu : `loreContext` nullable, `campaignContext` et `narrativeEntity` ajoutés. Un chat Lore continue à fonctionner inchangé.
|
||||||
|
|
||||||
|
###### b5.7.2 — Builders applicatifs (DRY + cross-context) ✅
|
||||||
|
- [x] `LoreStructuralContextBuilder` extrait en `@Component` partagé : `build(loreId)` lance une exception si absent, `buildOptional(loreId)` retourne `Optional.empty()` pour dégradation gracieuse (Lore supprimé entre deux appels).
|
||||||
|
- [x] `CampaignStructuralContextBuilder` : traverse Campagne → Arcs (triés par `order`) → Chapters (triés) → noms de Scenes (triés). Pas de contenu, juste la structure.
|
||||||
|
- [x] `NarrativeEntityContextBuilder` : switch sur `entityType`, mappe les champs domaine vers la Map via `putIfNotBlank`. Ne fuit aucun secret MJ vers le prompt joueur (c'est un chat MJ, donc tout est exposé — mais le découpage par champ reste explicite).
|
||||||
|
- [x] `StreamChatForCampaignUseCase` : orchestre Campaign → Lore optionnel (via `campaign.isLinkedToLore()`) → Narrative entity optionnelle → délégation au port `AiChatProvider`.
|
||||||
|
- [x] `StreamChatForLoreUseCase` refactoré : 182 → 114 lignes, délègue à `LoreStructuralContextBuilder` (DRY).
|
||||||
|
|
||||||
|
###### b5.7.3 — Pont Java ↔ Python ✅
|
||||||
|
- [x] `BrainAiChatClient.toPayload()` : 4 contextes optionnels (`lore_context`, `page_context`, `campaign_context`, `narrative_entity`) ajoutés au JSON snake_case seulement s'ils existent.
|
||||||
|
- [x] `brain/app/domain/models.py` : dataclasses `ArcSummary`, `ChapterSummary`, `CampaignStructuralContext`, `NarrativeEntityContext`.
|
||||||
|
- [x] `brain/app/application/chat.py` : `_BASE_SYSTEM` rendu générique ("contexte ci-dessous"), `stream(…)` en kw-only args, `_build_system_prompt` assemble les sections conditionnelles. Formatters dédiés `_format_campaign`, `_format_arcs`, `_format_chapter_block`, `_format_narrative_entity`.
|
||||||
|
- [x] `brain/app/main.py` : DTOs `ArcSummaryDTO`, `ChapterSummaryDTO`, `CampaignContextDTO`, `NarrativeEntityDTO` (validation `entity_type` via pattern). `ChatStreamRequestDTO.has_scope()` → HTTP 422 si aucun scope.
|
||||||
|
|
||||||
|
###### b5.7.4 — Controller REST + service Angular ✅
|
||||||
|
- [x] `AiChatController.POST /api/ai/chat/stream-campaign` : `ChatStreamCampaignRequestDTO(campaignId, entityType?, entityId?, messages)`. SSE helpers réutilisés.
|
||||||
|
- [x] `AiChatService.streamChatForCampaign(...)` + type `NarrativeEntityType = 'arc' | 'chapter' | 'scene'`. Helper privé `streamSse(...)` partagé avec `streamChat(...)` (Lore).
|
||||||
|
- [x] `AiChatDrawerComponent` : nouveaux `@Input()` `campaignId`, `entityType`, `entityId`. Dispatch : `campaignId` truthy → mode Campagne, sinon mode Lore (backward compatible).
|
||||||
|
|
||||||
|
###### b5.7.5 — Intégration UI dans les 3 écrans ✅
|
||||||
|
- [x] `arc-edit`, `chapter-edit`, `scene-edit` : bouton `btn-ai` "Assistant IA" dans le header (Sparkles icon + état `active`), `<app-ai-chat-drawer>` injecté avec `entityType` + `entityId` appropriés, `quickSuggestions` adaptées au rôle narratif (thèmes/enjeux pour l'arc, objectifs/tensions pour le chapitre, ambiance/narration/choix pour la scène).
|
||||||
|
- [x] Style global `.btn-ai` extrait en `_buttons.scss` (violet `#a5b4fc`, variante `.active` bordure `#6c63ff`) pour éviter la duplication.
|
||||||
|
- [x] Validation finale : `mvn clean compile` BUILD SUCCESS + `npx tsc --noEmit` 0 erreur.
|
||||||
|
|
||||||
|
###### b5.7.6 — À faire plus tard
|
||||||
|
- [ ] Persistance optionnelle de la conversation (entité `Conversation` côté Java, historique reprenable entre sessions).
|
||||||
|
- [ ] Fiche academy dédiée à la composition de prompts multi-contextes (Lore + Campaign + Entity).
|
||||||
|
|
||||||
|
###### b5.8 — Enrichissement du Structural Context Campagne ✅ (20 avril 2026, après-midi)
|
||||||
|
|
||||||
|
> ✅ **Problème remonté par l'utilisateur** : en éditant une scène, impossible de demander à l'IA "c'est quoi la scène X (qui est ailleurs dans la campagne) ?" — elle ne connaissait QUE les noms. Résolu en ajoutant les descriptions courtes à chaque niveau de l'arbre narratif, sans basculer vers du RAG sémantique.
|
||||||
|
|
||||||
|
- [x] **Domain (core)** : `CampaignStructuralContext.ArcSummary` gagne un champ `description`. `ChapterSummary.sceneNames: List<String>` remplacé par `scenes: List<SceneSummary>` avec `name + description`. `ChapterSummary` gagne également `description`.
|
||||||
|
- [x] **Builder (application)** : `CampaignStructuralContextBuilder` peuple maintenant `arc.description`, `chapter.description`, `scene.description` depuis les entités domaine (qui les exposent déjà — on consommait juste les noms).
|
||||||
|
- [x] **Pont Java ↔ Python** : `BrainAiChatClient` sérialise les nouveaux champs. Côté Python : `models.py` gagne la dataclass `SceneSummary` et les champs description ; `main.py` ajoute `SceneSummaryDTO` + helper `_to_campaign_context` mis à jour.
|
||||||
|
- [x] **System prompt (chat.py)** : `_format_arcs` et `_format_chapter_block` ajoutent une ligne `Synopsis : …` / `Description : …` sous chaque nœud quand renseigné. Format conditionnel (pas de ligne vide si description absente).
|
||||||
|
- [x] **Budget tokens** : ~30 tokens par scène × 100 scènes ≈ 3k tokens. Confortable. Si un jour une campagne explose ce budget, on basculera en Option C (RAG sémantique).
|
||||||
|
- [x] Validation finale : `mvn clean compile` BUILD SUCCESS + `python -m py_compile` sur les 3 fichiers Python + `npx tsc --noEmit` 0 erreur.
|
||||||
|
|
||||||
##### Étape b6 — IA dans la création de page (wizard) ✅ (20 avril 2026, nuit)
|
##### Étape b6 — IA dans la création de page (wizard) ✅ (20 avril 2026, nuit)
|
||||||
|
|
||||||
@@ -412,6 +455,64 @@ Stack retenue : **FastAPI + Ollama local + Architecture Hexagonale** (Ports/Adap
|
|||||||
- [ ] **Validation Pydantic** plus stricte : `max_length` sur `prompt`, `max_items` sur `template_fields` (ex: 20 max), longueur du `page_title`.
|
- [ ] **Validation Pydantic** plus stricte : `max_length` sur `prompt`, `max_items` sur `template_fields` (ex: 20 max), longueur du `page_title`.
|
||||||
- [ ] **Gestion `output_format` autres que `"json"`** : aujourd'hui on passe la valeur brute à Ollama. Si le Brain doit supporter un adapter qui ne comprend que certains formats, valider côté port.
|
- [ ] **Gestion `output_format` autres que `"json"`** : aujourd'hui on passe la valeur brute à Ollama. Si le Brain doit supporter un adapter qui ne comprend que certains formats, valider côté port.
|
||||||
|
|
||||||
|
## Feature "Illustrations & images" ✅ (20-21 avril 2026, sessions 5 & 6)
|
||||||
|
|
||||||
|
> ✅ **Feature complète livrée en 6 étapes.** Upload d'images via MinIO (S3-compatible), galeries éditables sur Arc/Chapter/Scene, et support d'un nouveau type `IMAGE` dans les champs de Template → les Pages peuvent porter des galeries par champ en plus des textes. Synchro Brain Python pour que l'IA "sache" combien d'illustrations porte chaque entité narrative (sans jamais recevoir les binaires).
|
||||||
|
|
||||||
|
### Étape 1 — Shared Kernel images + MinIO ✅ (2026-04-20 sess.5)
|
||||||
|
Backend Java pur, testable via `curl`. Aucune intégration métier à ce stade.
|
||||||
|
- **Infrastructure** : `docker-compose.yml` (service `minio` + `minio-init` auto-création du bucket `loremind-images`) ; `core/pom.xml` + `io.minio:minio:8.5.11` ; `application.properties` (config `minio.*` + multipart 10 Mo).
|
||||||
|
- **Domaine** : `Image` (VO) + ports `ImageRepository` et `ImageStorage` **séparés** (SRP : la métadonnée DB et le binaire objet-storage sont deux responsabilités distinctes).
|
||||||
|
- **Application** : `ImageService` (validation MIME `jpeg/png/webp/gif`, taille max 10 Mo).
|
||||||
|
- **Adapters** : `MinioConfig` + `MinioImageStorageAdapter` (binaire) ; `ImageJpaEntity` + `PostgresImageRepository` (métadonnée).
|
||||||
|
- **REST** : `ImageController` → `POST /api/images` (multipart), `GET /api/images/{id}`, `GET /api/images/{id}/content` (proxy binaire), `DELETE /api/images/{id}`.
|
||||||
|
- **Academy** : `docs/academy/object-storage.md` + `docs/academy/shared-kernel.md`.
|
||||||
|
- Validation : `mvn compile` OK.
|
||||||
|
|
||||||
|
### Étape 2 — Composants Angular partagés ✅ (2026-04-20 sess.5)
|
||||||
|
Deux composants autonomes, réutilisables partout où une galerie d'images est nécessaire.
|
||||||
|
- `web/src/app/services/image.service.ts` : upload, getById, delete, contentUrl.
|
||||||
|
- `app-image-uploader` (`shared/image-uploader/`) : drop-zone standard OU mode compact (bouton `+ ajouter` pour galerie). Validation client alignée serveur. Gestion 413.
|
||||||
|
- `app-image-gallery` (`shared/image-gallery/`) : grille 120×120 lazy-loading, mode `editable` avec uploader compact intégré + bouton X par vignette (supprime serveur + émet nouvelle liste), **lightbox plein écran** au clic.
|
||||||
|
- Validation : `npx tsc --noEmit` OK.
|
||||||
|
|
||||||
|
### Étape 3 — Illustrations sur Scene / Chapter / Arc ✅ (2026-04-20 sess.5)
|
||||||
|
Première intégration métier : les 3 entités narratives portent une liste d'images.
|
||||||
|
- **Backend** : `List<String> illustrationImageIds` ajouté sur `Arc`/`Chapter`/`Scene` (domaine + JPA avec converter JSON + DTO + Mapper + Postgres repo + Service).
|
||||||
|
- **Frontend** : champ dans `campaign.model.ts` ; section "Illustrations" en tête des `*-view` (lecture) et `*-edit` (galerie éditable) pour les 3 entités.
|
||||||
|
- Validation : `mvn compile` + `npx tsc --noEmit` OK.
|
||||||
|
|
||||||
|
### Étape 4 — Refactor `Template.fields` ✅ (2026-04-21 sess.6)
|
||||||
|
**Pivot structurel** : un champ de template n'est plus juste un nom — il a un **type**. Prépare l'étape 5 (Pages avec champs IMAGE).
|
||||||
|
- **Backend** : nouveau enum `FieldType { TEXT, IMAGE }` + VO `TemplateField(name, type)`. `Template.fields` devient `List<TemplateField>` + helper `textFieldNames()` (utilisé par les use cases IA qui ne savent traiter que du texte).
|
||||||
|
- **Migration BDD transparente** : `TemplateFieldListJsonConverter` lit l'ancien format `["name", ...]` ET le nouveau `[{name, type}]`, écrit toujours au nouveau format → auto-migration à la première sauvegarde (pas de script SQL).
|
||||||
|
- **Tolérance** : `TemplateFieldMapper` traite un type inconnu → `TEXT` (robuste face à une régression DTO).
|
||||||
|
- **Use cases IA mis à jour** : `GeneratePageValuesUseCase` et `StreamChatForLoreUseCase` ne passent à l'IA que les champs TEXT (erreur claire si aucun).
|
||||||
|
- **Frontend** : sélecteur de type dans `template-create/edit`, chip verte (TEXT) vs indigo (IMAGE), bouton toggle inline. `page-view` rend un placeholder pour les champs IMAGE (la vraie UI vient en étape 5). `page-edit` hydrate les TEXT uniquement, `page-create` wizard ne liste que les TEXT.
|
||||||
|
- Validation : `mvn compile` + `npx tsc --noEmit` OK.
|
||||||
|
|
||||||
|
### Étape 5 — Support champs IMAGE dans Pages ✅ (2026-04-21 sess.6)
|
||||||
|
Les Pages gagnent une seconde zone de stockage, parallèle à `values` (TEXT).
|
||||||
|
- **Backend** : nouveau converter `StringListMapJsonConverter` (`Map<String, List<String>>` ↔ JSON). `Page.imageValues` ajouté avec helpers `setImageFieldValue` / `getImageFieldValue`. Nouvelle colonne `image_values_json` sur `PageJpaEntity`. Propagation dans DTO + Mapper + Service.
|
||||||
|
- **Frontend** : `Page.imageValues?: Record<string, string[]>` ; `page-view` affiche une galerie readonly par champ IMAGE via `ImageGalleryComponent` ; `page-edit` hydrate séparément TEXT et IMAGE et rend une galerie éditable par champ IMAGE.
|
||||||
|
- Validation : `mvn compile` + `npx tsc --noEmit` OK.
|
||||||
|
|
||||||
|
### Étape 6 — Brain Python : synchro DTOs ✅ (2026-04-21 sess.6)
|
||||||
|
L'IA ne reçoit **pas** les binaires — juste un signal de présence (`illustration_count`) pour qu'elle puisse en tenir compte dans le prompt.
|
||||||
|
- **Backend Java** : `illustration_count` ajouté sur `ArcSummary` / `ChapterSummary` / `SceneSummary` du `CampaignStructuralContext`. Le builder peuple depuis `getIllustrationImageIds()` (null-safe). `BrainAiChatClient` sérialise **uniquement si > 0** (payload léger pour une campagne sans images).
|
||||||
|
- **Brain Python** : `illustration_count: int = 0` sur les 3 summaries dans `domain/models.py` ; DTOs Pydantic + `_to_campaign_context` mis à jour dans `main.py`. Les champs inconnus (ex: `illustrationImageIds` des Pages) sont silencieusement ignorés par Pydantic v2 (pas d'erreur).
|
||||||
|
- **Prompt** : helper `_illustration_hint()` dans `chat.py` ; les lignes arc/chapter/scene du prompt affichent ` [N illustrations]` si présentes (ex: `- A (arc) [2 illustrations]`).
|
||||||
|
- Validation : `mvn compile` + `python -m py_compile` OK + démo runtime prompt validée.
|
||||||
|
|
||||||
|
### Résidu de scope (non bloquant)
|
||||||
|
- [ ] `PageSummary` (côté LoreStructuralContext) ne porte pas encore de signal sur les `imageValues` des Pages. Symétrique à faire si l'IA doit raisonner sur "cette page PNJ a 3 portraits".
|
||||||
|
|
||||||
|
### Notes transverses
|
||||||
|
- **Docker-compose** ne couvre aujourd'hui QUE MinIO. Postgres/Brain/Web restent lancés à la main.
|
||||||
|
- **Séparation `ImageRepository` / `ImageStorage`** volontaire (SRP, pattern Shared Kernel).
|
||||||
|
- URL publique d'une image : `/api/images/{id}/content` (proxy Java — évite d'exposer MinIO directement).
|
||||||
|
- Validation MIME côté `ImageService` : `jpeg/png/webp/gif` uniquement, max 10 Mo.
|
||||||
|
|
||||||
## Structure des dossiers
|
## Structure des dossiers
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -466,7 +567,7 @@ LoreMind/
|
|||||||
## Points à surveiller / Dette technique connue
|
## Points à surveiller / Dette technique connue
|
||||||
Ces points sont à garder en tête pour de futures refactorisations. Pas bloquant aujourd'hui — à traiter quand le besoin se manifestera.
|
Ces points sont à garder en tête pour de futures refactorisations. Pas bloquant aujourd'hui — à traiter quand le besoin se manifestera.
|
||||||
|
|
||||||
- **Champ `illustration` manquant sur Arc/Chapter/Scene (front + back)** — les maquettes prévoient une URL d'illustration. Reporté jusqu'à l'implémentation des écrans de détail (affichage) correspondants. Impact : domaine Java + JPA + DTO + migration Postgres + champ Angular.
|
- ~~**Champ `illustration` manquant sur Arc/Chapter/Scene**~~ ✅ Résolu le 20 avril 2026 (feature "Illustrations & images" étape 3 — champ devenu `illustrationImageIds: List<String>` avec galerie MinIO).
|
||||||
- **Chargement de l'arbre campagne via N+1 requêtes HTTP** — `campaign-tree.helper.ts` fait `1 arcs + N chapters + M scenes` appels HTTP en forkJoin. Correct pour des campagnes de taille modeste. À remplacer par un endpoint agrégé `GET /api/campaigns/:id/tree` quand les volumes l'exigeront (>20 chapitres par ex).
|
- **Chargement de l'arbre campagne via N+1 requêtes HTTP** — `campaign-tree.helper.ts` fait `1 arcs + N chapters + M scenes` appels HTTP en forkJoin. Correct pour des campagnes de taille modeste. À remplacer par un endpoint agrégé `GET /api/campaigns/:id/tree` quand les volumes l'exigeront (>20 chapitres par ex).
|
||||||
- **Calcul de `order` naïf** — actuellement `order = existingCount + 1`. Ne gère pas les réordonnancements ni les suppressions (risque de collisions). À reprendre avec un pattern de fractional indexing ou recalcul côté backend.
|
- **Calcul de `order` naïf** — actuellement `order = existingCount + 1`. Ne gère pas les réordonnancements ni les suppressions (risque de collisions). À reprendre avec un pattern de fractional indexing ou recalcul côté backend.
|
||||||
- ~~**Duplications de pattern côté Lore**~~ ✅ Résolu le 18 avril 2026 via `lore-sidebar.helper.ts` — utilisé par `lore-detail`, `lore-node-create`, `template-create`, `template-edit`. L'écran de création de page à venir suivra le même pattern.
|
- ~~**Duplications de pattern côté Lore**~~ ✅ Résolu le 18 avril 2026 via `lore-sidebar.helper.ts` — utilisé par `lore-detail`, `lore-node-create`, `template-create`, `template-edit`. L'écran de création de page à venir suivra le même pattern.
|
||||||
@@ -478,6 +579,71 @@ Ces points sont à garder en tête pour de futures refactorisations. Pas bloquan
|
|||||||
- **Gestion des migrations DB** — actuellement `spring.jpa.hibernate.ddl-auto=update` (auto-alter). Acceptable en dev, **inutilisable en prod** (perte de données possible). À remplacer par Flyway ou Liquibase avant la mise en prod (chaque changement de schéma devra être versionné en fichier SQL).
|
- **Gestion des migrations DB** — actuellement `spring.jpa.hibernate.ddl-auto=update` (auto-alter). Acceptable en dev, **inutilisable en prod** (perte de données possible). À remplacer par Flyway ou Liquibase avant la mise en prod (chaque changement de schéma devra être versionné en fichier SQL).
|
||||||
|
|
||||||
## Dernière mise à jour
|
## Dernière mise à jour
|
||||||
|
21 avril 2026 (session 6) — **Feature "Illustrations & images" complète (6 étapes)** : MinIO + galeries Arc/Chapter/Scene + refactor `Template.fields` avec types TEXT/IMAGE + champs IMAGE sur Pages + synchro Brain Python (`illustration_count`). Voir section dédiée au-dessus de "Structure des dossiers".
|
||||||
|
|
||||||
|
20 avril 2026 (soir, session 4) — **Split View ↔ Edit : mode consultation livré sur Page / Arc / Chapter / Scene**.
|
||||||
|
|
||||||
|
> ✅ **Problème UX remonté par l'utilisateur** : consulter et modifier partageaient le même écran (formulaire avec textareas), ce qui est bruité visuellement pour la simple lecture et impose des scrollbars internes à chaque champ. Résolu par un pattern classique *read-first design* : une route de vue distincte par entité, où chaque champ est un bloc titré dont le corps s'étend verticalement selon son contenu (`white-space: pre-wrap`, pas de textarea).
|
||||||
|
|
||||||
|
**Choix produit retenus** :
|
||||||
|
- **Routes séparées** : `/lore/:id/pages/:pid` = vue (défaut, bookmarkable), `/lore/:id/pages/:pid/edit` = édition. Idem pour Arc/Chapter/Scene côté Campagne.
|
||||||
|
- **Style "fiche de jeu"** : chaque champ = bloc avec titre (petit, violet #a5b4fc, uppercase, tracking) et corps texte pleine largeur. Séparateurs fins `#1e1e3a` entre blocs. Variante `--private` rouge discret pour les notes MJ.
|
||||||
|
- **Tout en une passe** : les 4 entités sont livrées ensemble pour garder un design system cohérent.
|
||||||
|
|
||||||
|
**Architecture mise en place** :
|
||||||
|
- Nouveau partial SCSS global `web/src/styles/_view.scss` (+ `@use` dans `styles.scss`) — responsabilité unique : le style "fiche de jeu". Réutilisé par les 4 composants (DRY). Contient `.view-page`, `.view-header`, `.view-section`, `.view-section--private`, `.view-row` (grille 2 colonnes), `.view-chips` (+ variante `.view-chip--tag`).
|
||||||
|
- 4 nouveaux composants standalone : `@app/lore/page-view/`, `@app/campaigns/arc-view/`, `@app/campaigns/chapter-view/`, `@app/campaigns/scene-view/`. Chacun charge les mêmes données que son pendant `-edit` (même sidebar, mêmes services) — le mode est juste cosmétique.
|
||||||
|
- Les 4 SCSS des composants `-view` sont volontairement quasi-vides : tout le style vient du partial global. Laissés en place pour cohérence structurelle avec le reste du projet.
|
||||||
|
|
||||||
|
**Routes (`app.routes.ts`)** :
|
||||||
|
- `/lore/:loreId/pages/:pageId` → `PageViewComponent` (ancien `PageEditComponent`).
|
||||||
|
- `/lore/:loreId/pages/:pageId/edit` → `PageEditComponent` (nouvelle).
|
||||||
|
- `/campaigns/:campaignId/arcs/:arcId` → `ArcViewComponent` (+ `/edit` → `ArcEditComponent`). Idem `chapters` et `scenes`.
|
||||||
|
- Les routes des tree items de sidebar pointent déjà vers `/…/:id` (pas modifié) → par construction, cliquer sur un item de l'arbre ouvre la **vue** (défaut). Parfait.
|
||||||
|
|
||||||
|
**Flux navigationnels ajustés** :
|
||||||
|
- `arc-edit` / `chapter-edit` / `scene-edit` : `cancel()` et redirection post-`Sauvegarder` pointent maintenant sur `/view` de l'entité courante (au lieu de la racine Campagne). UX : « je corrige, je valide, je vois le résultat ».
|
||||||
|
- `page-edit` : bouton **Annuler** (btn-secondary) ajouté dans le header + `save()` navigue vers la vue.
|
||||||
|
- `arc-create` / `chapter-create` / `scene-create` : captent désormais `(created)` et naviguent vers la vue de la nouvelle entité (au lieu de la racine Campagne).
|
||||||
|
- `page-create` (mode classique) : navigue vers `/edit` de la page créée — la coquille est vide, filer directement en édition fait sens. Le mode wizard IA (b6) continue à naviguer vers la vue (les values sont déjà remplies par l'IA).
|
||||||
|
|
||||||
|
**Rendu adaptatif des champs (sans scrollbar)** :
|
||||||
|
- Le texte est rendu dans un `<p class="view-section-body">` natif, jamais dans un textarea. CSS : `white-space: pre-wrap; word-wrap: break-word;` — conserve les sauts de ligne saisis en édition, la hauteur s'adapte automatiquement au contenu. Zéro JS, zéro `rows="…"` à maintenir.
|
||||||
|
- Champs vides : `<p class="view-section-empty">Non renseigné</p>` en italique gris discret. Évite de masquer les champs manquants sans pour autant encombrer visuellement.
|
||||||
|
- Sections entièrement optionnelles (tags, pages liées, notes privées, combat, choix…) affichées uniquement si non-vides (`*ngIf` sur la section entière).
|
||||||
|
|
||||||
|
**Validation** : `npx tsc --noEmit` — 0 erreur.
|
||||||
|
|
||||||
|
**À surveiller / ce qu'il reste à faire** :
|
||||||
|
- La duplication entre `arc-view` / `chapter-view` / `scene-view` reste acceptable car chaque entité a des champs différents. Si le domaine narratif continue à grossir, on pourra extraire un composant générique `<app-entity-view [sections]="…">` piloté par un tableau de sections — **YAGNI** tant qu'on en reste à 3 entités.
|
||||||
|
- `template-edit`, `lore-node-edit`, `campaign-detail`, `lore-detail` gardent leur format actuel (mix consultation/édition inline) — c'est volontaire car ces écrans sont très simples (nom + description). À reconsidérer si leur scope grossit.
|
||||||
|
- Raccourci clavier `Ctrl+E` pour basculer vue ↔ édition = idée backlog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
20 avril 2026 (soir, session 3) — **Phase 3 étape b9 bouclée : enrichissement du Structural Context Lore (values + tags + liens)**.
|
||||||
|
|
||||||
|
> ✅ **Problème remonté par l'utilisateur** : depuis un arc/chapter/scene, le chat IA ne voyait que les noms des pages du Lore (ex: "Borin le forgeron"), jamais leur contenu. Il ne pouvait donc pas raisonner sur les fiches de PNJ (apparence, motivations, background) ni sur les interconnexions (tags, pages liées). Résolu par un enrichissement symétrique à celui fait en b5.8 pour les scènes de campagne.
|
||||||
|
|
||||||
|
- [x] **Domain (core)** : `LoreStructuralContext.FolderPage` renommé `PageSummary` (Ubiquitous Language : c'est un résumé projeté, pas un conteneur). Nouveaux champs `values: Map<String,String>`, `tags: List<String>`, `relatedPageTitles: List<String>`.
|
||||||
|
- [x] **Builder (application)** : `LoreStructuralContextBuilder` peuple les nouveaux champs. Constante `MAX_VALUE_LENGTH = 500` + méthode privée `truncate()` pour éviter qu'un champ "Histoire" de 5000 caractères ne sature le prompt. Les `relatedPageIds` sont résolus en titres via une map `pageTitleById` construite une seule fois (pas de N²). Les IDs qui ne matchent rien (page supprimée) sont silencieusement ignorés.
|
||||||
|
- [x] **Pont Java ↔ Python** : `BrainAiChatClient.pageSummaryToMap()` sérialise les nouveaux champs en snake_case, **seulement s'ils contiennent de l'info** (payload léger pour un Lore avec beaucoup de pages vierges).
|
||||||
|
- [x] **Python (domain)** : nouveau dataclass `PageSummary` (title, template_name, values, tags, related_page_titles). `LoreStructuralContext.folders` passe de `dict[str, list[tuple[str, str]]]` à `dict[str, list[PageSummary]]`.
|
||||||
|
- [x] **Python (DTOs + mapping)** : `FolderPageDTO` renommé `PageSummaryDTO` avec champs optionnels par défaut vide. Nouveau mapper `_to_page_summary()`. Mapping `_to_lore_context()` mis à jour.
|
||||||
|
- [x] **System prompt (chat.py)** : `_format_folders` affiche pour chaque page une fiche indentée avec les valeurs des champs, les tags, et les pages liées (uniquement si non-vide — prompt compact pour les pages vierges). Format :
|
||||||
|
```
|
||||||
|
- PNJ (dossier)
|
||||||
|
- Borin le forgeron [template: PNJ]
|
||||||
|
· Apparence : Nain barbu au regard perçant…
|
||||||
|
· Motivation : Venger son clan décimé…
|
||||||
|
· tags : aventurier, forgeron
|
||||||
|
· liée à : Le marteau de Durin, Clan Feuillefer
|
||||||
|
```
|
||||||
|
- [x] **Budget tokens** : ~150-200 tokens par page pleine. Tient jusqu'à ~50-100 pages dans un prompt typique. Au-delà, bascule vers RAG sémantique (Option D, backlog).
|
||||||
|
- [x] Validation finale : `mvn -q compile` BUILD SUCCESS + `python -m py_compile` sur les 3 fichiers Python.
|
||||||
|
- [x] **Effet collatéral bénéfique** : cet enrichissement profite AUSSI au chat depuis la Lore (pas uniquement depuis la Campagne) — l'IA voit désormais le contenu de toutes les autres pages du Lore, pas seulement leurs noms.
|
||||||
|
- [x] **`num_ctx` porté à 16384** : sans ça, Ollama tronque silencieusement le prompt à 2048 tokens par défaut (~10 pages enrichies max). Nouveau setting `llm_num_ctx: int = 16384` dans `brain/app/core/config.py`, surchargeable via `LLM_NUM_CTX` dans `.env`. Méthode privée `_build_options()` factorisée dans `OllamaLLMProvider` — `num_ctx` est TOUJOURS injecté dans les deux payloads (`/api/generate` et `/api/chat`). Coût VRAM supplémentaire : ~600 MB de KV cache max vs défaut 2048.
|
||||||
|
|
||||||
20 avril 2026 (nuit, session 2) — **Phase 3 étape b8 bouclée : contextualisation page courante injectée côté serveur**.
|
20 avril 2026 (nuit, session 2) — **Phase 3 étape b8 bouclée : contextualisation page courante injectée côté serveur**.
|
||||||
|
|
||||||
**PageContext serveur (b8.1 → b8.3)** :
|
**PageContext serveur (b8.1 → b8.3)** :
|
||||||
@@ -509,7 +675,7 @@ Ces points sont à garder en tête pour de futures refactorisations. Pas bloquan
|
|||||||
- **Angular (b5.4)** : service `AiChatService` avec `fetch()` + `ReadableStream` (pas `EventSource` qui ne supporte que GET), composant standalone réutilisable `AiChatDrawerComponent` avec bulles user/assistant, typing indicator, caret clignotant, suggestions rapides, `primaryAction` optionnelle.
|
- **Angular (b5.4)** : service `AiChatService` avec `fetch()` + `ReadableStream` (pas `EventSource` qui ne supporte que GET), composant standalone réutilisable `AiChatDrawerComponent` avec bulles user/assistant, typing indicator, caret clignotant, suggestions rapides, `primaryAction` optionnelle.
|
||||||
- **Intégration page-edit (b5.5)** : bouton "Assistant IA" toggle le drawer, one-shot b4 relocalisé en `primaryAction` ("Remplir automatiquement"). Suggestions rapides hardcodées MVP.
|
- **Intégration page-edit (b5.5)** : bouton "Assistant IA" toggle le drawer, one-shot b4 relocalisé en `primaryAction` ("Remplir automatiquement"). Suggestions rapides hardcodées MVP.
|
||||||
- **Academy (b5.6)** : fiche `docs/academy/streaming-sse-rag.md` avec analogie JDR (pigeon voyageur), comparaison Full-dump/Structural/RAG sémantique, code des 3 étages, quiz 5 QCM.
|
- **Academy (b5.6)** : fiche `docs/academy/streaming-sse-rag.md` avec analogie JDR (pigeon voyageur), comparaison Full-dump/Structural/RAG sémantique, code des 3 étages, quiz 5 QCM.
|
||||||
- **Restera à étendre (b5.7)** : Campagne (asymétrique), page-create en mode wizard, éventuelle persistance de conversations.
|
- **Extension Campagne (b5.7, 20 avril après-midi)** : drawer branché sur `arc-edit`, `chapter-edit`, `scene-edit`. Asymétrie respectée (Campagne voit son Lore, Lore ne voit PAS ses campagnes). 4 contextes optionnels côté prompt (`lore_context`, `page_context`, `campaign_context`, `narrative_entity`). Extraction du shared `LoreStructuralContextBuilder` (DRY). Persistance des conversations restera pour plus tard.
|
||||||
|
|
||||||
19 avril 2026 (soir, session 2) — **Phase 3 étape b4 bouclée : chaîne IA de bout en bout opérationnelle**.
|
19 avril 2026 (soir, session 2) — **Phase 3 étape b4 bouclée : chaîne IA de bout en bout opérationnelle**.
|
||||||
|
|
||||||
|
|||||||
139
progress.txt
Normal file
139
progress.txt
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# LoreMind — feature "Illustrations & images"
|
||||||
|
# Plan d'execution en 6 etapes. Mets a jour apres chaque etape terminee.
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
## Etape 1 — Shared Kernel images + MinIO [x] TERMINEE (2026-04-20 sess.5)
|
||||||
|
Backend Java pur. Aucune integration metier. Tout est testable via curl.
|
||||||
|
Fichiers crees/modifies :
|
||||||
|
- docker-compose.yml (nouveau, service minio + minio-init)
|
||||||
|
- core/pom.xml (+ dep io.minio:minio:8.5.11)
|
||||||
|
- core/src/main/resources/application.properties (+ config minio.*, multipart 10MB)
|
||||||
|
- core/src/main/java/com/loremind/domain/images/Image.java
|
||||||
|
- core/src/main/java/com/loremind/domain/images/ports/ImageRepository.java
|
||||||
|
- core/src/main/java/com/loremind/domain/images/ports/ImageStorage.java
|
||||||
|
- core/src/main/java/com/loremind/application/images/ImageService.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/persistence/entity/ImageJpaEntity.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/persistence/jpa/ImageJpaRepository.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresImageRepository.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/storage/MinioConfig.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/web/dto/images/ImageDTO.java
|
||||||
|
- core/src/main/java/com/loremind/infrastructure/web/controller/ImageController.java
|
||||||
|
- docs/academy/object-storage.md
|
||||||
|
- docs/academy/shared-kernel.md
|
||||||
|
Validation : `mvn compile` OK (exit 0).
|
||||||
|
Tests manuels a faire par l'utilisateur :
|
||||||
|
1. docker-compose up -d
|
||||||
|
2. mvn spring-boot:run (dans /core)
|
||||||
|
3. curl -F file=@test.jpg http://localhost:8080/api/images
|
||||||
|
4. Verifier la reponse JSON avec id et url
|
||||||
|
5. Ouvrir http://localhost:8080/api/images/<id>/content dans le navigateur
|
||||||
|
|
||||||
|
## Etape 2 — Composants Angular partages [x] TERMINEE (2026-04-20 sess.5)
|
||||||
|
Fichiers crees :
|
||||||
|
- web/src/app/services/image.model.ts
|
||||||
|
- web/src/app/services/image.service.ts (upload, getById, delete, contentUrl)
|
||||||
|
- web/src/app/shared/image-uploader/ (ts + html + scss)
|
||||||
|
* Mode drop-zone standard OU compact (bouton "+ ajouter" pour galerie)
|
||||||
|
* Validation client MIME/taille alignee avec le backend
|
||||||
|
* Spinner + gestion erreur 413 et erreur generique
|
||||||
|
- web/src/app/shared/image-gallery/ (ts + html + scss)
|
||||||
|
* Grille de vignettes 120x120, lazy-loading
|
||||||
|
* Mode editable=true : bouton "+ ajouter" via app-image-uploader compact
|
||||||
|
* Bouton X par vignette, supprime cote serveur + emet nouvelle liste
|
||||||
|
* Lightbox plein ecran au clic (clic hors image pour fermer)
|
||||||
|
Validation : npx tsc --noEmit OK (exit 0).
|
||||||
|
|
||||||
|
## Etape 3 — Illustrations sur Scene / Chapter / Arc [x] TERMINEE (2026-04-20 sess.5)
|
||||||
|
Backend :
|
||||||
|
- domain/campaigncontext/{Arc,Chapter,Scene}.java : + List<String> illustrationImageIds
|
||||||
|
- persistence/entity/{Arc,Chapter,Scene}JpaEntity.java : colonne JSON illustration_image_ids
|
||||||
|
- persistence/postgres/Postgres{Arc,Chapter,Scene}Repository.java : mapping 2 sens
|
||||||
|
- web/dto/campaigncontext/{Arc,Chapter,Scene}DTO.java : + champ illustrationImageIds
|
||||||
|
- web/mapper/{Arc,Chapter,Scene}Mapper.java : propage dans les 2 sens
|
||||||
|
- application/campaigncontext/{Arc,Chapter,Scene}Service.java : update inclut le champ
|
||||||
|
Frontend :
|
||||||
|
- services/campaign.model.ts : + champ sur Arc/Chapter/Scene + leurs Create
|
||||||
|
- arc-view/chapter-view/scene-view : import + section "Illustrations" en haut (lecture)
|
||||||
|
- arc-edit/chapter-edit/scene-edit : import + propriete illustrationImageIds,
|
||||||
|
section galerie editable en tete de form, propagation dans submit()
|
||||||
|
Validation : mvn compile OK, npx tsc --noEmit OK.
|
||||||
|
|
||||||
|
## Etape 4 — Refactor Template.fields [x] TERMINEE (2026-04-21 sess.6)
|
||||||
|
Backend :
|
||||||
|
- domain/lorecontext/FieldType.java (enum TEXT | IMAGE)
|
||||||
|
- domain/lorecontext/TemplateField.java (VO name + type)
|
||||||
|
- domain/lorecontext/Template.java : fields devient List<TemplateField>,
|
||||||
|
helper textFieldNames() pour ne garder que les noms TEXT (use cases IA)
|
||||||
|
- persistence/converter/TemplateFieldListJsonConverter.java : lit le legacy
|
||||||
|
["name",...] ET le nouveau [{name,type}], ecrit toujours au nouveau format.
|
||||||
|
Migration automatique a la premiere sauvegarde.
|
||||||
|
- persistence/entity/TemplateJpaEntity.java : converter swap
|
||||||
|
- persistence/postgres/PostgresTemplateRepository.java : mapping typé
|
||||||
|
- web/dto/lorecontext/TemplateFieldDTO.java (nouveau)
|
||||||
|
- web/dto/lorecontext/TemplateDTO.java : fields -> List<TemplateFieldDTO>
|
||||||
|
- web/mapper/TemplateFieldMapper.java (nouveau, tolerance type inconnu -> TEXT)
|
||||||
|
- web/mapper/TemplateMapper.java : delegue au fieldMapper
|
||||||
|
- application/lorecontext/TemplateService.java : signature createTemplate
|
||||||
|
- web/controller/TemplateController.java : conversion DTO -> domain
|
||||||
|
- application/generationcontext/GeneratePageValuesUseCase.java : n'envoie
|
||||||
|
a l'IA que les champs TEXT (via textFieldNames()), erreur claire si aucun
|
||||||
|
- application/generationcontext/StreamChatForLoreUseCase.java : idem
|
||||||
|
Frontend :
|
||||||
|
- services/template.model.ts : FieldType + TemplateField
|
||||||
|
- template-create : liste TemplateField[], selecteur de type, toggle
|
||||||
|
- template-edit : idem + normalisation legacy en TEXT cote client
|
||||||
|
- page-view : rendu TEXT vs placeholder IMAGE (complete etape 5)
|
||||||
|
- page-edit : hydrate TEXT only, mergeSuggestions filtree, placeholder IMAGE
|
||||||
|
- page-create wizard prompt : ne liste que les TEXT fields
|
||||||
|
- Styles : chip verte (TEXT) vs indigo (IMAGE), bouton toggle inline
|
||||||
|
Validation : mvn compile OK, npx tsc --noEmit OK.
|
||||||
|
|
||||||
|
## Etape 5 — Support champs IMAGE dans Pages [x] TERMINEE (2026-04-21 sess.6)
|
||||||
|
Backend :
|
||||||
|
- persistence/converter/StringListMapJsonConverter.java (nouveau,
|
||||||
|
convertit Map<String, List<String>> <-> JSON pour Page.imageValues)
|
||||||
|
- domain/lorecontext/Page.java : + champ imageValues + helpers
|
||||||
|
setImageFieldValue/getImageFieldValue
|
||||||
|
- persistence/entity/PageJpaEntity.java : colonne image_values_json
|
||||||
|
- persistence/postgres/PostgresPageRepository.java : mapping 2 sens
|
||||||
|
- web/dto/lorecontext/PageDTO.java : + champ imageValues
|
||||||
|
- web/mapper/PageMapper.java : propage le champ
|
||||||
|
- application/lorecontext/PageService.java : update inclut imageValues
|
||||||
|
Frontend :
|
||||||
|
- services/page.model.ts : + imageValues?: Record<string, string[]>
|
||||||
|
- page-view : import ImageGalleryComponent + helper imageIdsOf()
|
||||||
|
+ rendu galerie (readonly) pour chaque champ IMAGE
|
||||||
|
- page-edit : import ImageGalleryComponent + propriete imageValues
|
||||||
|
+ hydrate separe TEXT/IMAGE + save propage imageValues + UI galerie editable
|
||||||
|
Validation : mvn compile OK, npx tsc --noEmit OK.
|
||||||
|
|
||||||
|
## Etape 6 — Brain Python : synchro DTOs [x] TERMINEE (2026-04-21 sess.6)
|
||||||
|
Backend Java :
|
||||||
|
- domain/generationcontext/CampaignStructuralContext.java : +illustrationCount
|
||||||
|
sur ArcSummary, ChapterSummary, SceneSummary
|
||||||
|
- application/generationcontext/CampaignStructuralContextBuilder.java : populate
|
||||||
|
depuis Arc/Chapter/Scene.getIllustrationImageIds() (null-safe)
|
||||||
|
- infrastructure/ai/BrainAiChatClient.java : serialise illustration_count dans
|
||||||
|
le JSON envoye au Brain (UNIQUEMENT si > 0, pour payload leger)
|
||||||
|
Brain Python :
|
||||||
|
- domain/models.py : +illustration_count: int = 0 sur les 3 summaries
|
||||||
|
- main.py : +illustration_count sur les 3 DTOs Pydantic + propagation dans
|
||||||
|
_to_campaign_context. Les champs inconnus (ex: illustrationImageIds envoyes
|
||||||
|
par le Core pour les pages) sont ignores par defaut par Pydantic v2.
|
||||||
|
- application/chat.py : nouveau helper _illustration_hint() ; les lignes
|
||||||
|
arcs/chapters/scenes du prompt affichent " [N illustrations]" si presentes.
|
||||||
|
Validation : mvn compile OK, python ast-parse OK, demo runtime prompt OK
|
||||||
|
(" - A (arc) [2 illustrations]", " - S (scène) [1 illustration]").
|
||||||
|
|
||||||
|
NON couvert (scope residuel evident, si besoin) :
|
||||||
|
- PageSummary ne porte pas encore de signal sur les imageValues des pages.
|
||||||
|
Les pages Lore exposent deja des values text via LoreStructuralContext ;
|
||||||
|
on pourrait ajouter le nombre d'images par champ IMAGE la aussi.
|
||||||
|
|
||||||
|
## Notes transverses
|
||||||
|
- Docker-compose pour l'instant ne couvre QUE MinIO. Postgres/brain/web restent lances a la main.
|
||||||
|
- Les ports `ImageRepository` et `ImageStorage` sont volontairement separes (SRP).
|
||||||
|
- Le binaire est stocke dans MinIO bucket `loremind-images` (cree auto par minio-init).
|
||||||
|
- L'URL publique d'une image est `/api/images/{id}/content` (proxy via backend Java).
|
||||||
|
- Validation MIME cote ImageService : jpeg/png/webp/gif uniquement. Max 10 Mo.
|
||||||
@@ -10,14 +10,18 @@ export const routes: Routes = [
|
|||||||
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
|
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
|
||||||
{ path: 'lore/:loreId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
|
{ path: 'lore/:loreId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
|
||||||
{ path: 'lore/:loreId/nodes/:nodeId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
|
{ path: 'lore/:loreId/nodes/:nodeId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
|
||||||
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-view/page-view.component').then(m => m.PageViewComponent) },
|
||||||
|
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
||||||
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
||||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-view/chapter-view.component').then(m => m.ChapterViewComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/edit', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||||
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
|
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
campaignId: this.campaignId,
|
campaignId: this.campaignId,
|
||||||
order: this.existingArcCount + 1
|
order: this.existingArcCount + 1
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
<div class="edit-page">
|
<div class="edit-page">
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>{{ arc?.name || 'Arc' }}</h1>
|
<div>
|
||||||
<p class="subtitle">Arc narratif</p>
|
<h1>{{ arc?.name || 'Arc' }}</h1>
|
||||||
|
<p class="subtitle">Arc narratif</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn-ai"
|
||||||
|
(click)="toggleChat()"
|
||||||
|
[class.active]="chatOpen"
|
||||||
|
title="Ouvrir l'Assistant IA pour dialoguer autour de cet arc">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Assistant IA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
|
<!-- Illustrations (galerie editable) -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Illustrations</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="illustrationImageIds"
|
||||||
|
[editable]="true"
|
||||||
|
(imageIdsChange)="illustrationImageIds = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
<small class="field-hint">Glisse-depose ou clique sur "+ Ajouter" pour uploader. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre de l'arc *</label>
|
<label>Titre de l'arc *</label>
|
||||||
<input
|
<input
|
||||||
@@ -108,3 +130,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
[campaignId]="campaignId"
|
||||||
|
entityType="arc"
|
||||||
|
[entityId]="arcId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
welcomeMessage="Je vois cet arc. Demande-moi d'enrichir ses thèmes, ses enjeux ou son dénouement."
|
||||||
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
|
(close)="chatOpen = false">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header local : titre à gauche, actions (Assistant IA) à droite.
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Formulaire vertical classique.
|
// Formulaire vertical classique.
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
@@ -13,6 +13,8 @@ import { Campaign, Arc } from '../../services/campaign.model';
|
|||||||
import { Page } from '../../services/page.model';
|
import { Page } from '../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
|
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Arc.
|
* Écran de détail/modification d'un Arc.
|
||||||
@@ -26,12 +28,23 @@ import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-arc-edit',
|
selector: 'app-arc-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
||||||
templateUrl: './arc-edit.component.html',
|
templateUrl: './arc-edit.component.html',
|
||||||
styleUrls: ['./arc-edit.component.scss']
|
styleUrls: ['./arc-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class ArcEditComponent implements OnInit, OnDestroy {
|
export class ArcEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
|
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||||
|
chatOpen = false;
|
||||||
|
readonly chatQuickSuggestions = [
|
||||||
|
'Propose 3 thèmes majeurs pour cet arc',
|
||||||
|
'Imagine des enjeux qui mettent la pression sur les joueurs',
|
||||||
|
'Suggère un dénouement en deux actes'
|
||||||
|
];
|
||||||
|
|
||||||
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
@@ -45,6 +58,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
/** IDs des pages liées à cet arc (bind sur app-lore-link-picker). */
|
/** IDs des pages liées à cet arc (bind sur app-lore-link-picker). */
|
||||||
relatedPageIds: string[] = [];
|
relatedPageIds: string[] = [];
|
||||||
|
|
||||||
|
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
|
||||||
|
illustrationImageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -102,6 +118,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
this.loreId = loreId;
|
this.loreId = loreId;
|
||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
||||||
|
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
|
||||||
this.pageTitleService.set(arc.name);
|
this.pageTitleService.set(arc.name);
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: arc.name,
|
name: arc.name,
|
||||||
@@ -143,9 +160,10 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
gmNotes: this.form.value.gmNotes,
|
gmNotes: this.form.value.gmNotes,
|
||||||
rewards: this.form.value.rewards,
|
rewards: this.form.value.rewards,
|
||||||
resolution: this.form.value.resolution,
|
resolution: this.form.value.resolution,
|
||||||
relatedPageIds: this.relatedPageIds
|
relatedPageIds: this.relatedPageIds,
|
||||||
|
illustrationImageIds: this.illustrationImageIds
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,7 +177,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|||||||
74
web/src/app/campaigns/arc-view/arc-view.component.html
Normal file
74
web/src/app/campaigns/arc-view/arc-view.component.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<div class="view-page" *ngIf="arc">
|
||||||
|
|
||||||
|
<header class="view-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ arc.name }}</h1>
|
||||||
|
<p class="view-subtitle">Arc narratif</p>
|
||||||
|
</div>
|
||||||
|
<div class="view-actions">
|
||||||
|
<button type="button" class="btn-primary" (click)="editMode()">
|
||||||
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Illustrations en tete de page (si presentes) -->
|
||||||
|
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
|
||||||
|
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">📜</span> Synopsis</h2>
|
||||||
|
<p class="view-section-body" *ngIf="arc.description?.trim(); else emptyDesc">{{ arc.description }}</p>
|
||||||
|
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="view-row">
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">✨</span> Thèmes principaux</h2>
|
||||||
|
<p class="view-section-body" *ngIf="arc.themes?.trim(); else emptyThemes">{{ arc.themes }}</p>
|
||||||
|
<ng-template #emptyThemes><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">⚖️</span> Enjeux globaux</h2>
|
||||||
|
<p class="view-section-body" *ngIf="arc.stakes?.trim(); else emptyStakes">{{ arc.stakes }}</p>
|
||||||
|
<ng-template #emptyStakes><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🎁</span> Récompenses et progression</h2>
|
||||||
|
<p class="view-section-body" *ngIf="arc.rewards?.trim(); else emptyRewards">{{ arc.rewards }}</p>
|
||||||
|
<ng-template #emptyRewards><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🎬</span> Dénouement prévu</h2>
|
||||||
|
<p class="view-section-body" *ngIf="arc.resolution?.trim(); else emptyResolution">{{ arc.resolution }}</p>
|
||||||
|
<ng-template #emptyResolution><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Notes MJ (bloc privé rouge discret) -->
|
||||||
|
<section class="view-section view-section--private" *ngIf="arc.gmNotes?.trim()">
|
||||||
|
<h2 class="view-section-title">
|
||||||
|
<span class="view-section-icon">🔒</span>
|
||||||
|
Notes et planification du MJ
|
||||||
|
</h2>
|
||||||
|
<p class="view-section-body">{{ arc.gmNotes }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pages Lore liées (chips cliquables) -->
|
||||||
|
<section class="view-section" *ngIf="loreId && (arc.relatedPageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
|
||||||
|
<div class="view-chips">
|
||||||
|
<a class="view-chip"
|
||||||
|
*ngFor="let relId of arc.relatedPageIds"
|
||||||
|
[routerLink]="['/lore', loreId, 'pages', relId]">
|
||||||
|
{{ titleOfRelated(relId) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
1
web/src/app/campaigns/arc-view/arc-view.component.scss
Normal file
1
web/src/app/campaigns/arc-view/arc-view.component.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Styles partagés via styles/_view.scss
|
||||||
107
web/src/app/campaigns/arc-view/arc-view.component.ts
Normal file
107
web/src/app/campaigns/arc-view/arc-view.component.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
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 } from 'lucide-angular';
|
||||||
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { PageService } from '../../services/page.service';
|
||||||
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
|
import { Campaign, Arc } from '../../services/campaign.model';
|
||||||
|
import { Page } from '../../services/page.model';
|
||||||
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||||
|
* Route : /campaigns/:campaignId/arcs/:arcId
|
||||||
|
* Bouton "Modifier" → /campaigns/:campaignId/arcs/:arcId/edit
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-arc-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
|
||||||
|
templateUrl: './arc-view.component.html',
|
||||||
|
styleUrls: ['./arc-view.component.scss']
|
||||||
|
})
|
||||||
|
export class ArcViewComponent implements OnInit, OnDestroy {
|
||||||
|
readonly Pencil = Pencil;
|
||||||
|
|
||||||
|
campaignId = '';
|
||||||
|
arcId = '';
|
||||||
|
arc: Arc | null = null;
|
||||||
|
|
||||||
|
/** ID du Lore associé à la campagne (null si pas d'univers lié). */
|
||||||
|
loreId: string | null = null;
|
||||||
|
/** Pages du Lore — pour résoudre relatedPageIds en titres. */
|
||||||
|
availablePages: Page[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private pageService: PageService,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private pageTitleService: PageTitleService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.paramMap.subscribe(pm => {
|
||||||
|
const newCampaignId = pm.get('campaignId')!;
|
||||||
|
const newArcId = pm.get('arcId')!;
|
||||||
|
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
|
this.arcId = newArcId;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
forkJoin({
|
||||||
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
|
arc: this.campaignService.getArcById(this.arcId),
|
||||||
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||||
|
}).pipe(
|
||||||
|
switchMap(data => {
|
||||||
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||||
|
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||||
|
})
|
||||||
|
).subscribe(({ campaign, allCampaigns, arc, treeData, pages, loreId }) => {
|
||||||
|
this.arc = arc;
|
||||||
|
this.loreId = loreId;
|
||||||
|
this.availablePages = pages;
|
||||||
|
this.pageTitleService.set(arc.name);
|
||||||
|
|
||||||
|
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||||
|
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||||
|
}));
|
||||||
|
this.layoutService.show({
|
||||||
|
title: campaign.name,
|
||||||
|
items: buildCampaignTree(this.campaignId, treeData),
|
||||||
|
footerLabel: 'Toutes les campagnes',
|
||||||
|
createActions: [
|
||||||
|
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||||
|
],
|
||||||
|
globalItems,
|
||||||
|
globalBackLabel: 'Toutes les campagnes',
|
||||||
|
globalBackRoute: '/campaigns'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
titleOfRelated(pageId: string): string {
|
||||||
|
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||||
|
}
|
||||||
|
|
||||||
|
editMode(): void {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'edit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.layoutService.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,10 +59,24 @@ export function loadCampaignTreeData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCampaignTree(campaignId: string, data: CampaignTreeData): TreeItem[] {
|
export function buildCampaignTree(campaignId: string, data: CampaignTreeData): TreeItem[] {
|
||||||
return data.arcs.map(arc => {
|
// Tri FR avec `numeric: true` pour que "1. Intro", "2. Voyage", "10. Final" soient
|
||||||
const chapterItems: TreeItem[] = (data.chaptersByArc[arc.id!] ?? []).map(ch => {
|
// classés 1, 2, 10 (et pas 1, 10, 2). `sensitivity: 'base'` ignore la casse.
|
||||||
const sceneItems: TreeItem[] = (data.scenesByChapter[ch.id!] ?? []).map(sc => ({
|
const byName = (a: { name: string }, b: { name: string }) =>
|
||||||
id: sc.id!,
|
a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' });
|
||||||
|
|
||||||
|
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
|
||||||
|
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
|
||||||
|
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
|
||||||
|
const sortedArcs = [...data.arcs].sort(byName);
|
||||||
|
|
||||||
|
return sortedArcs.map(arc => {
|
||||||
|
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
|
||||||
|
|
||||||
|
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
|
||||||
|
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byName);
|
||||||
|
|
||||||
|
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
||||||
|
id: `scene-${sc.id}`,
|
||||||
label: sc.name,
|
label: sc.name,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
||||||
}));
|
}));
|
||||||
@@ -73,7 +87,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: ch.id!,
|
id: `chapter-${ch.id}`,
|
||||||
label: ch.name,
|
label: ch.name,
|
||||||
children: sceneItems,
|
children: sceneItems,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`
|
||||||
@@ -86,7 +100,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: arc.id!,
|
id: `arc-${arc.id}`,
|
||||||
label: arc.name,
|
label: arc.name,
|
||||||
children: chapterItems,
|
children: chapterItems,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`
|
route: `/campaigns/${campaignId}/arcs/${arc.id}`
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
arcId: this.arcId,
|
arcId: this.arcId,
|
||||||
order: this.existingChapterCount + 1
|
order: this.existingChapterCount + 1
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création du chapitre')
|
error: () => console.error('Erreur lors de la création du chapitre')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
<div class="edit-page">
|
<div class="edit-page">
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
|
<div>
|
||||||
<p class="subtitle">Chapitre</p>
|
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
|
||||||
|
<p class="subtitle">Chapitre</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn-ai"
|
||||||
|
(click)="toggleChat()"
|
||||||
|
[class.active]="chatOpen"
|
||||||
|
title="Ouvrir l'Assistant IA pour dialoguer autour de ce chapitre">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Assistant IA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
|
<!-- Illustrations (galerie editable) -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Illustrations</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="illustrationImageIds"
|
||||||
|
[editable]="true"
|
||||||
|
(imageIdsChange)="illustrationImageIds = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
<small class="field-hint">Ajoute des cartes, portraits ou ambiances pour illustrer ce chapitre.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre du chapitre *</label>
|
<label>Titre du chapitre *</label>
|
||||||
<input
|
<input
|
||||||
@@ -90,3 +112,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
[campaignId]="campaignId"
|
||||||
|
entityType="chapter"
|
||||||
|
[entityId]="chapterId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
welcomeMessage="Je vois ce chapitre. Demande-moi d'étoffer ses objectifs, ses enjeux ou sa scène d'ouverture."
|
||||||
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
|
(close)="chatOpen = false">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header local : titre à gauche, actions (Assistant IA) à droite.
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
@@ -13,6 +13,8 @@ import { Campaign, Chapter } from '../../services/campaign.model';
|
|||||||
import { Page } from '../../services/page.model';
|
import { Page } from '../../services/page.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
|
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Chapitre.
|
* Écran de détail/modification d'un Chapitre.
|
||||||
@@ -24,12 +26,23 @@ import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chapter-edit',
|
selector: 'app-chapter-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
||||||
templateUrl: './chapter-edit.component.html',
|
templateUrl: './chapter-edit.component.html',
|
||||||
styleUrls: ['./chapter-edit.component.scss']
|
styleUrls: ['./chapter-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterEditComponent implements OnInit, OnDestroy {
|
export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
|
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||||
|
chatOpen = false;
|
||||||
|
readonly chatQuickSuggestions = [
|
||||||
|
'Propose des objectifs clairs pour les joueurs dans ce chapitre',
|
||||||
|
'Imagine 2 tensions narratives qui relancent l\'intérêt en milieu de chapitre',
|
||||||
|
'Suggère une scène d\'ouverture marquante'
|
||||||
|
];
|
||||||
|
|
||||||
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
@@ -40,6 +53,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
availablePages: Page[] = [];
|
availablePages: Page[] = [];
|
||||||
loreId: string | null = null;
|
loreId: string | null = null;
|
||||||
relatedPageIds: string[] = [];
|
relatedPageIds: string[] = [];
|
||||||
|
illustrationImageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
@@ -96,6 +110,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
this.loreId = loreId;
|
this.loreId = loreId;
|
||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
||||||
|
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: chapter.name,
|
name: chapter.name,
|
||||||
description: chapter.description ?? '',
|
description: chapter.description ?? '',
|
||||||
@@ -132,9 +147,10 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
gmNotes: this.form.value.gmNotes,
|
gmNotes: this.form.value.gmNotes,
|
||||||
playerObjectives: this.form.value.playerObjectives,
|
playerObjectives: this.form.value.playerObjectives,
|
||||||
narrativeStakes: this.form.value.narrativeStakes,
|
narrativeStakes: this.form.value.narrativeStakes,
|
||||||
relatedPageIds: this.relatedPageIds
|
relatedPageIds: this.relatedPageIds,
|
||||||
|
illustrationImageIds: this.illustrationImageIds
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -148,7 +164,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<div class="view-page" *ngIf="chapter">
|
||||||
|
|
||||||
|
<header class="view-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ chapter.name }}</h1>
|
||||||
|
<p class="view-subtitle">Chapitre</p>
|
||||||
|
</div>
|
||||||
|
<div class="view-actions">
|
||||||
|
<button type="button" class="btn-primary" (click)="editMode()">
|
||||||
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Illustrations -->
|
||||||
|
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
|
||||||
|
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Synopsis</h2>
|
||||||
|
<p class="view-section-body" *ngIf="chapter.description?.trim(); else emptyDesc">{{ chapter.description }}</p>
|
||||||
|
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="view-row">
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🎯</span> Objectifs des joueurs</h2>
|
||||||
|
<p class="view-section-body" *ngIf="chapter.playerObjectives?.trim(); else emptyObj">{{ chapter.playerObjectives }}</p>
|
||||||
|
<ng-template #emptyObj><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">⚡</span> Enjeux narratifs</h2>
|
||||||
|
<p class="view-section-body" *ngIf="chapter.narrativeStakes?.trim(); else emptyNs">{{ chapter.narrativeStakes }}</p>
|
||||||
|
<ng-template #emptyNs><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="view-section view-section--private" *ngIf="chapter.gmNotes?.trim()">
|
||||||
|
<h2 class="view-section-title">
|
||||||
|
<span class="view-section-icon">🔒</span>
|
||||||
|
Notes du Maître de Jeu
|
||||||
|
</h2>
|
||||||
|
<p class="view-section-body">{{ chapter.gmNotes }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="view-section" *ngIf="loreId && (chapter.relatedPageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
|
||||||
|
<div class="view-chips">
|
||||||
|
<a class="view-chip"
|
||||||
|
*ngFor="let relId of chapter.relatedPageIds"
|
||||||
|
[routerLink]="['/lore', loreId, 'pages', relId]">
|
||||||
|
{{ titleOfRelated(relId) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Styles partagés via styles/_view.scss
|
||||||
111
web/src/app/campaigns/chapter-view/chapter-view.component.ts
Normal file
111
web/src/app/campaigns/chapter-view/chapter-view.component.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
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 } from 'lucide-angular';
|
||||||
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { PageService } from '../../services/page.service';
|
||||||
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
|
import { Campaign, Chapter } from '../../services/campaign.model';
|
||||||
|
import { Page } from '../../services/page.model';
|
||||||
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran de consultation d'un Chapitre (lecture seule).
|
||||||
|
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chapter-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
|
||||||
|
templateUrl: './chapter-view.component.html',
|
||||||
|
styleUrls: ['./chapter-view.component.scss']
|
||||||
|
})
|
||||||
|
export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||||
|
readonly Pencil = Pencil;
|
||||||
|
|
||||||
|
campaignId = '';
|
||||||
|
arcId = '';
|
||||||
|
chapterId = '';
|
||||||
|
chapter: Chapter | null = null;
|
||||||
|
|
||||||
|
loreId: string | null = null;
|
||||||
|
availablePages: Page[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private pageService: PageService,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private pageTitleService: PageTitleService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.paramMap.subscribe(pm => {
|
||||||
|
const newCampaignId = pm.get('campaignId')!;
|
||||||
|
const newArcId = pm.get('arcId')!;
|
||||||
|
const newChapterId = pm.get('chapterId')!;
|
||||||
|
if (newChapterId !== this.chapterId ||
|
||||||
|
newArcId !== this.arcId ||
|
||||||
|
newCampaignId !== this.campaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
|
this.arcId = newArcId;
|
||||||
|
this.chapterId = newChapterId;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
forkJoin({
|
||||||
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||||
|
}).pipe(
|
||||||
|
switchMap(data => {
|
||||||
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||||
|
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||||
|
})
|
||||||
|
).subscribe(({ campaign, allCampaigns, chapter, treeData, pages, loreId }) => {
|
||||||
|
this.chapter = chapter;
|
||||||
|
this.loreId = loreId;
|
||||||
|
this.availablePages = pages;
|
||||||
|
this.pageTitleService.set(chapter.name);
|
||||||
|
|
||||||
|
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||||
|
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||||
|
}));
|
||||||
|
this.layoutService.show({
|
||||||
|
title: campaign.name,
|
||||||
|
items: buildCampaignTree(this.campaignId, treeData),
|
||||||
|
footerLabel: 'Toutes les campagnes',
|
||||||
|
createActions: [
|
||||||
|
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||||
|
],
|
||||||
|
globalItems,
|
||||||
|
globalBackLabel: 'Toutes les campagnes',
|
||||||
|
globalBackRoute: '/campaigns'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
titleOfRelated(pageId: string): string {
|
||||||
|
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||||
|
}
|
||||||
|
|
||||||
|
editMode(): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'edit'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.layoutService.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
chapterId: this.chapterId,
|
chapterId: this.chapterId,
|
||||||
order: this.existingSceneCount + 1
|
order: this.existingSceneCount + 1
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de la scène')
|
error: () => console.error('Erreur lors de la création de la scène')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
<div class="edit-page">
|
<div class="edit-page">
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>{{ scene?.name || 'Scène' }}</h1>
|
<div>
|
||||||
<p class="subtitle">Scène</p>
|
<h1>{{ scene?.name || 'Scène' }}</h1>
|
||||||
|
<p class="subtitle">Scène</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn-ai"
|
||||||
|
(click)="toggleChat()"
|
||||||
|
[class.active]="chatOpen"
|
||||||
|
title="Ouvrir l'Assistant IA pour dialoguer autour de cette scène">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Assistant IA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
|
<!-- Illustrations (galerie editable) -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Illustrations</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="illustrationImageIds"
|
||||||
|
[editable]="true"
|
||||||
|
(imageIdsChange)="illustrationImageIds = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
<small class="field-hint">Carte du lieu, portrait des PNJ presents, ambiance visuelle...</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre de la scène *</label>
|
<label>Titre de la scène *</label>
|
||||||
<input
|
<input
|
||||||
@@ -135,3 +157,14 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer chat IA (hors .edit-page pour couvrir le viewport à droite) -->
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
[campaignId]="campaignId"
|
||||||
|
entityType="scene"
|
||||||
|
[entityId]="sceneId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
welcomeMessage="Je vois cette scène. Demande-moi d'enrichir son ambiance, sa narration ou ses choix."
|
||||||
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
|
(close)="chatOpen = false">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header local : titre à gauche, actions (Assistant IA) à droite.
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
@@ -14,6 +14,8 @@ import { Page } from '../../services/page.model';
|
|||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
|
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
|
||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
|
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'une Scène.
|
* Écran de détail/modification d'une Scène.
|
||||||
@@ -22,12 +24,23 @@ import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-scene-edit',
|
selector: 'app-scene-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
||||||
templateUrl: './scene-edit.component.html',
|
templateUrl: './scene-edit.component.html',
|
||||||
styleUrls: ['./scene-edit.component.scss']
|
styleUrls: ['./scene-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class SceneEditComponent implements OnInit, OnDestroy {
|
export class SceneEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
|
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||||
|
chatOpen = false;
|
||||||
|
readonly chatQuickSuggestions = [
|
||||||
|
'Propose une ambiance sensorielle immersive pour cette scène',
|
||||||
|
'Suggère une narration d\'ouverture à lire aux joueurs',
|
||||||
|
'Imagine 2 choix avec conséquences marquantes'
|
||||||
|
];
|
||||||
|
|
||||||
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
@@ -39,6 +52,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
availablePages: Page[] = [];
|
availablePages: Page[] = [];
|
||||||
loreId: string | null = null;
|
loreId: string | null = null;
|
||||||
relatedPageIds: string[] = [];
|
relatedPageIds: string[] = [];
|
||||||
|
illustrationImageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
@@ -108,6 +122,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
this.loreId = loreId;
|
this.loreId = loreId;
|
||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||||
|
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: scene.name,
|
name: scene.name,
|
||||||
description: scene.description ?? '',
|
description: scene.description ?? '',
|
||||||
@@ -154,9 +169,10 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
choicesConsequences: this.form.value.choicesConsequences,
|
choicesConsequences: this.form.value.choicesConsequences,
|
||||||
combatDifficulty: this.form.value.combatDifficulty,
|
combatDifficulty: this.form.value.combatDifficulty,
|
||||||
enemies: this.form.value.enemies,
|
enemies: this.form.value.enemies,
|
||||||
relatedPageIds: this.relatedPageIds
|
relatedPageIds: this.relatedPageIds,
|
||||||
|
illustrationImageIds: this.illustrationImageIds
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -170,7 +186,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
|||||||
90
web/src/app/campaigns/scene-view/scene-view.component.html
Normal file
90
web/src/app/campaigns/scene-view/scene-view.component.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<div class="view-page" *ngIf="scene">
|
||||||
|
|
||||||
|
<header class="view-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ scene.name }}</h1>
|
||||||
|
<p class="view-subtitle">Scène</p>
|
||||||
|
</div>
|
||||||
|
<div class="view-actions">
|
||||||
|
<button type="button" class="btn-primary" (click)="editMode()">
|
||||||
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Illustrations -->
|
||||||
|
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
|
||||||
|
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Description courte -->
|
||||||
|
<section class="view-section">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">📝</span> Description</h2>
|
||||||
|
<p class="view-section-body" *ngIf="scene.description?.trim(); else emptyDesc">{{ scene.description }}</p>
|
||||||
|
<ng-template #emptyDesc><p class="view-section-empty">Non renseigné</p></ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Contexte et ambiance -->
|
||||||
|
<div class="view-row" *ngIf="scene.location?.trim() || scene.timing?.trim()">
|
||||||
|
<section class="view-section" *ngIf="scene.location?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">📍</span> Lieu</h2>
|
||||||
|
<p class="view-section-body">{{ scene.location }}</p>
|
||||||
|
</section>
|
||||||
|
<section class="view-section" *ngIf="scene.timing?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">⏰</span> Moment</h2>
|
||||||
|
<p class="view-section-body">{{ scene.timing }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="view-section" *ngIf="scene.atmosphere?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🌫️</span> Ambiance et atmosphère</h2>
|
||||||
|
<p class="view-section-body">{{ scene.atmosphere }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Narration pour les joueurs -->
|
||||||
|
<section class="view-section" *ngIf="scene.playerNarration?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">📖</span> Narration pour les joueurs</h2>
|
||||||
|
<p class="view-section-body">{{ scene.playerNarration }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Choix et conséquences -->
|
||||||
|
<section class="view-section" *ngIf="scene.choicesConsequences?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🔀</span> Choix et conséquences</h2>
|
||||||
|
<p class="view-section-body">{{ scene.choicesConsequences }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Combat ou rencontre -->
|
||||||
|
<ng-container *ngIf="scene.combatDifficulty?.trim() || scene.enemies?.trim()">
|
||||||
|
<section class="view-section" *ngIf="scene.combatDifficulty?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">⚔️</span> Difficulté estimée</h2>
|
||||||
|
<p class="view-section-body">{{ scene.combatDifficulty }}</p>
|
||||||
|
</section>
|
||||||
|
<section class="view-section" *ngIf="scene.enemies?.trim()">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🐲</span> Ennemis et créatures</h2>
|
||||||
|
<p class="view-section-body">{{ scene.enemies }}</p>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Notes et secrets du MJ (privé) -->
|
||||||
|
<section class="view-section view-section--private" *ngIf="scene.gmSecretNotes?.trim()">
|
||||||
|
<h2 class="view-section-title">
|
||||||
|
<span class="view-section-icon">🔒</span>
|
||||||
|
Notes et secrets du MJ
|
||||||
|
</h2>
|
||||||
|
<p class="view-section-body">{{ scene.gmSecretNotes }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pages Lore liées -->
|
||||||
|
<section class="view-section" *ngIf="loreId && (scene.relatedPageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🔗</span> Pages Lore associées</h2>
|
||||||
|
<div class="view-chips">
|
||||||
|
<a class="view-chip"
|
||||||
|
*ngFor="let relId of scene.relatedPageIds"
|
||||||
|
[routerLink]="['/lore', loreId, 'pages', relId]">
|
||||||
|
{{ titleOfRelated(relId) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Styles partagés via styles/_view.scss
|
||||||
116
web/src/app/campaigns/scene-view/scene-view.component.ts
Normal file
116
web/src/app/campaigns/scene-view/scene-view.component.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
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 } from 'lucide-angular';
|
||||||
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { PageService } from '../../services/page.service';
|
||||||
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
|
import { Campaign, Scene } from '../../services/campaign.model';
|
||||||
|
import { Page } from '../../services/page.model';
|
||||||
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran de consultation d'une Scène (lecture seule).
|
||||||
|
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-scene-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule, LucideAngularModule, ImageGalleryComponent],
|
||||||
|
templateUrl: './scene-view.component.html',
|
||||||
|
styleUrls: ['./scene-view.component.scss']
|
||||||
|
})
|
||||||
|
export class SceneViewComponent implements OnInit, OnDestroy {
|
||||||
|
readonly Pencil = Pencil;
|
||||||
|
|
||||||
|
campaignId = '';
|
||||||
|
arcId = '';
|
||||||
|
chapterId = '';
|
||||||
|
sceneId = '';
|
||||||
|
scene: Scene | null = null;
|
||||||
|
|
||||||
|
loreId: string | null = null;
|
||||||
|
availablePages: Page[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private pageService: PageService,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private pageTitleService: PageTitleService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.paramMap.subscribe(pm => {
|
||||||
|
const newCampaignId = pm.get('campaignId')!;
|
||||||
|
const newArcId = pm.get('arcId')!;
|
||||||
|
const newChapterId = pm.get('chapterId')!;
|
||||||
|
const newSceneId = pm.get('sceneId')!;
|
||||||
|
if (newSceneId !== this.sceneId ||
|
||||||
|
newChapterId !== this.chapterId ||
|
||||||
|
newArcId !== this.arcId ||
|
||||||
|
newCampaignId !== this.campaignId) {
|
||||||
|
this.campaignId = newCampaignId;
|
||||||
|
this.arcId = newArcId;
|
||||||
|
this.chapterId = newChapterId;
|
||||||
|
this.sceneId = newSceneId;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
forkJoin({
|
||||||
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
|
scene: this.campaignService.getSceneById(this.sceneId),
|
||||||
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||||
|
}).pipe(
|
||||||
|
switchMap(data => {
|
||||||
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||||
|
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||||
|
})
|
||||||
|
).subscribe(({ campaign, allCampaigns, scene, treeData, pages, loreId }) => {
|
||||||
|
this.scene = scene;
|
||||||
|
this.loreId = loreId;
|
||||||
|
this.availablePages = pages;
|
||||||
|
this.pageTitleService.set(scene.name);
|
||||||
|
|
||||||
|
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||||
|
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||||
|
}));
|
||||||
|
this.layoutService.show({
|
||||||
|
title: campaign.name,
|
||||||
|
items: buildCampaignTree(this.campaignId, treeData),
|
||||||
|
footerLabel: 'Toutes les campagnes',
|
||||||
|
createActions: [
|
||||||
|
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||||
|
],
|
||||||
|
globalItems,
|
||||||
|
globalBackLabel: 'Toutes les campagnes',
|
||||||
|
globalBackRoute: '/campaigns'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
titleOfRelated(pageId: string): string {
|
||||||
|
return this.availablePages.find(p => p.id === pageId)?.title ?? '(page supprimée)';
|
||||||
|
}
|
||||||
|
|
||||||
|
editMode(): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/campaigns', this.campaignId, 'arcs', this.arcId,
|
||||||
|
'chapters', this.chapterId, 'scenes', this.sceneId, 'edit'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.layoutService.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
const children: TreeItem[] = [
|
const children: TreeItem[] = [
|
||||||
...subFolders.map(buildFolderItem),
|
...subFolders.map(buildFolderItem),
|
||||||
...nodePages.map(p => ({
|
...nodePages.map(p => ({
|
||||||
id: p.id!,
|
id: `page-${p.id}`,
|
||||||
label: p.title,
|
label: p.title,
|
||||||
route: `/lore/${lore.id}/pages/${p.id}`
|
route: `/lore/${lore.id}/pages/${p.id}`
|
||||||
})),
|
})),
|
||||||
@@ -89,8 +89,11 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
route: `/lore/${lore.id}/nodes/${node.id}/pages/create`
|
route: `/lore/${lore.id}/nodes/${node.id}/pages/create`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
// IDs préfixés par type — chaque entité a sa propre séquence IDENTITY en base,
|
||||||
|
// donc node.id=1 et page.id=1 peuvent coexister et collisionner dans le
|
||||||
|
// Set<string> global de LayoutService.expanded.
|
||||||
return {
|
return {
|
||||||
id: node.id!,
|
id: `folder-${node.id}`,
|
||||||
label: node.name,
|
label: node.name,
|
||||||
iconKey: node.icon ?? undefined,
|
iconKey: node.icon ?? undefined,
|
||||||
route: `/lore/${lore.id}/folders/${node.id}/edit`,
|
route: `/lore/${lore.id}/folders/${node.id}/edit`,
|
||||||
|
|||||||
@@ -120,7 +120,10 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
templateId: this.selectedTemplateId!,
|
templateId: this.selectedTemplateId!,
|
||||||
title: raw.title
|
title: raw.title
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
// Après la création classique, la coquille est vide → on redirige
|
||||||
|
// vers l'écran d'édition pour que l'utilisateur remplisse les champs
|
||||||
|
// dynamiques du template.
|
||||||
|
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id, 'edit']),
|
||||||
error: () => console.error('Erreur lors de la création de la page')
|
error: () => console.error('Erreur lors de la création de la page')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,9 +193,11 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
const tpl = this.selectedTemplate;
|
const tpl = this.selectedTemplate;
|
||||||
if (!tpl || !this.canSubmit) return null;
|
if (!tpl || !this.canSubmit) return null;
|
||||||
const title = this.form.value.title as string;
|
const title = this.form.value.title as string;
|
||||||
const fieldsList = tpl.fields.length ? tpl.fields.map(f => `"${f}"`).join(', ') : '(aucun champ)';
|
// Seuls les champs TEXT sont proposes a l'IA : l'IA ne genere pas d'images.
|
||||||
const exampleJson = tpl.fields.length
|
const textFields = (tpl.fields ?? []).filter(f => f.type === 'TEXT');
|
||||||
? '{\n ' + tpl.fields.map(f => `"${f}": "valeur proposée"`).join(',\n ') + '\n}'
|
const fieldsList = textFields.length ? textFields.map(f => `"${f.name}"`).join(', ') : '(aucun champ)';
|
||||||
|
const exampleJson = textFields.length
|
||||||
|
? '{\n ' + textFields.map(f => `"${f.name}": "valeur proposée"`).join(',\n ') + '\n}'
|
||||||
: '{}';
|
: '{}';
|
||||||
|
|
||||||
return `MODE WIZARD — CRÉATION DE PAGE
|
return `MODE WIZARD — CRÉATION DE PAGE
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
|
{{ aiLoading ? 'Génération…' : 'Assistant IA' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" [routerLink]="['/lore', loreId, 'pages', pageId]">Annuler</button>
|
||||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||||||
Sauvegarder
|
Sauvegarder
|
||||||
@@ -47,15 +48,27 @@
|
|||||||
<!-- Champs dynamiques du template -------------------------------- -->
|
<!-- Champs dynamiques du template -------------------------------- -->
|
||||||
<ng-container *ngIf="template?.fields?.length">
|
<ng-container *ngIf="template?.fields?.length">
|
||||||
<h2 class="section-title">Champs</h2>
|
<h2 class="section-title">Champs</h2>
|
||||||
<div class="field" *ngFor="let fieldName of template!.fields">
|
<ng-container *ngFor="let field of template!.fields">
|
||||||
<label>{{ fieldName }}</label>
|
<!-- Champ TEXT : textarea editable -->
|
||||||
<textarea
|
<div class="field" *ngIf="field.type === 'TEXT'">
|
||||||
[(ngModel)]="values[fieldName]"
|
<label>{{ field.name }}</label>
|
||||||
[name]="'value_' + fieldName"
|
<textarea
|
||||||
rows="4"
|
[(ngModel)]="values[field.name]"
|
||||||
[placeholder]="'Valeur pour ' + fieldName + '...'">
|
[name]="'value_' + field.name"
|
||||||
</textarea>
|
rows="4"
|
||||||
</div>
|
[placeholder]="'Valeur pour ' + field.name + '...'">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
<!-- Champ IMAGE : galerie editable. -->
|
||||||
|
<div class="field" *ngIf="field.type === 'IMAGE'">
|
||||||
|
<label>{{ field.name }}</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="imageValues[field.name] || []"
|
||||||
|
[editable]="true"
|
||||||
|
(imageIdsChange)="imageValues[field.name] = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Tags --------------------------------------------------------- -->
|
<!-- Tags --------------------------------------------------------- -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Sparkles } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
@@ -17,6 +17,7 @@ import { ChipsInputComponent } from '../../shared/chips-input/chips-input.compon
|
|||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
import { Lore } from '../../services/lore.model';
|
import { Lore } from '../../services/lore.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +36,7 @@ import { Lore } from '../../services/lore.model';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-page-edit',
|
selector: 'app-page-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent],
|
imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
||||||
templateUrl: './page-edit.component.html',
|
templateUrl: './page-edit.component.html',
|
||||||
styleUrls: ['./page-edit.component.scss']
|
styleUrls: ['./page-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -55,8 +56,13 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
title = '';
|
title = '';
|
||||||
nodeId = '';
|
nodeId = '';
|
||||||
notes = '';
|
notes = '';
|
||||||
/** Valeurs des champs dynamiques, indexées par fieldName. */
|
/** Valeurs des champs dynamiques TEXT, indexées par fieldName. */
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
|
/**
|
||||||
|
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE,
|
||||||
|
* la liste ordonnee des IDs d'images uploadees.
|
||||||
|
*/
|
||||||
|
imageValues: Record<string, string[]> = {};
|
||||||
/** Étiquettes libres (Phase 5B). */
|
/** Étiquettes libres (Phase 5B). */
|
||||||
tags: string[] = [];
|
tags: string[] = [];
|
||||||
/** IDs des pages liées (Phase 5B). */
|
/** IDs des pages liées (Phase 5B). */
|
||||||
@@ -156,13 +162,22 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
this.title = page.title;
|
this.title = page.title;
|
||||||
this.nodeId = page.nodeId;
|
this.nodeId = page.nodeId;
|
||||||
this.notes = page.notes ?? '';
|
this.notes = page.notes ?? '';
|
||||||
// On initialise une entrée pour chaque field du template, même vide,
|
// On initialise une entrée pour chaque field TEXT du template, même vide,
|
||||||
// pour que le formulaire ait toujours les champs attendus.
|
// pour que le formulaire ait toujours les champs attendus.
|
||||||
|
// Les champs IMAGE ne sont pas geres dans `values` (ils auront leur propre
|
||||||
|
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
|
||||||
const base: Record<string, string> = {};
|
const base: Record<string, string> = {};
|
||||||
|
const imageBase: Record<string, string[]> = {};
|
||||||
for (const f of this.template?.fields ?? []) {
|
for (const f of this.template?.fields ?? []) {
|
||||||
base[f] = page.values?.[f] ?? '';
|
if (f.type === 'TEXT') {
|
||||||
|
base[f.name] = page.values?.[f.name] ?? '';
|
||||||
|
} else if (f.type === 'IMAGE') {
|
||||||
|
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
|
||||||
|
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.values = base;
|
this.values = base;
|
||||||
|
this.imageValues = imageBase;
|
||||||
this.tags = [...(page.tags ?? [])];
|
this.tags = [...(page.tags ?? [])];
|
||||||
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
||||||
this.pageTitleService.set(page.title);
|
this.pageTitleService.set(page.title);
|
||||||
@@ -176,11 +191,12 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
nodeId: this.nodeId,
|
nodeId: this.nodeId,
|
||||||
notes: this.notes,
|
notes: this.notes,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
|
imageValues: this.imageValues,
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
relatedPageIds: this.relatedPageIds
|
relatedPageIds: this.relatedPageIds
|
||||||
};
|
};
|
||||||
this.pageService.update(this.pageId, updated).subscribe({
|
this.pageService.update(this.pageId, updated).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId, 'pages', this.pageId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde de la page')
|
error: () => console.error('Erreur lors de la sauvegarde de la page')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -230,10 +246,12 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
|
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
|
||||||
*/
|
*/
|
||||||
private mergeSuggestions(suggestions: Record<string, string>): void {
|
private mergeSuggestions(suggestions: Record<string, string>): void {
|
||||||
|
// L'IA ne genere que des valeurs texte : on ignore les champs IMAGE.
|
||||||
for (const field of this.template?.fields ?? []) {
|
for (const field of this.template?.fields ?? []) {
|
||||||
const suggestion = suggestions[field];
|
if (field.type !== 'TEXT') continue;
|
||||||
|
const suggestion = suggestions[field.name];
|
||||||
if (suggestion && suggestion.trim()) {
|
if (suggestion && suggestion.trim()) {
|
||||||
this.values[field] = suggestion;
|
this.values[field.name] = suggestion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
web/src/app/lore/page-view/page-view.component.html
Normal file
65
web/src/app/lore/page-view/page-view.component.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="view-page" *ngIf="page">
|
||||||
|
|
||||||
|
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
|
||||||
|
|
||||||
|
<header class="view-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p class="view-subtitle">{{ template?.name || 'Page' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="view-actions">
|
||||||
|
<button type="button" class="btn-primary" (click)="editMode()">
|
||||||
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Champs dynamiques du template (seuls les champs TEXT sont rendus ici ;
|
||||||
|
le support complet des champs IMAGE arrive a l'etape 5). -->
|
||||||
|
<ng-container *ngIf="template?.fields?.length">
|
||||||
|
<ng-container *ngFor="let field of template!.fields">
|
||||||
|
<section class="view-section" *ngIf="field.type === 'TEXT'">
|
||||||
|
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||||
|
<p class="view-section-body" *ngIf="valueOf(field.name); else emptyField">{{ valueOf(field.name) }}</p>
|
||||||
|
<ng-template #emptyField>
|
||||||
|
<p class="view-section-empty">Non renseigné</p>
|
||||||
|
</ng-template>
|
||||||
|
</section>
|
||||||
|
<section class="view-section" *ngIf="field.type === 'IMAGE'">
|
||||||
|
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||||
|
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<section class="view-section" *ngIf="(page.tags?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title">Tags</h2>
|
||||||
|
<div class="view-chips">
|
||||||
|
<span class="view-chip view-chip--tag" *ngFor="let tag of page.tags">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pages liées -->
|
||||||
|
<section class="view-section" *ngIf="(page.relatedPageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title">Pages liées</h2>
|
||||||
|
<div class="view-chips">
|
||||||
|
<a class="view-chip"
|
||||||
|
*ngFor="let relId of page.relatedPageIds"
|
||||||
|
[routerLink]="['/lore', loreId, 'pages', relId]">
|
||||||
|
{{ titleOfRelated(relId) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Notes privées MJ -->
|
||||||
|
<section class="view-section view-section--private" *ngIf="page.notes?.trim()">
|
||||||
|
<h2 class="view-section-title">
|
||||||
|
<span class="view-section-icon">🔒</span>
|
||||||
|
Notes privées
|
||||||
|
</h2>
|
||||||
|
<p class="view-section-body">{{ page.notes }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
4
web/src/app/lore/page-view/page-view.component.scss
Normal file
4
web/src/app/lore/page-view/page-view.component.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Styles spécifiques à page-view.
|
||||||
|
// Le gros du style "fiche de jeu" vient du partial global `styles/_view.scss`.
|
||||||
|
// Aucun override nécessaire pour l'instant — ce fichier existe pour rester
|
||||||
|
// cohérent avec la structure des autres composants (ts/html/scss).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user