Compare commits

...

7 Commits

Author SHA1 Message Date
bc0cbb0f7b Correction sur le NotebookController....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 2m1s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m50s
2026-06-12 16:05:34 +02:00
6740ed2177 Mise en place de la sélection des source que l'on souhaite que ce soit la partie RAG ou la partie analyse approfondie : on est plus obligé d'envoyer tous les PDFs qu'on a dans la partie atelier PDF + IA.
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m41s
Build & Push Images / build (core) (push) Failing after 1m46s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m54s
Les réponses ne ce baseront que sur les sources que l'on aura cocher au préalable
2026-06-12 15:58:45 +02:00
8cc90bd24d Amélioration du feedback pendant les imports sur les PDF
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (core) (push) Successful in 1m59s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m54s
passage en 0.12.4-beta
2026-06-12 14:35:10 +02:00
14fc1c28fe Ajout de tableaux dans la partie templates / pages de lore : possibilité d'ajouter un tableau multiligne (par exemple pour faire des tableaux d'objets dans les boutiques) ; tableau type liste clé / valeur (pour des statistiques et ce genre de chose).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 1m53s
Build & Push Images / build-switcher (push) Successful in 25s
Build & Push Images / build (web) (push) Successful in 1m59s
Ajout de la possibilité de lié un PNJ à une page de lore
Ajout d'un graphe de liaison entre lore / PNJs
Passage en v.0.12.3-beta
2026-06-12 13:23:46 +02:00
7f519588b6 Amélioration de l'exploitation des PDF par l'IA
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 2m0s
Build & Push Images / build-switcher (push) Successful in 23s
Build & Push Images / build (web) (push) Successful in 1m50s
Amélioration des feedbacks en cas d'erreur d'exploitation des PDF
2026-06-12 01:28:45 +02:00
0799c850ec On essai de contraindre le modèle utilisé par ollama à répondre dans un certain format et ne plus mettre à l'interieur sa "réflexion"
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m30s
Build & Push Images / build (core) (push) Successful in 1m56s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m44s
2026-06-11 15:51:22 +02:00
113df6a391 amélioration import ollama
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m59s
Build & Push Images / build (core) (push) Successful in 1m59s
Build & Push Images / build-switcher (push) Successful in 29s
Build & Push Images / build (web) (push) Successful in 1m59s
2026-06-11 15:29:28 +02:00
84 changed files with 2585 additions and 239 deletions

View File

@@ -5,6 +5,7 @@ port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
ports et les use cases, jamais Ollama/Mistral/etc. ports et les use cases, jamais Ollama/Mistral/etc.
""" """
import logging
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
@@ -29,11 +30,39 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
logger = logging.getLogger(__name__)
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût # Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module. # (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
_PDF_EXTRACTOR = PyMuPdfTextExtractor() _PDF_EXTRACTOR = PyMuPdfTextExtractor()
def _effective_import_chunk_tokens(settings: Settings) -> int:
"""Taille de morceau réellement utilisable pour l'import.
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
≤ num_ctx → morceau ≤ (num_ctx 800) / 2.7. On plafonne, avec un log pour
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
"""
requested = settings.import_chunk_tokens
if settings.llm_provider != "ollama":
return requested
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
if requested > cap:
logger.warning(
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
requested, cap, settings.llm_num_ctx,
)
return cap
return requested
def get_llm_provider( def get_llm_provider(
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
) -> LLMProvider: ) -> LLMProvider:
@@ -82,8 +111,14 @@ def get_import_rules_use_case(
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
) -> ImportRulesUseCase: ) -> ImportRulesUseCase:
"""Factory du use case d'import de règles PDF (extraction + structuration).""" """Factory du use case d'import de règles PDF (extraction + structuration)."""
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
return ImportRulesUseCase( return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) llm=llm, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings),
segment_only=settings.llm_provider == "ollama")
def get_import_campaign_use_case( def get_import_campaign_use_case(
@@ -94,7 +129,7 @@ def get_import_campaign_use_case(
return ImportCampaignUseCase( return ImportCampaignUseCase(
llm=llm, llm=llm,
extractor=_PDF_EXTRACTOR, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=settings.import_chunk_tokens, chunk_target_tokens=_effective_import_chunk_tokens(settings),
map_concurrency=settings.llm_map_concurrency, map_concurrency=settings.llm_map_concurrency,
) )

View File

@@ -14,6 +14,11 @@ import asyncio
import logging import logging
from app.application.chunking import chunk_text, split_in_half from app.application.chunking import chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
@@ -29,7 +34,12 @@ from app.domain.models import (
RoomProposal, RoomProposal,
SceneProposal, SceneProposal,
) )
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -107,6 +117,84 @@ Format de réponse :
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait. - N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}.""" - Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
# requis : le _TreeMerger tolère déjà tous les champs absents.
_TREE_SCHEMA: dict = {
"type": "object",
"properties": {
"arcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
"chapters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"player_narration": {"type": "string"},
"gm_notes": {"type": "string"},
"rooms": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"enemies": {"type": "string"},
"loot": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
"npcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["arcs"],
"additionalProperties": False,
}
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités # Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les # séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle # mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
@@ -427,6 +515,11 @@ class ImportCampaignUseCase:
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
done_count = 0 done_count = 0
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
# publient des messages destinés à l'UI — cf. import_status.notify_status.
status_queue: asyncio.Queue = asyncio.Queue()
status_token = set_status_queue(status_queue)
try:
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency` # PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# appels simultanés. L'ordre narratif est préservé : la fusion se fait # appels simultanés. L'ordre narratif est préservé : la fusion se fait
# vague par vague, dans l'ordre du livre. # vague par vague, dans l'ordre du livre.
@@ -443,9 +536,12 @@ class ImportCampaignUseCase:
return_exceptions=True, return_exceptions=True,
) )
results: list | None = None results: list | None = None
async for kind, payload in with_heartbeat(gathered): async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
if kind == "heartbeat": if kind == "heartbeat":
yield {"type": "heartbeat", "current": done_count + 1, "total": total} yield {"type": "heartbeat", "current": done_count + 1, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": done_count + 1, "total": total}
else: else:
results = payload results = payload
for (i, _), res in zip(wave, results or []): for (i, _), res in zip(wave, results or []):
@@ -480,13 +576,29 @@ class ImportCampaignUseCase:
f"Dernier message : {last_error or 'inconnu'}"} f"Dernier message : {last_error or 'inconnu'}"}
return return
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
# l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune structure exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
# Consolidation finale : fusion des quasi-doublons inter-morceaux # Consolidation finale : fusion des quasi-doublons inter-morceaux
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau. # (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
if total > 1: if total > 1:
yield {"type": "consolidating", "total": total} yield {"type": "consolidating", "total": total}
async for kind, _ in with_heartbeat(self._consolidate(merger)): async for kind, payload in with_heartbeat(
self._consolidate(merger), status_queue=status_queue
):
if kind == "heartbeat": if kind == "heartbeat":
yield {"type": "heartbeat", "current": total, "total": total} yield {"type": "heartbeat", "current": total, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": total, "total": total}
yield { yield {
"type": "done", "type": "done",
@@ -496,6 +608,8 @@ class ImportCampaignUseCase:
"ocr_page_count": doc.ocr_page_count, "ocr_page_count": doc.ocr_page_count,
"skipped": skipped, "skipped": skipped,
} }
finally:
reset_status_queue(status_token)
# --- Consolidation finale (fusion des quasi-doublons) --------------------- # --- Consolidation finale (fusion des quasi-doublons) ---------------------
@@ -557,8 +671,29 @@ class ImportCampaignUseCase:
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence." "Renvoie maintenant le JSON de l'arborescence."
) )
try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Génération trop lente pour la taille demandée (fréquent en local /
# tier gratuit) : même remède que la troncature, deux moitiés →
# sortie 2× plus courte. Re-lever si plus découpable.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
payload, truncated = self._parse_payload(raw, index=index) payload, truncated = self._parse_payload(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
@@ -567,6 +702,9 @@ class ImportCampaignUseCase:
logger.info( logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_payload( a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block) left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload( b = await self._extract_payload(

View File

@@ -13,8 +13,16 @@ Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import asyncio
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
@@ -39,6 +47,16 @@ logger = logging.getLogger(__name__)
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). # Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1 _TEMPERATURE = 0.1
# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à
# Ollama (structured outputs : la grammaire interdit physiquement les objets
# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON…
# indispensable pour les petits modèles locaux qui ne suivent pas les consignes).
# Les adapters cloud le traduisent en mode JSON natif (json_object).
_SECTIONS_SCHEMA: dict = {
"type": "object",
"additionalProperties": {"type": "string"},
}
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre # Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). # morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond. # Le modèle reste libre d'en créer d'autres si rien ne correspond.
@@ -61,9 +79,13 @@ On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES. Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
Règles impératives : Règles impératives :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. - Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown. - Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond : - Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
{canonical} {canonical}
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français). - Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
@@ -72,6 +94,55 @@ Règles impératives :
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond. - N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle).""" - Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible.
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en français.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"titre": {"type": "string"},
"debut": {"type": "string"},
},
"required": ["titre", "debut"],
"additionalProperties": False,
},
},
},
"required": ["sections"],
"additionalProperties": False,
}
class _SectionMerger: class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé. """Fusionne les sections issues des différents morceaux, ordre préservé.
@@ -107,6 +178,40 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()} return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement,
# schéma title/content inventé…) : jamais des titres de section voulus.
_META_KEYS = frozenset({
"thought", "thoughts", "thinking", "reasoning", "raisonnement",
"comment", "commentaire", "commentaires", "note", "notes", "explanation",
})
def _normalize_sections(parsed: dict) -> dict:
"""Ramène les formes déviantes courantes au format attendu {titre: contenu}.
Observé sur les petits modèles locaux (gemma 12b) malgré les consignes :
- enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ;
- schéma inventé {"title": "...", "content": "...", "thought": "..."} →
une seule section dont le titre est la valeur de "title" ;
- clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées.
"""
by_lower = {str(k).strip().lower(): k for k in parsed}
# Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu.
if len(parsed) == 1:
only_key, only_val = next(iter(parsed.items()))
if (isinstance(only_val, dict)
and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}):
return _normalize_sections(only_val)
# Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé.
if "title" in by_lower and "content" in by_lower:
title = str(parsed[by_lower["title"]]).strip()
content = parsed[by_lower["content"]]
if title and not isinstance(content, dict):
return {title: content}
return {k: v for k, v in parsed.items()
if str(k).strip().lower() not in _META_KEYS}
def _coerce_markdown(value: object) -> str: def _coerce_markdown(value: object) -> str:
"""Convertit une valeur de section renvoyée par le LLM en markdown plat. """Convertit une valeur de section renvoyée par le LLM en markdown plat.
@@ -130,6 +235,27 @@ def _coerce_markdown(value: object) -> str:
return "" if value is None else str(value) return "" if value is None else str(value)
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
"""Position de `anchor` dans `text` à partir de `start`, ou None.
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
pos = text.find(anchor, start)
if pos != -1:
return pos
words = anchor.split()
if not words:
return None
pattern = r"\s+".join(re.escape(w) for w in words)
match = re.compile(pattern).search(text, start)
if match:
return match.start()
match = re.compile(pattern, re.IGNORECASE).search(text, start)
return match.start() if match else None
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]: 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é). """Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
@@ -156,10 +282,16 @@ class ImportRulesUseCase:
llm: LLMProvider, llm: LLMProvider,
extractor: PdfTextExtractor, extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS, chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
segment_only: bool = False,
) -> None: ) -> None:
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
frontières des sections (titre + premiers mots) et le texte original est
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
le LLM réécrit le contenu en sections markdown nettoyées."""
self._llm = llm self._llm = llm
self._extractor = extractor self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes) -> RulesImportResult: async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet.""" """Variante non-streamée : traite tout puis renvoie le résultat complet."""
@@ -207,6 +339,11 @@ class ImportRulesUseCase:
merger = _SectionMerger() merger = _SectionMerger()
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
# publient des messages destinés à l'UI — cf. import_status.notify_status.
status_queue: asyncio.Queue = asyncio.Queue()
status_token = set_status_queue(status_queue)
try:
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue. # RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle). # Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
@@ -216,10 +353,14 @@ class ImportRulesUseCase:
try: try:
sections: dict[str, str] | None = None sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat( async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total) self._map_chunk(chunk, index=i, total=total),
status_queue=status_queue,
): ):
if kind == "heartbeat": if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total} yield {"type": "heartbeat", "current": i + 1, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": i + 1, "total": total}
else: else:
sections = payload sections = payload
new_titles = merger.add(sections or {}) new_titles = merger.add(sections or {})
@@ -236,6 +377,8 @@ class ImportRulesUseCase:
"new_sections": new_titles, "new_sections": new_titles,
"skipped": skipped, "skipped": skipped,
} }
finally:
reset_status_queue(status_token)
if total > 0 and skipped == total: if total > 0 and skipped == total:
yield {"type": "error", yield {"type": "error",
@@ -243,9 +386,21 @@ class ImportRulesUseCase:
f"Dernier message : {last_error or 'inconnu'}"} f"Dernier message : {last_error or 'inconnu'}"}
return return
sections = merger.result()
if total > 0 and not sections:
# Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON
# exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit
# un `done` vide et l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune section exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
yield { yield {
"type": "done", "type": "done",
"sections": merger.result(), "sections": sections,
"page_count": doc.page_count, "page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count, "ocr_page_count": doc.ocr_page_count,
"skipped": skipped, "skipped": skipped,
@@ -262,8 +417,10 @@ class ImportRulesUseCase:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le """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 — 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.""" ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = ( prompt = (
_MAP_SYSTEM.format( system.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
) )
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
@@ -271,7 +428,7 @@ class ImportRulesUseCase:
) )
try: try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
except LLMGenerationTimeout: except LLMGenerationTimeout:
# Le modèle générait mais trop lentement pour réécrire tout le morceau # Le modèle générait mais trop lentement pour réécrire tout le morceau
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux). # dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
@@ -284,9 +441,15 @@ class ImportRulesUseCase:
logger.info( logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=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) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b) return _combine_sections(a, b)
if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index)
else:
sections, truncated = self._parse_sections(raw, index=index) sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
@@ -295,6 +458,9 @@ class ImportRulesUseCase:
logger.info( logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=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) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b) return _combine_sections(a, b)
@@ -303,6 +469,68 @@ class ImportRulesUseCase:
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections return sections
@staticmethod
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
Une ancre introuvable est abandonnée (son contenu reste dans la section
précédente — aucun texte n'est perdu). Le texte avant la première ancre
trouvée est rattaché à la première section (le prompt demande au modèle de
faire démarrer la première entrée aux premiers mots de l'extrait)."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
entries = parsed.get("sections") if isinstance(parsed, dict) else None
if not isinstance(entries, list):
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
return {}, False
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
located: list[tuple[str, int]] = []
cursor = 0
dropped = 0
for entry in entries:
if not isinstance(entry, dict):
continue
title = str(entry.get("titre") or "").strip()
anchor = str(entry.get("debut") or "").strip()
if not title or not anchor:
continue
pos = _find_anchor(text, anchor, cursor)
if pos is None:
dropped += 1
continue
located.append((title, pos))
cursor = pos + 1
if dropped:
logger.info(
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
"à la section précédente.", index, dropped)
if not located:
return {}, False
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
located[0] = (located[0][0], 0)
sections: dict[str, str] = {}
for i, (title, start) in enumerate(located):
end = located[i + 1][1] if i + 1 < len(located) else len(text)
content = text[start:end].strip()
if not content:
continue
if title in sections:
sections[title] = f"{sections[title]}\n\n{content}"
else:
sections[title] = content
return sections, recovered
@staticmethod @staticmethod
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]: def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle.""" """Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
@@ -320,4 +548,5 @@ class ImportRulesUseCase:
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False return {}, False
return {str(k): _coerce_markdown(v) for k, v in parsed.items()}, recovered normalized = _normalize_sections(parsed)
return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered

View File

@@ -0,0 +1,39 @@
"""Canal de statut des imports : remonte à l'UI ce qui n'existait qu'en logs.
Problème résolu : pendant un import, les événements internes (retry parce que
le fournisseur IA est saturé, re-découpage d'un morceau trop gros…) n'étaient
visibles que dans les logs Docker. L'utilisateur voyait une barre de
progression figée sans explication.
Mécanisme : le flux d'import (use case `stream()`) installe une Queue dans une
ContextVar ; les couches profondes (retry LLM, re-découpage) y publient des
messages via `notify_status()` sans connaître le flux SSE. La ContextVar est
propagée automatiquement aux tâches asyncio enfants → chaque import concurrent
a SA queue, sans couplage ni paramètre à faire transiter partout.
"""
from __future__ import annotations
import asyncio
from contextvars import ContextVar, Token
_QUEUE: ContextVar[asyncio.Queue | None] = ContextVar("import_status_queue", default=None)
def set_status_queue(queue: asyncio.Queue | None) -> Token:
"""Installe la queue de statut pour le contexte courant (et ses tâches filles).
Renvoie le token à passer à `reset_status_queue` en fin d'import.
"""
return _QUEUE.set(queue)
def reset_status_queue(token: Token) -> None:
_QUEUE.reset(token)
def notify_status(message: str) -> None:
"""Publie un message de statut si un import écoute. No-op sinon (appels
LLM hors import : chat, génération de page…)."""
queue = _QUEUE.get()
if queue is not None:
queue.put_nowait(message)

View File

@@ -57,12 +57,20 @@ def looks_like_truncated_json(raw: str) -> bool:
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés) """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 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 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 sous-structure complète).
faux positifs sur une courte réponse non-JSON."""
s = (raw or "").strip() Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
if "{" not in s or len(s) < 100: même très courte : en mode JSON un `{"` de 2 caractères est une génération
interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le
signal de re-découpage. Pour le reste (prose contenant des accolades), on exige
un contenu substantiel pour éviter les faux positifs."""
s = _strip_reasoning(raw or "").strip()
if "{" not in s:
return False return False
return s.count("{") > s.count("}") or s.count("[") > s.count("]") unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]")
if s.startswith("{"):
return unbalanced
return len(s) >= 100 and unbalanced
def extract_json_object(raw: str) -> str | None: def extract_json_object(raw: str) -> str | None:

