From edc443429862ff6884bc6db7fda880ceaaad5aac Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Sun, 7 Jun 2026 09:52:15 +0200 Subject: [PATCH] =?UTF-8?q?Plusieurs=20gros=20ajouts=20:=20-=20Possibilit?= =?UTF-8?q?=C3=A9=20de=20discuter=20avec=20un=20PDF=20;=20RAG=20ou=20analy?= =?UTF-8?q?se=20approfondie.=20Enl=C3=A8vement=20de=20l'autre=20outil=20PD?= =?UTF-8?q?F=20de=20discussion=20qui=20analysait=20d'abord=20un=20PDF=20en?= =?UTF-8?q?=20proposant=20directement=20une=20int=C3=A9gration=20sans=20at?= =?UTF-8?q?tendre=20qu'on=20pose=20de=20question=20-=20Mise=20en=20place?= =?UTF-8?q?=20de=20l'import=20directement=20dans=20les=20outils=20dans=20l?= =?UTF-8?q?a=20sidebar=20-=20Mise=20en=20place=20d'un=20outil=20pour=20cr?= =?UTF-8?q?=C3=A9er=20des=20tables=20al=C3=A9atoires=20avec=20possibilit?= =?UTF-8?q?=C3=A9=20d'utiliser=20pendant=20la=20partie=20-=20Mise=20en=20p?= =?UTF-8?q?lace=20d'un=20outil=20pour=20mettre=20en=20place=20des=20PNJ,?= =?UTF-8?q?=20sc=C3=A8nes,=20chapitre....=20directement=20=C3=A0=20partir?= =?UTF-8?q?=20de=20la=20discussion=20avec=20le=20PDF=20-=20Mise=20en=20pla?= =?UTF-8?q?ce=20RAG=20avec=20mistal-embeding=20ou=20nomic=20si=20on=20util?= =?UTF-8?q?ise=20ollama=20-=20Mise=20en=20place=20mistral,=20google=20en?= =?UTF-8?q?=20fournisseurs=20alternatifs=20pour=20l'IA=20dans=20le=20cloud?= =?UTF-8?q?=20-=20version=200.11.0-b=C3=AAta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + brain/app/application/chunking.py | 24 + brain/app/application/embeddings.py | 20 + brain/app/application/import_campaign.py | 91 +++- brain/app/application/import_rules.py | 110 +++- brain/app/application/llm_json.py | 23 + brain/app/application/llm_retry.py | 5 +- brain/app/application/notebook_chat.py | 90 ++++ brain/app/application/notebook_deep.py | 127 +++++ brain/app/application/notebook_rag.py | 79 +++ brain/app/application/streaming.py | 45 ++ brain/app/core/config.py | 34 +- brain/app/core/settings_store.py | 9 + brain/app/infrastructure/gemini_adapter.py | 178 +++++++ brain/app/infrastructure/mistral_adapter.py | 188 +++++++ .../mistral_embedding_adapter.py | 58 +++ .../ollama_embedding_adapter.py | 43 ++ .../app/infrastructure/openrouter_adapter.py | 76 ++- brain/app/infrastructure/vector_store.py | 109 ++++ brain/app/main.py | 478 +++++++++++++++++- core/pom.xml | 2 +- .../campaigncontext/CampaignAdaptService.java | 84 +-- .../campaigncontext/CampaignBriefBuilder.java | 93 ++++ .../campaigncontext/NotebookService.java | 132 +++++ .../campaigncontext/RandomTableService.java | 133 +++++ .../domain/campaigncontext/Notebook.java | 23 + .../campaigncontext/NotebookMessage.java | 20 + .../campaigncontext/NotebookSource.java | 24 + .../domain/campaigncontext/RandomTable.java | 50 ++ .../campaigncontext/RandomTableEntry.java | 29 ++ .../ports/NotebookChatStreamer.java | 36 ++ .../ports/NotebookException.java | 15 + .../ports/NotebookIndexer.java | 17 + .../ports/NotebookRepository.java | 32 ++ .../ports/RandomTableGenerationException.java | 15 + .../ports/RandomTableGenerator.java | 26 + .../ports/RandomTableRepository.java | 22 + .../ai/BrainCampaignImportClient.java | 6 +- .../ai/BrainNotebookChatClient.java | 139 +++++ .../ai/BrainNotebookIndexClient.java | 100 ++++ .../ai/BrainRandomTableClient.java | 120 +++++ .../ai/BrainRulesImportClient.java | 7 +- .../persistence/entity/NotebookJpaEntity.java | 47 ++ .../entity/NotebookMessageJpaEntity.java | 41 ++ .../entity/NotebookSourceJpaEntity.java | 47 ++ .../entity/RandomTableEntryJpaEntity.java | 51 ++ .../entity/RandomTableJpaEntity.java | 73 +++ .../jpa/NotebookJpaRepository.java | 12 + .../jpa/NotebookMessageJpaRepository.java | 13 + .../jpa/NotebookSourceJpaRepository.java | 13 + .../jpa/RandomTableJpaRepository.java | 13 + .../postgres/PostgresNotebookRepository.java | 155 ++++++ .../PostgresRandomTableRepository.java | 106 ++++ .../web/controller/NotebookController.java | 206 ++++++++ .../web/controller/RandomTableController.java | 100 ++++ .../web/controller/SettingsController.java | 10 + .../dto/campaigncontext/RandomTableDTO.java | 21 + .../campaigncontext/RandomTableEntryDTO.java | 14 + .../web/mapper/RandomTableMapper.java | 51 ++ web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app/app.routes.ts | 6 +- .../arc/arc-create/arc-create.component.ts | 4 +- .../arc/arc-edit/arc-edit.component.ts | 4 +- .../arc/arc-view/arc-view.component.ts | 4 +- web/src/app/campaigns/campaign-tree.helper.ts | 63 ++- .../campaign-adapt.component.html | 61 --- .../campaign-adapt.component.scss | 184 ------- .../campaign-adapt.component.ts | 130 ----- .../campaign-detail.component.html | 8 - .../campaign-detail.component.ts | 22 +- .../campaign-import.component.ts | 4 +- .../chapter-create.component.ts | 4 +- .../chapter-edit/chapter-edit.component.ts | 4 +- .../chapter-graph/chapter-graph.component.ts | 4 +- .../chapter-view/chapter-view.component.ts | 4 +- .../notebook-action-card.component.html | 41 ++ .../notebook-action-card.component.scss | 52 ++ .../notebook-action-card.component.ts | 185 +++++++ .../notebook-detail.component.html | 98 ++++ .../notebook-detail.component.scss | 106 ++++ .../notebook-detail.component.ts | 187 +++++++ .../notebook-list.component.html | 35 ++ .../notebook-list.component.scss | 49 ++ .../notebook-list/notebook-list.component.ts | 85 ++++ .../playthrough-detail.component.ts | 4 +- .../playthrough-flags-page.component.ts | 4 +- .../random-table-edit.component.html | 85 ++++ .../random-table-edit.component.scss | 172 +++++++ .../random-table-edit.component.ts | 185 +++++++ .../random-table-view.component.html | 57 +++ .../random-table-view.component.scss | 133 +++++ .../random-table-view.component.ts | 85 ++++ .../scene-create/scene-create.component.ts | 4 +- .../scene/scene-edit/scene-edit.component.ts | 4 +- .../scene/scene-view/scene-view.component.ts | 4 +- web/src/app/lore/lore-icons.ts | 4 +- .../app/services/campaign-adapt.service.ts | 108 ---- .../app/services/campaign-sidebar.service.ts | 5 +- web/src/app/services/notebook-action.model.ts | 49 ++ web/src/app/services/notebook.model.ts | 40 ++ web/src/app/services/notebook.service.ts | 126 +++++ web/src/app/services/random-table.model.ts | 34 ++ web/src/app/services/random-table.service.ts | 49 ++ web/src/app/services/settings.service.ts | 42 +- ...session-random-tables-panel.component.html | 51 ++ ...session-random-tables-panel.component.scss | 108 ++++ .../session-random-tables-panel.component.ts | 110 ++++ .../session-reference-panel.component.html | 16 + .../session-reference-panel.component.ts | 10 +- web/src/app/settings/settings.component.html | 147 ++++++ web/src/app/settings/settings.component.ts | 71 ++- web/src/app/shared/dice.utils.ts | 66 +++ 113 files changed, 6347 insertions(+), 662 deletions(-) create mode 100644 brain/app/application/embeddings.py create mode 100644 brain/app/application/notebook_chat.py create mode 100644 brain/app/application/notebook_deep.py create mode 100644 brain/app/application/notebook_rag.py create mode 100644 brain/app/application/streaming.py create mode 100644 brain/app/infrastructure/gemini_adapter.py create mode 100644 brain/app/infrastructure/mistral_adapter.py create mode 100644 brain/app/infrastructure/mistral_embedding_adapter.py create mode 100644 brain/app/infrastructure/ollama_embedding_adapter.py create mode 100644 brain/app/infrastructure/vector_store.py create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java create mode 100644 core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/Notebook.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/NotebookSource.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/RandomTable.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/RandomTableEntry.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookException.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookIndexer.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerationException.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerator.java create mode 100644 core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableEntryJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookSourceJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableEntryDTO.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/mapper/RandomTableMapper.java delete mode 100644 web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html delete mode 100644 web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss delete mode 100644 web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts create mode 100644 web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.html create mode 100644 web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss create mode 100644 web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts create mode 100644 web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html create mode 100644 web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss create mode 100644 web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts create mode 100644 web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html create mode 100644 web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss create mode 100644 web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts create mode 100644 web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html create mode 100644 web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss create mode 100644 web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts create mode 100644 web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html create mode 100644 web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss create mode 100644 web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts delete mode 100644 web/src/app/services/campaign-adapt.service.ts create mode 100644 web/src/app/services/notebook-action.model.ts create mode 100644 web/src/app/services/notebook.model.ts create mode 100644 web/src/app/services/notebook.service.ts create mode 100644 web/src/app/services/random-table.model.ts create mode 100644 web/src/app/services/random-table.service.ts create mode 100644 web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html create mode 100644 web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.scss create mode 100644 web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts create mode 100644 web/src/app/shared/dice.utils.ts diff --git a/.gitignore b/.gitignore index d977628..b569797 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,4 @@ docker-compose.override.yml # ============================================================================ relay/ scripts/bump-version.mjs +brain/data/notebooks/5.json diff --git a/brain/app/application/chunking.py b/brain/app/application/chunking.py index e127a2d..186fae8 100644 --- a/brain/app/application/chunking.py +++ b/brain/app/application/chunking.py @@ -53,3 +53,27 @@ def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]: for i in range(0, len(tokens), target_tokens): out.append(enc.decode(tokens[i : i + target_tokens])) return out + + +def split_in_half(text: str) -> tuple[str, str]: + """Coupe `text` en deux moitiés ~égales, de préférence sur un saut de ligne + proche du milieu (pour ne pas trancher en plein mot/phrase). + + Sert au repli anti-troncature des imports : quand la SORTIE d'un morceau est + coupée (le modèle ne peut pas tout réécrire en une réponse), on retraite ce + morceau en deux moitiés. Renvoie ('', '') si le texte est trop court pour + être découpé utilement (garde-fou anti-récursion infinie). + """ + text = text.strip() + if len(text) < 400: + return "", "" + mid = len(text) // 2 + # Cherche un saut de ligne juste avant le milieu, sinon juste après. + cut = text.rfind("\n", 0, mid) + if cut < len(text) // 4: + nxt = text.find("\n", mid) + cut = nxt if nxt != -1 else mid + left, right = text[:cut].strip(), text[cut:].strip() + if not left or not right: + return "", "" + return left, right diff --git a/brain/app/application/embeddings.py b/brain/app/application/embeddings.py new file mode 100644 index 0000000..5252dbd --- /dev/null +++ b/brain/app/application/embeddings.py @@ -0,0 +1,20 @@ +"""Port d'embeddings (RAG des notebooks). + +Abstraction du calcul de vecteurs : un texte → une liste de floats. Les adapters +concrets (Ollama local, Mistral cloud) la satisfont par duck typing, comme pour +les LLMProvider. Le RAG n'en dépend que via cette interface. +""" +from __future__ import annotations + +from typing import Protocol + + +class EmbeddingError(Exception): + """Échec du calcul d'embeddings (modèle indisponible, réseau, quota…).""" + + +class EmbeddingProvider(Protocol): + """Calcule les vecteurs d'une liste de textes (ordre préservé).""" + + async def embed(self, texts: list[str]) -> list[list[float]]: + ... diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index fbffc0c..b24d8f0 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -12,9 +12,14 @@ from __future__ import annotations import logging -from app.application.chunking import chunk_text -from app.application.llm_json import load_json_object +from app.application.chunking import chunk_text, split_in_half +from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_retry import generate_with_retry +from app.application.streaming import with_heartbeat + +# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en +# 2 moitiés. Borné en profondeur (3 niveaux => jusqu'à 8 sous-blocs). +_MAX_SPLIT_DEPTH = 3 from app.domain.models import ( ArcProposal, CampaignImportResult, @@ -22,7 +27,7 @@ from app.domain.models import ( RoomProposal, SceneProposal, ) -from app.domain.ports import LLMProvider, PdfTextExtractor +from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor logger = logging.getLogger(__name__) @@ -229,8 +234,30 @@ class ImportCampaignUseCase: } merger = _TreeMerger() + skipped = 0 + last_error: str | None = None for i, chunk in enumerate(chunks): - merger.add(await self._map_chunk(chunk, index=i, total=total)) + # RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est + # SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que + # si AUCUN morceau ne passe (cf. après la boucle). + # HEARTBEAT : keep-alive pendant l'appel LLM pour ne jamais laisser le + # flux SSE silencieux (sinon le Core coupe sur timeout d'inactivité). + try: + arcs_payload: list[dict] | None = None + async for kind, payload in with_heartbeat( + self._map_chunk(chunk, index=i, total=total) + ): + if kind == "heartbeat": + yield {"type": "heartbeat", "current": i + 1, "total": total} + else: + arcs_payload = payload + merger.add(arcs_payload or []) + except LLMProviderError as exc: + skipped += 1 + last_error = str(exc) + logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc) + yield {"type": "chunk_failed", "current": i + 1, "total": total, + "message": str(exc)[:300]} arcs, chapters, scenes = merger.counts() yield { "type": "progress", @@ -239,42 +266,74 @@ class ImportCampaignUseCase: "arc_count": arcs, "chapter_count": chapters, "scene_count": scenes, + "skipped": skipped, } + if total > 0 and skipped == total: + # Tout a échoué : "done" vide serait trompeur → erreur explicite. + yield {"type": "error", + "message": "Tous les morceaux ont échoué auprès du fournisseur IA. " + f"Dernier message : {last_error or 'inconnu'}"} + return + yield { "type": "done", "arcs": _serialize_arcs(merger.result()), "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count, + "skipped": skipped, } # --- MAP : un morceau → sous-arbre --------------------------------------- async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]: + return await self._extract_arcs(chunk, index=index, total=total, depth=0) + + async def _extract_arcs( + self, text: str, *, index: int, total: int, depth: int + ) -> list[dict]: + """Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le + texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par + nom (un arc/chapitre coupé entre les moitiés est recollé).""" prompt = ( _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) - + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n" + + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON de l'arborescence." ) raw = await generate_with_retry( self._llm, prompt, output_format="json", temperature=_TEMPERATURE) - return self._parse_arcs(raw, index=index) + arcs, truncated = self._parse_arcs(raw, index=index) + + if truncated and depth < _MAX_SPLIT_DEPTH: + left, right = split_in_half(text) + if left and right: + logger.info( + "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", + index, depth + 1) + a = await self._extract_arcs(left, index=index, total=total, depth=depth + 1) + b = await self._extract_arcs(right, index=index, total=total, depth=depth + 1) + return a + b + if truncated: + logger.warning( + "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) + return arcs @staticmethod - def _parse_arcs(raw: str, *, index: int) -> list[dict]: - """Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué.""" + def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]: + """Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle.""" parsed, recovered = load_json_object(raw) if parsed is None: - logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index) - return [] - if recovered: - logger.warning( - "Morceau %s : sortie tronquée — récupération des éléments complets " - "(envisagez des morceaux plus petits).", index) + truncated = looks_like_truncated_json(raw) + if not truncated: + logger.warning( + "Morceau %s : aucun objet JSON exploitable, ignoré. " + "Début de la réponse du modèle : %r", + index, (raw or "").strip()[:300] or "(réponse VIDE)") + return [], truncated if isinstance(parsed, dict): arcs = parsed.get("arcs", []) - return arcs if isinstance(arcs, list) else [] - return [] + return (arcs if isinstance(arcs, list) else []), recovered + return [], recovered def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]: diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index 13caf52..cc1b3e0 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -14,9 +14,16 @@ from __future__ import annotations import logging -from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text -from app.application.llm_json import load_json_object +from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half +from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_retry import generate_with_retry +from app.application.streaming import with_heartbeat + +# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut +# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en +# profondeur pour éviter une récursion infinie (3 niveaux => jusqu'à 8 sous-blocs ; +# 1-2 niveaux suffisent en pratique, le reste est un garde-fou). +_MAX_SPLIT_DEPTH = 3 from app.domain.models import RulesImportResult from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor @@ -95,6 +102,24 @@ class _SectionMerger: return {title: "\n\n".join(parts) for title, parts in self._merged.items()} +def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]: + """Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé). + + Titres insensibles à la casse : un même titre présent des deux côtés (une section + coupée par le re-découpage) voit ses contenus concaténés au lieu d'être écrasés. + """ + out = dict(a) + by_lower = {k.lower(): k for k in out} + for title, content in b.items(): + key = by_lower.get(title.lower()) + if key is not None: + out[key] = f"{out[key]}\n\n{content}".strip() + else: + out[title] = content + by_lower[title.lower()] = title + return out + + class ImportRulesUseCase: """Transforme un PDF de règles en proposition de sections markdown.""" @@ -152,48 +177,103 @@ class ImportRulesUseCase: } merger = _SectionMerger() + skipped = 0 + last_error: str | None = None for i, chunk in enumerate(chunks): - new_titles = merger.add(await self._map_chunk(chunk, index=i, total=total)) + # RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue. + # Abandon seulement si AUCUN morceau ne passe (cf. après la boucle). + # HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un + # provider lent) pour que le flux SSE ne soit jamais coupé par le Core. + new_titles: list[str] = [] + try: + sections: dict[str, str] | None = None + async for kind, payload in with_heartbeat( + self._map_chunk(chunk, index=i, total=total) + ): + if kind == "heartbeat": + yield {"type": "heartbeat", "current": i + 1, "total": total} + else: + sections = payload + new_titles = merger.add(sections or {}) + except LLMProviderError as exc: + skipped += 1 + last_error = str(exc) + logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc) + yield {"type": "chunk_failed", "current": i + 1, "total": total, + "message": str(exc)[:300]} yield { "type": "progress", "current": i + 1, "total": total, "new_sections": new_titles, + "skipped": skipped, } + if total > 0 and skipped == total: + yield {"type": "error", + "message": "Tous les morceaux ont échoué auprès du fournisseur IA. " + f"Dernier message : {last_error or 'inconnu'}"} + return + yield { "type": "done", "sections": merger.result(), "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count, + "skipped": skipped, } # --- MAP : un morceau → sections ----------------------------------------- async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]: + return await self._extract_sections(chunk, index=index, total=total, depth=0) + + async def _extract_sections( + self, text: str, *, index: int, total: int, depth: int + ) -> dict[str, str]: + """Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le + texte en DEUX moitiés (chacune produit une réponse complète) et fusionne — + ainsi aucune section n'est perdue, quel que soit le plafond de sortie.""" prompt = ( _MAP_SYSTEM.format( canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) ) - + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n" + + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON des sections." ) raw = await generate_with_retry( self._llm, prompt, output_format="json", temperature=_TEMPERATURE) - return self._parse_sections(raw, index=index) + sections, truncated = self._parse_sections(raw, index=index) + + if truncated and depth < _MAX_SPLIT_DEPTH: + left, right = split_in_half(text) + if left and right: + logger.info( + "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", + index, depth + 1) + a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) + b = await self._extract_sections(right, index=index, total=total, depth=depth + 1) + return _combine_sections(a, b) + if truncated: + logger.warning( + "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) + return sections @staticmethod - def _parse_sections(raw: str, *, index: int) -> dict[str, str]: - """Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué.""" + def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]: + """Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle.""" parsed, recovered = load_json_object(raw) if parsed is None: - logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index) - return {} - if recovered: - logger.warning( - "Morceau %s : sortie tronquée — récupération des sections complètes " - "(envisagez des morceaux plus petits).", index) + # Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute + # structure complète (→ on signalera 'tronqué' pour re-découper). + truncated = looks_like_truncated_json(raw) + if not truncated: + logger.warning( + "Morceau %s : aucun objet JSON exploitable, ignoré. " + "Début de la réponse du modèle : %r", + index, (raw or "").strip()[:300] or "(réponse VIDE)") + return {}, truncated if not isinstance(parsed, dict): logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) - return {} - return {str(k): str(v) for k, v in parsed.items()} + return {}, False + return {str(k): str(v) for k, v in parsed.items()}, recovered diff --git a/brain/app/application/llm_json.py b/brain/app/application/llm_json.py index bf07127..0d33f4e 100644 --- a/brain/app/application/llm_json.py +++ b/brain/app/application/llm_json.py @@ -13,6 +13,16 @@ et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé from __future__ import annotations import json +import re + +# Blocs de "réflexion" des modèles raisonneurs (Nemotron, DeepSeek-R1, QwQ…). +# Leur contenu est de la prose truffée d'accolades qui piège le détecteur de JSON +# (et n'est jamais la réponse) → on le retire avant toute analyse. +_REASONING_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) + + +def _strip_reasoning(raw: str) -> str: + return _REASONING_RE.sub("", raw) def load_json_object(raw: str) -> tuple[object | None, bool]: @@ -24,6 +34,7 @@ def load_json_object(raw: str) -> tuple[object | None, bool]: auquel cas le second élément vaut True. (None, False) si rien d'exploitable. """ + raw = _strip_reasoning(raw) obj = extract_json_object(raw) if obj is not None: try: @@ -39,6 +50,18 @@ def load_json_object(raw: str) -> tuple[object | None, bool]: return None, False +def looks_like_truncated_json(raw: str) -> bool: + """La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés) + plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a + pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute + sous-structure complète). On exige un contenu substantiel pour éviter les + faux positifs sur une courte réponse non-JSON.""" + s = (raw or "").strip() + if "{" not in s or len(s) < 100: + return False + return s.count("{") > s.count("}") or s.count("[") > s.count("]") + + def extract_json_object(raw: str) -> str | None: if not raw: return None diff --git a/brain/app/application/llm_retry.py b/brain/app/application/llm_retry.py index 9db9796..2c68fb1 100644 --- a/brain/app/application/llm_retry.py +++ b/brain/app/application/llm_retry.py @@ -18,7 +18,10 @@ from app.domain.ports import LLMProvider, LLMProviderError logger = logging.getLogger(__name__) -_ATTEMPTS = 4 +# 3 tentatives : assez pour absorber un hoquet transitoire, sans s'acharner des +# minutes sur un modèle durablement lent/saturé (les heartbeats gardent le flux +# vivant, mais inutile de faire patienter l'utilisateur 15 min pour rien). +_ATTEMPTS = 3 _BASE_DELAY_SECONDS = 3.0 # Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus # longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min). diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py new file mode 100644 index 0000000..8e88ac3 --- /dev/null +++ b/brain/app/application/notebook_chat.py @@ -0,0 +1,90 @@ +"""Use case : chat ANCRÉ sur les sources d'un notebook (RAG). + +À chaque message, on retrouve les passages pertinents des sources (via le RAG) et +on les injecte dans le prompt système, en plus du contexte de campagne. Le modèle +répond donc en s'appuyant sur la/les source(s) — pas sur ses connaissances générales. +""" +from __future__ import annotations + +from typing import AsyncIterator + +from app.application.notebook_rag import NotebookRagUseCase +from app.domain.models import ChatMessage +from app.domain.ports import LLMChatProvider + +_SYSTEM_PROMPT = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur. + +Tu disposes de DEUX connaissances, toutes deux ci-dessous : +1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ; +2) LA SOURCE (extraits pertinents du PDF). + +Règles : +- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE. +- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE. +- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante. +- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le. +- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »). + +{context_block} +--- EXTRAITS PERTINENTS DE LA SOURCE --- +{sources_block} +--- FIN DES EXTRAITS --- + +PROPOSITIONS D'INTÉGRATION (IMPORTANT) : +Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ, +une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou +plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action. +L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si +c'est pertinent et explicitement souhaité. Formats acceptés : + +```loremind-action +{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}} +``` +```loremind-action +{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}} +``` +```loremind-action +{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}} +``` +```loremind-action +{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}} +``` +```loremind-action +{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} +``` + +Réponds en français, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action.""" + + +class NotebookChatUseCase: + def __init__(self, rag: NotebookRagUseCase, llm: LLMChatProvider) -> None: + self._rag = rag + self._llm = llm + + async def stream( + self, + source_ids: list[str], + messages: list[ChatMessage], + context: str = "", + top_k: int = 6, + ) -> AsyncIterator[str]: + last_user = next((m.content for m in reversed(messages) if m.role == "user"), "") + passages = await self._rag.retrieve(source_ids, last_user, top_k=top_k) + sources_block = ( + "\n\n".join(self._format_passage(p) for p in passages) + if passages else "(aucun passage pertinent trouvé dans les sources)" + ) + context_block = ( + f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" + if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n" + ) + system_prompt = _SYSTEM_PROMPT.format( + context_block=context_block, sources_block=sources_block) + async for token in self._llm.stream_chat(messages, system_prompt=system_prompt): + yield token + + @staticmethod + def _format_passage(p: dict) -> str: + page = p.get("page") + prefix = f"(p. {page}) " if page else "" + return f"• {prefix}{p['text'].strip()}" diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py new file mode 100644 index 0000000..0b78cb8 --- /dev/null +++ b/brain/app/application/notebook_deep.py @@ -0,0 +1,127 @@ +"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document. + +Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit +l'INTÉGRALITÉ des sources par lots : + - MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question + (ou « RAS » si rien) ; + - REDUCE : il synthétise toutes les notes en une réponse finale (streamée). + +→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le +modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par +`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de +lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif). +""" +from __future__ import annotations + +import logging +from typing import AsyncIterator + +import tiktoken + +from app.application.llm_retry import generate_with_retry +from app.domain.models import ChatMessage +from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError +from app.infrastructure import vector_store + +logger = logging.getLogger(__name__) + +_NO_MATCH = "RAS" +_MAP_TEMPERATURE = 0.2 + +_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations +pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et +indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de +pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre. + +QUESTION : {question} + +--- EXTRAIT --- +{excerpt} +--- FIN EXTRAIT --- + +Informations pertinentes (ou « {no_match} ») :""" + +_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de +l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait). +Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »), +et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta +réponse à sa structure / ses PNJ pour des adaptations cohérentes. + +{context_block} +--- NOTES EXTRAITES DE TOUT LE DOCUMENT --- +{notes_block} +--- FIN DES NOTES --- + +Réponds en français.""" + + +class NotebookDeepUseCase: + def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None: + self._llm = llm + self._batch_tokens = max(2000, batch_tokens) + + async def stream( + self, + source_ids: list[str], + question: str, + context: str = "", + ) -> AsyncIterator[dict]: + """Yield des évènements : {type:'progress',current,total}, {type:'token',token}, + {type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)""" + chunks: list[dict] = [] + for sid in source_ids: + chunks.extend(vector_store.all_chunks(sid)) + if not chunks: + yield {"type": "token", "token": "Aucune source indexée à analyser."} + yield {"type": "done"} + return + + batches = self._group(chunks) + total = len(batches) + notes: list[str] = [] + for i, batch in enumerate(batches): + yield {"type": "progress", "current": i, "total": total} + excerpt = "\n\n".join( + f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip() + for c in batch + ) + prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) + try: + raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE) + except LLMProviderError as exc: + logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc) + continue + answer = raw.strip() + if answer and answer.upper().rstrip(".") != _NO_MATCH: + notes.append(answer) + yield {"type": "progress", "current": total, "total": total} + + notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)" + context_block = ( + f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" + if context.strip() else "" + ) + system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block) + llm_chat: LLMChatProvider = self._llm # type: ignore[assignment] + async for token in llm_chat.stream_chat( + [ChatMessage(role="user", content=question)], system_prompt=system_prompt + ): + yield {"type": "token", "token": token} + yield {"type": "done"} + + def _group(self, chunks: list[dict]) -> list[list[dict]]: + """Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken).""" + enc = tiktoken.get_encoding("cl100k_base") + batches: list[list[dict]] = [] + current: list[dict] = [] + current_tokens = 0 + for c in chunks: + t = len(enc.encode(c.get("text", ""))) + if current and current_tokens + t > self._batch_tokens: + batches.append(current) + current, current_tokens = [], 0 + current.append(c) + current_tokens += t + if current: + batches.append(current) + return batches diff --git a/brain/app/application/notebook_rag.py b/brain/app/application/notebook_rag.py new file mode 100644 index 0000000..a8db28f --- /dev/null +++ b/brain/app/application/notebook_rag.py @@ -0,0 +1,79 @@ +"""Use case RAG des notebooks : indexer une source PDF et retrouver les passages +pertinents pour une question. + +Chaîne d'indexation : PDF → extraction texte (+OCR) → découpage en extraits courts +→ embeddings → stockage vectoriel (fichier). À la requête : on embed la question +et on récupère les extraits les plus proches (cosinus) pour ancrer le chat. + +Extraits PLUS COURTS que pour l'import (recopie) : ici on veut une granularité fine +pour que la recherche pointe un passage précis, pas un demi-chapitre. +""" +from __future__ import annotations + +import logging + +from app.application.chunking import chunk_text +from app.application.embeddings import EmbeddingProvider +from app.domain.ports import PdfTextExtractor +from app.infrastructure import vector_store + +logger = logging.getLogger(__name__) + +_RAG_CHUNK_TOKENS = 600 +# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro +# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et +# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase. +_MIN_LETTERS = 15 + + +def _has_enough_text(piece: str) -> bool: + return sum(c.isalpha() for c in piece) >= _MIN_LETTERS + + +class NotebookRagUseCase: + def __init__( + self, + extractor: PdfTextExtractor, + embedder: EmbeddingProvider, + chunk_target_tokens: int = _RAG_CHUNK_TOKENS, + ) -> None: + self._extractor = extractor + self._embedder = embedder + self._chunk_target_tokens = chunk_target_tokens + + async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict: + """Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed + et stocke une source. Renvoie un récap.""" + doc = self._extractor.extract(pdf_bytes) + chunks: list[str] = [] + pages: list[int] = [] + for page in doc.pages: + for piece in chunk_text(page.text, self._chunk_target_tokens): + if not _has_enough_text(piece): + continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré + chunks.append(piece) + pages.append(page.index + 1) # n° de page 1-based pour l'affichage + logger.info( + "Indexation notebook source=%s : %s page(s) (%s OCR), %s extrait(s).", + source_id, doc.page_count, doc.ocr_page_count, len(chunks), + ) + if not chunks: + vector_store.save(source_id, [], []) + return {"chunks": 0, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count} + vectors = await self._embedder.embed(chunks) + count = vector_store.save(source_id, chunks, vectors, pages) + return { + "chunks": count, + "page_count": doc.page_count, + "ocr_page_count": doc.ocr_page_count, + } + + async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]: + """Passages les plus pertinents (toutes sources) pour `query`.""" + ids = [s for s in source_ids if vector_store.exists(s)] + if not ids or not query.strip(): + return [] + query_vectors = await self._embedder.embed([query]) + if not query_vectors: + return [] + return vector_store.search(ids, query_vectors[0], top_k) diff --git a/brain/app/application/streaming.py b/brain/app/application/streaming.py new file mode 100644 index 0000000..2d8d7db --- /dev/null +++ b/brain/app/application/streaming.py @@ -0,0 +1,45 @@ +"""Heartbeats pour garder un flux SSE 'vivant' pendant une coroutine longue. + +Problème résolu : pendant un appel LLM lent (import sur provider gratuit), le +Brain ne produit AUCUN évènement SSE. Le Core (WebClient) ne 'voit aucun item' +et coupe la connexion sur timeout d'inactivité : + + ReactiveException: Did not observe any item or terminal signal within Nms + +C'est le piège classique du SSE long. La parade standard = envoyer un keep-alive +périodique. `with_heartbeat` exécute une coroutine en émettant un évènement +'heartbeat' toutes les `interval` secondes tant qu'elle tourne, puis son résultat +('result', valeur). Le Core remet son chrono à zéro sur n'importe quel évènement +reçu (même inconnu) → plus de coupure, quelle que soit la lenteur du modèle. +""" +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator, Awaitable + +# Bien sous le timeout d'inactivité du Core (600s) ET de tout proxy (nginx ~60s). +HEARTBEAT_INTERVAL_SECONDS = 15.0 + + +async def with_heartbeat( + coro: Awaitable[Any], + *, + interval: float = HEARTBEAT_INTERVAL_SECONDS, +) -> AsyncIterator[tuple[str, Any]]: + """Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant + qu'elle n'est pas terminée, puis ('result', valeur). + + L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`), + donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée + (client déconnecté), la tâche sous-jacente est annulée. + """ + task: asyncio.Task = asyncio.ensure_future(coro) + try: + while not task.done(): + done, _ = await asyncio.wait({task}, timeout=interval) + if not done: + yield ("heartbeat", None) + yield ("result", task.result()) + finally: + if not task.done(): + task.cancel() diff --git a/brain/app/core/config.py b/brain/app/core/config.py index a5039e0..3696416 100644 --- a/brain/app/core/config.py +++ b/brain/app/core/config.py @@ -25,8 +25,9 @@ class Settings(BaseSettings): extra="ignore", ) - # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter. - llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama" + # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; + # "openrouter" = OpenRouter ; "mistral" = Mistral ; "gemini" = Google Gemini. + llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] = "ollama" ollama_base_url: str = "http://localhost:11434" llm_model: str = "gemma4:26b" @@ -53,6 +54,35 @@ class Settings(BaseSettings): openrouter_api_key: str = "" openrouter_model: str = "openrouter/free" + # Mistral (La Plateforme, OpenAI-compatible). Cle + modele modifiables depuis + # l'UI. Tier gratuit « Experiment » sur console.mistral.ai (sans CB). Defaut = + # mistral-large-latest (128k contexte, bon en francais et en JSON fidele). + mistral_api_key: str = "" + mistral_model: str = "mistral-large-latest" + + # Google Gemini (endpoint OpenAI-compatible). Cle gratuite sur + # aistudio.google.com (sans CB). Defaut = gemini-2.0-flash : ~1M de contexte + # (un livre tient en 1-2 appels), rapide, fidele, quota gratuit genereux. + gemini_api_key: str = "" + gemini_model: str = "gemini-2.0-flash" + + # Embeddings (RAG des notebooks/ateliers). Modele SEPARE du chat. + # "ollama" = local (gratuit, illimite, ideal pour indexer un livre = bcp + # d'appels) ; "mistral" = cloud EU (mistral-embed, soumis au rate limit). + embedding_provider: Literal["ollama", "mistral"] = "ollama" + ollama_embedding_model: str = "nomic-embed-text" + mistral_embedding_model: str = "mistral-embed" + # Au démarrage, si le provider d'embeddings est Ollama et que le modèle n'est + # pas présent, le Brain le télécharge automatiquement (en arrière-plan) → le RAG + # marche "out of the box" pour un nouvel utilisateur. Désactivable (connexion + # limitée, gestion manuelle des modèles). + auto_pull_embedding_model: bool = True + + # Nombre d'extraits récupérés par question dans le chat des ateliers (RAG). + # Plus haut = plus de couverture pour les questions larges (« liste les… »), + # mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte). + rag_top_k: int = 8 + # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. diff --git a/brain/app/core/settings_store.py b/brain/app/core/settings_store.py index 3e5d534..756de29 100644 --- a/brain/app/core/settings_store.py +++ b/brain/app/core/settings_store.py @@ -31,6 +31,15 @@ _ALLOWED_KEYS = frozenset({ "onemin_model", "openrouter_api_key", "openrouter_model", + "mistral_api_key", + "mistral_model", + "gemini_api_key", + "gemini_model", + "embedding_provider", + "ollama_embedding_model", + "mistral_embedding_model", + "auto_pull_embedding_model", + "rag_top_k", "import_chunk_tokens", }) diff --git a/brain/app/infrastructure/gemini_adapter.py b/brain/app/infrastructure/gemini_adapter.py new file mode 100644 index 0000000..7640d43 --- /dev/null +++ b/brain/app/infrastructure/gemini_adapter.py @@ -0,0 +1,178 @@ +"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider. + +Gemini expose un endpoint COMPATIBLE OpenAI +(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client +"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral. + +Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour +l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2 +appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais +atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle). +""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import AsyncIterator + +import httpx + +from app.core.config import Settings +from app.domain.models import ChatMessage +from app.domain.ports import LLMProviderError + +logger = logging.getLogger(__name__) + +_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + +# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit +# rien). Gemini répond vite ; 120s est large. +_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 + + +class GeminiLLMProvider: + """Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" + + def __init__(self, settings: Settings) -> None: + if not settings.gemini_api_key: + raise LLMProviderError( + "Clé API Gemini manquante. Configure-la depuis l'écran Paramètres " + "(clé gratuite sur aistudio.google.com)." + ) + self._api_key = settings.gemini_api_key + self._model = settings.gemini_model + self._timeout = settings.llm_timeout_seconds + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + async def generate( + self, + prompt: str, + *, + output_format: str | None = None, + temperature: float | None = None, + ) -> str: + """One-shot via streaming (puis recollage), avec garde-fous au temps écoulé.""" + return await self._collect_with_timeouts( + [ChatMessage(role="user", content=prompt)], temperature, output_format + ) + + async def _collect_with_timeouts( + self, + messages: list[ChatMessage], + temperature: float | None, + output_format: str | None, + ) -> str: + """Collecte le stream avec deux garde-fous : 1er token borné (échec rapide + si rien ne sort) + ceiling global `self._timeout`.""" + async def _collect() -> str: + chunks: list[str] = [] + agen = self._stream(messages, None, temperature, output_format) + try: + while True: + first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None + try: + token = await asyncio.wait_for(agen.__anext__(), timeout=first) + except StopAsyncIteration: + break + except asyncio.TimeoutError: + raise LLMProviderError( + f"Erreur Gemini : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez " + "votre quota gratuit." + ) + chunks.append(token) + finally: + await agen.aclose() + return "".join(chunks) + + try: + return await asyncio.wait_for(_collect(), timeout=self._timeout) + except asyncio.TimeoutError as exc: + raise LLMProviderError( + f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la " + "taille des morceaux d'import ou augmentez le timeout." + ) from exc + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + ) -> AsyncIterator[str]: + async for token in self._stream(messages, system_prompt, temperature): + yield token + + async def _stream( + self, + messages: list[ChatMessage], + system_prompt: str | None, + temperature: float | None, + output_format: str | None = None, + ) -> AsyncIterator[str]: + payload_messages: list[dict[str, str]] = [] + if system_prompt: + payload_messages.append({"role": "system", "content": system_prompt}) + for m in messages: + payload_messages.append({"role": m.role, "content": m.content}) + + body: dict[str, object] = { + "model": self._model, + "messages": payload_messages, + "stream": True, + } + if temperature is not None: + body["temperature"] = temperature + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + async with client.stream( + "POST", _API_URL, headers=self._headers(), json=body + ) as response: + if response.status_code >= 400: + detail = (await response.aread()).decode("utf-8", "replace").strip() + raise LLMProviderError( + f"Erreur Gemini (HTTP {response.status_code})" + + (f" : {detail[:500]}" if detail else "") + ) + async for token in self._parse_sse(response): + yield token + except httpx.HTTPError as exc: + raise LLMProviderError(self._format_http_error(exc)) from exc + + @staticmethod + async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: + """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`.""" + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue + data = line[len("data:"):].strip() + if data == "[DONE]": + return + try: + obj = json.loads(data) + except json.JSONDecodeError: + continue + choices = obj.get("choices") + if not choices: + continue + delta = choices[0].get("delta") or {} + content = delta.get("content") + if content: + yield content + + def _format_http_error(self, exc: httpx.HTTPError) -> str: + if isinstance(exc, httpx.TimeoutException): + return ( + f"Erreur Gemini : délai dépassé (timeout {self._timeout}s). Le modèle a " + "mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout." + ) + detail = str(exc) or exc.__class__.__name__ + return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}" diff --git a/brain/app/infrastructure/mistral_adapter.py b/brain/app/infrastructure/mistral_adapter.py new file mode 100644 index 0000000..bc63486 --- /dev/null +++ b/brain/app/infrastructure/mistral_adapter.py @@ -0,0 +1,188 @@ +"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider. + +Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions, +SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que +l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis +recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini. + +Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à +coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand +contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from typing import AsyncIterator + +import httpx + +from app.core.config import Settings +from app.domain.models import ChatMessage +from app.domain.ports import LLMProviderError + +logger = logging.getLogger(__name__) + +_API_URL = "https://api.mistral.ai/v1/chat/completions" + +# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file +# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit +# peut être longue. +_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 + + +class MistralLLMProvider: + """Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" + + def __init__(self, settings: Settings) -> None: + if not settings.mistral_api_key: + raise LLMProviderError( + "Clé API Mistral manquante. Configure-la depuis l'écran Paramètres." + ) + self._api_key = settings.mistral_api_key + self._model = settings.mistral_model + self._timeout = settings.llm_timeout_seconds + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + async def generate( + self, + prompt: str, + *, + output_format: str | None = None, + temperature: float | None = None, + ) -> str: + """One-shot via streaming (puis recollage) pour robustesse sur longues sorties. + + Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : + si le provider envoyait des keep-alive sans contenu, l'appel pendrait à + l'infini. Ici on coupe net après `self._timeout` secondes. + """ + return await self._collect_with_timeouts( + [ChatMessage(role="user", content=prompt)], temperature, output_format + ) + + async def _collect_with_timeouts( + self, + messages: list[ChatMessage], + temperature: float | None, + output_format: str | None, + ) -> str: + """Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné + (file d'attente → échec rapide) + ceiling global `self._timeout`.""" + async def _collect() -> str: + chunks: list[str] = [] + agen = self._stream(messages, None, temperature, output_format) + try: + while True: + first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None + try: + token = await asyncio.wait_for(agen.__anext__(), timeout=first) + except StopAsyncIteration: + break + except asyncio.TimeoutError: + raise LLMProviderError( + f"Erreur Mistral : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement " + "en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou " + "choisissez un modèle plus disponible." + ) + chunks.append(token) + finally: + await agen.aclose() + return "".join(chunks) + + try: + return await asyncio.wait_for(_collect(), timeout=self._timeout) + except asyncio.TimeoutError as exc: + raise LLMProviderError( + f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la " + "taille des morceaux d'import, augmentez le timeout, ou changez de modèle." + ) from exc + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + ) -> AsyncIterator[str]: + async for token in self._stream(messages, system_prompt, temperature): + yield token + + async def _stream( + self, + messages: list[ChatMessage], + system_prompt: str | None, + temperature: float | None, + output_format: str | None = None, + ) -> AsyncIterator[str]: + payload_messages: list[dict[str, str]] = [] + if system_prompt: + payload_messages.append({"role": "system", "content": system_prompt}) + for m in messages: + payload_messages.append({"role": m.role, "content": m.content}) + + body: dict[str, object] = { + "model": self._model, + "messages": payload_messages, + "stream": True, + } + if temperature is not None: + body["temperature"] = temperature + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + async with client.stream( + "POST", _API_URL, headers=self._headers(), json=body + ) as response: + if response.status_code >= 400: + # En streaming le corps n'est pas lu automatiquement : on le + # lit pour exposer le détail de Mistral (modèle inconnu, clé + # invalide 401, quota 429…), sinon on n'a que le code HTTP nu. + detail = (await response.aread()).decode("utf-8", "replace").strip() + raise LLMProviderError( + f"Erreur Mistral (HTTP {response.status_code})" + + (f" : {detail[:500]}" if detail else "") + ) + async for token in self._parse_sse(response): + yield token + except httpx.HTTPError as exc: + raise LLMProviderError(self._format_http_error(exc)) from exc + + @staticmethod + async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: + """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`.""" + async for line in response.aiter_lines(): + if not line or not line.startswith("data:"): + continue # lignes vides ou keep-alive (`: ...`) + data = line[len("data:"):].strip() + if data == "[DONE]": + return + try: + obj = json.loads(data) + except json.JSONDecodeError: + continue + choices = obj.get("choices") + if not choices: + continue + delta = choices[0].get("delta") or {} + content = delta.get("content") + if content: + yield content + + def _format_http_error(self, exc: httpx.HTTPError) -> str: + """Message lisible (timeout, quota 429, clé invalide 401, modèle inconnu…).""" + if isinstance(exc, httpx.TimeoutException): + return ( + f"Erreur Mistral : délai dépassé (timeout {self._timeout}s). Le modèle a " + "mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout." + ) + detail = str(exc) or exc.__class__.__name__ + return f"Erreur Mistral ({exc.__class__.__name__}) : {detail}" diff --git a/brain/app/infrastructure/mistral_embedding_adapter.py b/brain/app/infrastructure/mistral_embedding_adapter.py new file mode 100644 index 0000000..7dbf9e1 --- /dev/null +++ b/brain/app/infrastructure/mistral_embedding_adapter.py @@ -0,0 +1,58 @@ +"""Adapter d'embeddings Mistral (cloud, EU) — POST /v1/embeddings. + +Soumis au rate limit du tier gratuit : pour indexer un gros document on envoie +les textes par lots (et l'appelant peut espacer les appels si besoin). +""" +from __future__ import annotations + +import httpx + +from app.application.embeddings import EmbeddingError +from app.core.config import Settings + +_API_URL = "https://api.mistral.ai/v1/embeddings" +# Lot raisonnable pour ne pas envoyer un payload géant d'un coup. +_BATCH_SIZE = 64 + + +class MistralEmbeddingProvider: + """Implémente EmbeddingProvider via l'API Mistral embeddings.""" + + def __init__(self, settings: Settings) -> None: + if not settings.mistral_api_key: + raise EmbeddingError( + "Clé API Mistral manquante (requise pour les embeddings Mistral). " + "Configure-la dans les Paramètres ou choisis Ollama pour les embeddings." + ) + self._api_key = settings.mistral_api_key + self._model = settings.mistral_embedding_model + self._timeout = settings.llm_timeout_seconds + + async def embed(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + out: list[list[float]] = [] + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=self._timeout) as client: + for start in range(0, len(texts), _BATCH_SIZE): + batch = texts[start:start + _BATCH_SIZE] + try: + response = await client.post( + _API_URL, headers=headers, json={"model": self._model, "input": batch}) + if response.status_code >= 400: + raise EmbeddingError( + f"Mistral embeddings HTTP {response.status_code} : " + f"{response.text.strip()[:300]}") + data = response.json() + except httpx.HTTPError as exc: + raise EmbeddingError(f"Erreur Mistral embeddings : {exc}") from exc + + items = data.get("data") + if not isinstance(items, list) or len(items) != len(batch): + raise EmbeddingError("Réponse d'embeddings Mistral inattendue (taille incohérente).") + for item in items: + out.append([float(x) for x in item.get("embedding", [])]) + return out diff --git a/brain/app/infrastructure/ollama_embedding_adapter.py b/brain/app/infrastructure/ollama_embedding_adapter.py new file mode 100644 index 0000000..e584e24 --- /dev/null +++ b/brain/app/infrastructure/ollama_embedding_adapter.py @@ -0,0 +1,43 @@ +"""Adapter d'embeddings Ollama (local) — endpoint /api/embed. + +Gratuit et illimité (tourne sur la machine). Nécessite d'avoir pullé le modèle +d'embedding (ex. `ollama pull nomic-embed-text`). +""" +from __future__ import annotations + +import httpx + +from app.application.embeddings import EmbeddingError +from app.core.config import Settings + + +class OllamaEmbeddingProvider: + """Implémente EmbeddingProvider via Ollama /api/embed (batch).""" + + def __init__(self, settings: Settings) -> None: + self._base_url = settings.ollama_base_url + self._model = settings.ollama_embedding_model + self._timeout = settings.llm_timeout_seconds + + async def embed(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + url = f"{self._base_url}/api/embed" + payload = {"model": self._model, "input": texts} + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload) + if response.status_code >= 400: + body = response.text + raise EmbeddingError( + f"Ollama embeddings HTTP {response.status_code} : {body.strip()[:300]}. " + f"Le modèle '{self._model}' est-il installé ? (ollama pull {self._model})" + ) + data = response.json() + except httpx.HTTPError as exc: + raise EmbeddingError(f"Erreur Ollama embeddings : {exc}") from exc + + vectors = data.get("embeddings") + if not isinstance(vectors, list) or len(vectors) != len(texts): + raise EmbeddingError("Réponse d'embeddings Ollama inattendue (taille incohérente).") + return [[float(x) for x in v] for v in vectors] diff --git a/brain/app/infrastructure/openrouter_adapter.py b/brain/app/infrastructure/openrouter_adapter.py index b63e8cd..7e43943 100644 --- a/brain/app/infrastructure/openrouter_adapter.py +++ b/brain/app/infrastructure/openrouter_adapter.py @@ -11,17 +11,26 @@ qui choisit automatiquement un modèle gratuit — aucun crédit consommé. """ from __future__ import annotations +import asyncio import json +import logging from typing import AsyncIterator import httpx from app.core.config import Settings from app.domain.models import ChatMessage + +logger = logging.getLogger(__name__) from app.domain.ports import LLMProviderError _API_URL = "https://openrouter.ai/api/v1/chat/completions" +# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente" +# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu +# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue. +_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0 + class OpenRouterLLMProvider: """Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" @@ -51,11 +60,63 @@ class OpenRouterLLMProvider: output_format: str | None = None, temperature: float | None = None, ) -> str: - """One-shot via streaming (puis recollage) pour robustesse sur longues sorties.""" - chunks: list[str] = [] - async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature): - chunks.append(token) - return "".join(chunks) + """One-shot via streaming (puis recollage) pour robustesse sur longues sorties. + + Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un + modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER + PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout + (des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après + `self._timeout` secondes, quoi qu'il arrive. + """ + return await self._collect_with_timeouts( + [ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter" + ) + + async def _collect_with_timeouts( + self, + messages: list[ChatMessage], + temperature: float | None, + output_format: str | None, + provider: str, + ) -> str: + """Collecte le stream avec DEUX garde-fous au temps écoulé : + - 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué + en file d'attente (que des keep-alive, aucun contenu) → échec rapide ; + - ceiling global (`self._timeout`) : génération qui ne se termine jamais. + Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des + octets' et empêchent son read-timeout de se déclencher. + """ + async def _collect() -> str: + chunks: list[str] = [] + agen = self._stream(messages, None, temperature, output_format) + try: + while True: + # Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite + # on laisse générer (le ceiling global couvre le reste). + first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None + try: + token = await asyncio.wait_for(agen.__anext__(), timeout=first) + except StopAsyncIteration: + break + except asyncio.TimeoutError: + raise LLMProviderError( + f"Erreur {provider} : aucun contenu produit en " + f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est " + "probablement en file d'attente / saturé. Réessayez plus tard ou " + "choisissez un autre modèle (1min.ai, ou payant)." + ) + chunks.append(token) + finally: + await agen.aclose() + return "".join(chunks) + + try: + return await asyncio.wait_for(_collect(), timeout=self._timeout) + except asyncio.TimeoutError as exc: + raise LLMProviderError( + f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la " + "taille des morceaux d'import, augmentez le timeout, ou changez de modèle." + ) from exc async def stream_chat( self, @@ -72,6 +133,7 @@ class OpenRouterLLMProvider: messages: list[ChatMessage], system_prompt: str | None, temperature: float | None, + output_format: str | None = None, ) -> AsyncIterator[str]: payload_messages: list[dict[str, str]] = [] if system_prompt: @@ -86,6 +148,10 @@ class OpenRouterLLMProvider: } if temperature is not None: body["temperature"] = temperature + # NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/ + # providers GRATUITS ne le supportent pas et renvoient une réponse VIDE. + # On laisse le modèle répondre librement ; l'extraction JSON en aval + # (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose. async with httpx.AsyncClient(timeout=self._timeout) as client: try: diff --git a/brain/app/infrastructure/vector_store.py b/brain/app/infrastructure/vector_store.py new file mode 100644 index 0000000..383e92a --- /dev/null +++ b/brain/app/infrastructure/vector_store.py @@ -0,0 +1,109 @@ +"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde. + +Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain : + data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]} + +À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en +Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle. +""" +from __future__ import annotations + +import json +import math +import re +from pathlib import Path + +_STORE_DIR = Path("data/notebooks") +_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]") + + +def _path(source_id: str) -> Path: + safe = _SAFE_ID.sub("_", str(source_id)) + return _STORE_DIR / f"{safe}.json" + + +def save( + source_id: str, + chunks: list[str], + vectors: list[list[float]], + pages: list[int] | None = None, +) -> int: + """Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits.""" + if len(chunks) != len(vectors): + raise ValueError("chunks et vectors de tailles différentes") + if pages is not None and len(pages) != len(chunks): + raise ValueError("pages et chunks de tailles différentes") + _STORE_DIR.mkdir(parents=True, exist_ok=True) + items = [] + for i, (c, v) in enumerate(zip(chunks, vectors)): + item = {"text": c, "vector": v} + if pages is not None: + item["page"] = pages[i] + items.append(item) + payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items} + _path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + return len(chunks) + + +def exists(source_id: str) -> bool: + return _path(source_id).exists() + + +def delete(source_id: str) -> None: + _path(source_id).unlink(missing_ok=True) + + +def _load(source_id: str) -> list[dict]: + p = _path(source_id) + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + return data.get("chunks", []) if isinstance(data, dict) else [] + + +def all_chunks(source_id: str) -> list[dict]: + """Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode + « analyse approfondie » (map-reduce sur tout le document).""" + return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)] + + +def _cosine(a: list[float], b: list[float]) -> float: + if not a or not b or len(a) != len(b): + return 0.0 + dot = 0.0 + na = 0.0 + nb = 0.0 + for x, y in zip(a, b): + dot += x * y + na += x * x + nb += y * y + if na == 0.0 or nb == 0.0: + return 0.0 + return dot / (math.sqrt(na) * math.sqrt(nb)) + + +def search( + source_ids: list[str], + query_vector: list[float], + top_k: int = 6, +) -> list[dict]: + """Renvoie les `top_k` extraits les plus proches, toutes sources confondues. + + Chaque résultat : {"text": str, "score": float, "source_id": str}. + """ + scored: list[dict] = [] + for sid in source_ids: + for chunk in _load(sid): + vector = chunk.get("vector") or [] + score = _cosine(query_vector, vector) + scored.append({ + "text": chunk.get("text", ""), + "score": score, + "source_id": sid, + "page": chunk.get("page"), + }) + scored.sort(key=lambda c: c["score"], reverse=True) + return scored[:top_k] diff --git a/brain/app/main.py b/brain/app/main.py index b1b98a8..b7eda84 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -4,7 +4,9 @@ Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue au domaine via injection de dépendance (ports + use cases), et transforme les erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici. """ +import asyncio import json +import logging from typing import Annotated, AsyncIterator, Literal import hmac @@ -14,11 +16,22 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field +import re + from app.application.adapt_campaign import AdaptCampaignUseCase from app.application.chat import ChatUseCase from app.application.generate_page import GeneratePageUseCase from app.application.import_campaign import ImportCampaignUseCase from app.application.import_rules import ImportRulesUseCase +from app.application.llm_json import load_json_object +from app.application.llm_retry import generate_with_retry +from app.application.notebook_rag import NotebookRagUseCase +from app.application.notebook_chat import NotebookChatUseCase +from app.application.notebook_deep import NotebookDeepUseCase +from app.application.embeddings import EmbeddingError +from app.infrastructure import vector_store +from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider +from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider from app.core.config import Settings, get_settings from app.core.settings_store import save_overrides from app.domain.models import ( @@ -46,14 +59,18 @@ from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.onemin_adapter import OneMinAiLLMProvider from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider +from app.infrastructure.mistral_adapter import MistralLLMProvider +from app.infrastructure.gemini_adapter import GeminiLLMProvider from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.10.3-beta", + version="0.11.0-beta", ) +logger = logging.getLogger(__name__) + # Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup # à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer @@ -357,6 +374,10 @@ def get_llm_provider( return OneMinAiLLMProvider(settings) if settings.llm_provider == "openrouter": return OpenRouterLLMProvider(settings) + if settings.llm_provider == "mistral": + return MistralLLMProvider(settings) + if settings.llm_provider == "gemini": + return GeminiLLMProvider(settings) return OllamaLLMProvider(settings) except LLMProviderError as exc: # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 @@ -416,6 +437,38 @@ def get_adapt_campaign_use_case( llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens) +def get_embedding_provider( + settings: Annotated[Settings, Depends(get_settings)], +): + """Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`.""" + try: + if settings.embedding_provider == "mistral": + return MistralEmbeddingProvider(settings) + return OllamaEmbeddingProvider(settings) + except EmbeddingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def get_notebook_rag_use_case( + embedder: Annotated[object, Depends(get_embedding_provider)], +) -> NotebookRagUseCase: + return NotebookRagUseCase(extractor=_PDF_EXTRACTOR, embedder=embedder) # type: ignore[arg-type] + + +def get_notebook_chat_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], +) -> NotebookChatUseCase: + return NotebookChatUseCase(rag=rag, llm=llm) # type: ignore[arg-type] + + +def get_notebook_deep_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> NotebookDeepUseCase: + return NotebookDeepUseCase(llm=llm, batch_tokens=settings.import_chunk_tokens) + + # --- Endpoints --- @@ -425,6 +478,54 @@ def health() -> dict[str, str]: return {"status": "ok", "service": "brain"} +@app.on_event("startup") +async def _auto_install_embedding_model() -> None: + """Au démarrage : si le provider d'embeddings est Ollama et que le modèle n'est + pas installé, on le télécharge EN ARRIÈRE-PLAN → le RAG marche d'emblée pour un + nouvel utilisateur, sans bloquer le démarrage du Brain. Best-effort (Ollama peut + être absent / la connexion limitée) ; désactivable via `auto_pull_embedding_model`. + """ + settings = get_settings() + if not settings.auto_pull_embedding_model or settings.embedding_provider != "ollama": + return + asyncio.create_task(_ensure_ollama_embedding_model(settings.ollama_base_url, settings.ollama_embedding_model)) + + +async def _ensure_ollama_embedding_model(base_url: str, model: str) -> None: + # Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis + # vérifie la présence du modèle avant de le tirer. + for attempt in range(10): + try: + async with httpx.AsyncClient(timeout=10) as client: + tags = await client.get(f"{base_url}/api/tags") + tags.raise_for_status() + names = [m.get("name", "") for m in tags.json().get("models", [])] + if any(n == model or n.startswith(model + ":") for n in names): + logger.info("Modèle d'embedding '%s' déjà présent.", model) + return + break # Ollama joignable, modèle absent → on tire (ci-dessous) + except httpx.HTTPError: + await asyncio.sleep(min(5 * (attempt + 1), 30)) + else: + logger.warning( + "Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé " + "(il sera tirable manuellement : ollama pull %s).", model, model) + return + + logger.info("Téléchargement automatique du modèle d'embedding '%s'…", model) + try: + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp: + resp.raise_for_status() + async for _line in resp.aiter_lines(): + pass # on draine la progression NDJSON jusqu'à la fin + logger.info("Modèle d'embedding '%s' prêt.", model) + except httpx.HTTPError as exc: + logger.warning( + "Auto-installation du modèle d'embedding '%s' échouée : %s " + "(tirage manuel possible : ollama pull %s).", model, exc, model) + + @app.post("/generate", response_model=GenerateResponse) async def generate( body: GenerateRequest, @@ -555,6 +656,11 @@ async def import_rules_stream( yield _sse("error", {"message": str(exc)}) except LLMProviderError as exc: yield _sse("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit + # PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique + # sans détail). On la transforme en évènement `error` propre + log avec trace. + logger.exception("Import règles : erreur inattendue dans le flux.") + yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) return StreamingResponse(event_stream(), media_type="text/event-stream") @@ -590,6 +696,10 @@ async def import_campaign_stream( yield _sse("error", {"message": str(exc)}) except LLMProviderError as exc: yield _sse("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas + # une erreur inattendue casser le flux sans détail. + logger.exception("Import campagne : erreur inattendue dans le flux.") + yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) return StreamingResponse(event_stream(), media_type="text/event-stream") @@ -773,6 +883,245 @@ async def summarize_conversation_title( return SummarizeTitleResponseDTO(title=title) +# --- Tables aléatoires : génération IA + improvisation ----------------------- + +_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$") + + +def _dice_total_range(formula: str) -> tuple[int, int] | None: + """(min, max) des totaux possibles d'une formule NdM, ou None si invalide.""" + match = _DICE_FORMULA_RE.match(formula or "") + if not match: + return None + count = int(match.group(1)) if match.group(1) else 1 + faces = int(match.group(2)) + if count < 1 or count > 100 or faces < 2 or faces > 10000: + return None + return count, count * faces + + +class GenerateTableRequestDTO(BaseModel): + description: str + dice_formula: str = Field(default="1d20") + # Contexte libre assemblé par le Core (nom de campagne, système, ambiance…). + context: str = Field(default="") + + +class GeneratedTableEntryDTO(BaseModel): + min_roll: int + max_roll: int + label: str + detail: str = "" + + +class GenerateTableResponseDTO(BaseModel): + name: str + description: str = "" + entries: list[GeneratedTableEntryDTO] + + +@app.post("/generate/random-table", response_model=GenerateTableResponseDTO) +async def generate_random_table( + body: GenerateTableRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> GenerateTableResponseDTO: + """Génère une table aléatoire (entrées par plage) couvrant la formule de dé.""" + rng = _dice_total_range(body.dice_formula) + if rng is None: + raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") + lo, hi = rng + context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" + prompt = ( + "Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n" + f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n" + f"Sujet : {body.description.strip()}\n" + f"{context_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format : {"name": "...", "description": "...", "entries": ' + '[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n' + f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, " + "sans trou ni chevauchement, dans l'ordre croissant.\n" + "- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n" + "- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n" + "Renvoie maintenant le JSON." + ) + try: + raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + parsed, _ = load_json_object(raw) + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de table exploitable.") + + entries: list[GeneratedTableEntryDTO] = [] + for e in parsed.get("entries", []) or []: + if not isinstance(e, dict): + continue + try: + mn = int(e["min_roll"]) + mx = int(e["max_roll"]) + except (KeyError, TypeError, ValueError): + continue + label = str(e.get("label") or "").strip() + if not label: + continue + entries.append(GeneratedTableEntryDTO( + min_roll=mn, max_roll=max(mn, mx), label=label[:200], + detail=str(e.get("detail") or "").strip(), + )) + if not entries: + raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.") + + name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée" + return GenerateTableResponseDTO( + name=name, + description=str(parsed.get("description") or "").strip(), + entries=entries, + ) + + +class ImproviseRollRequestDTO(BaseModel): + table_name: str + result_label: str + result_detail: str = Field(default="") + context: str = Field(default="") + + +class ImproviseRollResponseDTO(BaseModel): + narration: str + + +@app.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO) +async def improvise_table_roll( + body: ImproviseRollRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> ImproviseRollResponseDTO: + """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" + detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" + context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" + prompt = ( + "Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table " + f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}." + f"{context_block}\n\n" + "Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. " + "Pas de méta, pas d'options : juste la narration, en français." + ) + try: + raw = await llm.generate(prompt, temperature=0.8) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return ImproviseRollResponseDTO(narration=raw.strip()) + + +# --- Notebooks (atelier RAG) : indexation des sources + chat ancré ---------- + + +class IndexSourceResponseDTO(BaseModel): + chunks: int + page_count: int + ocr_page_count: int + + +@app.post("/index/notebook-source", response_model=IndexSourceResponseDTO) +async def index_notebook_source( + rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], + source_id: str = Form(...), + file: UploadFile = File(...), +) -> IndexSourceResponseDTO: + """Indexe une source PDF (extraction + embeddings + stockage vectoriel).""" + content = await file.read() + if not content: + raise HTTPException(status_code=422, detail="Fichier PDF vide.") + if len(content) > _MAX_PDF_BYTES: + raise HTTPException( + status_code=413, detail=f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo).") + try: + recap = await rag.index_source(source_id, content) + except PdfExtractionError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except EmbeddingError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return IndexSourceResponseDTO(**recap) + + +@app.delete("/index/notebook-source/{source_id}") +def delete_notebook_source(source_id: str) -> dict[str, str]: + """Supprime les vecteurs d'une source (au DELETE d'une source/notebook).""" + vector_store.delete(source_id) + return {"status": "deleted", "source_id": source_id} + + +class NotebookChatMessageDTO(BaseModel): + role: str + content: str + + +class NotebookChatRequestDTO(BaseModel): + source_ids: list[str] = Field(default_factory=list) + messages: list[NotebookChatMessageDTO] = Field(default_factory=list) + context: str = Field(default="") + + +@app.post("/chat/notebook/stream") +async def chat_notebook_stream( + body: NotebookChatRequestDTO, + use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)], + settings: Annotated[Settings, Depends(get_settings)], +) -> StreamingResponse: + """Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis + streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}.""" + messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] + top_k = max(1, min(settings.rag_top_k, 200)) + + def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + async def event_stream() -> AsyncIterator[str]: + try: + async for token in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): + if token: + yield _sse("token", {"token": token}) + yield _sse("done", {}) + except (LLMProviderError, EmbeddingError) as exc: + yield _sse("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux. + logger.exception("Chat notebook : erreur inattendue.") + yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@app.post("/chat/notebook/deep/stream") +async def chat_notebook_deep_stream( + body: NotebookChatRequestDTO, + use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)], +) -> StreamingResponse: + """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : + `progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`.""" + question = next((m.content for m in reversed(body.messages) if m.role == "user"), "") + + def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + async def event_stream() -> AsyncIterator[str]: + if not question.strip(): + yield _sse("error", {"message": "Question vide."}) + return + try: + async for ev in use_case.stream(body.source_ids, question, context=body.context): + ev_type = ev.pop("type") + yield _sse(ev_type, ev) + except (LLMProviderError, EmbeddingError) as exc: + yield _sse("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale. + logger.exception("Analyse approfondie : erreur inattendue.") + yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + # --- Mapping DTO → domaine (frontière HTTP) --------------------------------- @@ -890,7 +1239,7 @@ class SettingsDTO(BaseModel): Les secrets (onemin_api_key) sont masques en lecture. """ - llm_provider: Literal["ollama", "onemin", "openrouter"] + llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] ollama_base_url: str llm_model: str onemin_model: str @@ -899,6 +1248,18 @@ class SettingsDTO(BaseModel): openrouter_model: str # True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee). openrouter_api_key_set: bool + mistral_model: str + # True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee). + mistral_api_key_set: bool + gemini_model: str + # True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee). + gemini_api_key_set: bool + # Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama. + embedding_provider: Literal["ollama", "mistral"] + ollama_embedding_model: str + mistral_embedding_model: str + auto_pull_embedding_model: bool + rag_top_k: int # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert # aussi de plafond a la jauge de contexte UI. llm_num_ctx: int @@ -911,7 +1272,7 @@ class SettingsDTO(BaseModel): class SettingsUpdateDTO(BaseModel): """Patch partiel des settings. Tous les champs sont optionnels.""" - llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None + llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None ollama_base_url: str | None = None llm_model: str | None = None onemin_model: str | None = None @@ -919,6 +1280,15 @@ class SettingsUpdateDTO(BaseModel): onemin_api_key: str | None = None openrouter_model: str | None = None openrouter_api_key: str | None = None + mistral_model: str | None = None + mistral_api_key: str | None = None + gemini_model: str | None = None + gemini_api_key: str | None = None + embedding_provider: Literal["ollama", "mistral"] | None = None + ollama_embedding_model: str | None = None + mistral_embedding_model: str | None = None + auto_pull_embedding_model: bool | None = None + rag_top_k: int | None = None llm_num_ctx: int | None = None import_chunk_tokens: int | None = None llm_timeout_seconds: int | None = None @@ -933,6 +1303,15 @@ def _to_settings_dto(s: Settings) -> SettingsDTO: onemin_api_key_set=bool(s.onemin_api_key), openrouter_model=s.openrouter_model, openrouter_api_key_set=bool(s.openrouter_api_key), + mistral_model=s.mistral_model, + mistral_api_key_set=bool(s.mistral_api_key), + gemini_model=s.gemini_model, + gemini_api_key_set=bool(s.gemini_api_key), + embedding_provider=s.embedding_provider, + ollama_embedding_model=s.ollama_embedding_model, + mistral_embedding_model=s.mistral_embedding_model, + auto_pull_embedding_model=s.auto_pull_embedding_model, + rag_top_k=s.rag_top_k, llm_num_ctx=s.llm_num_ctx, import_chunk_tokens=s.import_chunk_tokens, llm_timeout_seconds=s.llm_timeout_seconds, @@ -1138,6 +1517,99 @@ async def list_openrouter_models() -> dict[str, list[dict[str, object]]]: return {"models": models} +# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est +# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee +# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`. +_MISTRAL_FALLBACK_MODELS = [ + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "open-mistral-nemo", + "ministral-8b-latest", + "ministral-3b-latest", + "magistral-medium-latest", + "magistral-small-latest", + "pixtral-large-latest", + "codestral-latest", +] + + +@app.get("/models/mistral") +async def list_mistral_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[dict[str, object]]]: + """Catalogue des modeles Mistral. Dynamique si une cle est configuree + (GET /v1/models, qui requiert l'auth), sinon repli statique. + + Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment).""" + key = settings.mistral_api_key + if not key: + return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]} + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get( + "https://api.mistral.ai/v1/models", + headers={"Authorization": f"Bearer {key}"}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + # Cle invalide / API down : on ne casse pas l'UI, on propose le repli. + return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]} + + ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")}) + if not ids: + ids = _MISTRAL_FALLBACK_MODELS + return {"models": [{"id": i} for i in ids]} + + +# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand +# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux. +_GEMINI_FALLBACK_MODELS = [ + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-pro", + "gemini-1.5-flash", + "gemini-1.5-pro", +] + + +@app.get("/models/gemini") +async def list_gemini_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[dict[str, object]]]: + """Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint + OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}.""" + key = settings.gemini_api_key + if not key: + return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]} + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get( + "https://generativelanguage.googleapis.com/v1beta/openai/models", + headers={"Authorization": f"Bearer {key}"}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]} + + # Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur + # selectionnee soit directement utilisable dans l'appel chat. On garde les + # modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste. + ids: set[str] = set() + for m in data.get("data", []) or []: + mid = str(m.get("id") or "") + if mid.startswith("models/"): + mid = mid[len("models/"):] + if mid.startswith("gemini-"): + ids.add(mid) + clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS + return {"models": [{"id": i} for i in clean]} + + @app.get("/models/onemin") def list_onemin_models() -> dict[str, list[dict[str, object]]]: """Catalogue statique des modeles 1min.ai, groupes par fournisseur. diff --git a/core/pom.xml b/core/pom.xml index 511c4e9..177bebd 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.10.3-beta + 0.11.0-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java index c0c2c31..5ef4d5c 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignAdaptService.java @@ -1,46 +1,32 @@ package com.loremind.application.campaigncontext; -import com.loremind.application.generationcontext.CampaignStructuralContextBuilder; -import com.loremind.application.generationcontext.LoreStructuralContextBuilder; import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; import com.loremind.domain.campaigncontext.ports.CampaignRepository; -import com.loremind.domain.generationcontext.CampaignStructuralContext; -import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; -import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; -import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; -import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; -import com.loremind.domain.generationcontext.LoreStructuralContext; -import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; import org.springframework.stereotype.Service; -import java.util.List; -import java.util.Map; import java.util.function.Consumer; /** * Service applicatif : conseils d'adaptation d'un PDF à une campagne existante. * - *

