Compare commits

..

5 Commits

Author SHA1 Message Date
809e00ce49 Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (brain) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 27s
Build & Push Images / build (web) (push) Successful in 1m57s
Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité.
Passage bêta 0.12.6
2026-06-12 16:57:57 +02:00
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
86 changed files with 2672 additions and 266 deletions

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
@@ -510,86 +515,101 @@ class ImportCampaignUseCase:
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
done_count = 0 done_count = 0
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency` # Canal de statut : les couches profondes (retry LLM, re-découpage) y
# appels simultanés. L'ordre narratif est préservé : la fusion se fait # publient des messages destinés à l'UI — cf. import_status.notify_status.
# vague par vague, dans l'ordre du livre. status_queue: asyncio.Queue = asyncio.Queue()
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est status_token = set_status_queue(status_queue)
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que try:
# si AUCUN morceau ne passe (cf. après la boucle). # PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais # appels simultanés. L'ordre narratif est préservé : la fusion se fait
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité). # vague par vague, dans l'ordre du livre.
for start in range(0, total, self._map_concurrency): # RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
wave = list(enumerate(chunks))[start:start + self._map_concurrency] # SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
gathered = asyncio.gather( # si AUCUN morceau ne passe (cf. après la boucle).
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block) # HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
for i, c in wave), # laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
return_exceptions=True, for start in range(0, total, self._map_concurrency):
) wave = list(enumerate(chunks))[start:start + self._map_concurrency]
results: list | None = None gathered = asyncio.gather(
async for kind, payload in with_heartbeat(gathered): *(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
if kind == "heartbeat": for i, c in wave),
yield {"type": "heartbeat", "current": done_count + 1, "total": total} return_exceptions=True,
else: )
results = payload results: list | None = None
for (i, _), res in zip(wave, results or []): async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
done_count += 1 if kind == "heartbeat":
if isinstance(res, LLMProviderError): yield {"type": "heartbeat", "current": done_count + 1, "total": total}
skipped += 1 elif kind == "status":
last_error = str(res) yield {"type": "status", "message": payload,
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res) "current": done_count + 1, "total": total}
yield {"type": "chunk_failed", "current": i + 1, "total": total, else:
"message": str(res)[:300]} results = payload
elif isinstance(res, BaseException): for (i, _), res in zip(wave, results or []):
raise res # bug inattendu : ne pas l'avaler en silence done_count += 1
else: if isinstance(res, LLMProviderError):
merger.add((res or {}).get("arcs") or []) skipped += 1
merger.add_npcs((res or {}).get("npcs") or []) last_error = str(res)
arcs, chapters, scenes = merger.counts() logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
yield { yield {"type": "chunk_failed", "current": i + 1, "total": total,
"type": "progress", "message": str(res)[:300]}
"current": done_count, elif isinstance(res, BaseException):
"total": total, raise res # bug inattendu : ne pas l'avaler en silence
"arc_count": arcs, else:
"chapter_count": chapters, merger.add((res or {}).get("arcs") or [])
"scene_count": scenes, merger.add_npcs((res or {}).get("npcs") or [])
"npc_count": len(merger.npcs()), arcs, chapters, scenes = merger.counts()
"skipped": skipped, yield {
} "type": "progress",
"current": done_count,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"npc_count": len(merger.npcs()),
"skipped": skipped,
}
if total > 0 and skipped == total: if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite. # Tout a échoué : "done" vide serait trompeur → erreur explicite.
yield {"type": "error", yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. " "message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
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(): 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 # 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 # exploitable : sans ce signal, l'UI reçoit un `done` vide et
# l'utilisateur conclut à tort que le PDF est illisible. # l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error", yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit " "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). " "aucune structure exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre " "Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."} "de contexte (num_ctx) ou essayez un autre modèle."}
return 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(
if kind == "heartbeat": self._consolidate(merger), status_queue=status_queue
yield {"type": "heartbeat", "current": total, "total": total} ):
if kind == "heartbeat":
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",
"arcs": _serialize_arcs(merger.result()), "arcs": _serialize_arcs(merger.result()),
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()], "npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
"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,
} }
finally:
reset_status_queue(status_token)
# --- Consolidation finale (fusion des quasi-doublons) --------------------- # --- Consolidation finale (fusion des quasi-doublons) ---------------------
@@ -666,6 +686,9 @@ class ImportCampaignUseCase:
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_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(
@@ -679,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

@@ -15,7 +15,14 @@ from __future__ import annotations
import logging import logging
import re 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
@@ -332,35 +339,46 @@ class ImportRulesUseCase:
merger = _SectionMerger() merger = _SectionMerger()
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
for i, chunk in enumerate(chunks): # Canal de statut : les couches profondes (retry LLM, re-découpage) y
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue. # publient des messages destinés à l'UI — cf. import_status.notify_status.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle). status_queue: asyncio.Queue = asyncio.Queue()
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un status_token = set_status_queue(status_queue)
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core. try:
new_titles: list[str] = [] for i, chunk in enumerate(chunks):
try: # RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
sections: dict[str, str] | None = None # Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
async for kind, payload in with_heartbeat( # HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
self._map_chunk(chunk, index=i, total=total) # provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
): new_titles: list[str] = []
if kind == "heartbeat": try:
yield {"type": "heartbeat", "current": i + 1, "total": total} sections: dict[str, str] | None = None
else: async for kind, payload in with_heartbeat(
sections = payload self._map_chunk(chunk, index=i, total=total),
new_titles = merger.add(sections or {}) status_queue=status_queue,
except LLMProviderError as exc: ):
skipped += 1 if kind == "heartbeat":
last_error = str(exc) yield {"type": "heartbeat", "current": i + 1, "total": total}
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc) elif kind == "status":
yield {"type": "chunk_failed", "current": i + 1, "total": total, yield {"type": "status", "message": payload,
"message": str(exc)[:300]} "current": i + 1, "total": total}
yield { else:
"type": "progress", sections = payload
"current": i + 1, new_titles = merger.add(sections or {})
"total": total, except LLMProviderError as exc:
"new_sections": new_titles, skipped += 1
"skipped": skipped, last_error = str(exc)
} logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
yield {
"type": "progress",
"current": i + 1,
"total": total,
"new_sections": new_titles,
"skipped": skipped,
}
finally:
reset_status_queue(status_token)
if total > 0 and skipped == total: if total > 0 and skipped == total:
yield {"type": "error", yield {"type": "error",
@@ -423,6 +441,9 @@ 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)
@@ -437,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)

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

@@ -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__)
@@ -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

@@ -144,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

@@ -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.2-beta", version="0.12.6-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.2-beta</version> <version>0.12.6-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

@@ -119,6 +119,58 @@ public class NotebookService {
.notebookId(notebookId).role(role).content(content).build()); .notebookId(notebookId).role(role).content(content).build());
} }
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
public void clearChat(String notebookId) {
repository.archiveMessagesByNotebookId(notebookId);
}
/** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */
public List<NotebookMessage> getArchivedMessages(String notebookId) {
return repository.findArchivedMessagesByNotebookId(notebookId);
}
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
/**
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
* budget : la fin d'une conversation (conclusions) est la partie utile.
*/
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
var wanted = new java.util.HashSet<>(archivedAtKeys);
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
}
}
if (groups.isEmpty()) return "";
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
StringBuilder out = new StringBuilder(
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
groups.forEach((archivedAt, messages) -> {
StringBuilder convo = new StringBuilder();
for (NotebookMessage m : messages) {
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
.append(m.getContent()).append('\n');
}
String text = convo.toString();
if (text.length() > budgetPerArchive) {
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
}
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
});
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
return out.toString();
}
// --- Contexte campagne (oriente l'IA) --- // --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :

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

@@ -17,4 +17,6 @@ public class NotebookMessage {
private String role; private String role;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
private LocalDateTime archivedAt;
} }

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

@@ -28,5 +28,10 @@ public interface NotebookRepository {
// --- Messages (conversation) --- // --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message); NotebookMessage saveMessage(NotebookMessage message);
/** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessage> findMessagesByNotebookId(String notebookId); List<NotebookMessage> findMessagesByNotebookId(String notebookId);
/** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */
void archiveMessagesByNotebookId(String notebookId);
/** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */
List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId);
} }

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

@@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity {
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* Null = message de la conversation ACTIVE. Non-null = message archivé lors
* d'un « vider la conversation » ; tous les messages d'un même clear portent
* le même horodatage, qui sert d'identifiant de lot d'archive.
*/
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now();

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

@@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Repository @Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> { public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId); /** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId);
/** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId); void deleteByNotebookId(Long notebookId);
/** « Vider la conversation » : archive le fil actif en un lot horodaté. */
@Modifying
@Query("update NotebookMessageJpaEntity m set m.archivedAt = :now "
+ "where m.notebookId = :notebookId and m.archivedAt is null")
int archiveActiveMessages(@Param("notebookId") Long notebookId, @Param("now") LocalDateTime now);
} }

View File

@@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
@Override
@Transactional
public void archiveMessagesByNotebookId(String notebookId) {
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
}
@Override
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).collect(Collectors.toList());
} }
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
.role(e.getRole()) .role(e.getRole())
.content(e.getContent()) .content(e.getContent())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
.archivedAt(e.getArchivedAt())
.build(); .build();
} }
} }

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

@@ -88,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

@@ -170,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

@@ -105,6 +105,43 @@ public class NotebookController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// --- Conversation : vider (= archiver) et consulter les archives ---
/**
* « Vider la conversation » : le fil actif est ARCHIVÉ en un lot horodaté,
* jamais supprimé — consultable ensuite via {@link #listArchives}.
*/
@PostMapping("/{id}/chat/clear")
public ResponseEntity<Void> clearChat(@PathVariable String id) {
if (service.getNotebook(id).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable");
}
service.clearChat(id);
return ResponseEntity.noContent().build();
}
/** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */
@GetMapping("/{id}/chat/archives")
public ResponseEntity<List<Map<String, Object>>> listArchives(@PathVariable String id) {
var grouped = new java.util.TreeMap<java.time.LocalDateTime, List<Map<String, Object>>>(
java.util.Comparator.reverseOrder());
for (var m : service.getArchivedMessages(id)) {
grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>())
.add(Map.of(
"role", m.getRole(),
"content", m.getContent(),
"createdAt", m.getCreatedAt().toString()));
}
List<Map<String, Object>> out = new java.util.ArrayList<>();
grouped.forEach((archivedAt, messages) -> {
Map<String, Object> archive = new LinkedHashMap<>();
archive.put("archivedAt", archivedAt.toString());
archive.put("messages", messages);
out.add(archive);
});
return ResponseEntity.ok(out);
}
// --- Chat ancré streamé --- // --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -124,8 +161,26 @@ 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
String context = service.buildContext(nb.getCampaignId()); // 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;
}
// Contexte = brief de campagne + archives cochées en référence (le tout
// dans une variable finale : capturée par la lambda du taskExecutor).
String campaignContext = service.buildContext(nb.getCampaignId());
String archiveContext = service.buildArchiveContext(id, req.archiveIds());
final String context = archiveContext.isEmpty()
? campaignContext
: (campaignContext.isEmpty() ? archiveContext
: campaignContext + "\n\n" + archiveContext);
boolean deep = req.deep() != null && req.deep(); boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -220,5 +275,14 @@ 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é).
* @param archiveIds Optionnel : archives de conversation cochées comme RÉFÉRENCE
* (clés = archivedAt). Leur contenu est injecté dans le contexte
* du prompt — toujours résolu dans CE notebook (sécurité).
*/
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
List<String> archiveIds) {}
} }

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.2-beta", "version": "0.12.6-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.2-beta", "version": "0.12.6-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.2-beta", "version": "0.12.6-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)">
<lucide-icon @if (s.status === 'READY') {
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)" <input
[size]="14" type="checkbox"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'"> class="nbd-source-check"
</lucide-icon> [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
[img]="s.status === 'FAILED' ? AlertCircle : Loader"
[size]="14"
[class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</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">
@@ -54,55 +71,140 @@
</aside> </aside>
<!-- Chat --> <!-- Chat -->
<section class="nbd-chat"> <section class="nbd-chat">
<div class="nbd-messages"> <!-- Barre d'actions du chat : archives + vider -->
@if (messages.length === 0) { <div class="nbd-chat-head">
<p class="nbd-empty"> @if (viewingArchive) {
Pose une question sur ta source, ou demande une adaptation pour ta campagne. <span class="nbd-archive-banner">
@if (!hasReadySource()) { <lucide-icon [img]="History" [size]="13"></lucide-icon>
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span> Archive du {{ archiveLabel(viewingArchive) }} — lecture seule
} </span>
</p> <button type="button" class="nbd-chat-btn" (click)="closeArchive()">
} <lucide-icon [img]="X" [size]="13"></lucide-icon>
@for (m of messages; track m) { Revenir au chat
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'"> </button>
<div class="nbd-msg-role"> } @else {
@if (m.role === 'assistant') { @if (referencedArchiveIds.size > 0) {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon> <span class="nbd-ref-badge"
} title="Le contenu de ces archives est injecté comme référence dans chaque question">
{{ m.role === 'user' ? 'Vous' : 'IA' }} <lucide-icon [img]="History" [size]="12"></lucide-icon>
</div> {{ referencedArchiveIds.size }} archive(s) en référence
@if (m.role === 'assistant') { </span>
@if (parsedOf(m); as p) { }
@if (sending && deepProgress && !p.text) { <span class="nbd-chat-head-spacer"></span>
<div class="nbd-deep-progress"> <button type="button" class="nbd-chat-btn" (click)="toggleArchives()"
<lucide-icon [img]="Layers" [size]="13"></lucide-icon> [class.active]="archivesOpen"
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} title="Conversations archivées (lecture seule + référence)">
</div> <lucide-icon [img]="History" [size]="13"></lucide-icon>
} Archives
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) { </button>
<span class="cursor"></span> <button type="button" class="nbd-chat-btn nbd-chat-btn--danger" (click)="clearChat()"
}</div> [disabled]="sending || messages.length === 0"
@for (a of p.actions; track $index) { title="Vider la conversation (le fil actuel est archivé, pas supprimé)">
<app-notebook-action-card <lucide-icon [img]="Eraser" [size]="13"></lucide-icon>
[action]="a" Vider
[campaignId]="campaignId" </button>
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
@if (sourcesLabel(m); as label) {
<div class="nbd-msg-sources" title="Pages de la source utilisées pour ancrer cette réponse">
📖 {{ label }}
</div>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
} }
</div> </div>
<!-- Panneau des archives -->
@if (archivesOpen && !viewingArchive) {
<div class="nbd-archives">
@if (archives.length === 0) {
<p class="nbd-empty">Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.</p>
} @else {
<p class="nbd-archives-help">
Cochez une archive pour l'utiliser comme <strong>référence</strong> dans le chat ;
cliquez sur son nom pour la relire.
</p>
}
@for (a of archives; track a.archivedAt) {
<div class="nbd-archive-row" [class.referenced]="isReferenced(a)">
<input
type="checkbox"
class="nbd-archive-check"
[checked]="isReferenced(a)"
(change)="toggleReference(a)"
[title]="isReferenced(a)
? 'Archive utilisée comme référence décocher pour la retirer'
: 'Utiliser cette archive comme référence dans le chat'" />
<button type="button" class="nbd-archive-item" (click)="openArchive(a)">
<lucide-icon [img]="History" [size]="13"></lucide-icon>
{{ archiveLabel(a) }}
</button>
</div>
}
</div>
}
<div class="nbd-messages">
@if (viewingArchive) {
<!-- Archive en lecture seule -->
@for (m of viewingArchive.messages; track $index) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
<div class="nbd-msg-content md" [innerHTML]="m.content | markdown"></div>
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
} @else {
@if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p>
}
@for (m of messages; track m) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
@if (parsedOf(m); as p) {
@if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
}
@if (p.text) {
<div class="nbd-msg-content md" [innerHTML]="p.text | markdown"></div>
} @else if (sending && !p.actions.length && !deepProgress) {
<div class="nbd-msg-content"><span class="cursor"></span></div>
}
@for (a of p.actions; track $index) {
<app-notebook-action-card
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
@if (sourcesLabel(m); as label) {
<div class="nbd-msg-sources" title="Pages de la source utilisées pour ancrer cette réponse">
📖 {{ label }}
</div>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
}
</div>
@if (!viewingArchive) {
<div class="nbd-input"> <div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2" <textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…" placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
@@ -116,6 +218,7 @@
<lucide-icon [img]="Send" [size]="15"></lucide-icon> <lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button> </button>
</div> </div>
}
</section> </section>
</div> </div>
</div> </div>

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); }
@@ -57,8 +81,84 @@
/* Chat */ /* Chat */
.nbd-chat { .nbd-chat {
display: flex; flex-direction: column; min-height: 0; display: flex; flex-direction: column; min-height: 0;
// min-width: 0 indispensable : enfant de grille (colonne 1fr), sinon sa largeur
// MINIMALE devient celle de la plus longue ligne insécable d'un message
// (tableau markdown, longue URL…) et le chat pousse toute la page hors écran.
min-width: 0;
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
} }
// Barre d'actions du chat : archives + vider (ou bandeau « archive en lecture seule »).
.nbd-chat-head {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.45rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08);
.nbd-chat-head-spacer { flex: 1; }
.nbd-archive-banner {
flex: 1;
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.8rem; color: #e0c074; font-style: italic;
}
// Badge « N archive(s) en référence » : rappelle que le contexte du chat
// inclut d'anciennes conversations, même panneau fermé.
.nbd-ref-badge {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.74rem;
background: rgba(168,130,255,0.12); border: 1px solid rgba(168,130,255,0.4);
color: #c4a8ff;
}
.nbd-chat-btn {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.28rem 0.55rem; border-radius: 6px; font-size: 0.78rem;
border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04);
color: inherit; cursor: pointer;
&:hover:not(:disabled) { background: rgba(255,255,255,0.09); }
&.active { border-color: #667eea; }
&:disabled { opacity: 0.45; cursor: default; }
&--danger {
color: #e88; border-color: rgba(224,90,90,0.35);
&:hover:not(:disabled) { background: rgba(224,90,90,0.12); }
}
}
}
// Panneau des conversations archivées.
.nbd-archives {
display: flex; flex-direction: column; gap: 0.3rem;
padding: 0.5rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.08);
.nbd-archives-help {
margin: 0 0 0.2rem;
font-size: 0.74rem;
color: var(--color-text-muted, #9aa0aa);
}
.nbd-archive-row {
display: flex; align-items: center; gap: 0.45rem;
// Archive cochée en référence : léger liseré violet pour la repérer.
&.referenced .nbd-archive-item { border-color: rgba(168,130,255,0.5); }
}
.nbd-archive-check {
accent-color: #c4a8ff;
cursor: pointer;
flex-shrink: 0;
}
.nbd-archive-item {
flex: 1;
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.35rem 0.55rem; border-radius: 6px; font-size: 0.82rem;
border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03);
color: inherit; cursor: pointer; text-align: left;
&:hover { background: rgba(255,255,255,0.08); }
}
}
.nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; } .nbd-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
.nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; } .nbd-empty { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
@@ -69,7 +169,90 @@
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem; color: var(--color-text-muted, #9aa0aa); margin-bottom: 0.2rem;
} }
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; } // overflow-wrap: anywhere : autorise la coupure DANS une séquence insécable
// (ligne de tableau markdown, URL) plutôt que de déborder du conteneur.
.nbd-msg-content { white-space: pre-wrap; line-height: 1.5; overflow-wrap: anywhere; }
// Rendu markdown des messages assistant (même esthétique que ai-chat-drawer).
// white-space normal : marked génère des <p>/<li>/<br>, pre-wrap doublerait
// les espacements entre blocs.
.nbd-msg-content.md {
white-space: normal;
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
p { margin: 0 0 0.5em; }
p:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 0.75em 0 0.35em;
font-weight: 600;
color: #f3f4f6;
line-height: 1.25;
}
h1 { font-size: 1.05rem; }
h2 { font-size: 1rem; }
h3 { font-size: 0.95rem; }
h4, h5, h6 { font-size: 0.9rem; }
strong { color: #f3f4f6; font-weight: 600; }
em { color: #d1d5db; font-style: italic; }
ul, ol { margin: 0.35em 0 0.5em; padding-left: 1.4em; }
li { margin: 0.15em 0; }
ul ul, ul ol, ol ul, ol ol { margin: 0.15em 0; }
code {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 3px;
padding: 0 0.25em;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.82em;
}
pre {
background: #0b0b15;
border: 1px solid #2a2a3d;
border-radius: 6px;
padding: 0.6em 0.75em;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.82em;
code { background: transparent; border: 0; padding: 0; font-size: inherit; }
}
blockquote {
margin: 0.4em 0;
padding: 0.2em 0.8em;
border-left: 3px solid #3a3a5a;
color: #9ca3af;
font-style: italic;
}
a {
color: #a5b4fc;
text-decoration: underline;
&:hover { color: #c7d2fe; }
}
hr { border: 0; border-top: 1px solid #2a2a3d; margin: 0.6em 0; }
// Les tableaux (inventaires de boutique…) peuvent être plus larges que la
// bulle : on les laisse défiler horizontalement DANS le message.
table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0.4em 0;
font-size: 0.85em;
th, td { border: 1px solid #2a2a3d; padding: 0.3em 0.5em; white-space: nowrap; }
th { background: #14142a; font-weight: 600; }
}
}
&.user { align-self: flex-end; text-align: right; &.user { align-self: flex-end; text-align: right;
.nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; } .nbd-msg-content { background: rgba(102,126,234,0.16); padding: 0.5rem 0.75rem; border-radius: 10px; display: inline-block; text-align: left; }
} }

View File

@@ -2,17 +2,19 @@ import { Component, OnInit } 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, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers, Eraser, History, X } from 'lucide-angular';
import { NotebookService } from '../../../services/notebook.service'; import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model'; import { parseNotebookActions, NotebookAction } from '../../../services/notebook-action.model';
import { Arc, Chapter } from '../../../services/campaign.model'; import { Arc, Chapter } from '../../../services/campaign.model';
import { loadCampaignTreeData } from '../../campaign-tree.helper'; import { loadCampaignTreeData } from '../../campaign-tree.helper';
import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component'; import { NotebookActionCardComponent } from '../notebook-action-card/notebook-action-card.component';
import { MarkdownPipe } from '../../../shared/markdown.pipe';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/** /**
* Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite). * Atelier (workspace) : sources indexées (gauche) + chat ancré RAG (droite).
@@ -20,7 +22,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/ */
@Component({ @Component({
selector: 'app-notebook-detail', selector: 'app-notebook-detail',
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent], imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent, MarkdownPipe],
templateUrl: './notebook-detail.component.html', templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss'] styleUrls: ['./notebook-detail.component.scss']
}) })
@@ -35,6 +37,9 @@ export class NotebookDetailComponent implements OnInit {
readonly AlertCircle = AlertCircle; readonly AlertCircle = AlertCircle;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Layers = Layers; readonly Layers = Layers;
readonly Eraser = Eraser;
readonly History = History;
readonly X = X;
campaignId = ''; campaignId = '';
notebookId = ''; notebookId = '';
@@ -60,7 +65,8 @@ export class NotebookDetailComponent implements OnInit {
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService private npcService: NpcService,
private confirmDialog: ConfirmDialogService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -137,13 +143,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 ---
@@ -169,6 +216,81 @@ export class NotebookDetailComponent implements OnInit {
this.service.deleteSource(s.id).subscribe(() => this.reloadSources()); this.service.deleteSource(s.id).subscribe(() => this.reloadSources());
} }
// --- Vider la conversation / archives -------------------------------------
// « Vider » ARCHIVE le fil (rien n'est supprimé) ; les archives restent
// consultables en lecture seule via le sélecteur.
/** Conversations archivées (chargées à l'ouverture du panneau). */
archives: NotebookArchive[] = [];
/** Archive affichée en lecture seule (null = chat actif). */
viewingArchive: NotebookArchive | null = null;
/** Panneau « archives » ouvert ? */
archivesOpen = false;
clearChat(): void {
if (this.sending || this.messages.length === 0) return;
this.confirmDialog.confirm({
title: 'Vider la conversation',
message: 'Repartir d\'une conversation vierge ?',
details: ['Le fil actuel est archivé (pas supprimé) : il restera consultable via « Archives ».'],
confirmLabel: 'Vider',
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.service.clearChat(this.notebookId).subscribe({
next: () => {
this.messages = [];
this.archives = []; // re-chargées à la prochaine ouverture du panneau
this.archivesOpen = false;
},
error: () => { /* le fil reste affiché : rien n'a été modifié côté serveur */ }
});
});
}
toggleArchives(): void {
this.archivesOpen = !this.archivesOpen;
if (this.archivesOpen && this.archives.length === 0) {
this.service.getArchives(this.notebookId).subscribe({
next: (a) => this.archives = a,
error: () => { this.archives = []; }
});
}
}
/**
* Archives cochées « en référence » : leur contenu est injecté dans le
* contexte de chaque tour de chat (normal et approfondi). Clés = archivedAt.
*/
referencedArchiveIds = new Set<string>();
isReferenced(a: NotebookArchive): boolean {
return this.referencedArchiveIds.has(a.archivedAt);
}
toggleReference(a: NotebookArchive): void {
if (this.referencedArchiveIds.has(a.archivedAt)) this.referencedArchiveIds.delete(a.archivedAt);
else this.referencedArchiveIds.add(a.archivedAt);
}
openArchive(a: NotebookArchive): void {
this.viewingArchive = a;
this.archivesOpen = false;
}
closeArchive(): void {
this.viewingArchive = null;
}
/** Libellé d'une archive : date du clear + nb d'échanges. */
archiveLabel(a: NotebookArchive): string {
const date = new Date(a.archivedAt);
const when = isNaN(date.getTime())
? a.archivedAt
: date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
return `${when} · ${a.messages.length} message(s)`;
}
// --- Chat --- // --- Chat ---
send(deep = false): void { send(deep = false): void {
@@ -181,7 +303,10 @@ 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], [...this.referencedArchiveIds]
).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

@@ -74,6 +74,12 @@ export class CampaignImportService {
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; 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);

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

@@ -105,6 +105,12 @@ export class GameSystemService {
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; 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);

View File

@@ -34,6 +34,15 @@ export interface NotebookMessage {
sources?: NotebookChatSource[]; sources?: NotebookChatSource[];
} }
/**
* Conversation archivée par « Vider la conversation » : un lot horodaté de
* messages, consultable en lecture seule (rien n'est supprimé au clear).
*/
export interface NotebookArchive {
archivedAt: string;
messages: NotebookMessage[];
}
export interface NotebookDetail { export interface NotebookDetail {
id: string; id: string;
name: string; name: string;

View File

@@ -1,7 +1,7 @@
import { Injectable, NgZone } from '@angular/core'; import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Notebook, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model'; import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
/** /**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources, * Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
@@ -44,11 +44,26 @@ export class NotebookService {
return this.http.delete<void>(`${this.apiUrl}/sources/${sourceId}`); return this.http.delete<void>(`${this.apiUrl}/sources/${sourceId}`);
} }
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
clearChat(notebookId: string): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/${notebookId}/chat/clear`, {});
}
/** Conversations archivées, plus récentes d'abord. */
getArchives(notebookId: string): Observable<NotebookArchive[]> {
return this.http.get<NotebookArchive[]>(`${this.apiUrl}/${notebookId}/chat/archives`);
}
/** /**
* 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.
* `archiveIds` : archives de conversation cochées comme référence (clés archivedAt) ;
* leur contenu est injecté dans le contexte du prompt.
*/ */
streamChat(notebookId: string, message: string, deep = false): Observable<NotebookChatEvent> { streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[], archiveIds?: 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 +74,11 @@ 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,
archiveIds: archiveIds?.length ? archiveIds : 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;