View File

@@ -14,6 +14,7 @@ import asyncio
import logging import logging
import re import re
from app.application.import_status import notify_status
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ async def generate_with_retry(
llm: LLMProvider, llm: LLMProvider,
prompt: str, prompt: str,
*, *,
output_format: str | None = None, output_format: str | dict | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff). """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
@@ -103,6 +104,14 @@ async def generate_with_retry(
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "", attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
exc, wait, exc, wait,
) )
# Remonte aussi l'info à l'UI (flux d'import) : sans ça l'utilisateur
# voit une barre figée sans savoir que le fournisseur est saturé.
notify_status(
("Fournisseur IA saturé (rate limit)" if _is_rate_limit(exc)
else "Appel IA échoué")
+ f" — tentative {attempt + 1}/{_ATTEMPTS}, nouvel essai dans {int(wait)}s. "
+ str(exc)[:160]
)
await asyncio.sleep(wait) await asyncio.sleep(wait)
assert last_error is not None assert last_error is not None
raise last_error raise last_error

View File

@@ -25,21 +25,43 @@ async def with_heartbeat(
coro: Awaitable[Any], coro: Awaitable[Any],
*, *,
interval: float = HEARTBEAT_INTERVAL_SECONDS, interval: float = HEARTBEAT_INTERVAL_SECONDS,
status_queue: "asyncio.Queue | None" = None,
) -> AsyncIterator[tuple[str, Any]]: ) -> AsyncIterator[tuple[str, Any]]:
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant """Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
qu'elle n'est pas terminée, puis ('result', valeur). qu'elle n'est pas terminée, puis ('result', valeur).
Si `status_queue` est fournie, les messages qui y sont publiés pendant
l'exécution (cf. import_status.notify_status : retry LLM, re-découpage…)
sont émis AU FIL DE L'EAU sous forme ('status', message) — c'est ce qui
permet à l'UI d'expliquer une attente au lieu d'une barre figée.
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`), 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 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. (client déconnecté), la tâche sous-jacente est annulée.
""" """
task: asyncio.Task = asyncio.ensure_future(coro) task: asyncio.Task = asyncio.ensure_future(coro)
getter: asyncio.Task | None = None
try: try:
while not task.done(): while not task.done():
done, _ = await asyncio.wait({task}, timeout=interval) waiters: set[asyncio.Task] = {task}
if status_queue is not None and getter is None:
getter = asyncio.ensure_future(status_queue.get())
if getter is not None:
waiters.add(getter)
done, _ = await asyncio.wait(
waiters, timeout=interval, return_when=asyncio.FIRST_COMPLETED)
if getter is not None and getter in done:
yield ("status", getter.result())
getter = None # un nouveau get() sera créé au tour suivant
if not done: if not done:
yield ("heartbeat", None) yield ("heartbeat", None)
# Vide les statuts restés en file (publiés juste avant la fin de la tâche).
if status_queue is not None:
while not status_queue.empty():
yield ("status", status_queue.get_nowait())
yield ("result", task.result()) yield ("result", task.result())
finally: finally:
if getter is not None and not getter.done():
getter.cancel()
if not task.done(): if not task.done():
task.cancel() task.cancel()

View File

@@ -24,17 +24,20 @@ class LLMProvider(Protocol):
self, self,
prompt: str, prompt: str,
*, *,
output_format: str | None = None, output_format: str | dict | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""Génère une réponse textuelle à partir d'un prompt donné. """Génère une réponse textuelle à partir d'un prompt donné.
Args: Args:
prompt: le texte envoyé au modèle. prompt: le texte envoyé au modèle.
output_format: contrainte de format optionnelle. Exemple : "json" output_format: contrainte de format optionnelle. "json" pour forcer
pour forcer le modèle à renvoyer du JSON valide. Les un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
fournisseurs qui ne supportent pas une valeur donnée doivent attendue (les fournisseurs qui supportent les sorties
l'ignorer silencieusement ou la traduire au mieux. structurées — ex. Ollama — contraignent la génération au schéma,
les autres retombent sur leur mode JSON natif). Les fournisseurs
qui ne supportent pas une valeur donnée doivent l'ignorer
silencieusement ou la traduire au mieux.
temperature: créativité du modèle, 0.0 (déterministe/factuel) à temperature: créativité du modèle, 0.0 (déterministe/factuel) à
1.0+ (très créatif, hallucine plus facilement). None = 1.0+ (très créatif, hallucine plus facilement). None =
valeur par défaut de l'adapter. Recommandation LoreMind : valeur par défaut de l'adapter. Recommandation LoreMind :

View File

@@ -131,8 +131,10 @@ class GeminiLLMProvider:
if temperature is not None: if temperature is not None:
body["temperature"] = temperature body["temperature"] = temperature
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) : # Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
# supprime fences ```json et JSON invalide, principale cause de morceaux ignorés. # supprime fences ```json et JSON invalide, principale cause de morceaux
if output_format == "json": # ignorés. Un SCHÉMA (dict) est traduit en json_object : suffisant, les
# grands modèles cloud respectent la structure demandée par le prompt.
if output_format is not None:
body["response_format"] = {"type": "json_object"} body["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
@@ -142,6 +144,16 @@ class GeminiLLMProvider:
) as response: ) as response:
if response.status_code >= 400: if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace").strip() detail = (await response.aread()).decode("utf-8", "replace").strip()
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
# message actionnable plutôt que le JSON brut de l'API.
if response.status_code in (401, 403):
raise LLMProviderError(
"Erreur Gemini : clé API refusée par Google "
f"(HTTP {response.status_code}). Vérifiez que la clé vient bien "
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
f"Détail : {detail[:300]}"
)
raise LLMProviderError( raise LLMProviderError(
f"Erreur Gemini (HTTP {response.status_code})" f"Erreur Gemini (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "") + (f" : {detail[:500]}" if detail else "")

View File

@@ -138,8 +138,10 @@ class MistralLLMProvider:
body["temperature"] = temperature body["temperature"] = temperature
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences # Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes), # ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
# principale cause de morceaux d'import ignorés. # principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
if output_format == "json": # traduit en json_object : suffisant ici, les grands modèles cloud
# respectent la structure demandée par le prompt.
if output_format is not None:
body["response_format"] = {"type": "json_object"} body["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:

View File

@@ -5,6 +5,7 @@ Isole le reste de l'application des spécificités du protocole Ollama
demain, on écrit un nouvel adapter sans toucher au reste du code. demain, on écrit un nouvel adapter sans toucher au reste du code.
""" """
import json import json
import logging
from typing import AsyncIterator from typing import AsyncIterator
import httpx import httpx
@@ -13,6 +14,8 @@ from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__)
class OllamaLLMProvider: class OllamaLLMProvider:
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP. """Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
@@ -45,7 +48,7 @@ class OllamaLLMProvider:
self, self,
prompt: str, prompt: str,
*, *,
output_format: str | None = None, output_format: str | dict | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
url = f"{self._base_url}/api/generate" url = f"{self._base_url}/api/generate"
@@ -55,6 +58,10 @@ class OllamaLLMProvider:
"stream": False, "stream": False,
"options": self._build_options(temperature), "options": self._build_options(temperature),
} }
# "json" (mode JSON simple) ou un SCHÉMA JSON complet (structured outputs) :
# Ollama contraint alors la grammaire de génération au schéma — un petit
# modèle local ne PEUT physiquement plus produire d'objets imbriqués, de
# clés "thought" bavardes ou de texte hors JSON.
if output_format is not None: if output_format is not None:
payload["format"] = output_format payload["format"] = output_format
@@ -92,7 +99,24 @@ class OllamaLLMProvider:
f"Erreur lors de l'appel à Ollama : {exc}" f"Erreur lors de l'appel à Ollama : {exc}"
) from exc ) from exc
return response.json()["response"] data = response.json()
# Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que
# la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…)
# et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé
# en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille
# du prompt en tokens du modèle (les morceaux sont mesurés en tokens
# cl100k, ~20-40% plus compacts que les tokenizers locaux).
done_reason = data.get("done_reason")
if done_reason and done_reason != "stop":
logger.warning(
"Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, "
"sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la "
"fenêtre de contexte est pleine : réduisez la taille des morceaux "
"d'import ou augmentez num_ctx (Paramètres).",
done_reason, data.get("prompt_eval_count"),
data.get("eval_count"), self._num_ctx,
)
return data["response"]
async def stream_chat( async def stream_chat(
self, self,

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.12.1-beta", version="0.12.5-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

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

View File

@@ -65,10 +65,11 @@ public class CampaignImportService {
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming( campaignPdfImporter.importCampaignStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError); pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
} }
/** /**
@@ -163,7 +164,7 @@ public class CampaignImportService {
isBlank(p.description()) isBlank(p.description())
? java.util.Map.of() ? java.util.Map.of()
: java.util.Map.of("Description", p.description().trim()), : java.util.Map.of("Description", p.description().trim()),
null, null, campaignId, null, null)); null, null, campaignId, null, null, null));
created++; created++;
} }
return created; return created;

View File

@@ -1,9 +1,12 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,9 +19,11 @@ import java.util.Optional;
public class NpcService { public class NpcService {
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository) { public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
} }
public record NpcData( public record NpcData(
@@ -29,6 +34,7 @@ public class NpcService {
Map<String, List<String>> imageValues, Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues, Map<String, Map<String, String>> keyValueValues,
String campaignId, String campaignId,
List<String> relatedPageIds,
String folder, String folder,
Integer order Integer order
) {} ) {}
@@ -45,6 +51,7 @@ public class NpcService {
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) .imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.relatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>())
.folder(normalizeFolder(data.folder())) .folder(normalizeFolder(data.folder()))
.order(order) .order(order)
.build(); .build();
@@ -59,6 +66,21 @@ public class NpcService {
return npcRepository.findByCampaignId(campaignId); return npcRepository.findByCampaignId(campaignId);
} }
/**
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
*/
public List<Npc> getNpcsByLoreId(String loreId) {
List<Npc> out = new ArrayList<>();
for (Campaign campaign : campaignRepository.findAll()) {
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
}
}
return out;
}
public Npc updateNpc(String id, NpcData data) { public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id) Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
@@ -68,6 +90,7 @@ public class NpcService {
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
existing.setRelatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>());
existing.setFolder(normalizeFolder(data.folder())); existing.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());

View File

@@ -40,10 +40,11 @@ public class GameSystemService {
String filename, String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress, java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
java.util.function.Consumer<String> onStatus,
java.util.function.Consumer<RulesImportResult> onDone, java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) { java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming( rulesPdfImporter.importRulesStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError); pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
} }
/** /**

View File

@@ -76,6 +76,8 @@ public class PageService {
existing.setNodeId(changes.getNodeId()); existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.setValues(CollectionUtils.copyMap(changes.getValues()));
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues())); existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes()); existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags())); existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds())); existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));

View File

@@ -4,6 +4,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -46,6 +47,13 @@ public class Npc {
/** Référence vers la Campaign parente (cross-aggregate via ID). */ /** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId; private String campaignId;
/**
* IDs de Pages de Lore référencées par ce PNJ (sa ville, sa faction, sa
* région…). Référence faible cross-context, même principe que sur
* Arc/Chapter/Scene — alimente notamment le graphe du Lore.
*/
private List<String> relatedPageIds;
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */ /** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
private String folder; private String folder;
@@ -69,4 +77,9 @@ public class Npc {
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues; return keyValueValues;
} }
public List<String> getRelatedPageIds() {
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
return relatedPageIds;
}
} }

View File

@@ -19,6 +19,10 @@ public interface CampaignPdfImporter {
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune * @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* avancée à afficher, mais le canal SSE vers le navigateur * avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe). * doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onStatus invoqué avec un message lisible quand quelque chose se
* passe pendant l'attente (fournisseur saturé → retry,
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
* pour que l'utilisateur n'ait pas à lire les logs.
* @param onDone invoqué une fois avec l'arbre proposé (non persisté). * @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue. * @param onError invoqué si l'extraction/structuration échoue.
*/ */
@@ -27,6 +31,7 @@ public interface CampaignPdfImporter {
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError); Consumer<Throwable> onError);
} }

View File

@@ -30,6 +30,10 @@ public interface RulesPdfImporter {
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune * @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* avancée à afficher, mais le canal SSE vers le navigateur * avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe). * doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onStatus invoqué avec un message lisible quand quelque chose se
* passe pendant l'attente (fournisseur saturé → retry,
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
* pour que l'utilisateur n'ait pas à lire les logs.
* @param onDone invoqué une fois avec le résultat final. * @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue. * @param onError invoqué si l'extraction/structuration échoue.
*/ */
@@ -38,6 +42,7 @@ public interface RulesPdfImporter {
String filename, String filename,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError); Consumer<Throwable> onError);
} }

View File

@@ -39,6 +39,20 @@ public class Page {
*/ */
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;
/**
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
* fiches de personnage) : fieldName → (label → valeur). Les labels sont
* definis par le Template ; seules les valeurs vivent sur la page.
*/
private Map<String, Map<String, String>> keyValueValues;
/**
* Valeurs des champs TABLE (colonnes figees au template, lignes libres) :
* fieldName → liste ordonnee de lignes, chaque ligne = colonne → cellule.
* Usage type : inventaire de boutique, table d'objets.
*/
private Map<String, List<Map<String, String>>> tableValues;
/** Notes privées du MJ (non exportées vers FoundryVTT). */ /** Notes privées du MJ (non exportées vers FoundryVTT). */
private String notes; private String notes;

View File

@@ -9,6 +9,11 @@ package com.loremind.domain.shared.template;
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template * - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
* (Map<String, Map<String, String>> : fieldName -> label -> value). * (Map<String, Map<String, String>> : fieldName -> label -> value).
* Usage : stat blocks, listes de competences, traits. * Usage : stat blocks, listes de competences, traits.
* - TABLE : tableau a colonnes figees au template (TemplateField.labels =
* noms de colonnes) et lignes LIBRES ajoutees au remplissage
* (Map<String, List<Map<String, String>>> : fieldName -> lignes,
* chaque ligne = colonne -> cellule).
* Usage : inventaire de boutique, tables d'objets, listes de prix.
* <p> * <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE... * Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/ */
@@ -16,5 +21,6 @@ public enum FieldType {
TEXT, TEXT,
IMAGE, IMAGE,
NUMBER, NUMBER,
KEY_VALUE_LIST KEY_VALUE_LIST,
TABLE
} }

View File

@@ -30,8 +30,9 @@ public class TemplateField {
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */ /** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout; private ImageLayout layout;
/** /**
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif). * Labels predefinis (ordre significatif), selon le type :
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques". * - KEY_VALUE_LIST : libelles des lignes. Ex: ["FOR","DEX","CON","INT","SAG","CHA"].
* - TABLE : noms des COLONNES. Ex: ["Objet","Prix","Description"].
* Null/vide pour les autres types. * Null/vide pour les autres types.
*/ */
private List<String> labels; private List<String> labels;
@@ -70,4 +71,9 @@ public class TemplateField {
public static TemplateField keyValueList(String name, List<String> labels) { public static TemplateField keyValueList(String name, List<String> labels) {
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels); return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
} }
/** Raccourci : construit un champ TABLE avec ses noms de colonnes. */
public static TemplateField table(String name, List<String> columns) {
return new TemplateField(name, FieldType.TABLE, null, columns);
}
} }

View File

@@ -61,6 +61,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -85,7 +86,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
.timeout(Duration.ofSeconds(importTimeoutSeconds)) .timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent( .doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onDone, onError)) onProgress, onHeartbeat, onStatus, onDone, onError))
.blockLast(); .blockLast();
if (!terminated[0]) { if (!terminated[0]) {
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(
@@ -110,6 +111,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
boolean[] terminated, boolean[] terminated,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -122,6 +124,22 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
onHeartbeat.run(); onHeartbeat.run();
return; return;
} }
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
return;
}
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(

View File

@@ -115,6 +115,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
String filename, String filename,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -141,7 +142,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
.timeout(Duration.ofSeconds(importTimeoutSeconds)) .timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent( .doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onDone, onError)) onProgress, onHeartbeat, onStatus, onDone, onError))
.blockLast(); .blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale. // Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) { if (!terminated[0]) {
@@ -168,6 +169,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
boolean[] terminated, boolean[] terminated,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat, Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -181,6 +183,22 @@ public class BrainRulesImportClient implements RulesPdfImporter {
onHeartbeat.run(); onHeartbeat.run();
return; return;
} }
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
return;
}
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new RulesImportException( onError.accept(new RulesImportException(

View File

@@ -0,0 +1,51 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Convertit une Map<String, List<Map<String, String>>> en JSON et inversement.
* <p>
* Utilise pour Page.tableValues : pour chaque champ TABLE du template, stocke
* la liste ordonnee des LIGNES du tableau, chaque ligne etant une map
* colonne -> cellule. Exemple :
* {"Inventaire": [{"Objet":"Potion","Prix":"50 po"}, {"Objet":"Corde","Prix":"1 po"}]}
* <p>
* Adaptateur technique pur : le domaine ignore ce converter.
*/
@Converter
public class StringRowListMapJsonConverter
implements AttributeConverter<Map<String, List<Map<String, String>>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, List<Map<String, String>>>> TYPE_REF =
new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(Map<String, List<Map<String, String>>> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, List<Map<String,String>>> -> JSON", e);
}
}
@Override
public Map<String, List<Map<String, String>>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(dbData, TYPE_REF);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, List<Map<String,String>>>", e);
}
}
}

View File

@@ -86,7 +86,7 @@ public class TemplateFieldListJsonConverter
} }
} }
List<String> labels = null; List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST) { if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) {
JsonNode labelsNode = item.path("labels"); JsonNode labelsNode = item.path("labels");
if (labelsNode.isArray()) { if (labelsNode.isArray()) {
labels = new ArrayList<>(); labels = new ArrayList<>();

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
@@ -54,6 +55,11 @@ public class NpcJpaEntity {
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
/** IDs de Pages de Lore référencées (référence faible cross-context). JSON TEXT. */
@Convert(converter = StringListJsonConverter.class)
@Column(name = "related_page_ids", columnDefinition = "TEXT")
private List<String> relatedPageIds;
@Column(name = "folder") @Column(name = "folder")
private String folder; private String folder;

View File

@@ -3,6 +3,8 @@ package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringRowListMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -52,6 +54,16 @@ public class PageJpaEntity {
@Convert(converter = StringListMapJsonConverter.class) @Convert(converter = StringListMapJsonConverter.class)
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
@Column(name = "key_value_values", columnDefinition = "TEXT")
@Convert(converter = StringMapMapJsonConverter.class)
private Map<String, Map<String, String>> keyValueValues;
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). JSON TEXT. */
@Column(name = "table_values", columnDefinition = "TEXT")
@Convert(converter = StringRowListMapJsonConverter.class)
private Map<String, List<Map<String, String>>> tableValues;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String notes; private String notes;

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -59,6 +60,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
.folder(e.getFolder()) .folder(e.getFolder())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -77,6 +79,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()) .imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()) .keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId())) .campaignId(Long.parseLong(n.getCampaignId()))
.relatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>())
.folder(n.getFolder()) .folder(n.getFolder())
.order(n.getOrder()) .order(n.getOrder())
.createdAt(n.getCreatedAt()) .createdAt(n.getCreatedAt())

View File

@@ -93,6 +93,8 @@ public class PostgresPageRepository implements PageRepository {
.title(e.getTitle()) .title(e.getTitle())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>()) .values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>())
.notes(e.getNotes()) .notes(e.getNotes())
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>()) .tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>()) .relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
@@ -111,6 +113,8 @@ public class PostgresPageRepository implements PageRepository {
.title(p.getTitle()) .title(p.getTitle())
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>()) .values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>()) .imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
.tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>())
.notes(p.getNotes()) .notes(p.getNotes())
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>()) .tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>()) .relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())

View File

@@ -31,8 +31,13 @@ public class CampaignImportController {
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class); private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ /**
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; * Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
private final CampaignImportService campaignImportService; private final CampaignImportService campaignImportService;
private final TaskExecutor taskExecutor; private final TaskExecutor taskExecutor;
@@ -66,7 +71,15 @@ public class CampaignImportController {
// amont (ClientGoneException remonte dans le doOnNext du WebClient → // amont (ClientGoneException remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM). // annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false); AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> clientGone.set(true)); emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true)); emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -75,6 +88,8 @@ public class CampaignImportController {
bytes, filename, bytes, filename,
progress -> sendEvent(emitter, clientGone, "progress", progress), progress -> sendEvent(emitter, clientGone, "progress", progress),
() -> sendHeartbeat(emitter, clientGone), () -> sendHeartbeat(emitter, clientGone),
status -> sendEvent(emitter, clientGone, "status",
Map.of("message", status != null ? status : "")),
proposal -> { proposal -> {
sendEvent(emitter, clientGone, "done", proposal); sendEvent(emitter, clientGone, "done", proposal);
emitter.complete(); emitter.complete();

View File

@@ -35,8 +35,13 @@ public class GameSystemController {
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class); private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ /**
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; * Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
private final GameSystemService gameSystemService; private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper; private final GameSystemMapper gameSystemMapper;
@@ -148,7 +153,15 @@ public class GameSystemController {
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient → // amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM). // annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false); AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> clientGone.set(true)); emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendImportError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true)); emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -157,6 +170,8 @@ public class GameSystemController {
bytes, filename, bytes, filename,
progress -> sendImportEvent(emitter, clientGone, "progress", progress), progress -> sendImportEvent(emitter, clientGone, "progress", progress),
() -> sendImportHeartbeat(emitter, clientGone), () -> sendImportHeartbeat(emitter, clientGone),
status -> sendImportEvent(emitter, clientGone, "status",
Map.of("message", status != null ? status : "")),
result -> { result -> {
sendImportEvent(emitter, clientGone, "done", result); sendImportEvent(emitter, clientGone, "done", result);
emitter.complete(); emitter.complete();

View File

@@ -124,7 +124,18 @@ public class NotebookController {
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream() List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList(); .toList();
List<String> sourceIds = service.readySourceIds(id); // Sélection de l'UI (cases cochées) : on ne garde que les sources qui
// appartiennent bien à CE notebook et sont prêtes — un id étranger est
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
// Variable finale : elle est capturée par la lambda du taskExecutor.
List<String> readyIds = service.readySourceIds(id);
final List<String> sourceIds;
if (req.sourceIds() != null) {
var wanted = new java.util.HashSet<>(req.sourceIds());
sourceIds = readyIds.stream().filter(wanted::contains).toList();
} else {
sourceIds = readyIds;
}
String context = service.buildContext(nb.getCampaignId()); String context = service.buildContext(nb.getCampaignId());
boolean deep = req.deep() != null && req.deep(); boolean deep = req.deep() != null && req.deep();
@@ -220,5 +231,10 @@ public class NotebookController {
public record CreateRequest(String campaignId, String name) {} public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {} public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {} /**
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
* Toujours intersecté avec les sources du notebook (sécurité).
*/
public record ChatRequest(String message, Boolean deep, List<String> sourceIds) {}
} }

View File

@@ -43,6 +43,15 @@ public class NpcController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
List<NpcDTO> dtos = npcService.getNpcsByLoreId(loreId).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder())); Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
@@ -64,6 +73,7 @@ public class NpcController {
dto.getImageValues(), dto.getImageValues(),
dto.getKeyValueValues(), dto.getKeyValueValues(),
dto.getCampaignId(), dto.getCampaignId(),
dto.getRelatedPageIds(),
dto.getFolder(), dto.getFolder(),
order order
); );

View File

@@ -2,6 +2,7 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -20,6 +21,8 @@ public class NpcDTO {
private Map<String, List<String>> imageValues = new HashMap<>(); private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>(); private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId; private String campaignId;
/** IDs de Pages de Lore référencées par ce PNJ (référence faible cross-context). */
private List<String> relatedPageIds = new ArrayList<>();
private String folder; private String folder;
private int order; private int order;
} }

View File

@@ -20,6 +20,10 @@ public class PageDTO {
private Map<String, String> values; private Map<String, String> values;
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */ /** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;
/** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */
private Map<String, Map<String, String>> keyValueValues;
/** Pour chaque champ TABLE du template : lignes (colonne → cellule). */
private Map<String, List<Map<String, String>>> tableValues;
private String notes; private String notes;
private List<String> tags; private List<String> tags;
private List<String> relatedPageIds; private List<String> relatedPageIds;

View File

@@ -4,6 +4,7 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@Component @Component
@@ -20,6 +21,7 @@ public class NpcMapper {
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()); dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()); dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId()); dto.setCampaignId(n.getCampaignId());
dto.setRelatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>());
dto.setFolder(n.getFolder()); dto.setFolder(n.getFolder());
dto.setOrder(n.getOrder()); dto.setOrder(n.getOrder());
return dto; return dto;
@@ -36,6 +38,7 @@ public class NpcMapper {
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>()) .imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>()) .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())
.folder(dto.getFolder()) .folder(dto.getFolder())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -23,6 +23,8 @@ public class PageMapper {
dto.setTitle(page.getTitle()); dto.setTitle(page.getTitle());
dto.setValues(CollectionUtils.copyMap(page.getValues())); dto.setValues(CollectionUtils.copyMap(page.getValues()));
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues())); dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
dto.setTableValues(CollectionUtils.copyMap(page.getTableValues()));
dto.setNotes(page.getNotes()); dto.setNotes(page.getNotes());
dto.setTags(CollectionUtils.copyList(page.getTags())); dto.setTags(CollectionUtils.copyList(page.getTags()));
dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds())); dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds()));
@@ -41,6 +43,8 @@ public class PageMapper {
.title(dto.getTitle()) .title(dto.getTitle())
.values(CollectionUtils.copyMap(dto.getValues())) .values(CollectionUtils.copyMap(dto.getValues()))
.imageValues(CollectionUtils.copyMap(dto.getImageValues())) .imageValues(CollectionUtils.copyMap(dto.getImageValues()))
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
.tableValues(CollectionUtils.copyMap(dto.getTableValues()))
.notes(dto.getNotes()) .notes(dto.getNotes())
.tags(CollectionUtils.copyList(dto.getTags())) .tags(CollectionUtils.copyList(dto.getTags()))
.relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds())) .relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds()))

View File

@@ -29,7 +29,8 @@ public class TemplateFieldMapper {
layoutStr = layout.name(); layoutStr = layout.name();
} }
List<String> labels = null; List<String> labels = null;
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) { if ((field.getType() == FieldType.KEY_VALUE_LIST || field.getType() == FieldType.TABLE)
&& field.getLabels() != null) {
labels = new ArrayList<>(field.getLabels()); labels = new ArrayList<>(field.getLabels());
} }
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels); return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
@@ -54,7 +55,7 @@ public class TemplateFieldMapper {
} }
} }
List<String> labels = null; List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) { if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
labels = new ArrayList<>(dto.getLabels()); labels = new ArrayList<>(dto.getLabels());
} }
return new TemplateField(dto.getName(), type, layout, labels); return new TemplateField(dto.getName(), type, layout, labels);

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -29,6 +30,9 @@ public class NpcServiceTest {
@Mock @Mock
private NpcRepository npcRepository; private NpcRepository npcRepository;
@Mock
private CampaignRepository campaignRepository;
@InjectMocks @InjectMocks
private NpcService npcService; private NpcService npcService;
@@ -51,7 +55,7 @@ public class NpcServiceTest {
Npc result = npcService.createNpc( Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", null, null, new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, null, "camp-1", null,5)); Map.of("Notes", "Borin"), null, null, "camp-1", null, null, 5));
assertNotNull(result); assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -67,7 +71,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b)); when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null,null)); npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null, null, null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture()); verify(npcRepository).save(captor.capture());
@@ -79,7 +83,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null,null)); npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null, null, null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture()); verify(npcRepository).save(captor.capture());
@@ -124,7 +128,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1", Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", null, null, new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, null, "camp-1", null,7)); Map.of("Notes", "v2"), null, null, "camp-1", null, null, 7));
assertEquals("Borin renommé", result.getName()); assertEquals("Borin renommé", result.getName());
assertEquals("v2", result.getValues().get("Notes")); assertEquals("v2", result.getValues().get("Notes"));
@@ -138,7 +142,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1", Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", null, null, new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, null, "camp-1", null,null)); Map.of("Notes", "txt"), null, null, "camp-1", null, null, null));
// testNpc avait order=1 → préservé // testNpc avait order=1 → préservé
assertEquals(1, result.getOrder()); assertEquals(1, result.getOrder());
@@ -150,7 +154,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing", () -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null))); new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null, null, null)));
assertTrue(ex.getMessage().contains("missing")); assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any()); verify(npcRepository, never()).save(any());
} }

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) }, { path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) }, { path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) }, { path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },

View File

@@ -31,6 +31,9 @@
</div> </div>
</div> </div>
} }
@if (importStatus) {
<p class="import-status" role="status">{{ importStatus }}</p>
}
@if (importCounts) { @if (importCounts) {
<p class="import-counts"> <p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ

View File

@@ -87,6 +87,16 @@
.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; } .import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; }
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
.import-status {
margin: 0.55rem 0 0;
color: #fbbf24;
font-size: 0.8rem;
font-style: italic;
line-height: 1.4;
}
.import-error, .import-error,
.apply-error { .apply-error {
margin: 1rem 0 0; margin: 1rem 0 0;

View File

@@ -74,6 +74,12 @@ export class CampaignImportComponent implements OnInit {
importing = false; importing = false;
importPhase = ''; importPhase = '';
importProgress: { current: number; total: number } | null = null; importProgress: { current: number; total: number } | null = null;
/**
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
* en cours, pas l'historique.
*/
importStatus: string | null = null;
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null; importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
importError: string | null = null; importError: string | null = null;
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */ /** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
@@ -132,12 +138,15 @@ export class CampaignImportComponent implements OnInit {
this.applyError = null; this.applyError = null;
this.importPhase = 'Extraction du texte…'; this.importPhase = 'Extraction du texte…';
this.importProgress = null; this.importProgress = null;
this.importStatus = null;
this.importCounts = null; this.importCounts = null;
this.tree = []; this.tree = [];
this.service.importStructureStream(this.campaignId, file).subscribe({ this.service.importStructureStream(this.campaignId, file).subscribe({
next: (ev) => { next: (ev) => {
if (ev.type === 'progress') { if (ev.type === 'progress') {
// Un morceau vient d'aboutir : le message d'attente est obsolète.
this.importStatus = null;
if (ev.total === 0) { if (ev.total === 0) {
this.importPhase = 'Extraction du texte…'; this.importPhase = 'Extraction du texte…';
this.importProgress = null; this.importProgress = null;
@@ -149,10 +158,13 @@ export class CampaignImportComponent implements OnInit {
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0 scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
}; };
} }
} else if (ev.type === 'status') {
this.importStatus = ev.message;
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
this.importing = false; this.importing = false;
this.importPhase = ''; this.importPhase = '';
this.importProgress = null; this.importProgress = null;
this.importStatus = null;
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) { if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
this.importError = "Aucune structure narrative détectée dans ce PDF."; this.importError = "Aucune structure narrative détectée dans ce PDF.";
this.reviewing = false; this.reviewing = false;

View File

@@ -20,13 +20,30 @@
@if (uploadError) { @if (uploadError) {
<p class="nbd-upload-error">{{ uploadError }}</p> <p class="nbd-upload-error">{{ uploadError }}</p>
} }
@if (readySourceCount > 1) {
<p class="nbd-sources-hint"
[class.partial]="selectedSourceIds.size < readySourceCount">
{{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie
</p>
}
@for (s of sources; track s) { @for (s of sources; track s) {
<div class="nbd-source"> <div class="nbd-source" [class.nbd-source--off]="s.status === 'READY' && !isSelected(s)">
@if (s.status === 'READY') {
<input
type="checkbox"
class="nbd-source-check"
[checked]="isSelected(s)"
(change)="toggleSource(s)"
[title]="isSelected(s)
? 'Source utilisée par le chat décocher pour l\'exclure'
: 'Source ignorée par le chat cocher pour l\'inclure'" />
} @else {
<lucide-icon <lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)" [img]="s.status === 'FAILED' ? AlertCircle : Loader"
[size]="14" [size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'"> [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon> </lucide-icon>
}
<div class="nbd-source-info"> <div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div> <div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta"> <div class="nbd-source-meta">

View File

@@ -39,12 +39,36 @@
} }
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; } .nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
// Compteur de sources actives — ambre quand une partie est décochée, pour
// rappeler que le chat ne voit pas tout.
.nbd-sources-hint {
font-size: 0.72rem;
color: var(--color-text-muted, #9aa0aa);
margin: 0.1rem 0 0.2rem;
&.partial { color: #fbbf24; }
}
.nbd-source { .nbd-source {
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
border-radius: 7px; background: rgba(255,255,255,0.03); border-radius: 7px; background: rgba(255,255,255,0.03);
lucide-icon.ok { color: #6bd08a; } lucide-icon.ok { color: #6bd08a; }
lucide-icon.fail { color: #e88; } lucide-icon.fail { color: #e88; }
lucide-icon.busy { color: #e0c074; } lucide-icon.busy { color: #e0c074; }
// Case « source utilisée par le chat » (sources prêtes uniquement).
.nbd-source-check {
margin: 2px 0 0;
accent-color: #6bd08a;
cursor: pointer;
flex-shrink: 0;
}
// Source décochée : grisée = hors du périmètre du chat et de l'analyse.
&.nbd-source--off {
opacity: 0.5;
}
.nbd-source-info { flex: 1; min-width: 0; } .nbd-source-info { flex: 1; min-width: 0; }
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; } .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-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }

View File

@@ -137,13 +137,54 @@ export class NotebookDetailComponent implements OnInit {
this.detail = d; this.detail = d;
this.sources = d.sources ?? []; this.sources = d.sources ?? [];
this.messages = d.messages ?? []; this.messages = d.messages ?? [];
this.syncSelection();
}, },
error: () => this.back() error: () => this.back()
}); });
} }
reloadSources(): void { reloadSources(): void {
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] }); this.service.get(this.notebookId).subscribe({ next: (d) => {
this.sources = d.sources ?? [];
this.syncSelection();
} });
}
// --- Sélection des sources utilisées par le chat -------------------------
// Par défaut tout est coché ; décocher permet de limiter une question (et
// surtout une analyse approfondie, coûteuse en requêtes) à certains PDF.
/** IDs des sources cochées (sous-ensemble des sources READY). */
selectedSourceIds = new Set<string>();
/** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */
private knownSourceIds = new Set<string>();
/** Aligne la sélection sur la liste courante : nouvelles sources cochées par
* défaut, sources supprimées retirées, choix de l'utilisateur préservés. */
private syncSelection(): void {
const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id));
for (const id of readyIds) {
if (!this.knownSourceIds.has(id)) {
this.selectedSourceIds.add(id);
this.knownSourceIds.add(id);
}
}
for (const id of [...this.selectedSourceIds]) {
if (!readyIds.has(id)) this.selectedSourceIds.delete(id);
}
}
isSelected(s: NotebookSource): boolean {
return this.selectedSourceIds.has(s.id);
}
toggleSource(s: NotebookSource): void {
if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id);
else this.selectedSourceIds.add(s.id);
}
get readySourceCount(): number {
return this.sources.filter(s => s.status === 'READY').length;
} }
// --- Sources --- // --- Sources ---
@@ -181,7 +222,7 @@ export class NotebookDetailComponent implements OnInit {
this.messages.push(assistant); this.messages.push(assistant);
this.sending = true; this.sending = true;
this.service.streamChat(this.notebookId, text, deep).subscribe({ this.service.streamChat(this.notebookId, text, deep, [...this.selectedSourceIds]).subscribe({
next: (ev) => { next: (ev) => {
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
else if (ev.type === 'sources') assistant.sources = ev.sources; else if (ev.type === 'sources') assistant.sources = ev.sources;

View File

@@ -87,6 +87,20 @@
</app-dynamic-fields-form> </app-dynamic-fields-form>
</div> </div>
<!-- Pages de Lore liées (uniquement si la campagne est rattachée à un Lore) -->
@if (loreId) {
<div class="field">
<label>Pages de Lore liées</label>
<app-lore-link-picker
[value]="relatedPageIds"
[availablePages]="lorePages"
[loreId]="loreId"
(valueChange)="relatedPageIds = $event">
</app-lore-link-picker>
<p class="hint">Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.</p>
</div>
}
<div class="actions"> <div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()"> <button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon> <lucide-icon [img]="Save" [size]="16"></lucide-icon>

View File

@@ -6,11 +6,14 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service'; import { GameSystemService } from '../../../services/game-system.service';
import { PageService } from '../../../services/page.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model'; import { TemplateField } from '../../../services/template.model';
import { Page } from '../../../services/page.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component'; import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component'; import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/** /**
@@ -21,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-npc-edit', selector: 'app-npc-edit',
imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent], imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent, LoreLinkPickerComponent],
templateUrl: './npc-edit.component.html', templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss'] styleUrls: ['./npc-edit.component.scss']
}) })
@@ -56,12 +59,20 @@ export class NpcEditComponent implements OnInit {
templateFields: TemplateField[] = []; templateFields: TemplateField[] = [];
private order = 0; private order = 0;
/** Lore lié à la campagne (null = pas de lore → section liens masquée). */
loreId: string | null = null;
/** Pages du lore lié — référentiel du picker. */
lorePages: Page[] = [];
/** IDs des pages de lore référencées par ce PNJ. */
relatedPageIds: string[] = [];
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: NpcService, private service: NpcService,
private campaignService: CampaignService, private campaignService: CampaignService,
private gameSystemService: GameSystemService, private gameSystemService: GameSystemService,
private pageService: PageService,
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService private confirmDialog: ConfirmDialogService
) {} ) {}
@@ -87,6 +98,7 @@ export class NpcEditComponent implements OnInit {
this.values = n.values ?? {}; this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {}; this.imageValues = n.imageValues ?? {};
this.keyValueValues = n.keyValueValues ?? {}; this.keyValueValues = n.keyValueValues ?? {};
this.relatedPageIds = [...(n.relatedPageIds ?? [])];
this.order = n.order ?? 0; this.order = n.order ?? 0;
}, },
error: () => this.back() error: () => this.back()
@@ -108,6 +120,14 @@ export class NpcEditComponent implements OnInit {
private loadTemplateForCampaign(campaignId: string): void { private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({ this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => { next: (campaign) => {
// Lore lié → charge ses pages pour le picker de références.
if (campaign.loreId) {
this.loreId = campaign.loreId;
this.pageService.getByLoreId(campaign.loreId).subscribe({
next: (pages) => { this.lorePages = pages; },
error: () => { this.lorePages = []; }
});
}
if (!campaign.gameSystemId) { if (!campaign.gameSystemId) {
this.templateFields = []; this.templateFields = [];
return; return;
@@ -132,7 +152,8 @@ export class NpcEditComponent implements OnInit {
values: this.values, values: this.values,
imageValues: this.imageValues, imageValues: this.imageValues,
keyValueValues: this.keyValueValues, keyValueValues: this.keyValueValues,
campaignId: this.campaignId campaignId: this.campaignId,
relatedPageIds: this.relatedPageIds
}; };
const isCreation = !this.npcId; const isCreation = !this.npcId;
const req = this.npcId const req = this.npcId

View File

@@ -18,6 +18,23 @@
</div> </div>
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view> <app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
<!-- Pages de Lore liées à ce PNJ (sa ville, sa faction…) -->
@if (loreId && (npc?.relatedPageIds?.length ?? 0) > 0) {
<section class="nv-lore-links">
<h2 class="nv-lore-links-title">
<lucide-icon [img]="Link2" [size]="14"></lucide-icon>
Pages de Lore liées
</h2>
<div class="nv-lore-chips">
@for (pageId of npc!.relatedPageIds; track pageId) {
<a class="nv-lore-chip" [routerLink]="['/lore', loreId, 'pages', pageId]">
{{ titleOfPage(pageId) }}
</a>
}
</div>
</section>
}
</div> </div>
@if (npcId && campaignId) { @if (npcId && campaignId) {

View File

@@ -49,3 +49,46 @@
border-color: rgba(168, 85, 247, 0.5); border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe; color: #d8b4fe;
} }
// Pages de Lore liées au PNJ — chips cliquables sous la fiche.
.nv-lore-links {
max-width: 1100px;
margin: 24px auto 0;
padding: 0 32px;
.nv-lore-links-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
font-weight: 600;
color: #a5b4fc;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 0.6rem;
}
.nv-lore-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.nv-lore-chip {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.7rem;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 999px;
color: #d1d5db;
font-size: 0.82rem;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
&:hover {
border-color: #6c63ff;
color: white;
}
}
}

View File

@@ -1,14 +1,16 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Edit3, Sparkles, Link2 } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service'; import { GameSystemService } from '../../../services/game-system.service';
import { PageService } from '../../../services/page.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model'; import { TemplateField } from '../../../services/template.model';
import { Npc } from '../../../services/npc.model'; import { Npc } from '../../../services/npc.model';
import { Page } from '../../../services/page.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component'; import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
@@ -18,7 +20,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
*/ */
@Component({ @Component({
selector: 'app-npc-view', selector: 'app-npc-view',
imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], imports: [LucideAngularModule, RouterLink, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html', templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss'] styleUrls: ['./npc-view.component.scss']
}) })
@@ -26,12 +28,17 @@ export class NpcViewComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft; readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3; readonly Edit3 = Edit3;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Link2 = Link2;
campaignId: string | null = null; campaignId: string | null = null;
npcId: string | null = null; npcId: string | null = null;
npc: Npc | null = null; npc: Npc | null = null;
templateFields: TemplateField[] = []; templateFields: TemplateField[] = [];
/** Lore lié à la campagne (résolution des chips de pages liées). */
loreId: string | null = null;
/** Pages du lore lié, indexées pour résoudre les titres des chips. */
private lorePagesById = new Map<string, Page>();
chatOpen = false; chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; } toggleChat(): void { this.chatOpen = !this.chatOpen; }
@@ -44,6 +51,7 @@ export class NpcViewComponent implements OnInit, OnDestroy {
private service: NpcService, private service: NpcService,
private campaignService: CampaignService, private campaignService: CampaignService,
private gameSystemService: GameSystemService, private gameSystemService: GameSystemService,
private pageService: PageService,
private campaignSidebar: CampaignSidebarService private campaignSidebar: CampaignSidebarService
) {} ) {}
@@ -75,6 +83,13 @@ export class NpcViewComponent implements OnInit, OnDestroy {
this.templateFields = gs.npcTemplate ?? []; this.templateFields = gs.npcTemplate ?? [];
}); });
} }
// Lore lié → référentiel de pages pour résoudre les chips de liens.
if (camp.loreId) {
this.loreId = camp.loreId;
this.pageService.getByLoreId(camp.loreId).subscribe(pages => {
this.lorePagesById = new Map(pages.map(p => [p.id!, p]));
});
}
}); });
} else if (newCampaignId) { } else if (newCampaignId) {
this.campaignId = newCampaignId; this.campaignId = newCampaignId;
@@ -86,6 +101,11 @@ export class NpcViewComponent implements OnInit, OnDestroy {
this.paramsSub?.unsubscribe(); this.paramsSub?.unsubscribe();
} }
/** Titre d'une page de lore liée (pour les chips). */
titleOfPage(pageId: string): string {
return this.lorePagesById.get(pageId)?.title ?? '(page supprimée)';
}
edit(): void { edit(): void {
if (this.campaignId && this.npcId) { if (this.campaignId && this.npcId) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']); this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);

View File

@@ -60,6 +60,9 @@
</div> </div>
</div> </div>
} }
@if (importStatus) {
<p class="import-status" role="status">{{ importStatus }}</p>
}
@if (importFound.length) { @if (importFound.length) {
<p class="import-found"> <p class="import-found">
Sections trouvées : {{ importFound.join(' · ') }} Sections trouvées : {{ importFound.join(' · ') }}

View File

@@ -163,6 +163,16 @@
line-height: 1.4; line-height: 1.4;
} }
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
.import-status {
margin: 0.55rem 0 0;
color: #fbbf24;
font-size: 0.8rem;
font-style: italic;
line-height: 1.4;
}
.import-note { .import-note {
margin: -0.4rem 0 1rem; margin: -0.4rem 0 1rem;
padding: 0.55rem 0.8rem; padding: 0.55rem 0.8rem;

View File

@@ -69,6 +69,12 @@ export class GameSystemEditComponent implements OnInit {
importProgress: { current: number; total: number } | null = null; importProgress: { current: number; total: number } | null = null;
/** Titres de sections trouvés au fil de l'eau (affichage live). */ /** Titres de sections trouvés au fil de l'eau (affichage live). */
importFound: string[] = []; importFound: string[] = [];
/**
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
* en cours, pas l'historique.
*/
importStatus: string | null = null;
name = ''; name = '';
description = ''; description = '';
@@ -139,10 +145,13 @@ export class GameSystemEditComponent implements OnInit {
this.importPhase = 'Extraction du texte…'; this.importPhase = 'Extraction du texte…';
this.importProgress = null; this.importProgress = null;
this.importFound = []; this.importFound = [];
this.importStatus = null;
this.service.importRulesStream(file).subscribe({ this.service.importRulesStream(file).subscribe({
next: (ev) => { next: (ev) => {
if (ev.type === 'progress') { if (ev.type === 'progress') {
// Un morceau vient d'aboutir : le message d'attente est obsolète.
this.importStatus = null;
if (ev.total === 0) { if (ev.total === 0) {
// Phase d'extraction (total encore inconnu). // Phase d'extraction (total encore inconnu).
this.importPhase = 'Extraction du texte…'; this.importPhase = 'Extraction du texte…';
@@ -154,6 +163,8 @@ export class GameSystemEditComponent implements OnInit {
if (!this.importFound.includes(t)) this.importFound.push(t); if (!this.importFound.includes(t)) this.importFound.push(t);
} }
} }
} else if (ev.type === 'status') {
this.importStatus = ev.message;
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount); this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
} }

View File

@@ -8,6 +8,11 @@
<p class="description">{{ lore.description }}</p> <p class="description">{{ lore.description }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
title="Visualiser le graphe des pages et de leurs liens (PNJ inclus)">
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
Graphe
</button>
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore"> <button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier Modifier

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Folder, Plus, Pencil, Trash2, Network } from 'lucide-angular';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
@@ -23,6 +23,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
readonly Plus = Plus; readonly Plus = Plus;
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Network = Network;
lore: Lore | null = null; lore: Lore | null = null;
/** Tous les dossiers du Lore (racines + enfants). */ /** Tous les dossiers du Lore (racines + enfants). */
@@ -81,6 +82,13 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]); this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
} }
/** Ouvre la vue graphe : pages du Lore + PNJ liés, reliés par leurs liens. */
openGraph(): void {
if (this.lore?.id) {
this.router.navigate(['/lore', this.lore.id, 'graph']);
}
}
// ─────────────── Édition / suppression du Lore ─────────────── // ─────────────── Édition / suppression du Lore ───────────────
startEdit(): void { startEdit(): void {

View File

@@ -0,0 +1,71 @@
@if (lore) {
<div class="graph-page">
<header class="graph-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour au Lore
</button>
<div class="graph-title">
<h1>
<lucide-icon [img]="Network" [size]="20"></lucide-icon>
{{ lore.name }} — Graphe
</h1>
<p class="graph-subtitle">
{{ nodes.length - npcCount }} page(s) · {{ npcCount }} PNJ · {{ edgeCount }} lien(s).
Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger.
</p>
</div>
<div class="graph-legend">
<span class="legend-item"><span class="legend-dot legend-dot--page"></span> Page de Lore</span>
<span class="legend-item"><span class="legend-dot legend-dot--npc"></span> PNJ</span>
<span class="legend-item"><span class="legend-line legend-line--npc"></span> Lien PNJ → page</span>
</div>
</header>
@if (nodes.length === 0) {
<div class="graph-empty">
Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure.
</div>
} @else {
<div class="graph-scroll">
<svg #svgEl
[attr.width]="svgWidth"
[attr.height]="svgHeight"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp($event)"
(pointerleave)="onPointerUp($event)">
<!-- Arêtes (sous les nœuds) -->
@for (e of edges; track e.key) {
<line
[attr.x1]="e.x1" [attr.y1]="e.y1"
[attr.x2]="e.x2" [attr.y2]="e.y2"
[class.edge-page]="e.kind === 'page'"
[class.edge-npc]="e.kind === 'npc'" />
}
<!-- Nœuds -->
@for (n of nodes; track n.id) {
<g class="node"
[class.node--npc]="n.kind === 'npc'"
[class.dragging]="draggingId === n.id"
(pointerdown)="onPointerDown($event, n)">
<circle [attr.cx]="n.x" [attr.cy]="n.y" [attr.r]="radiusOf(n)" />
<text class="node-label"
[attr.x]="n.x"
[attr.y]="n.y + radiusOf(n) + 14"
text-anchor="middle">{{ n.displayLabel }}</text>
<title>{{ n.label }}</title>
</g>
}
</svg>
</div>
@if (edgeCount === 0) {
<p class="graph-hint">
Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page)
ou rattachez un PNJ à des pages de Lore depuis sa fiche.
</p>
}
}
</div>
}

View File

@@ -0,0 +1,163 @@
.graph-page {
padding: 1.5rem 2rem;
}
.graph-header {
display: flex;
align-items: flex-start;
gap: 1.25rem;
flex-wrap: wrap;
margin-bottom: 1rem;
.btn-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
&:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
}
.graph-title {
flex: 1;
min-width: 240px;
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.4rem;
color: white;
margin: 0 0 0.25rem;
}
.graph-subtitle {
color: #9ca3af;
font-size: 0.82rem;
margin: 0;
}
}
.graph-legend {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: #9ca3af;
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
&.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; }
&.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; }
}
.legend-line {
width: 18px;
height: 0;
display: inline-block;
&.legend-line--npc { border-top: 2px dashed #d97706; }
}
}
}
// Le SVG peut dépasser l'écran sur un gros lore : scroll dans les 2 sens.
.graph-scroll {
overflow: auto;
border: 1px solid #1e1e3a;
border-radius: 8px;
background:
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0) 0 0 / 26px 26px,
#0d0d1c;
}
svg {
display: block;
touch-action: none; // requis pour le drag au pointeur sur tactile
line {
&.edge-page {
stroke: #4f4f7a;
stroke-width: 1.6;
}
&.edge-npc {
stroke: #d97706;
stroke-width: 1.4;
stroke-dasharray: 5 4;
opacity: 0.8;
}
}
.node {
cursor: grab;
circle {
fill: #1e1b4b;
stroke: #6366f1;
stroke-width: 2;
transition: stroke 0.12s, fill 0.12s;
}
.node-label {
fill: #c7d2fe;
font-size: 11px;
pointer-events: none;
user-select: none;
}
&:hover circle {
fill: #312e81;
stroke: #a5b4fc;
}
&.dragging { cursor: grabbing; }
// PNJ : palette ambre, distincte des pages.
&.node--npc {
circle {
fill: #451a03;
stroke: #d97706;
}
.node-label { fill: #fcd34d; }
&:hover circle {
fill: #78350f;
stroke: #fbbf24;
}
}
}
}
.graph-empty {
padding: 3rem;
text-align: center;
color: #6b7280;
font-style: italic;
border: 1px dashed #2a2a3d;
border-radius: 8px;
}
.graph-hint {
margin-top: 0.75rem;
font-size: 0.82rem;
color: #6b7280;
font-style: italic;
}

View File

@@ -0,0 +1,350 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { NpcService } from '../../services/npc.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Lore } from '../../services/lore.model';
import { Page } from '../../services/page.model';
import { Npc } from '../../services/npc.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */
interface GraphNode {
id: string; // 'page:<id>' ou 'npc:<id>' (évite les collisions d'IDs)
kind: 'page' | 'npc';
label: string;
displayLabel: string;
route: string[]; // navigation au clic
x: number; // centre du nœud (coords SVG)
y: number;
degree: number; // nombre de liens (taille du nœud)
}
interface GraphEdge {
key: string;
kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct)
x1: number; y1: number; x2: number; y2: number;
}
/**
* Graphe du Lore : vue d'ensemble des pages et de leurs liens.
*
* Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore)
* qui référencent au moins une page. Arêtes = `relatedPageIds` des pages
* (liens page↔page) et des PNJ (liens PNJ→page).
*
* Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au
* chargement, puis nœuds déplaçables à la souris — même approche SVG custom
* que chapter-graph, sans dépendance externe.
*/
@Component({
selector: 'app-lore-graph',
imports: [RouterModule, LucideAngularModule],
templateUrl: './lore-graph.component.html',
styleUrls: ['./lore-graph.component.scss']
})
export class LoreGraphComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly Network = Network;
loreId = '';
lore: Lore | null = null;
nodes: GraphNode[] = [];
edges: GraphEdge[] = [];
npcCount = 0;
edgeCount = 0;
readonly MAX_LABEL_CHARS = 22;
private readonly MARGIN = 70;
svgWidth = 800;
svgHeight = 600;
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
draggingId: string | null = null;
private dragOffsetX = 0;
private dragOffsetY = 0;
private dragMoved = false;
private readonly DRAG_THRESHOLD = 4;
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private npcService: NpcService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
npcs: this.npcService.getByLore(this.loreId)
}).subscribe(({ sidebar, npcs }) => {
this.lore = sidebar.lore;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.pageTitleService.set(`${sidebar.lore.name} — Graphe`);
this.buildGraph(sidebar.pages, npcs);
});
}
// --- Construction du graphe ----------------------------------------------
private buildGraph(pages: Page[], npcs: Npc[]): void {
const pageIds = new Set(pages.map(p => p.id!));
// Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines).
const nodes: GraphNode[] = pages.map(p => ({
id: `page:${p.id}`,
kind: 'page' as const,
label: p.title,
displayLabel: this.truncate(p.title),
route: ['/lore', this.loreId, 'pages', p.id!],
x: 0, y: 0, degree: 0
}));
// Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore
// (un PNJ sans lien n'apporte rien à la carte des connexions).
const linkedNpcs = npcs.filter(n =>
(n.relatedPageIds ?? []).some(pid => pageIds.has(pid)));
for (const n of linkedNpcs) {
nodes.push({
id: `npc:${n.id}`,
kind: 'npc',
label: n.name,
displayLabel: this.truncate(n.name),
route: ['/campaigns', n.campaignId, 'npcs', n.id!],
x: 0, y: 0, degree: 0
});
}
this.npcCount = linkedNpcs.length;
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
// (A→B et B→A = un seul trait).
const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = [];
const seenPairs = new Set<string>();
for (const p of pages) {
for (const targetId of p.relatedPageIds ?? []) {
if (!pageIds.has(targetId) || targetId === p.id) continue;
const pair = [p.id!, targetId].sort().join('|');
if (seenPairs.has(pair)) continue;
seenPairs.add(pair);
adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` });
}
}
for (const n of linkedNpcs) {
for (const targetId of new Set(n.relatedPageIds ?? [])) {
if (!pageIds.has(targetId)) continue;
adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` });
}
}
this.adjacency = adjacency;
this.edgeCount = adjacency.length;
// Degré (pondère la taille des nœuds : une capitale très liée ressort).
const degree = new Map<string, number>();
for (const e of adjacency) {
degree.set(e.a, (degree.get(e.a) ?? 0) + 1);
degree.set(e.b, (degree.get(e.b) ?? 0) + 1);
}
for (const node of nodes) {
node.degree = degree.get(node.id) ?? 0;
}
this.nodes = nodes;
this.runForceLayout();
this.recomputeEdges();
}
/**
* Layout force-directed (Fruchterman-Reingold simplifié) :
* répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le
* centre (regroupe les composantes déconnectées), refroidissement progressif.
* Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf.
* convention projet d'éviter Math.random pour des rendus reproductibles).
*/
private runForceLayout(): void {
const n = this.nodes.length;
if (n === 0) {
this.svgWidth = 800; this.svgHeight = 400;
return;
}
const side = Math.max(600, Math.ceil(170 * Math.sqrt(n)));
const w = side, h = side;
const cx = w / 2, cy = h / 2;
// Init en spirale : angle d'or → répartition uniforme et déterministe.
const golden = Math.PI * (3 - Math.sqrt(5));
this.nodes.forEach((node, i) => {
const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN);
const a = i * golden;
node.x = cx + r * Math.cos(a);
node.y = cy + r * Math.sin(a);
});
const index = new Map(this.nodes.map(node => [node.id, node]));
const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds
let temperature = side / 8;
for (let iter = 0; iter < 300; iter++) {
const dx = new Map<string, number>();
const dy = new Map<string, number>();
for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); }
// Répulsion entre toutes les paires.
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = this.nodes[i], b = this.nodes[j];
let vx = a.x - b.x, vy = a.y - b.y;
let d = Math.hypot(vx, vy);
if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); }
const force = (k * k) / d;
dx.set(a.id, dx.get(a.id)! + (vx / d) * force);
dy.set(a.id, dy.get(a.id)! + (vy / d) * force);
dx.set(b.id, dx.get(b.id)! - (vx / d) * force);
dy.set(b.id, dy.get(b.id)! - (vy / d) * force);
}
}
// Attraction le long des arêtes.
for (const e of this.adjacency) {
const a = index.get(e.a)!, b = index.get(e.b)!;
const vx = a.x - b.x, vy = a.y - b.y;
const d = Math.max(0.01, Math.hypot(vx, vy));
const force = (d * d) / k;
dx.set(a.id, dx.get(a.id)! - (vx / d) * force);
dy.set(a.id, dy.get(a.id)! - (vy / d) * force);
dx.set(b.id, dx.get(b.id)! + (vx / d) * force);
dy.set(b.id, dy.get(b.id)! + (vy / d) * force);
}
// Gravité douce vers le centre (sinon les composantes isolées fuient).
for (const node of this.nodes) {
dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06);
dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06);
}
// Application bornée par la température, dans le cadre.
for (const node of this.nodes) {
const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!;
const d = Math.max(0.01, Math.hypot(ddx, ddy));
const step = Math.min(d, temperature);
node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step));
node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step));
}
temperature *= 0.96;
}
this.svgWidth = w;
this.svgHeight = h;
}
/** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */
private recomputeEdges(): void {
const index = new Map(this.nodes.map(n => [n.id, n]));
this.edges = this.adjacency
.filter(e => index.has(e.a) && index.has(e.b))
.map(e => {
const a = index.get(e.a)!, b = index.get(e.b)!;
return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
});
}
/** Rayon d'un nœud : grossit doucement avec son nombre de liens. */
radiusOf(node: GraphNode): number {
return 14 + Math.min(10, node.degree * 1.5);
}
// --- Interactions (drag pour réarranger, clic pour ouvrir) ----------------
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
const svg = this.svgEl?.nativeElement;
if (!svg) return { x: evt.clientX, y: evt.clientY };
const pt = svg.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: evt.clientX, y: evt.clientY };
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
onPointerDown(evt: PointerEvent, node: GraphNode): void {
if (evt.button !== 0) return;
evt.preventDefault();
const { x, y } = this.toSvgCoords(evt);
this.draggingId = node.id;
this.dragOffsetX = x - node.x;
this.dragOffsetY = y - node.y;
this.dragMoved = false;
(evt.target as Element).setPointerCapture?.(evt.pointerId);
}
onPointerMove(evt: PointerEvent): void {
if (!this.draggingId) return;
const node = this.nodes.find(n => n.id === this.draggingId);
if (!node) return;
const { x, y } = this.toSvgCoords(evt);
const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX);
const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY);
if (!this.dragMoved) {
if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return;
this.dragMoved = true;
}
node.x = newX;
node.y = newY;
this.recomputeEdges();
this.fitSvgToNodes();
}
onPointerUp(evt: PointerEvent): void {
if (!this.draggingId) return;
const id = this.draggingId;
const moved = this.dragMoved;
this.draggingId = null;
this.dragMoved = false;
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
if (moved) return;
// Clic simple → ouvre la page / la fiche PNJ.
const node = this.nodes.find(n => n.id === id);
if (node) this.router.navigate(node.route);
}
/** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */
private fitSvgToNodes(): void {
for (const n of this.nodes) {
if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN;
if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN;
}
}
private truncate(text: string): string {
return text.length > this.MAX_LABEL_CHARS
? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…'
: text;
}
back(): void {
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement.
}
}

View File

@@ -71,6 +71,75 @@
</app-image-gallery> </app-image-gallery>
</div> </div>
} }
<!-- Champ KEY_VALUE_LIST : liste libellé/valeur (labels figés par le template). -->
@if (field.type === 'KEY_VALUE_LIST') {
<div class="field">
<label>{{ field.name }}</label>
<div class="kv-grid">
@for (lbl of field.labels ?? []; track $index) {
<div class="kv-cell">
<span class="kv-label">{{ lbl }}</span>
<input
type="text"
[(ngModel)]="keyValueValues[field.name][lbl]"
[name]="'kv_' + field.name + '_' + lbl"
placeholder="—" />
</div>
}
@if (!(field.labels ?? []).length) {
<p class="kv-empty">Aucun libellé défini dans le template pour ce champ.</p>
}
</div>
</div>
}
<!-- Champ TABLE : colonnes figées par le template, lignes libres. -->
@if (field.type === 'TABLE') {
<div class="field">
<label>{{ field.name }}</label>
@if ((field.labels ?? []).length) {
<div class="table-edit-wrap">
<table class="table-edit">
<thead>
<tr>
@for (col of field.labels; track $index) {
<th>{{ col }}</th>
}
<th class="table-edit-actions-col"></th>
</tr>
</thead>
<tbody>
@for (row of tableValues[field.name] ?? []; track $index; let ri = $index) {
<tr>
@for (col of field.labels; track $index) {
<td>
<input
type="text"
[(ngModel)]="row[col]"
[name]="'tbl_' + field.name + '_' + ri + '_' + col"
[placeholder]="col" />
</td>
}
<td class="table-edit-actions-col">
<button type="button" class="btn-row-delete"
(click)="removeTableRow(field.name, ri)"
[attr.aria-label]="'Supprimer la ligne ' + (ri + 1)" title="Supprimer la ligne">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
<button type="button" class="btn-row-add" (click)="addTableRow(field.name, field.labels)">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
Ajouter une ligne
</button>
</div>
} @else {
<p class="kv-empty">Aucune colonne définie dans le template pour ce tableau.</p>
}
</div>
}
} }
} }
<!-- Tags --------------------------------------------------------- --> <!-- Tags --------------------------------------------------------- -->

View File

@@ -126,6 +126,127 @@
} }
} }
// Grille de saisie d'un champ Tableau (KEY_VALUE_LIST) — même esthétique
// que la grille des fiches de personnage (dynamic-fields-form).
.kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
.kv-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 8px 6px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
.kv-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9ca3af;
}
input {
width: 100%;
text-align: center;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
color: white;
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1.05rem;
&:focus { outline: none; border-color: #6c63ff; }
}
}
.kv-empty {
padding: 8px;
font-size: 0.8rem;
color: #6b7280;
font-style: italic;
margin: 0;
}
}
// Éditeur d'un champ Tableau (TABLE) : colonnes du template, lignes libres.
.table-edit-wrap {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.table-edit {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #99f6e4;
padding: 6px 8px;
border-bottom: 1px solid rgba(20, 184, 166, 0.35);
}
td {
padding: 4px 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
input {
width: 100%;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
color: white;
font-size: 0.88rem;
&:focus { outline: none; border-color: #14b8a6; }
}
}
.table-edit-actions-col {
width: 34px;
text-align: center;
}
.btn-row-delete {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 4px;
&:hover { color: #f87171; }
}
}
.btn-row-add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px dashed rgba(20, 184, 166, 0.5);
border-radius: 5px;
color: #5eead4;
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
cursor: pointer;
&:hover { background: rgba(20, 184, 166, 0.08); }
}
.ai-error-banner { .ai-error-banner {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Sparkles, Plus, Trash2 } from 'lucide-angular';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
@@ -42,6 +42,8 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
}) })
export class PageEditComponent implements OnInit, OnDestroy { export class PageEditComponent implements OnInit, OnDestroy {
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Plus = Plus;
readonly Trash2 = Trash2;
loreId = ''; loreId = '';
pageId = ''; pageId = '';
@@ -63,6 +65,10 @@ export class PageEditComponent implements OnInit, OnDestroy {
* la liste ordonnee des IDs d'images uploadees. * la liste ordonnee des IDs d'images uploadees.
*/ */
imageValues: Record<string, string[]> = {}; imageValues: Record<string, string[]> = {};
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
keyValueValues: Record<string, Record<string, string>> = {};
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
tableValues: Record<string, Array<Record<string, string>>> = {};
/** Étiquettes libres (Phase 5B). */ /** Étiquettes libres (Phase 5B). */
tags: string[] = []; tags: string[] = [];
/** IDs des pages liées (Phase 5B). */ /** IDs des pages liées (Phase 5B). */
@@ -169,16 +175,27 @@ export class PageEditComponent implements OnInit, OnDestroy {
// structure `imageValues: Map<String, List<String>>` a l'etape 5). // structure `imageValues: Map<String, List<String>>` a l'etape 5).
const base: Record<string, string> = {}; const base: Record<string, string> = {};
const imageBase: Record<string, string[]> = {}; const imageBase: Record<string, string[]> = {};
const kvBase: Record<string, Record<string, string>> = {};
const tableBase: Record<string, Array<Record<string, string>>> = {};
for (const f of this.template?.fields ?? []) { for (const f of this.template?.fields ?? []) {
if (f.type === 'TEXT') { if (f.type === 'TEXT') {
base[f.name] = page.values?.[f.name] ?? ''; base[f.name] = page.values?.[f.name] ?? '';
} else if (f.type === 'IMAGE') { } else if (f.type === 'IMAGE') {
// Initialise la galerie d'images pour ce champ (vide si jamais rempli). // Initialise la galerie d'images pour ce champ (vide si jamais rempli).
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])]; imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
} else if (f.type === 'KEY_VALUE_LIST') {
// Toujours initialiser l'objet interne : le ngModel du formulaire
// bind directement keyValueValues[field.name][label].
kvBase[f.name] = { ...(page.keyValueValues?.[f.name] ?? {}) };
} else if (f.type === 'TABLE') {
// Copie profonde des lignes : chaque ligne est éditée par ngModel.
tableBase[f.name] = (page.tableValues?.[f.name] ?? []).map(row => ({ ...row }));
} }
} }
this.values = base; this.values = base;
this.imageValues = imageBase; this.imageValues = imageBase;
this.keyValueValues = kvBase;
this.tableValues = tableBase;
this.tags = [...(page.tags ?? [])]; this.tags = [...(page.tags ?? [])];
this.relatedPageIds = [...(page.relatedPageIds ?? [])]; this.relatedPageIds = [...(page.relatedPageIds ?? [])];
this.pageTitleService.set(page.title); this.pageTitleService.set(page.title);
@@ -193,6 +210,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
notes: this.notes, notes: this.notes,
values: this.values, values: this.values,
imageValues: this.imageValues, imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
tableValues: this.tableValues,
tags: this.tags, tags: this.tags,
relatedPageIds: this.relatedPageIds relatedPageIds: this.relatedPageIds
}; };
@@ -202,6 +221,20 @@ export class PageEditComponent implements OnInit, OnDestroy {
}); });
} }
// --- Champs TABLE (lignes libres) ---------------------------------------
// Mutation en place des lignes : recréer le tableau à chaque frappe ferait
// perdre le focus de la cellule en cours d'édition.
addTableRow(fieldName: string, columns: string[] | null | undefined): void {
const row: Record<string, string> = {};
for (const col of columns ?? []) row[col] = '';
(this.tableValues[fieldName] ??= []).push(row);
}
removeTableRow(fieldName: string, rowIndex: number): void {
this.tableValues[fieldName]?.splice(rowIndex, 1);
}
// --- Chat IA conversationnel (Phase b5) -------------------------------- // --- Chat IA conversationnel (Phase b5) --------------------------------
toggleChat(): void { toggleChat(): void {

View File

@@ -40,6 +40,50 @@
</app-image-gallery> </app-image-gallery>
</section> </section>
} }
@if (field.type === 'KEY_VALUE_LIST') {
<section class="view-section">
<h2 class="view-section-title">{{ field.name }}</h2>
@if (kvHasContent(field.name, field.labels)) {
<div class="view-kv-grid">
@for (lbl of field.labels ?? []; track $index) {
<div class="view-kv-cell">
<span class="view-kv-label">{{ lbl }}</span>
<span class="view-kv-value">{{ kvValueOf(field.name, lbl) || '—' }}</span>
</div>
}
</div>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
}
@if (field.type === 'TABLE') {
<section class="view-section">
<h2 class="view-section-title">{{ field.name }}</h2>
@if (tableRowsOf(field.name).length) {
<table class="view-table">
<thead>
<tr>
@for (col of field.labels ?? []; track $index) {
<th>{{ col }}</th>
}
</tr>
</thead>
<tbody>
@for (row of tableRowsOf(field.name); track $index) {
<tr>
@for (col of field.labels ?? []; track $index) {
<td>{{ row[col] || '—' }}</td>
}
</tr>
}
</tbody>
</table>
} @else {
<p class="view-section-empty">Non renseigné</p>
}
</section>
}
} }
} }
<!-- Tags --> <!-- Tags -->

View File

@@ -114,6 +114,21 @@ export class PageViewComponent implements OnInit, OnDestroy {
return this.page?.imageValues?.[fieldName] ?? []; return this.page?.imageValues?.[fieldName] ?? [];
} }
/** Valeur d'un libellé d'un champ KEY_VALUE_LIST (tableau). */
kvValueOf(fieldName: string, label: string): string {
return this.page?.keyValueValues?.[fieldName]?.[label] ?? '';
}
/** True si au moins une valeur de la liste clé/valeur est renseignée. */
kvHasContent(fieldName: string, labels: string[] | null | undefined): boolean {
return (labels ?? []).some(lbl => this.kvValueOf(fieldName, lbl).trim() !== '');
}
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
tableRowsOf(fieldName: string): Array<Record<string, string>> {
return this.page?.tableValues?.[fieldName] ?? [];
}
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */ /** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
titleOfRelated(pageId: string): string { titleOfRelated(pageId: string): string {
return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)';

View File

@@ -66,17 +66,24 @@
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon> <lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button> </button>
</div> </div>
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'"> <span class="field-chip"
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon> [class.field-chip-image]="f.type === 'IMAGE'"
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
[class.field-chip-table]="f.type === 'TABLE'">
<lucide-icon [img]="iconFor(f.type)" [size]="12"></lucide-icon>
{{ f.name }} {{ f.name }}
</span> </span>
<button type="button" <select
class="btn-icon btn-type-toggle" class="type-select"
(click)="toggleFieldType(i)" [ngModel]="f.type"
[attr.aria-label]="'Basculer vers ' + (f.type === 'TEXT' ? 'Image' : 'Texte')" [ngModelOptions]="{ standalone: true }"
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'"> (ngModelChange)="setFieldType(i, $event)"
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }} title="Type du champ">
</button> <option value="TEXT">Texte</option>
<option value="IMAGE">Image</option>
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
<option value="TABLE">Tableau</option>
</select>
@if (f.type === 'IMAGE') { @if (f.type === 'IMAGE') {
<select <select
class="layout-select" class="layout-select"
@@ -94,6 +101,36 @@
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</li> </li>
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
<li class="kv-labels-row">
@for (lbl of f.labels ?? []; track $index; let li = $index) {
<span class="kv-label-chip">
<input
type="text"
[ngModel]="lbl"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="updateLabel(f, li, $event)"
[placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'"
[attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" />
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
<lucide-icon [img]="X" [size]="11"></lucide-icon>
</button>
</span>
}
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
</button>
@if (!(f.labels ?? []).length) {
<span class="kv-labels-hint">
{{ f.type === 'TABLE'
? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)'
: 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }}
</span>
}
</li>
}
} }
</ul> </ul>
@@ -111,13 +148,15 @@
aria-label="Type du champ"> aria-label="Type du champ">
<option value="TEXT">Texte</option> <option value="TEXT">Texte</option>
<option value="IMAGE">Image</option> <option value="IMAGE">Image</option>
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
<option value="TABLE">Tableau</option>
</select> </select>
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ"> <button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button> </button>
</div> </div>
<p class="hint">Les champs Texte sont editables librement et utilisables par l'IA. Les champs Image hebergent une galerie d'illustrations.</p> <p class="hint">Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
</div> </div>

View File

@@ -90,6 +90,70 @@
gap: 0.5rem; gap: 0.5rem;
} }
// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST).
.kv-labels-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
// Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap).
padding-left: 30px;
.kv-label-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: #1a1a2e;
border: 1px solid #4a3a1f;
border-radius: 5px;
padding: 0.15rem 0.3rem;
input {
width: 90px;
background: transparent;
border: none;
color: #fbd38d;
font-size: 0.8rem;
padding: 0.2rem 0.3rem;
&:focus { outline: none; }
}
.kv-label-remove {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 2px;
&:hover { color: #f87171; }
}
}
.btn-kv-add-label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: 1px dashed #4a3a1f;
border-radius: 5px;
color: #fbd38d;
font-size: 0.78rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
&:hover { background: rgba(251, 211, 141, 0.08); }
}
.kv-labels-hint {
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
}
.field-row { .field-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -111,6 +175,18 @@
background: #312b5c; background: #312b5c;
color: #c7b8ff; color: #c7b8ff;
} }
// Couleur discriminante pour les champs Liste clé/valeur (palette ambre).
&.field-chip-kv {
background: #4a3a1f;
color: #fbd38d;
}
// Couleur discriminante pour les champs Tableau (palette sarcelle).
&.field-chip-table {
background: #134e4a;
color: #99f6e4;
}
} }
.btn-type-toggle { .btn-type-toggle {

View File

@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular'; import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service'; import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model'; import { LoreNode } from '../../services/lore.model';
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model'; import { FieldType, ImageLayout, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { popReturnTo } from '../return-stack.helper'; import { popReturnTo } from '../return-stack.helper';
@@ -31,6 +31,19 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
readonly ImageIcon = ImageIcon; readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp; readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown; readonly ChevronDown = ChevronDown;
readonly ListOrdered = ListOrdered;
readonly TableIcon = TableIcon;
readonly X = X;
/** Icone du chip selon le type du champ. */
iconFor(type: FieldType) {
switch (type) {
case 'IMAGE': return this.ImageIcon;
case 'KEY_VALUE_LIST': return this.ListOrdered;
case 'TABLE': return this.TableIcon;
default: return this.Type;
}
}
form: FormGroup; form: FormGroup;
loreId = ''; loreId = '';
@@ -123,10 +136,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
if (!name) return; if (!name) return;
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage). // Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
if (this.fields.some(f => f.name === name)) return; if (this.fields.some(f => f.name === name)) return;
const newField: TemplateField = this.newFieldType === 'IMAGE' this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = ''; this.newFieldName = '';
// Le type reste sur la derniere valeur choisie : pratique pour enchainer // Le type reste sur la derniere valeur choisie : pratique pour enchainer
// plusieurs champs du meme type. // plusieurs champs du meme type.
@@ -145,17 +155,11 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
this.fields = next; this.fields = next;
} }
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */ /** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
toggleFieldType(index: number): void { setFieldType(index: number, type: FieldType): void {
const field = this.fields[index]; this.fields = this.fields.map((f, i) =>
if (!field) return; i === index ? buildLoreTemplateField(f.name, type, f) : f
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT'; );
this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
} }
/** Met a jour le layout d'un champ IMAGE. */ /** Met a jour le layout d'un champ IMAGE. */
@@ -165,6 +169,24 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
); );
} }
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
// Mutation en place des labels : recreer le tableau de fields a chaque
// frappe ferait perdre le focus de l'input en cours d'edition.
addLabel(field: TemplateField): void {
field.labels = [...(field.labels ?? []), ''];
}
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
if (!field.labels) return;
field.labels[labelIndex] = value;
}
removeLabel(field: TemplateField, labelIndex: number): void {
if (!field.labels) return;
field.labels = field.labels.filter((_, i) => i !== labelIndex);
}
submit(): void { submit(): void {
if (this.form.invalid) return; if (this.form.invalid) return;
const raw = this.form.value; const raw = this.form.value;
@@ -173,7 +195,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
name: raw.name, name: raw.name,
description: raw.description, description: raw.description,
defaultNodeId: raw.defaultNodeId, defaultNodeId: raw.defaultNodeId,
fields: this.fields fields: cleanFieldLabels(this.fields)
}).subscribe({ }).subscribe({
next: (created) => this.navigateBack(created.id ?? null), next: (created) => this.navigateBack(created.id ?? null),
error: () => console.error('Erreur lors de la création du template') error: () => console.error('Erreur lors de la création du template')

View File

@@ -54,17 +54,24 @@
</div> </div>
<span class="field-chip" <span class="field-chip"
[class.field-chip-image]="f.type === 'IMAGE'" [class.field-chip-image]="f.type === 'IMAGE'"
[class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)" [class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
[class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)"> [class.field-chip-table]="f.type === 'TABLE'"
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon> [class.field-chip-existing]="f.type === 'TEXT' && isExistingField(f)"
[class.field-chip-new]="f.type === 'TEXT' && !isExistingField(f)">
<lucide-icon [img]="iconFor(f.type)" [size]="12"></lucide-icon>
{{ f.name }} {{ f.name }}
</span> </span>
<button type="button" <select
class="btn-icon-ghost btn-type-toggle" class="type-select"
(click)="toggleFieldType(i)" [ngModel]="f.type"
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'"> [ngModelOptions]="{ standalone: true }"
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }} (ngModelChange)="setFieldType(i, $event)"
</button> title="Type du champ">
<option value="TEXT">Texte</option>
<option value="IMAGE">Image</option>
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
<option value="TABLE">Tableau</option>
</select>
@if (f.type === 'IMAGE') { @if (f.type === 'IMAGE') {
<select <select
class="layout-select" class="layout-select"
@@ -82,6 +89,36 @@
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button> </button>
</li> </li>
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
<li class="kv-labels-row">
@for (lbl of f.labels ?? []; track $index; let li = $index) {
<span class="kv-label-chip">
<input
type="text"
[ngModel]="lbl"
[ngModelOptions]="{ standalone: true }"
(ngModelChange)="updateLabel(f, li, $event)"
[placeholder]="f.type === 'TABLE' ? 'Colonne' : 'Libellé'"
[attr.aria-label]="(f.type === 'TABLE' ? 'Colonne ' : 'Libellé ') + (li + 1)" />
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" aria-label="Retirer">
<lucide-icon [img]="X" [size]="11"></lucide-icon>
</button>
</span>
}
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
</button>
@if (!(f.labels ?? []).length) {
<span class="kv-labels-hint">
{{ f.type === 'TABLE'
? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)'
: 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }}
</span>
}
</li>
}
} }
</ul> </ul>
<div class="field-row add-row"> <div class="field-row add-row">
@@ -98,12 +135,14 @@
aria-label="Type du champ"> aria-label="Type du champ">
<option value="TEXT">Texte</option> <option value="TEXT">Texte</option>
<option value="IMAGE">Image</option> <option value="IMAGE">Image</option>
<option value="KEY_VALUE_LIST">Liste clé/valeur</option>
<option value="TABLE">Tableau</option>
</select> </select>
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ"> <button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> <lucide-icon [img]="Plus" [size]="14"></lucide-icon>
</button> </button>
</div> </div>
<p class="hint">Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.</p> <p class="hint">Texte = libre + generable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).</p>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -90,6 +90,70 @@
gap: 0.5rem; gap: 0.5rem;
} }
// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST).
.kv-labels-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
// Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap).
padding-left: 30px;
.kv-label-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: #1a1a2e;
border: 1px solid #4a3a1f;
border-radius: 5px;
padding: 0.15rem 0.3rem;
input {
width: 90px;
background: transparent;
border: none;
color: #fbd38d;
font-size: 0.8rem;
padding: 0.2rem 0.3rem;
&:focus { outline: none; }
}
.kv-label-remove {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 2px;
&:hover { color: #f87171; }
}
}
.btn-kv-add-label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: 1px dashed #4a3a1f;
border-radius: 5px;
color: #fbd38d;
font-size: 0.78rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
&:hover { background: rgba(251, 211, 141, 0.08); }
}
.kv-labels-hint {
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
}
.field-row { .field-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -127,6 +191,20 @@
border-color: #3d3566; border-color: #3d3566;
color: #c7b8ff; color: #c7b8ff;
} }
// Champ Liste clé/valeur (palette ambre) — prioritaire sur existing/new.
&.field-chip-kv {
background: #4a3a1f;
border-color: #6b5328;
color: #fbd38d;
}
// Champ Tableau (palette sarcelle) — prioritaire sur existing/new.
&.field-chip-table {
background: #134e4a;
border-color: #14b8a6;
color: #99f6e4;
}
} }
.btn-type-toggle { .btn-type-toggle {

View File

@@ -4,14 +4,14 @@ import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators }
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators'; import { switchMap, takeUntil } from 'rxjs/operators';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular'; import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service'; import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service'; import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service'; import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model'; import { LoreNode } from '../../services/lore.model';
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model'; import { FieldType, ImageLayout, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
@@ -32,6 +32,19 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
readonly ImageIcon = ImageIcon; readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp; readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown; readonly ChevronDown = ChevronDown;
readonly ListOrdered = ListOrdered;
readonly TableIcon = TableIcon;
readonly X = X;
/** Icone du chip selon le type du champ. */
iconFor(type: FieldType) {
switch (type) {
case 'IMAGE': return this.ImageIcon;
case 'KEY_VALUE_LIST': return this.ListOrdered;
case 'TABLE': return this.TableIcon;
default: return this.Type;
}
}
form: FormGroup; form: FormGroup;
loreId = ''; loreId = '';
@@ -100,10 +113,9 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant, // Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
// utile pour les templates legacy cote frontend meme si le backend le fait aussi). // utile pour les templates legacy cote frontend meme si le backend le fait aussi).
this.fields = (template.fields ?? []).map(f => { this.fields = (template.fields ?? []).map(f => {
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'; const type: FieldType =
return type === 'IMAGE' f.type === 'IMAGE' || f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' ? f.type : 'TEXT';
? { name: f.name, type, layout: f.layout ?? 'GALLERY' } return buildLoreTemplateField(f.name, type, f);
: { name: f.name, type };
}); });
this.originalFieldNames = new Set(this.fields.map(f => f.name)); this.originalFieldNames = new Set(this.fields.map(f => f.name));
this.form.patchValue({ this.form.patchValue({
@@ -118,10 +130,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
const name = this.newFieldName.trim(); const name = this.newFieldName.trim();
if (!name) return; if (!name) return;
if (this.fields.some(f => f.name === name)) return; if (this.fields.some(f => f.name === name)) return;
const newField: TemplateField = this.newFieldType === 'IMAGE' this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = ''; this.newFieldName = '';
} }
@@ -138,17 +147,11 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
this.fields = next; this.fields = next;
} }
/** Bascule le type d'un champ (TEXT <-> IMAGE). */ /** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
toggleFieldType(index: number): void { setFieldType(index: number, type: FieldType): void {
const field = this.fields[index]; this.fields = this.fields.map((f, i) =>
if (!field) return; i === index ? buildLoreTemplateField(f.name, type, f) : f
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT'; );
this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
} }
/** Met a jour le layout d'un champ IMAGE. */ /** Met a jour le layout d'un champ IMAGE. */
@@ -158,6 +161,24 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
); );
} }
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
// Mutation en place des labels : recreer le tableau de fields a chaque
// frappe ferait perdre le focus de l'input en cours d'edition.
addLabel(field: TemplateField): void {
field.labels = [...(field.labels ?? []), ''];
}
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
if (!field.labels) return;
field.labels[labelIndex] = value;
}
removeLabel(field: TemplateField, labelIndex: number): void {
if (!field.labels) return;
field.labels = field.labels.filter((_, i) => i !== labelIndex);
}
save(): void { save(): void {
if (this.form.invalid || !this.template) return; if (this.form.invalid || !this.template) return;
const raw = this.form.value; const raw = this.form.value;
@@ -166,7 +187,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
name: raw.name, name: raw.name,
description: raw.description, description: raw.description,
defaultNodeId: raw.defaultNodeId || null, defaultNodeId: raw.defaultNodeId || null,
fields: this.fields fields: cleanFieldLabels(this.fields)
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]), next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la sauvegarde du template') error: () => console.error('Erreur lors de la sauvegarde du template')

View File

@@ -63,10 +63,13 @@ export interface CampaignImportApplyResult {
/** /**
* Évènements du flux SSE d'import streamé. * Évènements du flux SSE d'import streamé.
* - progress : avancement (total=0 ⇒ extraction en cours). * - progress : avancement (total=0 ⇒ extraction en cours).
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
* - done : arbre proposé (à réviser). * - done : arbre proposé (à réviser).
* - error : message d'erreur côté serveur. * - error : message d'erreur côté serveur.
*/ */
export type CampaignImportStreamEvent = export type CampaignImportStreamEvent =
| { type: 'status'; message: string }
| { | {
type: 'progress'; type: 'progress';
current: number; current: number;

View File

@@ -61,17 +61,30 @@ export class CampaignImportService {
let buffer = ''; let buffer = '';
let currentEvent: string | null = null; let currentEvent: string | null = null;
let currentData = ''; let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
const dispatch = () => { const dispatch = () => {
const name = currentEvent ?? 'message'; const name = currentEvent ?? 'message';
if (name === 'error') { if (name === 'error') {
let message = 'Échec de l\'import.'; let message = 'Échec de l\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
terminated = true;
subscriber.error(new Error(message)); subscriber.error(new Error(message));
} else if (name === 'status') {
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
try {
const obj = JSON.parse(currentData) as { message?: string };
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
} catch { /* bloc malformé ignoré */ }
} else if (name === 'progress' || name === 'done') { } else if (name === 'progress' || name === 'done') {
try { try {
const obj = JSON.parse(currentData); const obj = JSON.parse(currentData);
if (name === 'done') { if (name === 'done') {
terminated = true;
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] }); subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
subscriber.complete(); subscriber.complete();
} else { } else {
@@ -105,9 +118,12 @@ export class CampaignImportService {
} }
} }
if (currentEvent !== null || currentData !== '') dispatch(); if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete(); if (!terminated) {
subscriber.error(new Error(
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
}
} catch (err) { } catch (err) {
subscriber.error(err); if (!terminated) subscriber.error(err);
} }
} }
} }

View File

@@ -32,11 +32,14 @@ export interface RulesImportResponse {
/** /**
* Évènements du flux SSE d'import streamé. * Évènements du flux SSE d'import streamé.
* - progress : avancement (total=0 ⇒ phase d'extraction en cours). * - progress : avancement (total=0 ⇒ phase d'extraction en cours).
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
* - done : résultat final (sections proposées). * - done : résultat final (sections proposées).
* - error : message d'erreur côté serveur. * - error : message d'erreur côté serveur.
*/ */
export type RulesImportStreamEvent = export type RulesImportStreamEvent =
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] } | { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
| { type: 'status'; message: string }
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number } | { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
| { type: 'error'; message: string }; | { type: 'error'; message: string };

View File

@@ -92,17 +92,30 @@ export class GameSystemService {
let buffer = ''; let buffer = '';
let currentEvent: string | null = null; let currentEvent: string | null = null;
let currentData = ''; let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
const dispatch = () => { const dispatch = () => {
const name = currentEvent ?? 'message'; const name = currentEvent ?? 'message';
if (name === 'error') { if (name === 'error') {
let message = 'Échec de l\'import.'; let message = 'Échec de l\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
terminated = true;
subscriber.error(new Error(message)); subscriber.error(new Error(message));
} else if (name === 'status') {
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
try {
const obj = JSON.parse(currentData) as { message?: string };
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
} catch { /* bloc malformé ignoré */ }
} else if (name === 'progress' || name === 'done') { } else if (name === 'progress' || name === 'done') {
try { try {
const obj = JSON.parse(currentData); const obj = JSON.parse(currentData);
if (name === 'done') { if (name === 'done') {
terminated = true;
subscriber.next({ type: 'done', ...obj }); subscriber.next({ type: 'done', ...obj });
subscriber.complete(); subscriber.complete();
} else { } else {
@@ -136,9 +149,12 @@ export class GameSystemService {
} }
} }
if (currentEvent !== null || currentData !== '') dispatch(); if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete(); if (!terminated) {
subscriber.error(new Error(
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
}
} catch (err) { } catch (err) {
subscriber.error(err); if (!terminated) subscriber.error(err);
} }
} }
} }

View File

@@ -47,8 +47,11 @@ export class NotebookService {
/** /**
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE). * Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
* Émissions forcées dans la zone Angular pour la détection de changement. * Émissions forcées dans la zone Angular pour la détection de changement.
*
* `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ;
* undefined = toutes les sources prêtes du notebook.
*/ */
streamChat(notebookId: string, message: string, deep = false): Observable<NotebookChatEvent> { streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[]): Observable<NotebookChatEvent> {
return new Observable<NotebookChatEvent>((subscriber) => { return new Observable<NotebookChatEvent>((subscriber) => {
const controller = new AbortController(); const controller = new AbortController();
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev)); const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
@@ -59,7 +62,7 @@ export class NotebookService {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ message, deep }), body: JSON.stringify({ message, deep, sourceIds: sourceIds ?? null }),
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {

View File

@@ -11,6 +11,8 @@ export interface Npc {
imageValues?: Record<string, string[]>; imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>; keyValueValues?: Record<string, Record<string, string>>;
campaignId: string; campaignId: string;
/** IDs de Pages de Lore référencées (sa ville, sa faction…). */
relatedPageIds?: string[];
/** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */ /** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */
folder?: string | null; folder?: string | null;
order?: number; order?: number;
@@ -24,5 +26,6 @@ export interface NpcCreate {
imageValues?: Record<string, string[]>; imageValues?: Record<string, string[]>;
keyValueValues?: Record<string, Record<string, string>>; keyValueValues?: Record<string, Record<string, string>>;
campaignId: string; campaignId: string;
relatedPageIds?: string[];
folder?: string | null; folder?: string | null;
} }

View File

@@ -16,6 +16,11 @@ export class NpcService {
return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`); return this.http.get<Npc[]>(`${this.apiUrl}/campaign/${campaignId}`);
} }
/** PNJ de toutes les campagnes liées à un Lore — alimente le graphe du Lore. */
getByLore(loreId: string): Observable<Npc[]> {
return this.http.get<Npc[]>(`${this.apiUrl}/lore/${loreId}`);
}
getById(id: string): Observable<Npc> { getById(id: string): Observable<Npc> {
return this.http.get<Npc>(`${this.apiUrl}/${id}`); return this.http.get<Npc>(`${this.apiUrl}/${id}`);
} }

View File

@@ -12,6 +12,16 @@ export interface Page {
* uploadees (Shared Kernel images). Structure separee de `values`. * uploadees (Shared Kernel images). Structure separee de `values`.
*/ */
imageValues?: Record<string, string[]>; imageValues?: Record<string, string[]>;
/**
* Pour chaque champ KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
* fiches de personnage) : fieldName → (label → valeur).
*/
keyValueValues?: Record<string, Record<string, string>>;
/**
* Pour chaque champ TABLE (colonnes figees au template, lignes libres) :
* fieldName → lignes ordonnees, chaque ligne = colonne → cellule.
*/
tableValues?: Record<string, Array<Record<string, string>>>;
notes?: string | null; notes?: string | null;
tags?: string[]; tags?: string[];
relatedPageIds?: string[]; relatedPageIds?: string[];

View File

@@ -6,8 +6,10 @@
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery) * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
* - 'NUMBER' : valeur numerique (rendu en input number) * - 'NUMBER' : valeur numerique (rendu en input number)
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template * - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
* - 'TABLE' : tableau a colonnes figees (labels = noms de colonnes) et
* lignes libres ajoutees au remplissage (boutique, inventaire…)
*/ */
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST'; export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST' | 'TABLE';
/** /**
* Variante de rendu pour un champ IMAGE. Miroir de * Variante de rendu pour un champ IMAGE. Miroir de
@@ -28,10 +30,45 @@ export interface TemplateField {
type: FieldType; type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */ /** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null; layout?: ImageLayout | null;
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */ /**
* Labels predefinis (ordre significatif) :
* KEY_VALUE_LIST = libelles des lignes ; TABLE = noms des colonnes.
*/
labels?: string[] | null; labels?: string[] | null;
} }
/**
* Construit un TemplateField propre pour un type donne (attributs par defaut,
* conservation des attributs compatibles de `previous` lors d'un changement de
* type). Partage par les editeurs de template du Lore (create/edit).
*/
export function buildLoreTemplateField(
name: string,
type: FieldType,
previous?: TemplateField
): TemplateField {
switch (type) {
case 'IMAGE':
return { name, type, layout: previous?.layout ?? 'GALLERY' };
case 'KEY_VALUE_LIST':
case 'TABLE':
// Les labels (lignes KV / colonnes TABLE) survivent au changement de type
// entre ces deux variantes.
return { name, type, labels: previous?.labels ?? [] };
default:
return { name, type: 'TEXT' };
}
}
/** Retire les libelles vides (lignes KV / colonnes TABLE) avant sauvegarde. */
export function cleanFieldLabels(fields: TemplateField[]): TemplateField[] {
return fields.map(f =>
f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE'
? { ...f, labels: (f.labels ?? []).map(l => l.trim()).filter(l => !!l) }
: f
);
}
export interface Template { export interface Template {
id?: string; id?: string;
loreId: string; loreId: string;

View File

@@ -142,6 +142,71 @@
} }
} }
// Tableau libellé/valeur (champ KEY_VALUE_LIST) en lecture seule.
// Même esthétique que la grille de saisie des fiches de personnage.
.view-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
.view-kv-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
.view-kv-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9ca3af;
}
.view-kv-value {
color: #e0e0e0;
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
font-size: 1.05rem;
text-align: center;
word-break: break-word;
}
}
}
// Tableau à colonnes (champ TABLE) en lecture seule : boutique, inventaire…
.view-table {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #99f6e4;
padding: 8px 10px;
border-bottom: 1px solid rgba(20, 184, 166, 0.35);
}
td {
color: #e0e0e0;
font-size: 0.92rem;
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
white-space: pre-wrap;
word-break: break-word;
}
tbody tr:nth-child(odd) {
background: rgba(255, 255, 255, 0.02);
}
}
// Chips (tags et pages liées en lecture seule) // Chips (tags et pages liées en lecture seule)
.view-chips { .view-chips {
display: flex; display: flex;