Assemble un « brief » de la campagne (structure + PNJ + univers/lore) — la même - * matière que le chat de campagne — et délègue la génération streamée au Brain via + *

Assemble un « brief » de la campagne (structure + PNJ + univers/lore) via + * {@link CampaignBriefBuilder} et délègue la génération streamée au Brain via * {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.

*/ @Service public class CampaignAdaptService { private final CampaignRepository campaignRepository; - private final CampaignStructuralContextBuilder campaignContextBuilder; - private final LoreStructuralContextBuilder loreContextBuilder; + private final CampaignBriefBuilder briefBuilder; private final CampaignPdfAdvisor advisor; public CampaignAdaptService( CampaignRepository campaignRepository, - CampaignStructuralContextBuilder campaignContextBuilder, - LoreStructuralContextBuilder loreContextBuilder, + CampaignBriefBuilder briefBuilder, CampaignPdfAdvisor advisor) { this.campaignRepository = campaignRepository; - this.campaignContextBuilder = campaignContextBuilder; - this.loreContextBuilder = loreContextBuilder; + this.briefBuilder = briefBuilder; this.advisor = advisor; } @@ -55,64 +41,6 @@ public class CampaignAdaptService { Campaign campaign = campaignRepository.findById(campaignId) .orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId)); advisor.adviseStreaming( - pdfBytes, filename, buildBrief(campaign), messagesJson, onToken, onComplete, onError); - } - - /** Construit un résumé markdown de la campagne (structure + PNJ + lore). */ - private String buildBrief(Campaign campaign) { - CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId()); - StringBuilder sb = new StringBuilder(); - - sb.append("# Campagne : ").append(cc.campaignName()).append("\n"); - if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); - - sb.append("\n## Structure (arcs → chapitres → scènes)\n"); - if (cc.arcs().isEmpty()) { - sb.append("_(aucun arc pour le moment)_\n"); - } - for (ArcSummary arc : cc.arcs()) { - sb.append("### Arc : ").append(arc.name()); - if (notBlank(arc.description())) sb.append(" — ").append(arc.description()); - sb.append("\n"); - for (ChapterSummary ch : arc.chapters()) { - sb.append("- Chapitre : ").append(ch.name()); - if (notBlank(ch.description())) sb.append(" — ").append(ch.description()); - sb.append("\n"); - for (SceneSummary sc : ch.scenes()) { - sb.append(" - Scène : ").append(sc.name()); - if (notBlank(sc.description())) sb.append(" — ").append(sc.description()); - sb.append("\n"); - } - } - } - - if (!cc.npcs().isEmpty()) { - sb.append("\n## PNJ existants\n"); - for (NpcSummary n : cc.npcs()) { - sb.append("- ").append(n.name()); - if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet()); - sb.append("\n"); - } - } - - if (campaign.isLinkedToLore()) { - loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore)); - } - return sb.toString(); - } - - private void appendLore(StringBuilder sb, LoreStructuralContext lore) { - sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n"); - if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n"); - for (Map.Entry> entry : lore.folders().entrySet()) { - sb.append("### ").append(entry.getKey()).append("\n"); - for (PageSummary page : entry.getValue()) { - sb.append("- ").append(page.title()).append("\n"); - } - } - } - - private static boolean notBlank(String s) { - return s != null && !s.isBlank(); + pdfBytes, filename, briefBuilder.build(campaign), messagesJson, onToken, onComplete, onError); } } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java new file mode 100644 index 0000000..d11b017 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignBriefBuilder.java @@ -0,0 +1,93 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.application.generationcontext.CampaignStructuralContextBuilder; +import com.loremind.application.generationcontext.LoreStructuralContextBuilder; +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.generationcontext.CampaignStructuralContext; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary; +import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary; +import com.loremind.domain.generationcontext.LoreStructuralContext; +import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * Construit un résumé markdown d'une campagne (structure arcs→chapitres→scènes + + * PNJ + univers/lore). Partagé par les fonctions IA qui doivent « voir » la + * campagne : conseils d'adaptation PDF (CampaignAdaptService) et ateliers RAG + * (NotebookService). Centralisé ici pour une seule source de vérité. + */ +@Service +public class CampaignBriefBuilder { + + private final CampaignStructuralContextBuilder campaignContextBuilder; + private final LoreStructuralContextBuilder loreContextBuilder; + + public CampaignBriefBuilder( + CampaignStructuralContextBuilder campaignContextBuilder, + LoreStructuralContextBuilder loreContextBuilder) { + this.campaignContextBuilder = campaignContextBuilder; + this.loreContextBuilder = loreContextBuilder; + } + + public String build(Campaign campaign) { + CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId()); + StringBuilder sb = new StringBuilder(); + + sb.append("# Campagne : ").append(cc.campaignName()).append("\n"); + if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); + + sb.append("\n## Structure (arcs → chapitres → scènes)\n"); + if (cc.arcs().isEmpty()) { + sb.append("_(aucun arc pour le moment)_\n"); + } + for (ArcSummary arc : cc.arcs()) { + sb.append("### Arc : ").append(arc.name()); + if (notBlank(arc.description())) sb.append(" — ").append(arc.description()); + sb.append("\n"); + for (ChapterSummary ch : arc.chapters()) { + sb.append("- Chapitre : ").append(ch.name()); + if (notBlank(ch.description())) sb.append(" — ").append(ch.description()); + sb.append("\n"); + for (SceneSummary sc : ch.scenes()) { + sb.append(" - Scène : ").append(sc.name()); + if (notBlank(sc.description())) sb.append(" — ").append(sc.description()); + sb.append("\n"); + } + } + } + + if (!cc.npcs().isEmpty()) { + sb.append("\n## PNJ existants\n"); + for (NpcSummary n : cc.npcs()) { + sb.append("- ").append(n.name()); + if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet()); + sb.append("\n"); + } + } + + if (campaign.isLinkedToLore()) { + loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore)); + } + return sb.toString(); + } + + private void appendLore(StringBuilder sb, LoreStructuralContext lore) { + sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n"); + if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n"); + for (Map.Entry> entry : lore.folders().entrySet()) { + sb.append("### ").append(entry.getKey()).append("\n"); + for (PageSummary page : entry.getValue()) { + sb.append("- ").append(page.title()).append("\n"); + } + } + } + + private static boolean notBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java b/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java new file mode 100644 index 0000000..44ce6b6 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java @@ -0,0 +1,132 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.Notebook; +import com.loremind.domain.campaigncontext.NotebookMessage; +import com.loremind.domain.campaigncontext.NotebookSource; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.NotebookIndexer; +import com.loremind.domain.campaigncontext.ports.NotebookRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service d'application des notebooks (atelier RAG) : CRUD, indexation des sources + * (déléguée au Brain), conversation persistée, et assemblage du contexte campagne. + */ +@Service +public class NotebookService { + + private final NotebookRepository repository; + private final NotebookIndexer indexer; + private final CampaignRepository campaignRepository; + private final CampaignBriefBuilder briefBuilder; + + public NotebookService( + NotebookRepository repository, + NotebookIndexer indexer, + CampaignRepository campaignRepository, + CampaignBriefBuilder briefBuilder) { + this.repository = repository; + this.indexer = indexer; + this.campaignRepository = campaignRepository; + this.briefBuilder = briefBuilder; + } + + // --- Notebooks --- + + public Notebook createNotebook(String campaignId, String name) { + String safeName = (name == null || name.isBlank()) ? "Nouvel atelier" : name.trim(); + return repository.save(Notebook.builder().campaignId(campaignId).name(safeName).build()); + } + + public java.util.Optional getNotebook(String id) { + return repository.findById(id); + } + + public List getNotebooksByCampaign(String campaignId) { + return repository.findByCampaignId(campaignId); + } + + public Notebook renameNotebook(String id, String name) { + Notebook nb = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Notebook introuvable: " + id)); + nb.setName((name == null || name.isBlank()) ? nb.getName() : name.trim()); + return repository.save(nb); + } + + public void deleteNotebook(String id) { + // Supprime les vecteurs de chaque source côté Brain (best-effort) avant la BDD. + for (NotebookSource s : repository.findSourcesByNotebookId(id)) { + indexer.delete(s.getId()); + } + repository.deleteById(id); + } + + // --- Sources --- + + public List getSources(String notebookId) { + return repository.findSourcesByNotebookId(notebookId); + } + + /** + * Ajoute une source : crée la ligne (INDEXING), lance l'indexation Brain, puis + * met à jour (READY + compteurs) ou (FAILED) en cas d'échec — et relaie l'erreur. + */ + public NotebookSource addSource(String notebookId, String filename, byte[] pdfBytes) { + if (!repository.existsById(notebookId)) { + throw new IllegalArgumentException("Notebook introuvable: " + notebookId); + } + NotebookSource source = repository.saveSource(NotebookSource.builder() + .notebookId(notebookId) + .filename(filename != null && !filename.isBlank() ? filename : "source.pdf") + .status("INDEXING") + .build()); + try { + NotebookIndexer.IndexResult result = indexer.index(source.getId(), pdfBytes, filename); + source.setStatus("READY"); + source.setChunkCount(result.chunks()); + source.setPageCount(result.pageCount()); + return repository.saveSource(source); + } catch (RuntimeException e) { + source.setStatus("FAILED"); + repository.saveSource(source); + throw e; + } + } + + public void deleteSource(String sourceId) { + repository.findSourceById(sourceId).ifPresent(s -> indexer.delete(s.getId())); + repository.deleteSourceById(sourceId); + } + + public List readySourceIds(String notebookId) { + return repository.findSourcesByNotebookId(notebookId).stream() + .filter(s -> "READY".equals(s.getStatus())) + .map(NotebookSource::getId) + .toList(); + } + + // --- Conversation --- + + public List getMessages(String notebookId) { + return repository.findMessagesByNotebookId(notebookId); + } + + public NotebookMessage addMessage(String notebookId, String role, String content) { + return repository.saveMessage(NotebookMessage.builder() + .notebookId(notebookId).role(role).content(content).build()); + } + + // --- Contexte campagne (oriente l'IA) --- + + /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : + * l'IA « voit » la campagne, pas seulement son nom. */ + public String buildContext(String campaignId) { + if (campaignId == null) return ""; + Campaign campaign = campaignRepository.findById(campaignId).orElse(null); + if (campaign == null) return ""; + return briefBuilder.build(campaign); + } +} diff --git a/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java b/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java new file mode 100644 index 0000000..3fb8599 --- /dev/null +++ b/core/src/main/java/com/loremind/application/campaigncontext/RandomTableService.java @@ -0,0 +1,133 @@ +package com.loremind.application.campaigncontext; + +import com.loremind.domain.campaigncontext.Campaign; +import com.loremind.domain.campaigncontext.RandomTable; +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; +import com.loremind.domain.campaigncontext.ports.RandomTableRepository; +import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Service d'application pour les tables aléatoires (campagne). + */ +@Service +public class RandomTableService { + + private final RandomTableRepository repository; + private final RandomTableGenerator generator; + private final CampaignRepository campaignRepository; + private final GameSystemRepository gameSystemRepository; + + public RandomTableService( + RandomTableRepository repository, + RandomTableGenerator generator, + CampaignRepository campaignRepository, + GameSystemRepository gameSystemRepository) { + this.repository = repository; + this.generator = generator; + this.campaignRepository = campaignRepository; + this.gameSystemRepository = gameSystemRepository; + } + + public record TableData( + String name, + String description, + String diceFormula, + String icon, + List entries, + String campaignId, + Integer order + ) {} + + public RandomTable createTable(TableData data) { + int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId()); + RandomTable table = RandomTable.builder() + .name(data.name()) + .description(data.description()) + .diceFormula(data.diceFormula()) + .icon(data.icon()) + .entries(copyEntries(data.entries())) + .campaignId(data.campaignId()) + .order(order) + .build(); + return repository.save(table); + } + + public Optional getTableById(String id) { + return repository.findById(id); + } + + public List getTablesByCampaignId(String campaignId) { + return repository.findByCampaignId(campaignId); + } + + public RandomTable updateTable(String id, TableData data) { + RandomTable existing = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Table aléatoire introuvable: " + id)); + existing.setName(data.name()); + existing.setDescription(data.description()); + existing.setDiceFormula(data.diceFormula()); + existing.setIcon(data.icon()); + existing.setEntries(copyEntries(data.entries())); + if (data.order() != null) { + existing.setOrder(data.order()); + } + return repository.save(existing); + } + + public void deleteTable(String id) { + repository.deleteById(id); + } + + /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ + public RandomTable generateProposal(String campaignId, String description, String diceFormula) { + String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; + RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId)); + return RandomTable.builder() + .name(g.name()) + .description(g.description()) + .diceFormula(formula) + .campaignId(campaignId) + .entries(g.entries() != null ? new ArrayList<>(g.entries()) : new ArrayList<>()) + .build(); + } + + /** Brode un court récit IA sur un résultat tiré (pour la partie). */ + public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) { + return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId)); + } + + /** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */ + private String buildContext(String campaignId) { + if (campaignId == null) return ""; + Campaign campaign = campaignRepository.findById(campaignId).orElse(null); + if (campaign == null) return ""; + StringBuilder sb = new StringBuilder(); + sb.append("Campagne : ").append(campaign.getName()); + if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) { + sb.append(" — ").append(campaign.getDescription().trim()); + } + if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) { + gameSystemRepository.findById(campaign.getGameSystemId()) + .ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName())); + } + return sb.toString(); + } + + private static List copyEntries(List entries) { + return entries != null ? new ArrayList<>(entries) : new ArrayList<>(); + } + + private int nextOrderFor(String campaignId) { + return repository.findByCampaignId(campaignId).stream() + .mapToInt(RandomTable::getOrder) + .max() + .orElse(-1) + 1; + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Notebook.java b/core/src/main/java/com/loremind/domain/campaigncontext/Notebook.java new file mode 100644 index 0000000..6fb6344 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Notebook.java @@ -0,0 +1,23 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Atelier d'adaptation (« notebook ») d'une campagne : une ou plusieurs sources + * PDF indexées (RAG) + une conversation, persistés pour y revenir. + *

+ * Les SOURCES ({@link NotebookSource}) et les MESSAGES ({@link NotebookMessage}) + * sont gérés comme entités liées par {@code notebookId} (chargées séparément). + */ +@Data +@Builder +public class Notebook { + private String id; + private String name; + private String campaignId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java b/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java new file mode 100644 index 0000000..b87a67d --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java @@ -0,0 +1,20 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Un message de la conversation d'un {@link Notebook}. {@code role} = "user" ou + * "assistant". Persisté pour recharger l'historique de l'atelier. + */ +@Data +@Builder +public class NotebookMessage { + private String id; + private String notebookId; + private String role; + private String content; + private LocalDateTime createdAt; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/NotebookSource.java b/core/src/main/java/com/loremind/domain/campaigncontext/NotebookSource.java new file mode 100644 index 0000000..db80b46 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/NotebookSource.java @@ -0,0 +1,24 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Une source (PDF) d'un {@link Notebook}. Son {@code id} sert de clé d'indexation + * vectorielle côté Brain (les vecteurs vivent sur le volume du Brain). + *

+ * {@code status} : INDEXING (en cours), READY (interrogeable), FAILED (échec). + */ +@Data +@Builder +public class NotebookSource { + private String id; + private String notebookId; + private String filename; + private String status; + private int chunkCount; + private int pageCount; + private LocalDateTime createdAt; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/RandomTable.java b/core/src/main/java/com/loremind/domain/campaigncontext/RandomTable.java new file mode 100644 index 0000000..e1fa78d --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/RandomTable.java @@ -0,0 +1,50 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Table aléatoire d'une campagne : on jette un dé ({@code diceFormula}) et la + * valeur tombée désigne une {@link RandomTableEntry} (par sa plage) → un résultat. + *

+ * Outil MJ classique (rencontres, butin, complications, noms…). Le JET lui-même + * est effectué côté client (instantané, comme le panneau de dés) ; le domaine ne + * fait que stocker la table et ses entrées. Scope campagne (cross-aggregate via ID). + */ +@Data +@Builder +public class RandomTable { + + private String id; + private String name; + + /** Description libre (à quoi sert la table). Nullable. */ + private String description; + + /** Formule du dé à lancer : "1d20", "2d6", "d100"… */ + private String diceFormula; + + /** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */ + private String icon; + + /** Référence vers la Campaign parente (cross-aggregate via ID). */ + private String campaignId; + + /** Ordre d'affichage dans la liste des tables de la campagne. */ + private int order; + + /** Entrées ordonnées (par plage de jet). Jamais null après construction. */ + private List entries; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public List getEntries() { + if (entries == null) entries = new ArrayList<>(); + return entries; + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/RandomTableEntry.java b/core/src/main/java/com/loremind/domain/campaigncontext/RandomTableEntry.java new file mode 100644 index 0000000..2e14d2b --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/RandomTableEntry.java @@ -0,0 +1,29 @@ +package com.loremind.domain.campaigncontext; + +import lombok.Builder; +import lombok.Data; + +/** + * Une entrée d'une {@link RandomTable} : une PLAGE de jet (minRoll..maxRoll, bornes + * incluses) qui mappe vers un résultat. Les plages permettent les tables PONDÉRÉES + * (un résultat couvrant 1–10 est plus probable qu'un couvrant 11–12). + *

+ * Value object possédé par la table (pas d'identité propre côté domaine) : à chaque + * mise à jour, les entrées sont remplacées en bloc. + */ +@Data +@Builder +public class RandomTableEntry { + + /** Borne basse du jet (incluse). */ + private int minRoll; + + /** Borne haute du jet (incluse). Pour une entrée unitaire, min == max. */ + private int maxRoll; + + /** Résultat court affiché (ex. "Embuscade de gobelins"). */ + private String label; + + /** Détail markdown : « ce que c'est » (effet, description). Nullable. */ + private String detail; +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java new file mode 100644 index 0000000..7922c70 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java @@ -0,0 +1,36 @@ +package com.loremind.domain.campaigncontext.ports; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Port de sortie : chat ANCRÉ (RAG) sur les sources d'un notebook, streamé. + * Le Brain récupère les passages pertinents puis streame la réponse token par token. + */ +public interface NotebookChatStreamer { + + /** Un message de la conversation transmis au Brain. */ + record Msg(String role, String content) {} + + /** Avancement de l'analyse approfondie (lecture du document par lots). */ + record Progress(int current, int total) {} + + /** + * Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil + * de l'eau : {@code onToken} par fragment, {@code onProgress} (mode approfondi + * uniquement) pendant la lecture du document, {@code onDone} à la fin, + * {@code onError} en cas d'échec. + * + * @param deep true = analyse approfondie (map-reduce sur tout le document) ; + * false = chat RAG (top-k). + */ + void stream( + List sourceIds, + List messages, + String context, + boolean deep, + Consumer onToken, + Consumer onProgress, + Runnable onDone, + Consumer onError); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookException.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookException.java new file mode 100644 index 0000000..f57b2b5 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookException.java @@ -0,0 +1,15 @@ +package com.loremind.domain.campaigncontext.ports; + +/** + * Échec d'indexation/chat d'un notebook (Brain injoignable, erreur du modèle…). + * Mappée en HTTP 502 par le contrôleur. + */ +public class NotebookException extends RuntimeException { + public NotebookException(String message) { + super(message); + } + + public NotebookException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookIndexer.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookIndexer.java new file mode 100644 index 0000000..08dc40a --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookIndexer.java @@ -0,0 +1,17 @@ +package com.loremind.domain.campaigncontext.ports; + +/** + * Port de sortie : indexation RAG d'une source de notebook (déléguée au Brain). + * Les vecteurs vivent côté Brain, keyés par {@code sourceId}. + */ +public interface NotebookIndexer { + + /** Récapitulatif d'indexation renvoyé par le Brain. */ + record IndexResult(int chunks, int pageCount, int ocrPageCount) {} + + /** Indexe une source (extraction + embeddings + stockage vectoriel). */ + IndexResult index(String sourceId, byte[] pdfBytes, String filename); + + /** Supprime les vecteurs d'une source (au DELETE d'une source/notebook). */ + void delete(String sourceId); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java new file mode 100644 index 0000000..ef0ecba --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java @@ -0,0 +1,32 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.Notebook; +import com.loremind.domain.campaigncontext.NotebookMessage; +import com.loremind.domain.campaigncontext.NotebookSource; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des notebooks (atelier), de leurs sources + * et de leur conversation. Port unique (3 agrégats liés) pour rester compact. + */ +public interface NotebookRepository { + + // --- Notebook --- + Notebook save(Notebook notebook); + Optional findById(String id); + List findByCampaignId(String campaignId); + void deleteById(String id); + boolean existsById(String id); + + // --- Sources --- + NotebookSource saveSource(NotebookSource source); + Optional findSourceById(String id); + List findSourcesByNotebookId(String notebookId); + void deleteSourceById(String id); + + // --- Messages (conversation) --- + NotebookMessage saveMessage(NotebookMessage message); + List findMessagesByNotebookId(String notebookId); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerationException.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerationException.java new file mode 100644 index 0000000..5a4edbb --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerationException.java @@ -0,0 +1,15 @@ +package com.loremind.domain.campaigncontext.ports; + +/** + * Échec de génération/improvisation IA d'une table (Brain injoignable, erreur du + * modèle, réponse inexploitable…). Mappée en HTTP 502 par le contrôleur. + */ +public class RandomTableGenerationException extends RuntimeException { + public RandomTableGenerationException(String message) { + super(message); + } + + public RandomTableGenerationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerator.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerator.java new file mode 100644 index 0000000..6ef45f8 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableGenerator.java @@ -0,0 +1,26 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.RandomTableEntry; + +import java.util.List; + +/** + * Port de sortie : génération IA d'une table aléatoire et improvisation narrative + * sur un résultat tiré. Implémenté par un client du Brain (service IA Python). + */ +public interface RandomTableGenerator { + + /** Table proposée (non persistée) à partir d'une description + formule de dé. */ + record GeneratedTable(String name, String description, List entries) {} + + /** + * Génère une proposition de table couvrant la formule de dé, sur le sujet + * donné, en s'appuyant sur le contexte (campagne, système…) s'il est fourni. + */ + GeneratedTable generate(String description, String diceFormula, String context); + + /** + * Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène. + */ + String improvise(String tableName, String resultLabel, String resultDetail, String context); +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java new file mode 100644 index 0000000..0292a61 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/RandomTableRepository.java @@ -0,0 +1,22 @@ +package com.loremind.domain.campaigncontext.ports; + +import com.loremind.domain.campaigncontext.RandomTable; + +import java.util.List; +import java.util.Optional; + +/** + * Port de sortie pour la persistance des {@link RandomTable}. + */ +public interface RandomTableRepository { + + RandomTable save(RandomTable table); + + Optional findById(String id); + + List findByCampaignId(String campaignId); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java index 9419066..ce49cd0 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java @@ -90,8 +90,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter { } } catch (Exception e) { if (!terminated[0]) { + // On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic + // est impossible (timeout WebClient, connexion coupée, réponse non-2xx…). + String cause = e.getClass().getSimpleName() + + (e.getMessage() != null ? " — " + e.getMessage() : ""); onError.accept(new CampaignImportException( - "Erreur lors du streaming d'import depuis le Brain.", e)); + "Erreur lors du streaming d'import depuis le Brain : " + cause, e)); } } } diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java new file mode 100644 index 0000000..f6de715 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java @@ -0,0 +1,139 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; +import com.loremind.domain.campaigncontext.ports.NotebookException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient). + * Pattern identique aux imports streamés (cf. BrainRulesImportClient). + */ +@Component +public class BrainNotebookChatClient implements NotebookChatStreamer { + + private static final String PATH = "/chat/notebook/stream"; + private static final String DEEP_PATH = "/chat/notebook/deep/stream"; + private static final ParameterizedTypeReference> SSE_STRING_TYPE = + new ParameterizedTypeReference<>() {}; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final long timeoutSeconds; + + public BrainNotebookChatClient( + WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + @Value("${brain.base-url}") String baseUrl, + @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) { + this.webClient = webClientBuilder.baseUrl(baseUrl).build(); + this.objectMapper = objectMapper; + this.timeoutSeconds = timeoutSeconds; + } + + @Override + public void stream( + List sourceIds, + List messages, + String context, + boolean deep, + Consumer onToken, + Consumer onProgress, + Runnable onDone, + Consumer onError) { + + Map payload = new LinkedHashMap<>(); + payload.put("source_ids", sourceIds); + payload.put("messages", messages.stream() + .map(m -> Map.of("role", m.role(), "content", m.content())) + .collect(Collectors.toList())); + payload.put("context", context == null ? "" : context); + + Flux> flux = webClient.post() + .uri(deep ? DEEP_PATH : PATH) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.TEXT_EVENT_STREAM) + .bodyValue(payload) + .retrieve() + .bodyToFlux(SSE_STRING_TYPE); + + boolean[] terminated = {false}; + try { + flux + .timeout(Duration.ofSeconds(timeoutSeconds)) + .doOnNext(sse -> handleEvent(sse, terminated, onToken, onProgress, onDone, onError)) + .blockLast(); + if (!terminated[0]) { + onDone.run(); // flux terminé sans event done explicite + } + } catch (Exception e) { + if (!terminated[0]) { + String cause = e.getClass().getSimpleName() + + (e.getMessage() != null ? " — " + e.getMessage() : ""); + onError.accept(new NotebookException( + "Erreur lors du streaming du chat depuis le Brain : " + cause, e)); + } + } + } + + private void handleEvent( + ServerSentEvent sse, + boolean[] terminated, + Consumer onToken, + Consumer onProgress, + Runnable onDone, + Consumer onError) { + + String event = sse.event(); + String data = sse.data() == null ? "" : sse.data(); + if ("token".equals(event)) { + String token = readField(data, "token"); + if (token != null && !token.isEmpty()) onToken.accept(token); + } else if ("progress".equals(event)) { + onProgress.accept(new Progress(readInt(data, "current"), readInt(data, "total"))); + } else if ("done".equals(event)) { + terminated[0] = true; + onDone.run(); + } else if ("error".equals(event)) { + terminated[0] = true; + onError.accept(new NotebookException("Le Brain a signalé une erreur : " + readMessage(data))); + } + } + + private int readInt(String data, String field) { + try { + JsonNode node = objectMapper.readTree(data); + return node.hasNonNull(field) ? node.get(field).asInt() : 0; + } catch (Exception e) { + return 0; + } + } + + private String readField(String data, String field) { + try { + JsonNode node = objectMapper.readTree(data); + return node.hasNonNull(field) ? node.get(field).asText() : null; + } catch (Exception e) { + return null; + } + } + + private String readMessage(String data) { + String msg = readField(data, "message"); + return msg != null ? msg : data; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java new file mode 100644 index 0000000..983fa8e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookIndexClient.java @@ -0,0 +1,100 @@ +package com.loremind.infrastructure.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.loremind.domain.campaigncontext.ports.NotebookException; +import com.loremind.domain.campaigncontext.ports.NotebookIndexer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +/** + * Adapter de sortie : indexe une source de notebook via le Brain (multipart, + * one-shot bloquant — l'indexation d'un livre peut prendre du temps, d'où le + * RestTemplate à timeout long {@code brainImportRestTemplate}). + */ +@Component +public class BrainNotebookIndexClient implements NotebookIndexer { + + private static final Logger LOG = LoggerFactory.getLogger(BrainNotebookIndexClient.class); + private static final String INDEX_PATH = "/index/notebook-source"; + + private final RestTemplate restTemplate; + private final String baseUrl; + + public BrainNotebookIndexClient( + @Qualifier("brainImportRestTemplate") RestTemplate restTemplate, + @Value("${brain.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + } + + @Override + public IndexResult index(String sourceId, byte[] pdfBytes, String filename) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("source_id", sourceId); + body.add("file", filePart(pdfBytes, filename)); + HttpEntity> entity = new HttpEntity<>(body, headers); + + try { + IndexResponse resp = restTemplate.postForObject( + baseUrl + INDEX_PATH, entity, IndexResponse.class); + if (resp == null) { + throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation."); + } + return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount); + } catch (ResourceAccessException e) { + throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e); + } catch (RestClientResponseException e) { + throw new NotebookException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + + " : " + e.getResponseBodyAsString(), e); + } catch (NotebookException e) { + throw e; + } catch (Exception e) { + throw new NotebookException("Erreur inattendue lors de l'indexation via le Brain.", e); + } + } + + @Override + public void delete(String sourceId) { + // Best-effort : si le Brain est down, on ne bloque pas la suppression côté Core + // (les vecteurs orphelins seront simplement ignorés / nettoyables plus tard). + try { + restTemplate.delete(baseUrl + INDEX_PATH + "/" + sourceId); + } catch (Exception e) { + LOG.warn("Suppression des vecteurs de la source {} échouée (ignorée) : {}", sourceId, e.getMessage()); + } + } + + private ByteArrayResource filePart(byte[] pdfBytes, String filename) { + return new ByteArrayResource(pdfBytes) { + @Override + public String getFilename() { + return (filename == null || filename.isBlank()) ? "source.pdf" : filename; + } + }; + } + + /** Réponse JSON du Brain (snake_case). */ + private static class IndexResponse { + public int chunks; + @JsonProperty("page_count") + public int pageCount; + @JsonProperty("ocr_page_count") + public int ocrPageCount; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java new file mode 100644 index 0000000..4e7edc5 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRandomTableClient.java @@ -0,0 +1,120 @@ +package com.loremind.infrastructure.ai; + +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Adapter de sortie : implémente {@link RandomTableGenerator} en appelant le Brain + * (POST one-shot, RestTemplate). Le secret inter-service est injecté par l'intercepteur + * du bean {@code brainRestTemplate}. + */ +@Component +public class BrainRandomTableClient implements RandomTableGenerator { + + private static final String GENERATE_PATH = "/generate/random-table"; + private static final String IMPROVISE_PATH = "/improvise/table-roll"; + + private final RestTemplate restTemplate; + private final String baseUrl; + + public BrainRandomTableClient( + RestTemplate restTemplate, + @Value("${brain.base-url}") String baseUrl) { + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + } + + @Override + public GeneratedTable generate(String description, String diceFormula, String context) { + Map req = new LinkedHashMap<>(); + req.put("description", description == null ? "" : description); + req.put("dice_formula", diceFormula == null ? "1d20" : diceFormula); + req.put("context", context == null ? "" : context); + + Map resp = post(GENERATE_PATH, req); + if (resp == null) { + throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide."); + } + List entries = new ArrayList<>(); + Object rawEntries = resp.get("entries"); + if (rawEntries instanceof List list) { + for (Object item : list) { + if (!(item instanceof Map m)) continue; + Integer min = asInt(m.get("min_roll")); + Integer max = asInt(m.get("max_roll")); + String label = asString(m.get("label")); + if (min == null || max == null || label == null || label.isBlank()) continue; + entries.add(RandomTableEntry.builder() + .minRoll(min) + .maxRoll(Math.max(min, max)) + .label(label) + .detail(asString(m.get("detail"))) + .build()); + } + } + if (entries.isEmpty()) { + throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule."); + } + String name = asString(resp.get("name")); + return new GeneratedTable( + name != null && !name.isBlank() ? name : description, + asString(resp.get("description")), + entries); + } + + @Override + public String improvise(String tableName, String resultLabel, String resultDetail, String context) { + Map req = new LinkedHashMap<>(); + req.put("table_name", tableName == null ? "" : tableName); + req.put("result_label", resultLabel == null ? "" : resultLabel); + req.put("result_detail", resultDetail == null ? "" : resultDetail); + req.put("context", context == null ? "" : context); + + Map resp = post(IMPROVISE_PATH, req); + String narration = resp != null ? asString(resp.get("narration")) : null; + return narration != null ? narration : ""; + } + + @SuppressWarnings("unchecked") + private Map post(String path, Map body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(body, headers); + try { + return restTemplate.postForObject(baseUrl + path, entity, Map.class); + } catch (ResourceAccessException e) { + throw new RandomTableGenerationException("Le Brain est injoignable (timeout ou arrêté).", e); + } catch (RestClientResponseException e) { + throw new RandomTableGenerationException( + "Le Brain a répondu HTTP " + e.getStatusCode().value() + " : " + e.getResponseBodyAsString(), e); + } catch (Exception e) { + throw new RandomTableGenerationException("Erreur inattendue lors de l'appel au Brain.", e); + } + } + + private static Integer asInt(Object o) { + if (o instanceof Number n) return n.intValue(); + if (o instanceof String s) { + try { return Integer.parseInt(s.trim()); } catch (NumberFormatException ignored) { return null; } + } + return null; + } + + private static String asString(Object o) { + return o != null ? o.toString() : null; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java index 3185ffb..3a49c08 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java @@ -148,8 +148,13 @@ public class BrainRulesImportClient implements RulesPdfImporter { } } catch (Exception e) { if (!terminated[0]) { + // On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un + // message générique et le diagnostic est impossible (timeout WebClient, + // connexion coupée, réponse non-2xx du Brain, etc.). + String cause = e.getClass().getSimpleName() + + (e.getMessage() != null ? " — " + e.getMessage() : ""); onError.accept(new RulesImportException( - "Erreur lors du streaming d'import depuis le Brain.", e)); + "Erreur lors du streaming d'import depuis le Brain : " + cause, e)); } } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java new file mode 100644 index 0000000..41009d4 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookJpaEntity.java @@ -0,0 +1,47 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "notebooks", indexes = { + @Index(name = "idx_notebooks_campaign_id", columnList = "campaign_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotebookJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java new file mode 100644 index 0000000..34dd262 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java @@ -0,0 +1,41 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "notebook_messages", indexes = { + @Index(name = "idx_notebook_messages_notebook_id", columnList = "notebook_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotebookMessageJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "notebook_id", nullable = false) + private Long notebookId; + + @Column(nullable = false, length = 16) + private String role; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java new file mode 100644 index 0000000..f207fd3 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookSourceJpaEntity.java @@ -0,0 +1,47 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "notebook_sources", indexes = { + @Index(name = "idx_notebook_sources_notebook_id", columnList = "notebook_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotebookSourceJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "notebook_id", nullable = false) + private Long notebookId; + + @Column(nullable = false) + private String filename; + + @Column(nullable = false, length = 16) + private String status; + + @Column(name = "chunk_count", nullable = false) + private int chunkCount; + + @Column(name = "page_count", nullable = false) + private int pageCount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableEntryJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableEntryJpaEntity.java new file mode 100644 index 0000000..96732e6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableEntryJpaEntity.java @@ -0,0 +1,51 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * Entité JPA d'une entrée de table aléatoire (enfant de {@link RandomTableJpaEntity}). + * Ordonnée par {@code position}. La référence parente est exclue de toString/equals + * pour éviter les récursions infinies (relation bidirectionnelle). + */ +@Entity +@Table(name = "random_table_entries", indexes = { + @Index(name = "idx_random_table_entries_table_id", columnList = "random_table_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RandomTableEntryJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "min_roll", nullable = false) + private int minRoll; + + @Column(name = "max_roll", nullable = false) + private int maxRoll; + + @Column(nullable = false) + private String label; + + @Column(columnDefinition = "TEXT") + private String detail; + + /** Position d'affichage dans la table (ordre des entrées). */ + @Column(nullable = false) + private int position; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "random_table_id", nullable = false) + @ToString.Exclude + @EqualsAndHashCode.Exclude + private RandomTableJpaEntity randomTable; +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java new file mode 100644 index 0000000..357f560 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/RandomTableJpaEntity.java @@ -0,0 +1,73 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Entité JPA d'une table aléatoire (parent) avec ses entrées ordonnées (enfants). + * Tables créées automatiquement par Hibernate (ddl-auto=update). + */ +@Entity +@Table(name = "random_tables") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RandomTableJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "dice_formula", nullable = false, length = 32) + private String diceFormula; + + @Column(length = 64) + private String icon; + + @Column(name = "campaign_id", nullable = false) + private Long campaignId; + + @Column(name = "\"order\"", nullable = false) + private int order; + + @OneToMany( + mappedBy = "randomTable", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY + ) + @OrderBy("position ASC") + @Builder.Default + private List entries = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookJpaRepository.java new file mode 100644 index 0000000..202e7cc --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookJpaRepository.java @@ -0,0 +1,12 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotebookJpaRepository extends JpaRepository { + List findByCampaignIdOrderByUpdatedAtDesc(Long campaignId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java new file mode 100644 index 0000000..95d484f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotebookMessageJpaRepository extends JpaRepository { + List findByNotebookIdOrderByCreatedAtAsc(Long notebookId); + void deleteByNotebookId(Long notebookId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookSourceJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookSourceJpaRepository.java new file mode 100644 index 0000000..34f1290 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookSourceJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotebookSourceJpaRepository extends JpaRepository { + List findByNotebookIdOrderByCreatedAtAsc(Long notebookId); + void deleteByNotebookId(Long notebookId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java new file mode 100644 index 0000000..78968db --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/RandomTableJpaRepository.java @@ -0,0 +1,13 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RandomTableJpaRepository extends JpaRepository { + + List findByCampaignIdOrderByOrderAsc(Long campaignId); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java new file mode 100644 index 0000000..83b47f6 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java @@ -0,0 +1,155 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.Notebook; +import com.loremind.domain.campaigncontext.NotebookMessage; +import com.loremind.domain.campaigncontext.NotebookSource; +import com.loremind.domain.campaigncontext.ports.NotebookRepository; +import com.loremind.infrastructure.persistence.entity.NotebookJpaEntity; +import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; +import com.loremind.infrastructure.persistence.entity.NotebookSourceJpaEntity; +import com.loremind.infrastructure.persistence.jpa.NotebookJpaRepository; +import com.loremind.infrastructure.persistence.jpa.NotebookMessageJpaRepository; +import com.loremind.infrastructure.persistence.jpa.NotebookSourceJpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresNotebookRepository implements NotebookRepository { + + private final NotebookJpaRepository notebookJpa; + private final NotebookSourceJpaRepository sourceJpa; + private final NotebookMessageJpaRepository messageJpa; + + public PostgresNotebookRepository( + NotebookJpaRepository notebookJpa, + NotebookSourceJpaRepository sourceJpa, + NotebookMessageJpaRepository messageJpa) { + this.notebookJpa = notebookJpa; + this.sourceJpa = sourceJpa; + this.messageJpa = messageJpa; + } + + // --- Notebook --- + + @Override + public Notebook save(Notebook notebook) { + NotebookJpaEntity entity = notebook.getId() != null + ? notebookJpa.findById(Long.parseLong(notebook.getId())).orElseGet(NotebookJpaEntity::new) + : new NotebookJpaEntity(); + entity.setName(notebook.getName()); + entity.setCampaignId(Long.parseLong(notebook.getCampaignId())); + return toNotebook(notebookJpa.save(entity)); + } + + @Override + public Optional findById(String id) { + return notebookJpa.findById(Long.parseLong(id)).map(this::toNotebook); + } + + @Override + public List findByCampaignId(String campaignId) { + return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream() + .map(this::toNotebook).collect(Collectors.toList()); + } + + @Override + @Transactional + public void deleteById(String id) { + Long nid = Long.parseLong(id); + messageJpa.deleteByNotebookId(nid); + sourceJpa.deleteByNotebookId(nid); + notebookJpa.deleteById(nid); + } + + @Override + public boolean existsById(String id) { + return notebookJpa.existsById(Long.parseLong(id)); + } + + // --- Sources --- + + @Override + public NotebookSource saveSource(NotebookSource source) { + NotebookSourceJpaEntity entity = source.getId() != null + ? sourceJpa.findById(Long.parseLong(source.getId())).orElseGet(NotebookSourceJpaEntity::new) + : new NotebookSourceJpaEntity(); + entity.setNotebookId(Long.parseLong(source.getNotebookId())); + entity.setFilename(source.getFilename()); + entity.setStatus(source.getStatus()); + entity.setChunkCount(source.getChunkCount()); + entity.setPageCount(source.getPageCount()); + return toSource(sourceJpa.save(entity)); + } + + @Override + public Optional findSourceById(String id) { + return sourceJpa.findById(Long.parseLong(id)).map(this::toSource); + } + + @Override + public List findSourcesByNotebookId(String notebookId) { + return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() + .map(this::toSource).collect(Collectors.toList()); + } + + @Override + public void deleteSourceById(String id) { + sourceJpa.deleteById(Long.parseLong(id)); + } + + // --- Messages --- + + @Override + public NotebookMessage saveMessage(NotebookMessage message) { + NotebookMessageJpaEntity entity = NotebookMessageJpaEntity.builder() + .notebookId(Long.parseLong(message.getNotebookId())) + .role(message.getRole()) + .content(message.getContent()) + .build(); + return toMessage(messageJpa.save(entity)); + } + + @Override + public List findMessagesByNotebookId(String notebookId) { + return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() + .map(this::toMessage).collect(Collectors.toList()); + } + + // --- Mapping --- + + private Notebook toNotebook(NotebookJpaEntity e) { + return Notebook.builder() + .id(e.getId().toString()) + .name(e.getName()) + .campaignId(e.getCampaignId().toString()) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } + + private NotebookSource toSource(NotebookSourceJpaEntity e) { + return NotebookSource.builder() + .id(e.getId().toString()) + .notebookId(e.getNotebookId().toString()) + .filename(e.getFilename()) + .status(e.getStatus()) + .chunkCount(e.getChunkCount()) + .pageCount(e.getPageCount()) + .createdAt(e.getCreatedAt()) + .build(); + } + + private NotebookMessage toMessage(NotebookMessageJpaEntity e) { + return NotebookMessage.builder() + .id(e.getId().toString()) + .notebookId(e.getNotebookId().toString()) + .role(e.getRole()) + .content(e.getContent()) + .createdAt(e.getCreatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java new file mode 100644 index 0000000..cf75236 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresRandomTableRepository.java @@ -0,0 +1,106 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.campaigncontext.RandomTable; +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.domain.campaigncontext.ports.RandomTableRepository; +import com.loremind.infrastructure.persistence.entity.RandomTableEntryJpaEntity; +import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity; +import com.loremind.infrastructure.persistence.jpa.RandomTableJpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class PostgresRandomTableRepository implements RandomTableRepository { + + private final RandomTableJpaRepository jpaRepository; + + public PostgresRandomTableRepository(RandomTableJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + @Transactional + public RandomTable save(RandomTable table) { + // Création OU mise à jour : on charge l'entité gérée si elle existe afin que + // le remplacement des entrées (clear + add) déclenche bien orphanRemoval. + RandomTableJpaEntity entity = (table.getId() != null) + ? jpaRepository.findById(Long.parseLong(table.getId())).orElseGet(RandomTableJpaEntity::new) + : new RandomTableJpaEntity(); + + entity.setName(table.getName()); + entity.setDescription(table.getDescription()); + entity.setDiceFormula(table.getDiceFormula()); + entity.setIcon(table.getIcon()); + entity.setCampaignId(Long.parseLong(table.getCampaignId())); + entity.setOrder(table.getOrder()); + + // Remplacement en bloc des entrées (les anciennes sont supprimées via orphanRemoval). + entity.getEntries().clear(); + int position = 0; + for (RandomTableEntry e : table.getEntries()) { + entity.getEntries().add(RandomTableEntryJpaEntity.builder() + .minRoll(e.getMinRoll()) + .maxRoll(e.getMaxRoll()) + .label(e.getLabel()) + .detail(e.getDetail()) + .position(position++) + .randomTable(entity) + .build()); + } + + RandomTableJpaEntity saved = jpaRepository.save(entity); + return toDomainEntity(saved); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity); + } + + @Override + @Transactional(readOnly = true) + public List findByCampaignId(String campaignId) { + return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() + .map(this::toDomainEntity) + .collect(Collectors.toList()); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + private RandomTable toDomainEntity(RandomTableJpaEntity e) { + List entries = e.getEntries().stream() + .map(c -> RandomTableEntry.builder() + .minRoll(c.getMinRoll()) + .maxRoll(c.getMaxRoll()) + .label(c.getLabel()) + .detail(c.getDetail()) + .build()) + .collect(Collectors.toCollection(ArrayList::new)); + return RandomTable.builder() + .id(e.getId().toString()) + .name(e.getName()) + .description(e.getDescription()) + .diceFormula(e.getDiceFormula()) + .icon(e.getIcon()) + .campaignId(e.getCampaignId().toString()) + .order(e.getOrder()) + .entries(entries) + .createdAt(e.getCreatedAt()) + .updatedAt(e.getUpdatedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java new file mode 100644 index 0000000..cd258bd --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java @@ -0,0 +1,206 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.NotebookService; +import com.loremind.domain.campaigncontext.Notebook; +import com.loremind.domain.campaigncontext.NotebookSource; +import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; +import com.loremind.domain.campaigncontext.ports.NotebookException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * REST Controller des notebooks (atelier RAG). CRUD + upload/indexation de sources + * + chat ancré streamé (SSE) qui persiste la conversation. + */ +@RestController +@RequestMapping("/api/notebooks") +public class NotebookController { + + private static final long SSE_TIMEOUT_MS = 10 * 60 * 1000L; + + private final NotebookService service; + private final NotebookChatStreamer chatStreamer; + private final TaskExecutor taskExecutor; + + public NotebookController( + NotebookService service, + NotebookChatStreamer chatStreamer, + @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) { + this.service = service; + this.chatStreamer = chatStreamer; + this.taskExecutor = taskExecutor; + } + + // --- Notebooks --- + + @PostMapping + public ResponseEntity create(@RequestBody CreateRequest req) { + return ResponseEntity.ok(service.createNotebook(req.campaignId(), req.name())); + } + + @GetMapping("/campaign/{campaignId}") + public ResponseEntity> listByCampaign(@PathVariable String campaignId) { + return ResponseEntity.ok(service.getNotebooksByCampaign(campaignId)); + } + + @GetMapping("/{id}") + public ResponseEntity> get(@PathVariable String id) { + Notebook nb = service.getNotebook(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable")); + Map out = new LinkedHashMap<>(); + out.put("id", nb.getId()); + out.put("name", nb.getName()); + out.put("campaignId", nb.getCampaignId()); + out.put("sources", service.getSources(id)); + out.put("messages", service.getMessages(id)); + return ResponseEntity.ok(out); + } + + @PutMapping("/{id}") + public ResponseEntity rename(@PathVariable String id, @RequestBody RenameRequest req) { + return ResponseEntity.ok(service.renameNotebook(id, req.name())); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + service.deleteNotebook(id); + return ResponseEntity.noContent().build(); + } + + // --- Sources --- + + @PostMapping("/{id}/sources") + public ResponseEntity addSource( + @PathVariable String id, + @RequestParam("file") MultipartFile file) { + try { + byte[] bytes = file.getBytes(); + NotebookSource source = service.addSource(id, file.getOriginalFilename(), bytes); + return ResponseEntity.ok(source); + } catch (IOException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier illisible", e); + } catch (NotebookException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } + + @DeleteMapping("/sources/{sourceId}") + public ResponseEntity deleteSource(@PathVariable String sourceId) { + service.deleteSource(sourceId); + return ResponseEntity.noContent().build(); + } + + // --- Chat ancré streamé --- + + @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chatStream(@PathVariable String id, @RequestBody ChatRequest req) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + Notebook nb = service.getNotebook(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable")); + + String userMessage = req.message() == null ? "" : req.message().trim(); + if (userMessage.isEmpty()) { + fail(emitter, new IllegalArgumentException("Message vide.")); + return emitter; + } + // Persiste le message utilisateur AVANT le stream (l'historique inclura ce tour). + service.addMessage(id, "user", userMessage); + + List history = service.getMessages(id).stream() + .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) + .toList(); + List sourceIds = service.readySourceIds(id); + String context = service.buildContext(nb.getCampaignId()); + + boolean deep = req.deep() != null && req.deep(); + taskExecutor.execute(() -> { + StringBuilder assistant = new StringBuilder(); + chatStreamer.stream( + sourceIds, history, context, deep, + token -> { assistant.append(token); sendToken(emitter, token); }, + progress -> sendProgress(emitter, progress), + () -> { + // Persiste la réponse de l'assistant à la fin du stream. + if (assistant.length() > 0) { + service.addMessage(id, "assistant", assistant.toString()); + } + complete(emitter); + }, + error -> fail(emitter, error)); + }); + return emitter; + } + + // --- Helpers SSE (mêmes conventions que AiChatController) --- + + private void sendToken(SseEmitter emitter, String token) { + try { + emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}")); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + private void sendProgress(SseEmitter emitter, NotebookChatStreamer.Progress p) { + try { + emitter.send(SseEmitter.event().name("progress") + .data("{\"current\":" + p.current() + ",\"total\":" + p.total() + "}")); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + private void complete(SseEmitter emitter) { + try { + emitter.send(SseEmitter.event().name("done").data("{}")); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + private void fail(SseEmitter emitter, Throwable error) { + try { + String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); + emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}")); + emitter.complete(); + } catch (IOException ioe) { + emitter.completeWithError(ioe); + } + } + + private String jsonEscape(String raw) { + if (raw == null) return "\"\""; + StringBuilder sb = new StringBuilder(raw.length() + 2).append('"'); + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + return sb.append('"').toString(); + } + + public record CreateRequest(String campaignId, String name) {} + public record RenameRequest(String name) {} + public record ChatRequest(String message, Boolean deep) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java new file mode 100644 index 0000000..dcdf5e4 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/RandomTableController.java @@ -0,0 +1,100 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.campaigncontext.RandomTableService; +import com.loremind.domain.campaigncontext.RandomTable; +import com.loremind.domain.campaigncontext.ports.RandomTableGenerationException; +import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO; +import com.loremind.infrastructure.web.mapper.RandomTableMapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/random-tables") +public class RandomTableController { + + private final RandomTableService service; + private final RandomTableMapper mapper; + + public RandomTableController(RandomTableService service, RandomTableMapper mapper) { + this.service = service; + this.mapper = mapper; + } + + @PostMapping + public ResponseEntity create(@RequestBody RandomTableDTO dto) { + RandomTable created = service.createTable(toData(dto, null)); + return ResponseEntity.ok(mapper.toDTO(created)); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable String id) { + return service.getTableById(id) + .map(t -> ResponseEntity.ok(mapper.toDTO(t))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/campaign/{campaignId}") + public ResponseEntity> getByCampaign(@PathVariable String campaignId) { + List dtos = service.getTablesByCampaignId(campaignId).stream() + .map(mapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable String id, @RequestBody RandomTableDTO dto) { + RandomTable updated = service.updateTable(id, toData(dto, dto.getOrder())); + return ResponseEntity.ok(mapper.toDTO(updated)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + service.deleteTable(id); + return ResponseEntity.noContent().build(); + } + + /** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */ + @PostMapping("/generate") + public ResponseEntity generate(@RequestBody GenerateRequest req) { + try { + RandomTable proposal = service.generateProposal(req.campaignId(), req.description(), req.diceFormula()); + return ResponseEntity.ok(mapper.toDTO(proposal)); + } catch (RandomTableGenerationException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } + + /** Improvisation IA d'un court récit sur un résultat tiré (utilisé en partie). */ + @PostMapping("/improvise") + public ResponseEntity> improvise(@RequestBody ImproviseRequest req) { + try { + String narration = service.improviseRoll( + req.campaignId(), req.tableName(), req.resultLabel(), req.resultDetail()); + return ResponseEntity.ok(Map.of("narration", narration)); + } catch (RandomTableGenerationException e) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, e.getMessage(), e); + } + } + + public record GenerateRequest(String campaignId, String description, String diceFormula) {} + + public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {} + + private RandomTableService.TableData toData(RandomTableDTO dto, Integer order) { + return new RandomTableService.TableData( + dto.getName(), + dto.getDescription(), + dto.getDiceFormula(), + dto.getIcon(), + mapper.toDomainEntries(dto.getEntries()), + dto.getCampaignId(), + order + ); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java index f70155d..b70027d 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java @@ -145,6 +145,16 @@ public class SettingsController { return forward(HttpMethod.GET, "/models/openrouter", null); } + @GetMapping("/models/mistral") + public ResponseEntity> listMistralModels() { + return forward(HttpMethod.GET, "/models/mistral", null); + } + + @GetMapping("/models/gemini") + public ResponseEntity> listGeminiModels() { + return forward(HttpMethod.GET, "/models/gemini", null); + } + /** * Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque * appel. Suffisant pour notre cas d'usage : Map avec des diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableDTO.java new file mode 100644 index 0000000..236af6d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableDTO.java @@ -0,0 +1,21 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * DTO d'une table aléatoire (avec ses entrées). + */ +@Data +public class RandomTableDTO { + private String id; + private String name; + private String description; + private String diceFormula; + private String icon; + private String campaignId; + private int order; + private List entries = new ArrayList<>(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableEntryDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableEntryDTO.java new file mode 100644 index 0000000..57d5d32 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/RandomTableEntryDTO.java @@ -0,0 +1,14 @@ +package com.loremind.infrastructure.web.dto.campaigncontext; + +import lombok.Data; + +/** + * DTO d'une entrée de table aléatoire (plage de jet → résultat). + */ +@Data +public class RandomTableEntryDTO { + private int minRoll; + private int maxRoll; + private String label; + private String detail; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/RandomTableMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/RandomTableMapper.java new file mode 100644 index 0000000..ffa584f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/RandomTableMapper.java @@ -0,0 +1,51 @@ +package com.loremind.infrastructure.web.mapper; + +import com.loremind.domain.campaigncontext.RandomTable; +import com.loremind.domain.campaigncontext.RandomTableEntry; +import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableDTO; +import com.loremind.infrastructure.web.dto.campaigncontext.RandomTableEntryDTO; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class RandomTableMapper { + + public RandomTableDTO toDTO(RandomTable t) { + if (t == null) return null; + RandomTableDTO dto = new RandomTableDTO(); + dto.setId(t.getId()); + dto.setName(t.getName()); + dto.setDescription(t.getDescription()); + dto.setDiceFormula(t.getDiceFormula()); + dto.setIcon(t.getIcon()); + dto.setCampaignId(t.getCampaignId()); + dto.setOrder(t.getOrder()); + dto.setEntries(t.getEntries().stream().map(this::toEntryDTO).collect(Collectors.toList())); + return dto; + } + + public List toDomainEntries(List dtos) { + if (dtos == null) return List.of(); + return dtos.stream().map(this::toDomainEntry).collect(Collectors.toList()); + } + + private RandomTableEntryDTO toEntryDTO(RandomTableEntry e) { + RandomTableEntryDTO dto = new RandomTableEntryDTO(); + dto.setMinRoll(e.getMinRoll()); + dto.setMaxRoll(e.getMaxRoll()); + dto.setLabel(e.getLabel()); + dto.setDetail(e.getDetail()); + return dto; + } + + private RandomTableEntry toDomainEntry(RandomTableEntryDTO dto) { + return RandomTableEntry.builder() + .minRoll(dto.getMinRoll()) + .maxRoll(dto.getMaxRoll()) + .label(dto.getLabel()) + .detail(dto.getDetail()) + .build(); + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 9d99c04..21f5518 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.10.3-beta", + "version": "0.11.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.10.3-beta", + "version": "0.11.0-beta", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index 5e41fb6..c51c450 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.10.3-beta", + "version": "0.11.0-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index ffd38eb..01dcd90 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -17,7 +17,6 @@ export const routes: Routes = [ { path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) }, - { path: 'campaigns/:campaignId/adapt', loadComponent: () => import('./campaigns/campaign/campaign-adapt/campaign-adapt.component').then(m => m.CampaignAdaptComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, @@ -26,6 +25,11 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, + { path: 'campaigns/:campaignId/notebooks', loadComponent: () => import('./campaigns/notebook/notebook-list/notebook-list.component').then(m => m.NotebookListComponent) }, + { path: 'campaigns/:campaignId/notebooks/:notebookId', loadComponent: () => import('./campaigns/notebook/notebook-detail/notebook-detail.component').then(m => m.NotebookDetailComponent) }, + { path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, + { path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, + { path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index 5b9c28c..cf42cf6 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -7,6 +7,7 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -40,6 +41,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -59,7 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) }).subscribe(({ campaign, allCampaigns, treeData }) => { this.existingArcCount = treeData.arcs.length; diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index 6ccaa99..3d74848 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -77,6 +78,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -116,7 +118,7 @@ export class ArcEditComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), arc: this.campaignService.getArcById(this.arcId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index 60f7836..8a7f4d5 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -59,6 +60,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -82,7 +84,7 @@ export class ArcViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), arc: this.campaignService.getArcById(this.arcId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index a3c3bbb..305eecd 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -3,10 +3,13 @@ import { switchMap, map } from 'rxjs/operators'; import { CampaignService } from '../services/campaign.service'; import { CharacterService } from '../services/character.service'; import { NpcService } from '../services/npc.service'; +import { RandomTableService } from '../services/random-table.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Character } from '../services/character.model'; import { Npc } from '../services/npc.model'; +import { RandomTable } from '../services/random-table.model'; +import { catchError } from 'rxjs/operators'; /** * Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes) @@ -22,13 +25,17 @@ export interface CampaignTreeData { scenesByChapter: Record; characters: Character[]; npcs: Npc[]; + randomTables: RandomTable[]; } export function loadCampaignTreeData( service: CampaignService, campaignId: string, characterService: CharacterService, - npcService: NpcService + npcService: NpcService, + // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les + // tables aléatoires sont chargées et apparaissent dans la sidebar. + randomTableService?: RandomTableService ): Observable { // Note refonte Playthrough : les PJ appartiennent désormais à une Partie, // pas à la campagne — on ne les charge plus ici (les vues qui les affichent @@ -36,11 +43,14 @@ export function loadCampaignTreeData( return forkJoin({ arcs: service.getArcs(campaignId), characters: of([] as Character[]), - npcs: npcService.getByCampaign(campaignId) + npcs: npcService.getByCampaign(campaignId), + randomTables: randomTableService + ? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[]))) + : of([] as RandomTable[]) }).pipe( - switchMap(({ arcs, characters, npcs }) => { + switchMap(({ arcs, characters, npcs, randomTables }) => { if (arcs.length === 0) { - return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs }); + return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables }); } const chapterCalls = arcs.map(a => service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) @@ -55,7 +65,7 @@ export function loadCampaignTreeData( }); if (allChapters.length === 0) { - return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs }); + return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables }); } const sceneCalls = allChapters.map(c => service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) @@ -64,7 +74,7 @@ export function loadCampaignTreeData( map(sceneResults => { const scenesByChapter: Record = {}; sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); - return { arcs, chaptersByArc, scenesByChapter, characters, npcs }; + return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables }; }) ); }) @@ -157,7 +167,46 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T }; }); - return [...arcNodes, npcsNode]; + const sortedTables = [...(data.randomTables ?? [])].sort(byName); + const tableItems: TreeItem[] = sortedTables.map(t => ({ + id: `random-table-${t.id}`, + label: t.name, + iconKey: t.icon ?? 'dice', + route: `/campaigns/${campaignId}/random-tables/${t.id}` + })); + + const tablesNode: TreeItem = { + id: 'random-tables-root', + label: 'Tables aléatoires', + iconKey: 'dice', + children: tableItems, + meta: tableItems.length ? String(tableItems.length) : undefined, + sectionHeaderBefore: 'Outils', + createActions: [{ + id: 'new-random-table', + label: 'Nouvelle table', + route: `/campaigns/${campaignId}/random-tables/create`, + actionIcon: 'plus' + }] + }; + + // Lien simple vers les ateliers (la liste se charge sur sa page — pas de fetch ici). + const notebooksNode: TreeItem = { + id: 'notebooks-root', + label: 'Ateliers (IA + PDF)', + iconKey: 'book-open', + route: `/campaigns/${campaignId}/notebooks` + }; + + // Importer un PDF de campagne → arborescence (outil, comme tables & ateliers). + const importNode: TreeItem = { + id: 'import-pdf-root', + label: 'Importer un PDF', + iconKey: 'file-up', + route: `/campaigns/${campaignId}/import` + }; + + return [...arcNodes, npcsNode, tablesNode, notebooksNode, importNode]; } /** diff --git a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html deleted file mode 100644 index 37471f7..0000000 --- a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.html +++ /dev/null @@ -1,61 +0,0 @@ -

- - - - -
- - - {{ fileName }} -
- -

{{ error }}

- - -
-
-
{{ m.role === 'user' ? 'Vous' : 'IA' }}
- -
{{ m.content }}
-
- -
- -
- - -
-
- - -
- - -
- -
diff --git a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss deleted file mode 100644 index 40280b4..0000000 --- a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.scss +++ /dev/null @@ -1,184 +0,0 @@ -.adapt-page { - padding: 2rem 2.5rem; - max-width: 900px; -} - -.page-header { - margin-bottom: 1.25rem; - - h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; } -} - -.btn-back { - display: inline-flex; - align-items: center; - gap: 0.4rem; - background: transparent; - border: none; - color: #9ca3af; - cursor: pointer; - font-size: 0.85rem; - padding: 0; - - &:hover { color: #c4b5fd; } -} - -.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; strong { color: #d1d5db; } } - -// --- Barre PDF -------------------------------------------------------------- -.upload-bar { - display: flex; - align-items: center; - gap: 0.8rem; - flex-wrap: wrap; - margin-bottom: 1rem; -} - -.btn-primary { - display: inline-flex; - align-items: center; - gap: 0.45rem; - padding: 0.6rem 1.1rem; - border-radius: 8px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - border: none; - background: #6c63ff; - color: white; - - &:hover:not(:disabled) { background: #5b52e0; } - &:disabled { opacity: 0.6; cursor: progress; } -} - -.file-name { color: #9ca3af; font-size: 0.82rem; } - -.adapt-error { - margin: 0 0 1rem; - padding: 0.55rem 0.8rem; - background: rgba(248, 113, 113, 0.1); - border: 1px solid rgba(248, 113, 113, 0.35); - border-radius: 8px; - color: #fca5a5; - font-size: 0.85rem; -} - -// --- Conversation ----------------------------------------------------------- -.chat { - display: flex; - flex-direction: column; - gap: 0.9rem; - margin-bottom: 1rem; -} - -.msg { - border-radius: 12px; - padding: 0.7rem 0.9rem; - border: 1px solid #1f2937; - - &--user { - background: rgba(108, 99, 255, 0.1); - border-color: rgba(108, 99, 255, 0.3); - align-self: flex-end; - max-width: 85%; - } - - &--assistant { - background: #0b1220; - } -} - -.msg-role { - font-size: 0.68rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #9ca3af; - margin-bottom: 0.35rem; -} - -.msg-body { - color: #d1d5db; - font-size: 0.92rem; - line-height: 1.6; - white-space: pre-wrap; - - &.markdown-body { white-space: normal; } - - ::ng-deep { - h1, h2, h3 { color: #f3f4f6; margin: 0.9rem 0 0.4rem; } - h2 { font-size: 1.08rem; } - h3 { font-size: 1rem; } - ul, ol { padding-left: 1.3rem; } - li { margin: 0.2rem 0; } - strong { color: #fff; } - code { background: #1f2937; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; } - a { color: #a78bfa; } - p { margin: 0.4rem 0; } - } -} - -.msg-actions { margin-top: 0.5rem; } - -.btn-copy { - display: inline-flex; - align-items: center; - gap: 0.3rem; - background: transparent; - border: 1px solid #374151; - border-radius: 6px; - color: #9ca3af; - cursor: pointer; - font-size: 0.75rem; - padding: 0.2rem 0.5rem; - - &:hover { color: #c4b5fd; border-color: #6c63ff; } -} - -.streaming-cursor { - display: inline-block; - color: #6c63ff; - animation: blink 1s step-start infinite; -} - -@keyframes blink { 50% { opacity: 0; } } - -// --- Composer --------------------------------------------------------------- -.composer { - display: flex; - gap: 0.5rem; - align-items: flex-end; - position: sticky; - bottom: 0; - padding-top: 0.5rem; - background: linear-gradient(to top, var(--color-bg, #0a0f1a) 70%, transparent); - - textarea { - flex: 1; - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 8px; - color: #f3f4f6; - padding: 0.55rem 0.7rem; - font-size: 0.9rem; - font-family: inherit; - resize: vertical; - &:focus { outline: none; border-color: #6c63ff; } - &:disabled { opacity: 0.6; } - } -} - -.btn-send { - display: inline-flex; - align-items: center; - justify-content: center; - width: 42px; - height: 42px; - border-radius: 8px; - border: none; - background: #6c63ff; - color: white; - cursor: pointer; - - &:hover:not(:disabled) { background: #5b52e0; } - &:disabled { opacity: 0.5; cursor: default; } -} diff --git a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts b/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts deleted file mode 100644 index 81e32f9..0000000 --- a/web/src/app/campaigns/campaign/campaign-adapt/campaign-adapt.component.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { FormsModule } from '@angular/forms'; -import { LucideAngularModule, ArrowLeft, Upload, Copy, Check, Send } from 'lucide-angular'; -import { CampaignAdaptService, AdaptMessage } from '../../../services/campaign-adapt.service'; -import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; -import { PageTitleService } from '../../../services/page-title.service'; -import { MarkdownPipe } from '../../../shared/markdown.pipe'; - -const FIRST_PROMPT = 'Propose-moi comment intégrer et adapter ce PDF à ma campagne.'; - -/** - * Page « Adapter un PDF » — CONVERSATIONNELLE. L'IA connaît la campagne (structure, - * PNJ, univers) + lit le PDF, propose une 1re adaptation, puis l'utilisateur peut - * répondre (corriger, demander des alternatives…) et l'IA rebondit. - * Route : /campaigns/:campaignId/adapt — rien n'est créé, conseils à appliquer à la main. - */ -@Component({ - selector: 'app-campaign-adapt', - standalone: true, - imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe], - templateUrl: './campaign-adapt.component.html', - styleUrls: ['./campaign-adapt.component.scss'] -}) -export class CampaignAdaptComponent implements OnInit { - readonly ArrowLeft = ArrowLeft; - readonly Upload = Upload; - readonly Copy = Copy; - readonly Check = Check; - readonly Send = Send; - - campaignId = ''; - - /** PDF choisi, conservé pour les tours de conversation suivants. */ - private file: File | null = null; - fileName = ''; - - /** Conversation affichée (user + assistant). */ - messages: AdaptMessage[] = []; - streaming = false; - error: string | null = null; - - /** Saisie du message en cours. */ - input = ''; - copiedIndex: number | null = null; - - constructor( - private route: ActivatedRoute, - private router: Router, - private service: CampaignAdaptService, - private campaignSidebar: CampaignSidebarService, - private pageTitle: PageTitleService - ) {} - - ngOnInit(): void { - this.campaignId = this.route.snapshot.paramMap.get('campaignId')!; - this.pageTitle.set('Adapter un PDF'); - this.campaignSidebar.show(this.campaignId); - } - - get hasConversation(): boolean { return this.messages.length > 0; } - - // --- Choix du PDF (démarre / réinitialise la conversation) --------------- - - onPdfSelected(event: Event): void { - const input = event.target as HTMLInputElement; - const file = input.files?.[0]; - input.value = ''; - if (!file) return; - this.file = file; - this.fileName = file.name; - this.messages = []; - this.error = null; - this.send(FIRST_PROMPT); - } - - // --- Envoi d'un message (1er tour ou feedback) --------------------------- - - sendCurrent(): void { - const text = this.input.trim(); - if (!text || this.streaming) return; - this.input = ''; - this.send(text); - } - - private send(text: string): void { - if (!this.file || this.streaming) return; - this.error = null; - - this.messages.push({ role: 'user', content: text }); - // Historique envoyé = tout jusqu'au message user inclus (sans la bulle vide). - const payload: AdaptMessage[] = this.messages.map(m => ({ role: m.role, content: m.content })); - - const assistant: AdaptMessage = { role: 'assistant', content: '' }; - this.messages.push(assistant); - this.streaming = true; - - this.service.adviseStream(this.campaignId, this.file, payload).subscribe({ - next: (ev) => { - if (ev.type === 'token') { - assistant.content += ev.value; - } else if (ev.type === 'done') { - this.streaming = false; - } - }, - error: (err: Error) => { - this.streaming = false; - // Bulle assistant restée vide → on la retire pour ne pas afficher de vide. - if (!assistant.content) { - this.messages = this.messages.filter(m => m !== assistant); - } - this.error = err?.message ? `Échec : ${err.message}` : "Échec de l'adaptation."; - } - }); - } - - copy(index: number): void { - const msg = this.messages[index]; - if (!msg) return; - navigator.clipboard?.writeText(msg.content).then(() => { - this.copiedIndex = index; - setTimeout(() => (this.copiedIndex = null), 2000); - }); - } - - back(): void { - this.router.navigate(['/campaigns', this.campaignId]); - } -} diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 45ae8eb..299d17a 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -144,14 +144,6 @@

Arcs narratifs

- - + + {{ errorMsg }} +
+
diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss new file mode 100644 index 0000000..8a066aa --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.scss @@ -0,0 +1,52 @@ +.nac { + margin-top: 0.5rem; + padding: 0.6rem 0.75rem; + border-radius: 9px; + border: 1px solid rgba(168, 130, 255, 0.3); + background: rgba(168, 130, 255, 0.08); + + &.created { border-color: rgba(107, 208, 138, 0.4); background: rgba(107, 208, 138, 0.08); } +} + +.nac-head { + display: flex; align-items: center; gap: 0.4rem; + color: #c4a8ff; + .nac-type { + font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; + padding: 0.05rem 0.4rem; border-radius: 4px; background: rgba(168,130,255,0.18); + } + .nac-name { font-weight: 600; color: inherit; } +} + +.nac-desc { + margin: 0.4rem 0 0; font-size: 0.85rem; color: var(--color-text-muted, #cfd3da); + white-space: pre-wrap; +} + +.nac-targets { + display: flex; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.5rem; + label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); } + select { + padding: 0.3rem 0.45rem; border-radius: 6px; font: inherit; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; + } +} + +.nac-warn { margin: 0.4rem 0 0; font-size: 0.78rem; color: #e0a458; } + +.nac-foot { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.55rem; } + +.nac-create, .nac-open { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.35rem 0.7rem; border-radius: 7px; cursor: pointer; font-size: 0.82rem; font-weight: 600; + border: none; +} +.nac-create { + background: #8a6dff; color: #fff; + &:hover:not(:disabled) { background: #7a5cf0; } + &:disabled { opacity: 0.5; cursor: default; } +} +.nac-open { background: rgba(107,208,138,0.2); color: #6bd08a; } +.nac-open:hover { background: rgba(107,208,138,0.3); } + +.nac-error { color: #e88; font-size: 0.8rem; } diff --git a/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts new file mode 100644 index 0000000..df75f10 --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-action-card/notebook-action-card.component.ts @@ -0,0 +1,185 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular'; +import { CampaignService } from '../../../services/campaign.service'; +import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; +import { Arc, Chapter } from '../../../services/campaign.model'; +import { NotebookAction } from '../../../services/notebook-action.model'; + +/** + * Carte « Créer dans la campagne » issue d'une proposition de l'IA. Gère la cible + * (chapitre pour une scène, arc pour un chapitre) et appelle les services existants. + */ +@Component({ + selector: 'app-notebook-action-card', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './notebook-action-card.component.html', + styleUrls: ['./notebook-action-card.component.scss'] +}) +export class NotebookActionCardComponent implements OnInit { + readonly Plus = Plus; + readonly Check = Check; + readonly ExternalLink = ExternalLink; + + @Input() action!: NotebookAction; + @Input() campaignId!: string; + @Input() arcs: Arc[] = []; + @Input() chaptersByArc: Record = {}; + /** Émis après une création réussie → l'atelier rafraîchit la sidebar. */ + @Output() created = new EventEmitter(); + + selectedArcId = ''; + selectedChapterId = ''; + + status: 'idle' | 'creating' | 'created' | 'error' = 'idle'; + errorMsg = ''; + createdRoute: string[] | null = null; + + constructor( + private campaignService: CampaignService, + private npcService: NpcService, + private tableService: RandomTableService, + private router: Router + ) {} + + ngOnInit(): void { + if (this.arcs.length) this.selectedArcId = this.arcs[0].id!; + this.syncChapter(); + } + + get typeLabel(): string { + switch (this.action.type) { + case 'npc': return 'PNJ'; + case 'scene': return 'Scène'; + case 'chapter': return 'Chapitre'; + case 'arc': return 'Arc'; + case 'table': return 'Table aléatoire'; + default: return this.action.type; + } + } + + get typeIcon() { + switch (this.action.type) { + case 'npc': return Drama; + case 'scene': return Clapperboard; + case 'chapter': return BookText; + case 'arc': return GitBranch; + case 'table': return Dices; + default: return Plus; + } + } + + get needsArc(): boolean { return this.action.type === 'chapter' || this.action.type === 'scene'; } + get needsChapter(): boolean { return this.action.type === 'scene'; } + + get targetChapters(): Chapter[] { return this.chaptersByArc[this.selectedArcId] ?? []; } + + syncChapter(): void { + const chs = this.targetChapters; + this.selectedChapterId = chs.length ? chs[0].id! : ''; + } + + get canCreate(): boolean { + if (this.status === 'creating' || this.status === 'created') return false; + if (this.needsArc && !this.selectedArcId) return false; + if (this.needsChapter && !this.selectedChapterId) return false; + return true; + } + + create(): void { + if (!this.canCreate) return; + this.status = 'creating'; + this.errorMsg = ''; + switch (this.action.type) { + case 'npc': return this.createNpc(); + case 'arc': return this.createArc(); + case 'chapter': return this.createChapter(); + case 'scene': return this.createScene(); + case 'table': return this.createTable(); + } + } + + private createNpc(): void { + this.npcService.create({ + name: this.action.name, + campaignId: this.campaignId, + values: this.action.description ? { Description: this.action.description } : {} + }).subscribe({ + next: (n) => this.done(['/campaigns', this.campaignId, 'npcs', n.id!]), + error: (e) => this.fail(e) + }); + } + + private createArc(): void { + this.campaignService.createArc({ + name: this.action.name, + description: this.action.description, + campaignId: this.campaignId, + order: this.arcs.length, + type: this.action.arcType === 'HUB' ? 'HUB' : 'LINEAR' + }).subscribe({ + next: (a) => this.done(['/campaigns', this.campaignId, 'arcs', a.id!]), + error: (e) => this.fail(e) + }); + } + + private createChapter(): void { + const order = (this.chaptersByArc[this.selectedArcId] ?? []).length; + this.campaignService.createChapter({ + name: this.action.name, + description: this.action.description, + arcId: this.selectedArcId, + order + }).subscribe({ + next: (c) => this.done(['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', c.id!]), + error: (e) => this.fail(e) + }); + } + + private createScene(): void { + this.campaignService.createScene({ + name: this.action.name, + description: this.action.description, + playerNarration: this.action.content, + chapterId: this.selectedChapterId, + order: 0 + }).subscribe({ + next: (s) => this.done( + ['/campaigns', this.campaignId, 'arcs', this.selectedArcId, 'chapters', this.selectedChapterId, 'scenes', s.id!]), + error: (e) => this.fail(e) + }); + } + + private createTable(): void { + this.tableService.create({ + name: this.action.name, + diceFormula: this.action.diceFormula || '1d20', + campaignId: this.campaignId, + entries: (this.action.entries ?? []).map(e => ({ + minRoll: e.minRoll, maxRoll: e.maxRoll, label: e.label, detail: e.detail + })) + }).subscribe({ + next: (t) => this.done(['/campaigns', this.campaignId, 'random-tables', t.id!]), + error: (e) => this.fail(e) + }); + } + + private done(route: string[]): void { + this.status = 'created'; + this.createdRoute = route; + this.created.emit(); + } + + private fail(err: unknown): void { + this.status = 'error'; + this.errorMsg = (err as { error?: { message?: string } })?.error?.message || 'Échec de la création.'; + } + + openCreated(): void { + if (this.createdRoute) this.router.navigate(this.createdRoute); + } +} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html new file mode 100644 index 0000000..8fdf44b --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html @@ -0,0 +1,98 @@ +
+
+ + +
+ +
+ + + + +
+
+

+ Pose une question sur ta source, ou demande une adaptation pour ta campagne. +
(Ajoute d'abord une source indexée pour des réponses ancrées.)
+

+
+
+ + {{ m.role === 'user' ? 'Vous' : 'IA' }} +
+ + + +
+ + Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} +
+
{{ p.text }}
+ + +
+
+ +
{{ m.content }}
+
+
+
+ +
+ + + +
+
+
+
diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss new file mode 100644 index 0000000..69dcb09 --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss @@ -0,0 +1,106 @@ +.nbd-page { max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 2rem; height: calc(100vh - 60px); display: flex; flex-direction: column; } + +.nbd-toolbar { + display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; + .btn-back { + display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0; + padding: 0.4rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } + } + .nbd-title { + flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 1.15rem; font-weight: 600; + border: 1px solid transparent; background: transparent; color: inherit; + &:hover, &:focus { border-color: rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); outline: none; } + } +} + +.nbd-grid { + flex: 1; min-height: 0; + display: grid; grid-template-columns: 280px 1fr; gap: 1rem; +} + +/* Sources */ +.nbd-sources { + display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto; + border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 0.75rem; +} +.nbd-sources-head { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; + h3 { display: flex; align-items: center; gap: 0.4rem; margin: 0; font-size: 0.95rem; } +} +.btn-upload { + display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; + padding: 0.3rem 0.55rem; border-radius: 6px; font-size: 0.78rem; + border: 1px solid rgba(102,126,234,0.4); color: #8ea2ff; background: rgba(102,126,234,0.1); + &:hover { background: rgba(102,126,234,0.2); } + &.disabled { opacity: 0.6; cursor: default; } +} +.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; } + +.nbd-source { + display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; + border-radius: 7px; background: rgba(255,255,255,0.03); + lucide-icon.ok { color: #6bd08a; } + lucide-icon.fail { color: #e88; } + lucide-icon.busy { color: #e0c074; } + .nbd-source-info { flex: 1; min-width: 0; } + .nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; } + .nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); } + .nbd-source-del { + border: none; background: none; color: #e88; cursor: pointer; padding: 0.15rem; + border-radius: 4px; &:hover { background: rgba(224,90,90,0.15); } + } +} + +/* Chat */ +.nbd-chat { + display: flex; flex-direction: column; min-height: 0; + border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; +} +.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; } +.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.nbd-msg { + max-width: 88%; + .nbd-msg-role { + display: flex; align-items: center; gap: 0.3rem; + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem; + } + .nbd-msg-content { white-space: pre-wrap; line-height: 1.5; } + &.user { align-self: flex-end; text-align: right; + .nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; } + } + &.assistant { align-self: flex-start; + .nbd-msg-content { background: rgba(255,255,255,0.04); padding: 0.5rem 0.75rem; border-radius: 10px; } + } +} +.cursor { opacity: 0.6; } + +.nbd-input { + display: flex; gap: 0.5rem; padding: 0.6rem; border-top: 1px solid rgba(255,255,255,0.08); + textarea { + flex: 1; resize: none; padding: 0.55rem 0.7rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); color: inherit; font: inherit; + } + .btn-send { + display: inline-flex; align-items: center; justify-content: center; width: 44px; + border: none; border-radius: 8px; background: #667eea; color: #fff; cursor: pointer; + &:hover:not(:disabled) { background: #5568d3; } + &:disabled { opacity: 0.5; cursor: default; } + } + .btn-deep { + display: inline-flex; align-items: center; justify-content: center; width: 44px; + border: 1px solid rgba(168,130,255,0.4); border-radius: 8px; + background: rgba(168,130,255,0.12); color: #c4a8ff; cursor: pointer; + &:hover:not(:disabled) { background: rgba(168,130,255,0.22); } + &:disabled { opacity: 0.5; cursor: default; } + } +} + +.nbd-deep-progress { + display: inline-flex; align-items: center; gap: 0.35rem; + font-size: 0.82rem; color: #c4a8ff; font-style: italic; +} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts new file mode 100644 index 0000000..6ab9d0e --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -0,0 +1,187 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular'; +import { NotebookService } from '../../../services/notebook.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { CampaignService } from '../../../services/campaign.service'; +import { CharacterService } from '../../../services/character.service'; +import { NpcService } from '../../../services/npc.service'; +import { NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; +import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model'; +import { Arc, Chapter } from '../../../services/campaign.model'; +import { loadCampaignTreeData } from '../../campaign-tree.helper'; +import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component'; + +/** + * Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite). + * Route : /campaigns/:campaignId/notebooks/:notebookId + */ +@Component({ + selector: 'app-notebook-detail', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent], + templateUrl: './notebook-detail.component.html', + styleUrls: ['./notebook-detail.component.scss'] +}) +export class NotebookDetailComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Upload = Upload; + readonly Trash2 = Trash2; + readonly Send = Send; + readonly FileText = FileText; + readonly Loader = Loader; + readonly CheckCircle2 = CheckCircle2; + readonly AlertCircle = AlertCircle; + readonly Sparkles = Sparkles; + readonly Layers = Layers; + + campaignId = ''; + notebookId = ''; + detail: NotebookDetail | null = null; + sources: NotebookSource[] = []; + messages: NotebookMessage[] = []; + + uploading = false; + uploadError = ''; + sending = false; + draft = ''; + /** Avancement de l'analyse approfondie (lecture du doc par lots). */ + deepProgress: { current: number; total: number } | null = null; + + // Arbre de la campagne — sert aux cartes d'action (cibles arc/chapitre). + arcs: Arc[] = []; + chaptersByArc: Record = {}; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: NotebookService, + private campaignSidebar: CampaignSidebarService, + private campaignService: CampaignService, + private characterService: CharacterService, + private npcService: NpcService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + this.notebookId = this.route.snapshot.paramMap.get('notebookId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.loadTree(); + } + this.load(); + } + + private loadTree(): void { + loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + .subscribe({ + next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; }, + error: () => { /* cibles indisponibles : les cartes le signaleront */ } + }); + } + + /** Après création depuis une carte : rafraîchit la sidebar (l'élément apparaît) + * et l'arbre des cibles (pour les cartes suivantes). */ + onActionCreated(): void { + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.loadTree(); + } + } + + /** Sépare le texte affiché des blocs d'action — MÉMORISÉ par message : renvoie + * une référence STABLE tant que le contenu n'a pas changé. Indispensable : + * appeler le parseur directement dans le template recréait le DOM à chaque + * détection de changement (au mousedown) → les clics sur les cartes étaient + * perdus. */ + parsedOf(m: NotebookMessage): { text: string; actions: NotebookAction[] } { + const cache = m as unknown as { + _parsedFor?: string; + _parsed?: { text: string; actions: NotebookAction[] }; + }; + if (cache._parsedFor !== m.content || !cache._parsed) { + cache._parsed = parseNotebookActions(m.content); + cache._parsedFor = m.content; + } + return cache._parsed; + } + + /** trackBy stable pour les cartes d'action (évite toute recréation parasite). */ + trackAction(index: number): number { return index; } + + load(): void { + this.service.get(this.notebookId).subscribe({ + next: (d) => { + this.detail = d; + this.sources = d.sources ?? []; + this.messages = d.messages ?? []; + }, + error: () => this.back() + }); + } + + reloadSources(): void { + this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] }); + } + + // --- Sources --- + + onFile(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) return; + this.uploading = true; + this.uploadError = ''; + this.service.addSource(this.notebookId, file).subscribe({ + next: () => { this.uploading = false; this.reloadSources(); }, + error: (err) => { + this.uploading = false; + this.uploadError = err?.error?.message || 'Échec de l\'indexation. Réessaie ou vérifie le modèle d\'embedding.'; + this.reloadSources(); + } + }); + } + + removeSource(s: NotebookSource): void { + this.service.deleteSource(s.id).subscribe(() => this.reloadSources()); + } + + // --- Chat --- + + send(deep = false): void { + const text = this.draft.trim(); + if (!text || this.sending) return; + this.draft = ''; + this.deepProgress = null; + this.messages.push({ role: 'user', content: text }); + const assistant: NotebookMessage = { role: 'assistant', content: '' }; + this.messages.push(assistant); + this.sending = true; + + this.service.streamChat(this.notebookId, text, deep).subscribe({ + next: (ev) => { + if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } + else if (ev.type === 'progress') this.deepProgress = { current: ev.current, total: ev.total }; + else if (ev.type === 'error') assistant.content += (assistant.content ? '\n\n' : '') + `⚠️ ${ev.message}`; + }, + complete: () => { this.sending = false; this.deepProgress = null; }, + error: () => { this.sending = false; this.deepProgress = null; } + }); + } + + rename(): void { + if (!this.detail || !this.detail.name.trim()) return; + this.service.rename(this.notebookId, this.detail.name.trim()).subscribe(); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId, 'notebooks']); + } + + hasReadySource(): boolean { + return this.sources.some(s => s.status === 'READY'); + } +} diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html new file mode 100644 index 0000000..9a50193 --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.html @@ -0,0 +1,35 @@ +
+
+ +
+ +
+

Ateliers d'adaptation

+

+ Discute avec l'IA à partir d'un PDF source (recherche dans le document) pour adapter + son contenu à ta campagne. La source et la conversation sont conservées. +

+
+ +
+ + +
+ +
+

Aucun atelier pour l'instant.

+ +
+
diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss new file mode 100644 index 0000000..f1e81bd --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.scss @@ -0,0 +1,49 @@ +.nbl-page { max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 3rem; } + +.nbl-toolbar { margin-bottom: 1rem; } +.btn-back { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.4rem 0.7rem; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); + color: inherit; cursor: pointer; font-size: 0.85rem; + &:hover { background: rgba(255,255,255,0.09); } +} + +.nbl-header { + margin-bottom: 1.25rem; + h1 { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: 1.5rem; } + .nbl-hint { margin: 0; color: var(--color-text-muted, #9aa0aa); font-size: 0.9rem; } +} + +.nbl-create { + display: flex; gap: 0.5rem; margin-bottom: 1.5rem; + input { + flex: 1; padding: 0.55rem 0.75rem; border-radius: 7px; + border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.04); + color: inherit; font: inherit; + } + .btn-create { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.55rem 1rem; border: none; border-radius: 7px; + background: #667eea; color: #fff; font-weight: 600; cursor: pointer; + &:hover:not(:disabled) { background: #5568d3; } + &:disabled { opacity: 0.55; cursor: default; } + } +} + +.nbl-list { display: flex; flex-direction: column; gap: 0.4rem; } +.empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.nbl-item { + display: flex; align-items: center; gap: 0.55rem; + padding: 0.7rem 0.85rem; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); + color: inherit; cursor: pointer; text-align: left; + &:hover { background: rgba(255,255,255,0.07); } + + .nbl-item-name { flex: 1; font-weight: 500; } + .nbl-del { + display: inline-flex; padding: 0.25rem; border-radius: 5px; color: #e88; + &:hover { background: rgba(224,90,90,0.15); } + } +} diff --git a/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts new file mode 100644 index 0000000..a01582b --- /dev/null +++ b/web/src/app/campaigns/notebook/notebook-list/notebook-list.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular'; +import { NotebookService } from '../../../services/notebook.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { Notebook } from '../../../services/notebook.model'; +import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Liste des ateliers (notebooks) d'une campagne + création. + * Route : /campaigns/:campaignId/notebooks + */ +@Component({ + selector: 'app-notebook-list', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './notebook-list.component.html', + styleUrls: ['./notebook-list.component.scss'] +}) +export class NotebookListComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly BookOpen = BookOpen; + + campaignId = ''; + notebooks: Notebook[] = []; + newName = ''; + creating = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: NotebookService, + private campaignSidebar: CampaignSidebarService, + private confirmDialog: ConfirmDialogService + ) {} + + ngOnInit(): void { + this.campaignId = this.route.snapshot.paramMap.get('campaignId') ?? ''; + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + this.load(); + } + } + + load(): void { + this.service.listByCampaign(this.campaignId).subscribe({ + next: (list) => this.notebooks = list, + error: () => this.notebooks = [] + }); + } + + create(): void { + if (this.creating) return; + this.creating = true; + this.service.create(this.campaignId, this.newName.trim() || 'Nouvel atelier').subscribe({ + next: (nb) => this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]), + error: () => this.creating = false + }); + } + + open(nb: Notebook): void { + this.router.navigate(['/campaigns', this.campaignId, 'notebooks', nb.id]); + } + + remove(nb: Notebook, ev: Event): void { + ev.stopPropagation(); + this.confirmDialog.confirm({ + title: 'Supprimer l\'atelier', + message: `Supprimer « ${nb.name} » et ses sources indexées ?`, + confirmLabel: 'Supprimer', + variant: 'danger' + }).then(ok => { + if (!ok) return; + this.service.delete(nb.id).subscribe(() => this.load()); + }); + } + + back(): void { + this.router.navigate(['/campaigns', this.campaignId]); + } +} diff --git a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts index 500471c..0d78a67 100644 --- a/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-detail/playthrough-detail.component.ts @@ -7,6 +7,7 @@ import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil, Plus import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { PlaythroughService } from '../../../services/playthrough.service'; import { SessionService } from '../../../services/session.service'; import { LayoutService } from '../../../services/layout.service'; @@ -55,6 +56,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private playthroughService: PlaythroughService, private sessionService: SessionService, private layoutService: LayoutService, @@ -78,7 +80,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService), playthrough: this.playthroughService.getById(this.playthroughId), sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), diff --git a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts index 1901d8f..b9e7910 100644 --- a/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts +++ b/web/src/app/campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component.ts @@ -6,6 +6,7 @@ import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { PlaythroughService } from '../../../services/playthrough.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -37,6 +38,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private playthroughService: PlaythroughService, private layoutService: LayoutService, private pageTitleService: PageTitleService @@ -58,7 +60,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService), + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService), playthrough: this.playthroughService.getById(this.playthroughId) }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { this.playthrough = playthrough; diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html new file mode 100644 index 0000000..95adbce --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.html @@ -0,0 +1,85 @@ +
+
+ + + +
+ +

{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}

+ +
{{ errorMessage }}
+ +
+ + +
+ +
+ + +
+ +
+ + + + {{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }} + +
+ + +
+
+ Générer avec l'IA +
+

Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.

+ +
+ + {{ aiError }} +
+
+ +
+

Entrées

+
+ + +
+
+ +
+ Min + Max + Résultat + Détail + +
+ +
+ + + + + +
+ +

Aucune entrée — clique « Ajouter ».

+
diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss new file mode 100644 index 0000000..922f5c9 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.scss @@ -0,0 +1,172 @@ +.rte-page { + max-width: 900px; + margin: 0 auto; + padding: 1rem 1.5rem 3rem; +} + +.rte-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + + .spacer { flex: 1; } + + button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.8rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font-size: 0.85rem; + &:hover { background: rgba(255, 255, 255, 0.09); } + &:disabled { opacity: 0.5; cursor: default; } + } + .btn-save { background: #667eea; border-color: #667eea; color: #fff; } + .btn-save:hover:not(:disabled) { background: #5568d3; } +} + +h1 { font-size: 1.4rem; margin: 0 0 1rem; } +h2 { font-size: 1rem; margin: 0; } + +.error { + padding: 0.6rem 0.9rem; + border-radius: 6px; + background: rgba(224, 90, 90, 0.12); + border: 1px solid rgba(224, 90, 90, 0.4); + color: #e88; + margin-bottom: 1rem; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 1rem; + + label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); } + + input, textarea { + padding: 0.5rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + font: inherit; + &.invalid { border-color: rgba(224, 90, 90, 0.6); } + } + + .hint { font-size: 0.75rem; color: var(--color-text-muted, #9aa0aa); } + .hint.bad { color: #e88; } +} + +.entries-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 1.5rem 0 0.5rem; + + .entries-actions { display: flex; gap: 0.4rem; } + .btn-mini { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.6rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font-size: 0.8rem; + &:hover { background: rgba(255, 255, 255, 0.09); } + } +} + +.entry-row { + display: grid; + grid-template-columns: 70px 70px 1.2fr 1.6fr 36px; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.4rem; + + &.head { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); + margin-bottom: 0.3rem; + } + + input { + padding: 0.4rem 0.55rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + font: inherit; + width: 100%; + } + + .btn-del { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem; + border-radius: 6px; + border: 1px solid rgba(224, 90, 90, 0.3); + background: rgba(224, 90, 90, 0.08); + color: #e88; + cursor: pointer; + &:hover { background: rgba(224, 90, 90, 0.18); } + } +} + +.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; } + +.ai-box { + margin: 1.25rem 0 0.5rem; + padding: 0.85rem 1rem; + border-radius: 10px; + background: rgba(168, 130, 255, 0.08); + border: 1px solid rgba(168, 130, 255, 0.28); + + .ai-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: #c4a8ff; + } + .ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); } + + textarea { + width: 100%; + padding: 0.5rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: inherit; + font: inherit; + } + + .ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; } + .btn-ai { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.9rem; + border: none; + border-radius: 7px; + background: #8a6dff; + color: #fff; + font-weight: 600; + cursor: pointer; + &:hover:not(:disabled) { background: #7a5cf0; } + &:disabled { opacity: 0.55; cursor: default; } + } + .ai-error { color: #e88; font-size: 0.82rem; } +} diff --git a/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts new file mode 100644 index 0000000..9c8b275 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-edit/random-table-edit.component.ts @@ -0,0 +1,185 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular'; +import { RandomTableService } from '../../../services/random-table.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { RandomTable, RandomTableEntry, RandomTableCreate } from '../../../services/random-table.model'; +import { DiceUtils } from '../../../shared/dice.utils'; + +/** + * Création/édition d'une table aléatoire (formule de dé + entrées par plage). + * Routes : /campaigns/:campaignId/random-tables/create + * /campaigns/:campaignId/random-tables/:tableId/edit + */ +@Component({ + selector: 'app-random-table-edit', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './random-table-edit.component.html', + styleUrls: ['./random-table-edit.component.scss'] +}) +export class RandomTableEditComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Save = Save; + readonly Plus = Plus; + readonly Trash2 = Trash2; + readonly Wand2 = Wand2; + readonly Sparkles = Sparkles; + + campaignId: string | null = null; + tableId: string | null = null; + + name = ''; + description = ''; + diceFormula = '1d20'; + entries: RandomTableEntry[] = []; + + saving = false; + errorMessage = ''; + + // --- Génération IA --- + aiPrompt = ''; + generating = false; + aiError = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: RandomTableService, + private campaignSidebar: CampaignSidebarService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.tableId = params.get('tableId'); + if (this.campaignId) this.campaignSidebar.show(this.campaignId); + + if (this.tableId) { + this.service.getById(this.tableId).subscribe({ + next: (t: RandomTable) => { + this.name = t.name; + this.description = t.description ?? ''; + this.diceFormula = t.diceFormula; + this.entries = t.entries.map(e => ({ ...e })); + }, + error: () => this.back() + }); + } else { + // Démarre avec une entrée vide pour guider. + this.addEntry(); + } + } + + get formulaValid(): boolean { + return DiceUtils.parse(this.diceFormula) !== null; + } + + addEntry(): void { + const range = DiceUtils.totalRange(this.diceFormula); + const next = this.entries.length ? this.entries[this.entries.length - 1].maxRoll + 1 : (range?.min ?? 1); + this.entries.push({ minRoll: next, maxRoll: next, label: '', detail: '' }); + } + + removeEntry(i: number): void { + this.entries.splice(i, 1); + } + + /** Répartit équitablement la plage du dé sur toutes les entrées. */ + autoRanges(): void { + const range = DiceUtils.totalRange(this.diceFormula); + if (!range || this.entries.length === 0) return; + const span = range.max - range.min + 1; + const n = this.entries.length; + const base = Math.floor(span / n); + let cursor = range.min; + this.entries.forEach((e, idx) => { + const size = idx === n - 1 ? (range.max - cursor + 1) : Math.max(1, base); + e.minRoll = cursor; + e.maxRoll = Math.min(range.max, cursor + size - 1); + cursor = e.maxRoll + 1; + }); + } + + /** Génère la table via l'IA et préremplit le formulaire (l'utilisateur révise avant d'enregistrer). */ + generateWithAI(): void { + if (!this.campaignId) return; + if (!this.aiPrompt.trim()) { this.aiError = 'Décris la table à générer.'; return; } + if (!this.formulaValid) { this.aiError = 'Choisis d\'abord une formule de dé valide.'; return; } + this.generating = true; + this.aiError = ''; + this.service.generate(this.campaignId, this.aiPrompt.trim(), this.diceFormula.trim()).subscribe({ + next: (t) => { + this.generating = false; + if (t.name && !this.name.trim()) this.name = t.name; + if (t.description) this.description = t.description; + this.entries = (t.entries ?? []).map(e => ({ ...e })); + }, + error: (err) => { + this.generating = false; + this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.'; + } + }); + } + + save(): void { + if (!this.campaignId) return; + if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; } + if (!this.formulaValid) { this.errorMessage = 'Formule de dé invalide (ex. 1d20, 2d6, d100).'; return; } + this.saving = true; + this.errorMessage = ''; + + const cleanEntries = this.entries + .filter(e => e.label.trim()) + .map(e => ({ + minRoll: e.minRoll, + maxRoll: Math.max(e.minRoll, e.maxRoll), + label: e.label.trim(), + detail: e.detail?.trim() || undefined + })); + + if (this.tableId) { + const payload: RandomTable = { + id: this.tableId, + name: this.name.trim(), + description: this.description.trim() || undefined, + diceFormula: this.diceFormula.trim(), + campaignId: this.campaignId, + entries: cleanEntries + }; + this.service.update(this.tableId, payload).subscribe({ + next: () => this.goToView(this.tableId!), + error: (e) => this.fail(e) + }); + } else { + const payload: RandomTableCreate = { + name: this.name.trim(), + description: this.description.trim() || undefined, + diceFormula: this.diceFormula.trim(), + campaignId: this.campaignId, + entries: cleanEntries + }; + this.service.create(payload).subscribe({ + next: (t) => this.goToView(t.id!), + error: (e) => this.fail(e) + }); + } + } + + private goToView(id: string): void { + this.saving = false; + this.router.navigate(['/campaigns', this.campaignId, 'random-tables', id]); + } + + private fail(err: unknown): void { + this.saving = false; + this.errorMessage = 'Échec de l\'enregistrement.'; + console.error('RandomTable save failed', err); + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html new file mode 100644 index 0000000..4a5ea63 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.html @@ -0,0 +1,57 @@ +
+
+ + + +
+ +
+

{{ table.name }}

+

{{ table.description }}

+ Dé : {{ table.diceFormula }} +
+ + +
+ +
+ {{ lastRoll.total }} + ({{ lastRoll.rolls.join(' + ') }}) + + {{ matched.label }} + Aucune entrée pour ce résultat +
+
+ +
{{ matched?.detail }}
+ + +
+
+ Aucune entrée — édite la table pour en ajouter. +
+ + + + + + + + + + +
JetRésultat
{{ rangeLabel(e) }} + +
{{ e.detail }}
+
+
+
diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss new file mode 100644 index 0000000..d91a554 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.scss @@ -0,0 +1,133 @@ +.rt-page { + max-width: 860px; + margin: 0 auto; + padding: 1rem 1.5rem 3rem; +} + +.rt-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + + .spacer { flex: 1; } + + button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.7rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font-size: 0.85rem; + + &:hover { background: rgba(255, 255, 255, 0.09); } + } +} + +.rt-header { + margin-bottom: 1.25rem; + + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.35rem; + font-size: 1.5rem; + } + + .rt-desc { margin: 0 0 0.4rem; color: var(--color-text-muted, #9aa0aa); } + .rt-formula code { + background: rgba(255, 255, 255, 0.08); + padding: 0.1rem 0.4rem; + border-radius: 4px; + } +} + +.rt-roll { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 1rem; + border-radius: 10px; + background: rgba(102, 126, 234, 0.08); + border: 1px solid rgba(102, 126, 234, 0.25); + margin-bottom: 1rem; + + .btn-roll { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.1rem; + border: none; + border-radius: 8px; + background: #667eea; + color: #fff; + font-weight: 600; + cursor: pointer; + + &:hover { background: #5568d3; } + } + + .rt-result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 1.05rem; + } + .rt-total { font-weight: 700; font-size: 1.3rem; color: #8ea2ff; } + .rt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; } + .rt-matched { font-weight: 600; } + .rt-nomatch { color: #e0a458; font-style: italic; } +} + +.rt-detail { + white-space: pre-wrap; + padding: 0.85rem 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border-left: 3px solid #667eea; +} + +.rt-entries { + .rt-empty { + color: var(--color-text-muted, #9aa0aa); + font-style: italic; + padding: 1rem 0; + } + + table { width: 100%; border-collapse: collapse; } + + th, td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + vertical-align: top; + } + th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted, #9aa0aa); + } + .col-range { width: 84px; font-variant-numeric: tabular-nums; white-space: nowrap; } + + .entry-label { font-weight: 500; } + .entry-detail { + margin-top: 0.2rem; + font-size: 0.85rem; + color: var(--color-text-muted, #9aa0aa); + white-space: pre-wrap; + } + + tr.matched { + background: rgba(102, 126, 234, 0.16); + td { border-bottom-color: rgba(102, 126, 234, 0.3); } + } +} diff --git a/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts new file mode 100644 index 0000000..c71f8c9 --- /dev/null +++ b/web/src/app/campaigns/random-table/random-table-view/random-table-view.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular'; +import { RandomTableService } from '../../../services/random-table.service'; +import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; +import { RandomTable, RandomTableEntry } from '../../../services/random-table.model'; +import { DiceUtils, DiceRoll } from '../../../shared/dice.utils'; + +/** + * Vue d'une table aléatoire : affiche les entrées et permet de LANCER le dé + * (jet côté client) → surligne l'entrée tombée + montre son détail. + * Route : /campaigns/:campaignId/random-tables/:tableId + */ +@Component({ + selector: 'app-random-table-view', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './random-table-view.component.html', + styleUrls: ['./random-table-view.component.scss'] +}) +export class RandomTableViewComponent implements OnInit { + readonly ArrowLeft = ArrowLeft; + readonly Edit3 = Edit3; + readonly Dices = Dices; + + campaignId: string | null = null; + tableId: string | null = null; + table: RandomTable | null = null; + + lastRoll: DiceRoll | null = null; + matched: RandomTableEntry | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private service: RandomTableService, + private campaignSidebar: CampaignSidebarService + ) {} + + ngOnInit(): void { + const params = this.route.snapshot.paramMap; + this.campaignId = params.get('campaignId'); + this.tableId = params.get('tableId'); + if (this.tableId) { + this.service.getById(this.tableId).subscribe({ + next: t => this.table = t, + error: () => this.back() + }); + } + if (this.campaignId) { + this.campaignSidebar.show(this.campaignId); + } + } + + roll(): void { + if (!this.table) return; + const result = DiceUtils.roll(this.table.diceFormula); + if (!result) { this.lastRoll = null; this.matched = null; return; } + this.lastRoll = result; + this.matched = this.table.entries.find( + e => result.total >= e.minRoll && result.total <= e.maxRoll + ) ?? null; + } + + isMatched(entry: RandomTableEntry): boolean { + return this.matched === entry; + } + + rangeLabel(entry: RandomTableEntry): string { + return entry.minRoll === entry.maxRoll + ? String(entry.minRoll) + : `${entry.minRoll}–${entry.maxRoll}`; + } + + edit(): void { + if (this.campaignId && this.tableId) { + this.router.navigate(['/campaigns', this.campaignId, 'random-tables', this.tableId, 'edit']); + } + } + + back(): void { + this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']); + } +} diff --git a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts index 78aa368..ad31340 100644 --- a/web/src/app/campaigns/scene/scene-create/scene-create.component.ts +++ b/web/src/app/campaigns/scene/scene-create/scene-create.component.ts @@ -7,6 +7,7 @@ import { LucideAngularModule } from 'lucide-angular'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { LayoutService } from '../../../services/layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; @@ -41,6 +42,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private layoutService: LayoutService ) { this.form = this.fb.group({ @@ -60,7 +62,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy { forkJoin({ campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) }).subscribe(({ campaign, allCampaigns, treeData }) => { const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId); this.chapterName = currentChapter?.name ?? ''; diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index 60be4ec..19d6c2f 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -8,6 +8,7 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -80,6 +81,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -132,7 +134,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { allCampaigns: this.campaignService.getAllCampaigns(), scene: this.campaignService.getSceneById(this.sceneId), chapterScenes: this.campaignService.getScenes(this.chapterId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts index 6fccbeb..295fc14 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.ts +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.ts @@ -8,6 +8,7 @@ import { resolveCampaignIcon } from '../../campaign-icons'; import { CampaignService } from '../../../services/campaign.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; +import { RandomTableService } from '../../../services/random-table.service'; import { PageService } from '../../../services/page.service'; import { LayoutService } from '../../../services/layout.service'; import { PageTitleService } from '../../../services/page-title.service'; @@ -48,6 +49,7 @@ export class SceneViewComponent implements OnInit, OnDestroy { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private pageService: PageService, private layoutService: LayoutService, private pageTitleService: PageTitleService, @@ -78,7 +80,7 @@ export class SceneViewComponent implements OnInit, OnDestroy { campaign: this.campaignService.getCampaignById(this.campaignId), allCampaigns: this.campaignService.getAllCampaigns(), scene: this.campaignService.getSceneById(this.sceneId), - treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService) + treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) }).pipe( switchMap(data => { const lid = data.campaign.loreId ?? null; diff --git a/web/src/app/lore/lore-icons.ts b/web/src/app/lore/lore-icons.ts index 3096a7f..29f5654 100644 --- a/web/src/app/lore/lore-icons.ts +++ b/web/src/app/lore/lore-icons.ts @@ -2,7 +2,7 @@ import { Folder, Users, Swords, MapPin, Shield, Crown, Skull, Gem, BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain, - Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData + Ship, Flame, Star, Moon, Key, Globe, Compass, Dices, FileUp, LucideIconData } from 'lucide-angular'; import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons'; @@ -42,6 +42,8 @@ export const LORE_ICON_OPTIONS: IconOption[] = [ { key: 'key', icon: Key }, { key: 'globe', icon: Globe }, { key: 'compass', icon: Compass }, + { key: 'dice', icon: Dices }, + { key: 'file-up', icon: FileUp }, ]; /** Icône par défaut pour un dossier sans icône. */ diff --git a/web/src/app/services/campaign-adapt.service.ts b/web/src/app/services/campaign-adapt.service.ts deleted file mode 100644 index f520629..0000000 --- a/web/src/app/services/campaign-adapt.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; - -/** Évènements du flux SSE de conseils d'adaptation. */ -export type AdaptStreamEvent = - | { type: 'token'; value: string } - | { type: 'done' } - | { type: 'error'; message: string }; - -/** Message de la conversation d'adaptation. */ -export interface AdaptMessage { - role: 'user' | 'assistant'; - content: string; -} - -/** - * Service : adaptation conversationnelle d'un PDF à une campagne (streamée). - * fetch() + SSE (POST multipart impossible avec EventSource), décodage ligne à ligne. - */ -@Injectable({ providedIn: 'root' }) -export class CampaignAdaptService { - - adviseStream(campaignId: string, file: File, messages: AdaptMessage[]): Observable { - return new Observable((subscriber) => { - const controller = new AbortController(); - const form = new FormData(); - form.append('file', file); - form.append('messages', JSON.stringify(messages ?? [])); - - fetch(`/api/campaigns/${campaignId}/adapt-pdf/stream`, { - method: 'POST', - headers: { 'Accept': 'text/event-stream' }, - body: form, - signal: controller.signal - }) - .then(async (response) => { - if (!response.ok || !response.body) { - subscriber.error(new Error(`HTTP ${response.status}`)); - return; - } - await this.consume(response.body, subscriber); - }) - .catch((err) => { - if (controller.signal.aborted) return; - subscriber.error(err); - }); - - return () => controller.abort(); - }); - } - - private async consume( - body: ReadableStream, - subscriber: { next: (e: AdaptStreamEvent) => void; error: (e: unknown) => void; complete: () => void } - ): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let currentEvent: string | null = null; - let currentData = ''; - - const dispatch = () => { - const name = currentEvent ?? 'message'; - if (name === 'error') { - let message = "Échec de l'adaptation."; - try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } - subscriber.error(new Error(message)); - } else if (name === 'done') { - subscriber.next({ type: 'done' }); - subscriber.complete(); - } else if (name === 'token') { - try { - const tok = (JSON.parse(currentData) as { token?: string }).token; - if (tok) subscriber.next({ type: 'token', value: tok }); - } catch { /* fragment ignoré */ } - } - currentEvent = null; - currentData = ''; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx: number; - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).replace(/\r$/, ''); - buffer = buffer.slice(idx + 1); - if (line === '') { - if (currentEvent !== null || currentData !== '') dispatch(); - continue; - } - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const chunk = line.slice(5).replace(/^ /, ''); - currentData = currentData ? `${currentData}\n${chunk}` : chunk; - } - } - } - if (currentEvent !== null || currentData !== '') dispatch(); - subscriber.complete(); - } catch (err) { - subscriber.error(err); - } - } -} diff --git a/web/src/app/services/campaign-sidebar.service.ts b/web/src/app/services/campaign-sidebar.service.ts index be9f887..e3cd751 100644 --- a/web/src/app/services/campaign-sidebar.service.ts +++ b/web/src/app/services/campaign-sidebar.service.ts @@ -3,6 +3,7 @@ import { forkJoin, Subscription } from 'rxjs'; import { CampaignService } from './campaign.service'; import { CharacterService } from './character.service'; import { NpcService } from './npc.service'; +import { RandomTableService } from './random-table.service'; import { LayoutService } from './layout.service'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper'; @@ -26,6 +27,7 @@ export class CampaignSidebarService { private campaignService: CampaignService, private characterService: CharacterService, private npcService: NpcService, + private randomTableService: RandomTableService, private layoutService: LayoutService ) {} @@ -42,7 +44,8 @@ export class CampaignSidebarService { this.campaignService, campaignId, this.characterService, - this.npcService + this.npcService, + this.randomTableService ) }).subscribe(({ campaign, allCampaigns, treeData }) => { this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId)); diff --git a/web/src/app/services/notebook-action.model.ts b/web/src/app/services/notebook-action.model.ts new file mode 100644 index 0000000..cbc6115 --- /dev/null +++ b/web/src/app/services/notebook-action.model.ts @@ -0,0 +1,49 @@ +/** + * Protocole d'« actions » proposées par l'IA dans le chat d'un atelier : des blocs + * ```loremind-action {json} ``` que le front transforme en cartes « Créer dans la + * campagne ». Ce module définit les types + le parseur (extraction + texte nettoyé). + */ + +export interface NotebookActionEntry { + minRoll: number; + maxRoll: number; + label: string; + detail?: string; +} + +export interface NotebookAction { + type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table'; + name: string; + description?: string; + content?: string; + arcType?: 'LINEAR' | 'HUB'; + diceFormula?: string; + entries?: NotebookActionEntry[]; +} + +const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g; +const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']); + +/** + * Extrait les blocs d'action COMPLETS d'un message et renvoie le texte sans ces + * blocs. Les blocs encore en cours de streaming (clôture absente) ne matchent pas + * → ils restent affichés tels quels brièvement, puis deviennent une carte. + */ +export function parseNotebookActions(content: string): { text: string; actions: NotebookAction[] } { + const actions: NotebookAction[] = []; + if (!content) return { text: '', actions }; + let match: RegExpExecArray | null; + ACTION_RE.lastIndex = 0; + while ((match = ACTION_RE.exec(content)) !== null) { + try { + const obj = JSON.parse(match[1].trim()); + if (obj && typeof obj.type === 'string' && VALID_TYPES.has(obj.type) && typeof obj.name === 'string') { + actions.push(obj as NotebookAction); + } + } catch { + /* bloc JSON invalide : ignoré */ + } + } + const text = content.replace(ACTION_RE, '').trim(); + return { text, actions }; +} diff --git a/web/src/app/services/notebook.model.ts b/web/src/app/services/notebook.model.ts new file mode 100644 index 0000000..e909600 --- /dev/null +++ b/web/src/app/services/notebook.model.ts @@ -0,0 +1,40 @@ +/** Reflet des entités notebook côté Core (atelier RAG). */ + +export interface Notebook { + id: string; + name: string; + campaignId: string; +} + +export interface NotebookSource { + id: string; + notebookId: string; + filename: string; + /** INDEXING | READY | FAILED */ + status: string; + chunkCount: number; + pageCount: number; +} + +export interface NotebookMessage { + id?: string; + notebookId?: string; + /** "user" | "assistant" */ + role: string; + content: string; +} + +export interface NotebookDetail { + id: string; + name: string; + campaignId: string; + sources: NotebookSource[]; + messages: NotebookMessage[]; +} + +/** Évènements du chat ancré streamé. */ +export type NotebookChatEvent = + | { type: 'token'; value: string } + | { type: 'progress'; current: number; total: number } + | { type: 'done' } + | { type: 'error'; message: string }; diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts new file mode 100644 index 0000000..fbd0aac --- /dev/null +++ b/web/src/app/services/notebook.service.ts @@ -0,0 +1,126 @@ +import { Injectable, NgZone } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Notebook, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model'; + +/** + * Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources, + * et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service). + */ +@Injectable({ providedIn: 'root' }) +export class NotebookService { + private readonly apiUrl = '/api/notebooks'; + + constructor(private http: HttpClient, private zone: NgZone) {} + + listByCampaign(campaignId: string): Observable { + return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); + } + + get(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(campaignId: string, name: string): Observable { + return this.http.post(this.apiUrl, { campaignId, name }); + } + + rename(id: string, name: string): Observable { + return this.http.put(`${this.apiUrl}/${id}`, { name }); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + /** Upload + indexation d'une source (multipart). Bloquant côté serveur (peut être long). */ + addSource(notebookId: string, file: File): Observable { + const form = new FormData(); + form.append('file', file, file.name); + return this.http.post(`${this.apiUrl}/${notebookId}/sources`, form); + } + + deleteSource(sourceId: string): Observable { + return this.http.delete(`${this.apiUrl}/sources/${sourceId}`); + } + + /** + * Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE). + * Émissions forcées dans la zone Angular pour la détection de changement. + */ + streamChat(notebookId: string, message: string, deep = false): Observable { + return new Observable((subscriber) => { + const controller = new AbortController(); + const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev)); + + (async () => { + try { + const response = await fetch(`${this.apiUrl}/${notebookId}/chat/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, + credentials: 'include', + body: JSON.stringify({ message, deep }), + signal: controller.signal, + }); + if (!response.ok || !response.body) { + emit({ type: 'error', message: `HTTP ${response.status}` }); + this.zone.run(() => subscriber.complete()); + return; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let currentEvent: string | null = null; + let currentData = ''; + + const dispatch = () => { + const name = currentEvent ?? 'message'; + if (name === 'token') { + try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); } + catch { /* ignore */ } + } else if (name === 'progress') { + try { + const o = JSON.parse(currentData) as { current?: number; total?: number }; + emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 }); + } catch { /* ignore */ } + } else if (name === 'done') { + emit({ type: 'done' }); + } else if (name === 'error') { + let msg = 'Erreur du chat.'; + try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ } + emit({ type: 'error', message: msg }); + } + currentEvent = null; + currentData = ''; + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; } + if (line.startsWith('event:')) currentEvent = line.slice(6).trim(); + else if (line.startsWith('data:')) { + const chunk = line.slice(5).replace(/^ /, ''); + currentData = currentData ? `${currentData}\n${chunk}` : chunk; + } + } + } + if (currentEvent !== null || currentData !== '') dispatch(); + this.zone.run(() => subscriber.complete()); + } catch (err) { + if ((err as Error).name !== 'AbortError') { + emit({ type: 'error', message: (err as Error).message || 'Erreur réseau' }); + } + this.zone.run(() => subscriber.complete()); + } + })(); + + return () => controller.abort(); + }); + } +} diff --git a/web/src/app/services/random-table.model.ts b/web/src/app/services/random-table.model.ts new file mode 100644 index 0000000..e26d9b5 --- /dev/null +++ b/web/src/app/services/random-table.model.ts @@ -0,0 +1,34 @@ +/** + * Reflet du RandomTableDTO côté Core. Une table aléatoire campagne + ses entrées. + */ +export interface RandomTableEntry { + /** Borne basse du jet (incluse). */ + minRoll: number; + /** Borne haute du jet (incluse). */ + maxRoll: number; + /** Résultat court. */ + label: string; + /** Détail markdown (« ce que c'est »). */ + detail?: string; +} + +export interface RandomTable { + id?: string; + name: string; + description?: string; + /** Formule du dé : "1d20", "2d6", "d100"… */ + diceFormula: string; + icon?: string; + campaignId: string; + order?: number; + entries: RandomTableEntry[]; +} + +export interface RandomTableCreate { + name: string; + description?: string; + diceFormula: string; + icon?: string; + campaignId: string; + entries: RandomTableEntry[]; +} diff --git a/web/src/app/services/random-table.service.ts b/web/src/app/services/random-table.service.ts new file mode 100644 index 0000000..2791c30 --- /dev/null +++ b/web/src/app/services/random-table.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { RandomTable, RandomTableCreate } from './random-table.model'; + +/** + * CRUD des tables aléatoires (campagne). Mirroir de NpcService. + */ +@Injectable({ providedIn: 'root' }) +export class RandomTableService { + private apiUrl = '/api/random-tables'; + + constructor(private http: HttpClient) {} + + getByCampaign(campaignId: string): Observable { + return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + create(payload: RandomTableCreate): Observable { + return this.http.post(this.apiUrl, payload); + } + + update(id: string, payload: RandomTable): Observable { + return this.http.put(`${this.apiUrl}/${id}`, payload); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + /** Génère une PROPOSITION de table via l'IA (non persistée) à préremplir dans le formulaire. */ + generate(campaignId: string, description: string, diceFormula: string): Observable { + return this.http.post(`${this.apiUrl}/generate`, { campaignId, description, diceFormula }); + } + + /** Improvisation IA d'un court récit sur un résultat tiré (en partie). */ + improvise( + campaignId: string, tableName: string, resultLabel: string, resultDetail: string + ): Observable<{ narration: string }> { + return this.http.post<{ narration: string }>( + `${this.apiUrl}/improvise`, + { campaignId, tableName, resultLabel, resultDetail } + ); + } +} diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts index a79bda4..a404900 100644 --- a/web/src/app/services/settings.service.ts +++ b/web/src/app/services/settings.service.ts @@ -6,7 +6,7 @@ import { Observable } from 'rxjs'; * Reflet de SettingsDTO cote Brain / SettingsController cote Core. * `onemin_api_key_set` indique si une cle est configuree, sans la reveler. */ -export type LlmProvider = 'ollama' | 'onemin' | 'openrouter'; +export type LlmProvider = 'ollama' | 'onemin' | 'openrouter' | 'mistral' | 'gemini'; export interface AppSettings { llm_provider: LlmProvider; @@ -16,6 +16,17 @@ export interface AppSettings { onemin_api_key_set: boolean; openrouter_model: string; openrouter_api_key_set: boolean; + mistral_model: string; + mistral_api_key_set: boolean; + gemini_model: string; + gemini_api_key_set: boolean; + /** Embeddings (RAG des ateliers). */ + embedding_provider: 'ollama' | 'mistral'; + ollama_embedding_model: string; + mistral_embedding_model: string; + auto_pull_embedding_model: boolean; + /** Nombre d'extraits récupérés par question (RAG). */ + rag_top_k: number; llm_num_ctx: number; /** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */ import_chunk_tokens: number; @@ -35,6 +46,15 @@ export interface AppSettingsUpdate { onemin_api_key?: string; openrouter_model?: string; openrouter_api_key?: string; + mistral_model?: string; + mistral_api_key?: string; + gemini_model?: string; + gemini_api_key?: string; + embedding_provider?: 'ollama' | 'mistral'; + ollama_embedding_model?: string; + mistral_embedding_model?: string; + auto_pull_embedding_model?: boolean; + rag_top_k?: number; llm_num_ctx?: number; import_chunk_tokens?: number; llm_timeout_seconds?: number; @@ -83,6 +103,16 @@ export class SettingsService { return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions); } + /** Catalogue des modeles Mistral (dynamique si cle configuree, repli statique sinon). */ + listMistralModels(): Observable<{ models: MistralModel[] }> { + return this.http.get<{ models: MistralModel[] }>(`${this.apiUrl}/models/mistral`, this.authOptions); + } + + /** Catalogue des modeles Gemini (dynamique si cle configuree, repli statique sinon). */ + listGeminiModels(): Observable<{ models: GeminiModel[] }> { + return this.http.get<{ models: GeminiModel[] }>(`${this.apiUrl}/models/gemini`, this.authOptions); + } + /** * Telecharge un modele Ollama et streame la progression au client. * @@ -205,3 +235,13 @@ export interface OpenRouterModel { /** True si le modele est gratuit (id `:free` ou prix nul). */ free: boolean; } + +/** Un modele Mistral (catalogue dynamique ou repli statique). */ +export interface MistralModel { + id: string; +} + +/** Un modele Gemini (catalogue dynamique ou repli statique). */ +export interface GeminiModel { + id: string; +} diff --git a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html new file mode 100644 index 0000000..0142c48 --- /dev/null +++ b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.html @@ -0,0 +1,51 @@ +
+

Chargement…

+ + +
+

+ Aucune table aléatoire dans cette campagne. Crée-en une depuis la sidebar. +

+ +
+ + +
+ + +

{{ selected.name }}

+ + + +
+
+ {{ lastRoll.total }} + ({{ lastRoll.rolls.join(' + ') }}) + → + {{ matched.label }} + aucune entrée +
+
{{ matched?.detail }}
+ +
+ + +
+
+
+
diff --git a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.scss b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.scss new file mode 100644 index 0000000..8f5f605 --- /dev/null +++ b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.scss @@ -0,0 +1,108 @@ +.srt-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.25rem; +} + +.loading-hint, .empty-hint { + color: var(--color-text-muted, #9aa0aa); + font-size: 0.85rem; + font-style: italic; + padding: 0.5rem 0; +} + +.srt-list { display: flex; flex-direction: column; gap: 0.3rem; } + +.srt-item { + display: flex; + align-items: center; + gap: 0.45rem; + width: 100%; + padding: 0.45rem 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 7px; + background: rgba(255, 255, 255, 0.03); + color: inherit; + cursor: pointer; + text-align: left; + + &:hover { background: rgba(255, 255, 255, 0.08); } + + .srt-item-name { flex: 1; } + .srt-item-formula { + font-size: 0.72rem; + color: var(--color-text-muted, #9aa0aa); + background: rgba(255, 255, 255, 0.06); + padding: 0.05rem 0.35rem; + border-radius: 4px; + } +} + +.srt-detail { display: flex; flex-direction: column; gap: 0.6rem; } + +.srt-back { + display: inline-flex; + align-items: center; + gap: 0.25rem; + align-self: flex-start; + padding: 0.2rem 0.4rem; + border: none; + background: none; + color: var(--color-text-muted, #9aa0aa); + cursor: pointer; + font-size: 0.8rem; + &:hover { color: inherit; } +} + +h4 { margin: 0; font-size: 0.95rem; } + +.srt-roll { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.5rem 0.9rem; + border: none; + border-radius: 8px; + background: #667eea; + color: #fff; + font-weight: 600; + cursor: pointer; + &:hover { background: #5568d3; } +} + +.srt-result { + padding: 0.6rem 0.7rem; + border-radius: 8px; + background: rgba(102, 126, 234, 0.1); + border: 1px solid rgba(102, 126, 234, 0.25); + + .srt-total { font-size: 1rem; } + .srt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.8rem; } + .srt-detail-text { + margin-top: 0.4rem; + font-size: 0.85rem; + color: var(--color-text-muted, #cfd3da); + white-space: pre-wrap; + } +} + +.srt-actions { display: flex; gap: 0.4rem; margin-top: 0.6rem; } +.srt-act { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + cursor: pointer; + font-size: 0.8rem; + &:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); } + &:disabled { opacity: 0.5; cursor: default; } + + &--ai { border-color: rgba(168, 130, 255, 0.35); color: #c4a8ff; } + &--ai:hover:not(:disabled) { background: rgba(168, 130, 255, 0.14); } +} diff --git a/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts new file mode 100644 index 0000000..4329941 --- /dev/null +++ b/web/src/app/sessions/session-random-tables-panel/session-random-tables-panel.component.ts @@ -0,0 +1,110 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LucideAngularModule, Dices, BookmarkPlus, Sparkles, ChevronLeft } from 'lucide-angular'; +import { catchError, of } from 'rxjs'; +import { RandomTableService } from '../../services/random-table.service'; +import { RandomTable, RandomTableEntry } from '../../services/random-table.model'; +import { DiceUtils, DiceRoll } from '../../shared/dice.utils'; +import { DiceRollResult } from '../session-dice-panel/session-dice-panel.component'; + +/** + * Panneau "Tables aléatoires" du mode jeu : choisir une table de la campagne, + * la JETER, consigner le résultat au journal, et (option) demander à l'IA + * d'improviser un court récit sur le tirage. + * + * Réutilise les sorties existantes du panneau de référence : + * - `rolled` (DiceRollResult) → entrée DICE_ROLL dans le journal ; + * - `aiReplyToJournal` (string) → entrée NOTE (récit IA). + */ +@Component({ + selector: 'app-session-random-tables-panel', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './session-random-tables-panel.component.html', + styleUrls: ['./session-random-tables-panel.component.scss'] +}) +export class SessionRandomTablesPanelComponent implements OnInit { + readonly Dices = Dices; + readonly BookmarkPlus = BookmarkPlus; + readonly Sparkles = Sparkles; + readonly ChevronLeft = ChevronLeft; + + @Input() campaignId!: string; + @Input() canAddToJournal = true; + @Output() rolled = new EventEmitter(); + @Output() aiReplyToJournal = new EventEmitter(); + + tables: RandomTable[] = []; + loading = false; + + selected: RandomTable | null = null; + lastRoll: DiceRoll | null = null; + matched: RandomTableEntry | null = null; + improvising = false; + + constructor(private service: RandomTableService) {} + + ngOnInit(): void { + if (!this.campaignId) return; + this.loading = true; + this.service.getByCampaign(this.campaignId).pipe(catchError(() => of([] as RandomTable[]))) + .subscribe(list => { this.tables = list; this.loading = false; }); + } + + select(table: RandomTable): void { + this.selected = table; + this.lastRoll = null; + this.matched = null; + } + + backToList(): void { + this.selected = null; + this.lastRoll = null; + this.matched = null; + } + + roll(): void { + if (!this.selected) return; + const result = DiceUtils.roll(this.selected.diceFormula); + if (!result) { this.lastRoll = null; this.matched = null; return; } + this.lastRoll = result; + this.matched = this.selected.entries.find( + e => result.total >= e.minRoll && result.total <= e.maxRoll + ) ?? null; + } + + rangeLabel(entry: RandomTableEntry): string { + return entry.minRoll === entry.maxRoll ? String(entry.minRoll) : `${entry.minRoll}–${entry.maxRoll}`; + } + + /** Consigne le tirage au journal (entrée DICE_ROLL via la sortie `rolled`). */ + addToJournal(): void { + if (!this.canAddToJournal || !this.selected || !this.lastRoll) return; + const label = this.matched?.label ?? 'aucun résultat'; + const result: DiceRollResult = { + notation: this.selected.diceFormula, + rolls: this.lastRoll.rolls, + modifier: 0, + total: this.lastRoll.total, + summary: `🎲 ${this.selected.name} : ${this.lastRoll.total} → ${label}` + }; + this.rolled.emit(result); + } + + /** Demande à l'IA un court récit sur le résultat, puis l'envoie au journal (NOTE). */ + improvise(): void { + if (!this.selected || !this.matched || this.improvising) return; + this.improvising = true; + this.service.improvise( + this.campaignId, this.selected.name, this.matched.label, this.matched.detail ?? '' + ).subscribe({ + next: (r) => { + this.improvising = false; + if (r.narration?.trim() && this.canAddToJournal) { + this.aiReplyToJournal.emit(r.narration.trim()); + } + }, + error: () => { this.improvising = false; } + }); + } +} diff --git a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html index 6972544..0a09004 100644 --- a/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html +++ b/web/src/app/sessions/session-reference-panel/session-reference-panel.component.html @@ -15,6 +15,13 @@ Dés +